feat: Phase 2 — ports package, secrets port move, postgres adapter move
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Problem: the hexagon's Phase 2 (plans/2026-08-15-hexagonal-architecture.md)
must give the use-cases-to-be their contract surface: driven-port
interfaces, test fakes, the secrets interface moved into core, and the
postgres package inside the adapters tree — before the first vertical
slice (Phase 3) can wire a composition root.

Change:
- internal/core/ports: full driven-port catalog per plan §3.3 —
  repositories as transaction-scoped aggregates whose inputs carry
  derived checks, audit, and events (§3.6), plus CommandExecutor,
  TargetResolver, Checker, Secrets, EventPublisher, Provisioner.
  Port-local payload types (Event, AuditEntry, CheckDef, KnowledgeEntry,
  ExecResult) keep signatures off infrastructure; TypeTree aliases
  internal/ontology (pure over domain) until checkdefaults is absorbed.
  ReadModels intentionally not declared yet — it materializes with the
  Phase 3 slice and grows as report handlers rewire.
- secrets.Backend is now an alias of ports.Secrets; implementations
  (Infisical, SOPS, Manager) unchanged. mcp's local secretBackend
  subset is deleted; tool constructors take ports.Secrets.
- internal/db → internal/adapters/postgres (mechanical import rewrite;
  package identifier stays db until the Phase 3 repository split).
  sqlc.yaml, Makefile, golangci exclusions, and docs follow the move;
  make generate-check verified.
- internal/adapters/ssh: Executor implements ports.CommandExecutor over
  the actuator dial pool + RunStreaming (10-min default timeout carried
  over from the httpapi path).
- internal/adapters/remote: Resolver implements ports.TargetResolver
  delegating to internal/remote (still pool-based; drops onto
  ports.EntityRepository when repositories land in Phase 3 — documented
  transitional import).
- internal/core/ports/portstest: importable fakes — in-memory
  EntityRepo (with check-then-act SetState, side-effect recording),
  RecordingExecutor, FakeChecker, SpyPublisher; port-satisfaction
  guards; tests.

Risk: ports are declared ahead of implementations — signatures firm up
per phase as slices land (documented in the package doc); the
remote→postgres transitional import is explicit and dissolves in
Phase 3.

Verification: go vet, make test (race, 19 packages), generate-check,
golangci on core+adapters — 0 issues; full-repo baseline down
365→344.
This commit is contained in:
2026-08-15 22:56:56 +02:00
parent d4d99a7473
commit 64f7d54011
93 changed files with 1102 additions and 103 deletions

View File

@@ -0,0 +1,34 @@
package db
import (
"context"
"github.com/dtoro/oikos/internal/checkdefaults"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// EnsureEntityChecks derives an entity's default check_defs from the
// monitoring spec of its type (resolving per-entity `monitoring` overrides).
//
// This is the single shared hook that keeps the check graph in sync with
// entity mutations. Both the HTTP create/patch handlers and the MCP
// entity-mutation tools (create_entity, update_entity_attributes) call it so
// that flipping an entity's `monitoring` attribute regenerates checks
// regardless of which surface made the change — previously only the HTTP
// path ran check derivation, so entities mutated via MCP silently produced no
// checks (see plans/2026-08-03-session-review-haos-monitoring-capability-gaps.md, A2).
func EnsureEntityChecks(ctx context.Context, tx pgx.Tx, id uuid.UUID, slug, entityType, name string, attrs []byte) (checkdefaults.Result, error) {
tree, err := LoadTypeTree(ctx, tx)
if err != nil {
return checkdefaults.Result{}, err
}
res, err := checkdefaults.Ensure(ctx, tx, tree, checkdefaults.Target{
ID: id, Slug: slug, Type: entityType, Name: name, Attrs: attrs,
})
if err != nil {
return res, err
}
checkdefaults.LogResult(slug, entityType, res)
return res, nil
}

View File

@@ -0,0 +1,6 @@
// Package db is the postgres adapter: connection pool, migrations, seed
// ingest, and sqlc-generated queries. It moved from internal/db in Phase 2
// of the hexagonal refactor (ADR 0016); the package identifier stays `db`
// until the repository split (Phase 3) renames it alongside the first
// ports implementations landing here.
package db

View File

@@ -0,0 +1,68 @@
package db
import (
"sync"
"time"
)
type entityCacheEntry struct {
slug string
id string
attrs string
exp time.Time
}
// EntityCache is a TTL cache mapping entity IDs to slugs and back,
// keyed for the hot resolution paths.
type EntityCache struct {
mu sync.RWMutex
m map[string]entityCacheEntry
ttl time.Duration
}
// NewEntityCache builds a cache with the given TTL.
func NewEntityCache(ttl time.Duration) *EntityCache {
return &EntityCache{
m: make(map[string]entityCacheEntry),
ttl: ttl,
}
}
// GetSlug resolves an entity ID to its slug.
func (c *EntityCache) GetSlug(id string) (string, bool) {
c.mu.RLock()
e, ok := c.m[id]
c.mu.RUnlock()
if !ok || time.Now().After(e.exp) {
return "", false
}
return e.slug, true
}
// GetID resolves a slug to its entity ID.
func (c *EntityCache) GetID(slug string) (string, bool) {
c.mu.RLock()
e, ok := c.m[slug]
c.mu.RUnlock()
if !ok || time.Now().After(e.exp) {
return "", false
}
return e.id, true
}
// Set records the slug/id pair and serialized attributes.
func (c *EntityCache) Set(slug, id, attrs string) {
exp := time.Now().Add(c.ttl)
c.mu.Lock()
c.m[slug] = entityCacheEntry{slug: slug, id: id, attrs: attrs, exp: exp}
c.m[id] = entityCacheEntry{slug: slug, id: id, attrs: attrs, exp: exp}
c.mu.Unlock()
}
// Invalidate drops the cached entries for one slug/id pair.
func (c *EntityCache) Invalidate(slug, id string) {
c.mu.Lock()
delete(c.m, slug)
delete(c.m, id)
c.mu.Unlock()
}

View File

@@ -0,0 +1,300 @@
package db
import (
"context"
"encoding/json"
"fmt"
"gopkg.in/yaml.v3"
)
// ExportToYAML regenerates the three seed YAMLs from the DB (DR / version
// control, plan D6). Output is deterministic: maps marshal with sorted keys
// (yaml.v3 default for map[string]any) and lists are ordered by slug/name,
// so export → ingest → export is byte-stable.
//
// Cognition-layer entities (signals, executions, patterns, …) are runtime
// state, not inventory — they are excluded from the export.
func ExportToYAML(ctx context.Context, pool *Pool) (map[string][]byte, error) {
result := make(map[string][]byte)
for name, fn := range map[string]func(context.Context, *Pool) (map[string]any, error){
"ontology.yaml": exportOntology,
"inventory.yaml": exportInventory,
"policy.yaml": exportPolicy,
} {
doc, err := fn(ctx, pool)
if err != nil {
return nil, fmt.Errorf("export %s: %w", name, err)
}
out, err := yaml.Marshal(doc)
if err != nil {
return nil, fmt.Errorf("marshal %s: %w", name, err)
}
result[name] = out
}
return result, nil
}
func exportOntology(ctx context.Context, pool *Pool) (map[string]any, error) {
lifecycles := map[string]any{}
rows, err := pool.Query(ctx,
`SELECT id, states, default_state, terminal_states, transitions
FROM lifecycle_defs ORDER BY id`)
if err != nil {
return nil, err
}
for rows.Next() {
var id, def string
var states, terminal []string
var transitionsJSON []byte
if err := rows.Scan(&id, &states, &def, &terminal, &transitionsJSON); err != nil {
rows.Close()
return nil, err
}
var transitions map[string]any
if err := json.Unmarshal(transitionsJSON, &transitions); err != nil {
rows.Close()
return nil, fmt.Errorf("lifecycle %s transitions: %w", id, err)
}
lifecycles[id] = map[string]any{
"states": states,
"default_state": def,
"terminal_states": terminal,
"transitions": transitions,
}
}
rows.Close()
if rows.Err() != nil {
return nil, rows.Err()
}
entityTypes := map[string]any{}
rows, err = pool.Query(ctx,
`SELECT name, COALESCE(parent_type,''), is_abstract, domain, layer,
COALESCE(description,''), COALESCE(lifecycle_id,''), attribute_schema
FROM entity_types ORDER BY name`)
if err != nil {
return nil, err
}
for rows.Next() {
var name, parent, dom, layer, desc, lc string
var isAbstract bool
var schemaJSON []byte
if err := rows.Scan(&name, &parent, &isAbstract, &dom, &layer, &desc, &lc, &schemaJSON); err != nil {
rows.Close()
return nil, err
}
et := map[string]any{"domain": dom, "layer": layer}
if parent != "" {
et["parent"] = parent
}
if isAbstract {
et["abstract"] = true
}
if desc != "" {
et["description"] = desc
}
if lc != "" {
et["lifecycle"] = lc
}
if len(schemaJSON) > 0 && string(schemaJSON) != "null" {
var schema map[string]any
if err := json.Unmarshal(schemaJSON, &schema); err == nil && schema != nil {
et["attributes"] = schema
}
}
entityTypes[name] = et
}
rows.Close()
if rows.Err() != nil {
return nil, rows.Err()
}
relTypes := map[string]any{}
rows, err = pool.Query(ctx,
`SELECT name, COALESCE(inverse,''), source_type, target_type, cardinality,
COALESCE(description,'')
FROM relationship_types ORDER BY name`)
if err != nil {
return nil, err
}
for rows.Next() {
var name, inverse, src, tgt, card, desc string
if err := rows.Scan(&name, &inverse, &src, &tgt, &card, &desc); err != nil {
rows.Close()
return nil, err
}
rt := map[string]any{"source": src, "target": tgt, "cardinality": card}
if inverse != "" {
rt["inverse"] = inverse
}
if desc != "" {
rt["description"] = desc
}
relTypes[name] = rt
}
rows.Close()
if rows.Err() != nil {
return nil, rows.Err()
}
return map[string]any{
"version": 1,
"lifecycles": lifecycles,
"entity_types": entityTypes,
"relationship_types": relTypes,
}, nil
}
func exportInventory(ctx context.Context, pool *Pool) (map[string]any, error) {
var entities []any
rows, err := pool.Query(ctx, `
SELECT e.slug, e.type, e.name, COALESCE(e.state,''), e.attributes
FROM entities e
JOIN entity_types et ON et.name = e.type
WHERE et.layer <> 'cognition'
ORDER BY e.slug`)
if err != nil {
return nil, err
}
for rows.Next() {
var slug, typ, name, state string
var attrsJSON []byte
if err := rows.Scan(&slug, &typ, &name, &state, &attrsJSON); err != nil {
rows.Close()
return nil, err
}
e := map[string]any{"slug": slug, "type": typ, "name": name}
if state != "" {
e["state"] = state
}
var attrs map[string]any
if err := json.Unmarshal(attrsJSON, &attrs); err == nil && len(attrs) > 0 {
e["attributes"] = attrs
}
entities = append(entities, e)
}
rows.Close()
if rows.Err() != nil {
return nil, rows.Err()
}
var rels []any
rows, err = pool.Query(ctx, `
SELECT se.slug, te.slug, r.type, r.attributes
FROM relationships r
JOIN entities se ON se.id = r.source_id
JOIN entities te ON te.id = r.target_id
WHERE r.valid_to IS NULL
ORDER BY r.type, se.slug, te.slug`)
if err != nil {
return nil, err
}
for rows.Next() {
var src, tgt, typ string
var attrsJSON []byte
if err := rows.Scan(&src, &tgt, &typ, &attrsJSON); err != nil {
rows.Close()
return nil, err
}
rel := map[string]any{"source": src, "target": tgt, "type": typ}
var attrs map[string]any
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
rel["attributes"] = attrs
}
rels = append(rels, rel)
}
rows.Close()
if rows.Err() != nil {
return nil, rows.Err()
}
return map[string]any{
"version": 1,
"entities": entities,
"relationships": rels,
}, nil
}
func exportPolicy(ctx context.Context, pool *Pool) (map[string]any, error) {
riskClasses := map[string]any{}
rows, err := pool.Query(ctx,
`SELECT name, COALESCE(description,''), approval_required, autonomy_allowed
FROM risk_classes ORDER BY name`)
if err != nil {
return nil, err
}
for rows.Next() {
var name, desc, approval string
var autonomy bool
if err := rows.Scan(&name, &desc, &approval, &autonomy); err != nil {
rows.Close()
return nil, err
}
rc := map[string]any{"approval_required": approval, "autonomy_allowed": autonomy}
if desc != "" {
rc["description"] = desc
}
riskClasses[name] = rc
}
rows.Close()
if rows.Err() != nil {
return nil, rows.Err()
}
var rules []any
rows, err = pool.Query(ctx, `
SELECT COALESCE(ar.entity_type,''), ar.action, ar.risk_class,
ar.autonomy_level, COALESCE(se.slug,'')
FROM approval_rules ar
LEFT JOIN entities se ON se.id = ar.scope_entity
ORDER BY COALESCE(ar.entity_type,''), ar.action, COALESCE(se.slug,'')`)
if err != nil {
return nil, err
}
for rows.Next() {
var et, action, rc, al, scope string
if err := rows.Scan(&et, &action, &rc, &al, &scope); err != nil {
rows.Close()
return nil, err
}
rule := map[string]any{"action": action, "risk_class": rc, "autonomy_level": al}
if et != "" {
rule["entity_type"] = et
}
if scope != "" {
rule["scope_entity"] = scope
}
rules = append(rules, rule)
}
rows.Close()
if rows.Err() != nil {
return nil, rows.Err()
}
settings := map[string]any{}
rows, err = pool.Query(ctx, `SELECT key, value FROM autonomy_settings ORDER BY key`)
if err != nil {
return nil, err
}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
rows.Close()
return nil, err
}
settings[k] = v
}
rows.Close()
if rows.Err() != nil {
return nil, rows.Err()
}
return map[string]any{
"version": 1,
"risk_classes": riskClasses,
"approval_rules": rules,
"autonomy_settings": settings,
}, nil
}

View File

@@ -0,0 +1,414 @@
package db
// Integration tests against a real TimescaleDB. Guarded by
// OIKOS_TEST_DATABASE_URL — skipped when unset. Run with:
//
// docker compose up -d postgres
// OIKOS_TEST_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disable" go test ./internal/db/
//
// or `make test-db`. Each run creates a throwaway database and drops it.
import (
"bytes"
"context"
"errors"
"fmt"
"math/rand"
"os"
"strings"
"testing"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/jackc/pgx/v5"
"gopkg.in/yaml.v3"
)
func seedsDir() string { return "../../seeds" }
// newTestPool creates a throwaway database (dropped on cleanup), runs all
// migrations, and returns a pool connected to it.
func newTestPool(t *testing.T) *Pool {
t.Helper()
baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL")
if baseURL == "" {
t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
}
ctx := context.Background()
admin, err := pgx.Connect(ctx, baseURL)
if err != nil {
t.Fatalf("connect admin: %v", err)
}
dbName := fmt.Sprintf("oikos_test_%08x", rand.Int63())
if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil {
admin.Close(ctx)
t.Fatalf("create test db: %v", err)
}
admin.Close(ctx)
testURL := swapDatabase(baseURL, dbName)
pool, err := New(ctx, testURL)
if err != nil {
t.Fatalf("connect test db: %v", err)
}
t.Cleanup(func() {
pool.Close()
admin, err := pgx.Connect(ctx, baseURL)
if err == nil {
admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)")
admin.Close(ctx)
}
})
if err := pool.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
return pool
}
// swapDatabase replaces the database name in a postgres URL.
func swapDatabase(url, db string) string {
// postgres://user:pass@host:port/dbname?params
qi := strings.Index(url, "?")
params := ""
base := url
if qi >= 0 {
base, params = url[:qi], url[qi:]
}
si := strings.LastIndex(base, "/")
return base[:si+1] + db + params
}
func seedAll(t *testing.T, pool *Pool, dir string) {
t.Helper()
for _, f := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} {
content, err := os.ReadFile(dir + "/" + f)
if err != nil {
t.Fatalf("read %s: %v", f, err)
}
ingestSeedContent(t, pool, f, content)
}
}
func ingestSeedContent(t *testing.T, pool *Pool, name string, content []byte) {
t.Helper()
ctx := context.Background()
err := pool.SeedIngest(ctx, name, content,
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
var err error
switch name {
case "ontology.yaml":
_, err = IngestOntologySeed(ctx, tx, data)
case "inventory.yaml":
_, err = IngestInventorySeed(ctx, tx, data)
case "policy.yaml":
_, err = IngestPolicySeed(ctx, tx, data)
}
return err
})
if err != nil {
t.Fatalf("ingest %s: %v", name, err)
}
}
func count(t *testing.T, pool *Pool, query string) int {
t.Helper()
var n int
if err := pool.QueryRow(context.Background(), query).Scan(&n); err != nil {
t.Fatalf("count %q: %v", query, err)
}
return n
}
func TestMigrateIdempotent(t *testing.T) {
pool := newTestPool(t)
// second run must be a clean no-op
if err := pool.Migrate(context.Background()); err != nil {
t.Fatalf("second migrate: %v", err)
}
}
func TestSeedIngestIdempotentAndNoDuplicateEdges(t *testing.T) {
pool := newTestPool(t)
seedAll(t, pool, seedsDir())
entities := count(t, pool, "SELECT count(*) FROM entities")
edges := count(t, pool, "SELECT count(*) FROM relationships WHERE valid_to IS NULL")
if entities == 0 || edges == 0 {
t.Fatalf("seed produced empty graph: %d entities, %d edges", entities, edges)
}
// Same content → hash no-op
seedAll(t, pool, seedsDir())
if got := count(t, pool, "SELECT count(*) FROM relationships WHERE valid_to IS NULL"); got != edges {
t.Errorf("unchanged re-seed altered edges: %d → %d", edges, got)
}
// Changed content (hash differs) → full re-ingest must NOT duplicate edges
// (regression: the old upsert conflicted on valid_from and duplicated all
// 144 edges on every re-ingest)
content, err := os.ReadFile(seedsDir() + "/inventory.yaml")
if err != nil {
t.Fatal(err)
}
touched := append(content, []byte("\n# touched for hash change\n")...)
ingestSeedContent(t, pool, "inventory.yaml", touched)
if got := count(t, pool, "SELECT count(*) FROM relationships WHERE valid_to IS NULL"); got != edges {
t.Errorf("touched re-seed duplicated edges: %d → %d", edges, got)
}
if dup := count(t, pool, `SELECT count(*) FROM (
SELECT source_id, target_id, type FROM relationships
WHERE valid_to IS NULL GROUP BY 1,2,3 HAVING count(*) > 1) d`); dup != 0 {
t.Errorf("%d duplicated current edges", dup)
}
if got := count(t, pool, "SELECT count(*) FROM entities"); got != entities {
t.Errorf("touched re-seed altered entity count: %d → %d", entities, got)
}
}
// Regression: insertOneEntityType read tMap["attribute_schema"], but
// seeds/ontology.yaml spells the key `attributes:`. The mismatch marshalled a
// nil into the JSON literal `null` for every one of the 60 types, so no
// attribute schema was ever ingested — the API and `oikos export` returned
// null across the board, silently, for the life of the project.
func TestSeedIngestsAttributeSchemas(t *testing.T) {
pool := newTestPool(t)
seedAll(t, pool, seedsDir())
if n := count(t, pool,
`SELECT count(*) FROM entity_types WHERE attribute_schema = 'null'::jsonb`); n != 0 {
t.Errorf("%d entity types stored the JSON literal null instead of a schema or SQL NULL", n)
}
if n := count(t, pool,
`SELECT count(*) FROM entity_types WHERE jsonb_typeof(attribute_schema) = 'object'`); n == 0 {
t.Fatal("no entity type ingested an attribute schema")
}
// A type declaring `attributes:` must round-trip its properties.
if n := count(t, pool, `SELECT count(*) FROM entity_types
WHERE name = 'lxc' AND attribute_schema #>> '{properties,pve_id,type}' = 'integer'`); n != 1 {
t.Error("lxc.attribute_schema lost its declared pve_id property")
}
// A type declaring none stores SQL NULL, not a JSON null.
if n := count(t, pool, `SELECT count(*) FROM entity_types
WHERE name = 'sensor' AND attribute_schema IS NULL`); n != 1 {
t.Error("a type declaring no attributes should store SQL NULL")
}
}
// monitoring_spec drives which entities coverageSweep may flag as unmonitored,
// so the three states have to survive ingest distinctly: SQL NULL (undeclared,
// resolved from an ancestor or the layer default), '[]' (explicitly
// unmonitorable), and a non-empty array (the kinds the type warrants).
func TestSeedIngestsMonitoringSpec(t *testing.T) {
pool := newTestPool(t)
seedAll(t, pool, seedsDir())
cases := []struct {
typ, where, desc string
}{
{"service", `monitoring_spec = '["http","process"]'::jsonb`, "declared kinds"},
{"machine", `monitoring_spec = '["ping","resource","updates"]'::jsonb`, "declared on an abstract type"},
{"site", `monitoring_spec = '[]'::jsonb`, "explicitly unmonitorable"},
{"lxc", `monitoring_spec IS NULL`, "inherits from container, so its own column is NULL"},
}
for _, c := range cases {
if n := count(t, pool, fmt.Sprintf(
`SELECT count(*) FROM entity_types WHERE name = '%s' AND %s`, c.typ, c.where)); n != 1 {
t.Errorf("%s (%s): monitoring_spec did not match %s", c.typ, c.desc, c.where)
}
}
}
func TestAbstractTypeRejected(t *testing.T) {
pool := newTestPool(t)
seedAll(t, pool, seedsDir())
bad := []byte(`
version: 1
entities:
- {slug: "machine:ghost", type: machine, name: ghost}
`)
ctx := context.Background()
err := pool.SeedIngest(ctx, "inventory.yaml", bad,
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
_, err := IngestInventorySeed(ctx, tx, data)
return err
})
if !errors.Is(err, domain.ErrAbstractType) {
t.Errorf("abstract instantiation = %v, want ErrAbstractType", err)
}
}
func TestEdgeEndpointValidation(t *testing.T) {
pool := newTestPool(t)
seedAll(t, pool, seedsDir())
// routes-to requires source ingress-route; a service source must fail
bad := []byte(`
version: 1
relationships:
- {source: "service:gitea", target: "service:caddy", type: routes-to}
`)
ctx := context.Background()
err := pool.SeedIngest(ctx, "inventory.yaml", bad,
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
_, err := IngestInventorySeed(ctx, tx, data)
return err
})
if !errors.Is(err, domain.ErrInvalidEdge) {
t.Errorf("bad edge = %v, want ErrInvalidEdge", err)
}
// hosts from a proxmox-host (is-a machine) to an lxc (is-a compute-entity)
// must PASS via hierarchy walk — already covered by the seed itself, but
// assert an explicit one for clarity
good := []byte(`
version: 1
relationships:
- {source: "host:strong", target: "lxc:jellyfin", type: hosts}
`)
err = pool.SeedIngest(ctx, "inventory.yaml", good,
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
_, err := IngestInventorySeed(ctx, tx, data)
return err
})
if err != nil {
t.Errorf("valid inherited edge rejected: %v", err)
}
}
func TestCardinalityEnforced(t *testing.T) {
pool := newTestPool(t)
seedAll(t, pool, seedsDir())
// routes-to is many-to-one: one ingress route cannot point at two services
bad := []byte(`
version: 1
relationships:
- {source: "ingress:git.hubris.network", target: "service:jellyfin", type: routes-to}
`)
ctx := context.Background()
err := pool.SeedIngest(ctx, "inventory.yaml", bad,
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
_, err := IngestInventorySeed(ctx, tx, data)
return err
})
if err == nil || !strings.Contains(err.Error(), "cardinality") {
t.Errorf("cardinality violation = %v, want cardinality error", err)
}
}
func TestBlastRadiusTerminatesOnCycles(t *testing.T) {
pool := newTestPool(t)
seedAll(t, pool, seedsDir())
// Build a dependency cycle: gitea → caddy → authentik → gitea.
//
// `depends-on` is declared blast_direction: backward — "A depends-on B"
// means B failing breaks A — so the blast radius of gitea walks the edges
// BACKWARDS: whoever depends on gitea is affected first. That is authentik
// (1 hop), then caddy which depends on authentik (2 hops).
//
// This test previously asserted caddy=1, authentik=2, which is the same
// cycle walked the wrong way round: blast_radius used to follow every edge
// source→target regardless of what the edge means, so it answered "what
// does gitea depend on" while being named for the opposite question.
cycle := []byte(`
version: 1
relationships:
- {source: "service:gitea", target: "service:caddy", type: depends-on}
- {source: "service:caddy", target: "service:authentik", type: depends-on}
- {source: "service:authentik", target: "service:gitea", type: depends-on}
`)
ctx := context.Background()
err := pool.SeedIngest(ctx, "inventory.yaml", cycle,
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
_, err := IngestInventorySeed(ctx, tx, data)
return err
})
if err != nil {
t.Fatalf("cycle ingest: %v", err)
}
rows, err := pool.Query(ctx, `
SELECT e.slug, b.depth
FROM blast_radius((SELECT id FROM entities WHERE slug='service:gitea'), 5,
ARRAY['depends-on']) b
JOIN entities e ON e.id = b.entity_id ORDER BY b.depth`)
if err != nil {
t.Fatalf("blast_radius: %v", err)
}
defer rows.Close()
got := map[string]int{}
for rows.Next() {
var slug string
var depth int
if err := rows.Scan(&slug, &depth); err != nil {
t.Fatal(err)
}
got[slug] = depth
}
want := map[string]int{"service:gitea": 0, "service:authentik": 1, "service:caddy": 2}
for slug, depth := range want {
if got[slug] != depth {
t.Errorf("blast_radius[%s] = %d, want %d (full: %v)", slug, got[slug], depth, got)
}
}
// Deliberately not an exact node count. Walking the right way round also
// surfaces the real seed's own dependents of gitea (homelab-mcp and what
// depends on it), which are correct answers — the old exact-count
// assertion only held because the forward walk found nothing real.
// What matters here is that the cycle terminates rather than recursing.
if len(got) > 20 {
t.Errorf("blast_radius did not terminate sensibly: %d nodes: %v", len(got), got)
}
for slug, depth := range got {
if depth > 5 {
t.Errorf("blast_radius[%s] = %d, beyond the max_depth bound", slug, depth)
}
}
}
// TestExportRoundTripStable: export → ingest into a fresh DB → export again
// must yield byte-identical YAML (the canonical-form fixpoint, plan D6).
func TestExportRoundTripStable(t *testing.T) {
pool := newTestPool(t)
seedAll(t, pool, seedsDir())
ctx := context.Background()
export1, err := ExportToYAML(ctx, pool)
if err != nil {
t.Fatalf("export 1: %v", err)
}
for name, content := range export1 {
var doc map[string]any
if err := yaml.Unmarshal(content, &doc); err != nil {
t.Fatalf("export %s is not valid YAML: %v", name, err)
}
}
pool2 := newTestPool(t)
for _, name := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} {
ingestSeedContent(t, pool2, name, export1[name])
}
export2, err := ExportToYAML(ctx, pool2)
if err != nil {
t.Fatalf("export 2: %v", err)
}
for _, name := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} {
if !bytes.Equal(export1[name], export2[name]) {
t.Errorf("%s round-trip not byte-stable (len %d vs %d)",
name, len(export1[name]), len(export2[name]))
}
}
// sanity: exported inventory carries the real graph, not a stub
// (regression: export used to write 11-byte "version: 1" stubs)
if len(export1["inventory.yaml"]) < 1000 {
t.Errorf("inventory export suspiciously small: %d bytes", len(export1["inventory.yaml"]))
}
}

View File

@@ -0,0 +1,182 @@
package db
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ErrTransitionInvalid is a sentinel returned by ValidateTransition when the
// from→to pair is not a declared lifecycle transition or a precondition fails.
// Callers test with errors.Is to distinguish semantic validation failures
// (→ HTTP 409) from infrastructure errors (→ HTTP 500).
var ErrTransitionInvalid = errors.New("invalid lifecycle transition")
// ValidateTransition enforces an entity type's lifecycle: fromState → toState
// must be a declared transition, and every precondition it lists must hold. A
// type with no lifecycle defined allows any state. A no-op (fromState ==
// toState) passes immediately.
//
// Shared by the HTTP PATCH path and the MCP set_entity_state tool so both
// surfaces apply identical lifecycle rules — previously only the HTTP path
// validated transitions, so an agent changing state via MCP could skip the
// graph's retire/deprecate guardrails entirely.
func ValidateTransition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, fromState, toState string) error {
if toState == fromState {
return nil
}
lc, err := sqlcgen.New(tx).GetLifecycleForType(ctx, entityType)
if err != nil {
if err == pgx.ErrNoRows {
return nil // no lifecycle defined → any state allowed
}
return err
}
var transitions map[string]map[string]json.RawMessage
if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
return fmt.Errorf("parse lifecycle transitions: %w", err)
}
tos, ok := transitions[fromState]
if !ok {
return fmt.Errorf("%w: no transitions defined from %q", ErrTransitionInvalid, fromState)
}
trans, ok := tos[toState]
if !ok {
return fmt.Errorf("%w: %s → %s is not a declared lifecycle transition", ErrTransitionInvalid, fromState, toState)
}
var gate struct {
Requires []string `json:"requires"`
}
if err := json.Unmarshal(trans, &gate); err == nil {
for _, check := range gate.Requires {
if err := checkPrecondition(ctx, tx, entityID, entityType, check); err != nil {
return fmt.Errorf("%w: precondition %q not met: %w", ErrTransitionInvalid, check, err)
}
}
}
return nil
}
// checkPrecondition evaluates one mechanical precondition named by a lifecycle
// transition's `requires` list. Soft/operator-confirmed checks pass; unknown
// checks are skipped (operator intent overrides). Moved here from httpapi so
// both surfaces share one implementation.
func checkPrecondition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, check string) error {
switch check {
case "no-inbound-edges":
var count int
if err := tx.QueryRow(ctx,
"SELECT count(*) FROM relationships WHERE target_id = $1 AND valid_to IS NULL", entityID).Scan(&count); err != nil {
return err
}
if count > 0 {
return fmt.Errorf("%d inbound relationship edges remaining", count)
}
case "backups-verified", "secrets-revoked", "ingress-dns-removed":
attrs, err := fetchAttrs(ctx, tx, entityID)
if err != nil {
return err
}
want := map[string]string{
"backups-verified": "backups_verified",
"secrets-revoked": "secrets_revoked",
"ingress-dns-removed": "ingress_dns_removed",
}[check]
if !attrTruthy(attrs, want) {
return fmt.Errorf("%s not recorded in entity attributes", want)
}
case "age-key-enrolled-if-needed":
if entityType == "workstation" {
attrs, err := fetchAttrs(ctx, tx, entityID)
if err != nil {
return err
}
if !attrTruthy(attrs, "age_pubkey") {
return fmt.Errorf("age key not enrolled (no age_pubkey in attributes)")
}
}
case "mesh-joined-if-needed":
if entityType == "workstation" {
attrs, err := fetchAttrs(ctx, tx, entityID)
if err != nil {
return err
}
if !attrTruthy(attrs, "mesh_ip") {
return fmt.Errorf("mesh not joined (no mesh_ip in attributes)")
}
}
case "health-check-answering":
st, err := sqlcgen.New(tx).GetEntityStatus(ctx, entityID)
if err != nil || st.Health == "unknown" || st.Health == "down" {
h := "unknown"
if err == nil {
h = st.Health
}
return fmt.Errorf("health check not answering (status: %s)", h)
}
case "doc-page-complete":
var count int
if err := tx.QueryRow(ctx, `
SELECT count(*) FROM relationships r
JOIN entities ke ON ke.id = r.source_id
WHERE r.target_id = $1 AND r.valid_to IS NULL
AND r.type = 'documents' AND ke.type IN ('document','runbook','investigation')`,
entityID).Scan(&count); err != nil {
return err
}
if count == 0 {
return fmt.Errorf("no documentation linked to entity")
}
case "inventory-entry", "ip-reserved", "storage-pool-chosen", "cancelled-note",
"preflight-passed", "error-summary", "replacement-live-or-role-retired",
"replacement-failed", "post-verify-passed", "recovery-verified", "written-off",
"ingress-live-if-public", "doc-page-stub", "un-deprecate-note", "write-off-note":
// Soft checks — always pass. Operator-confirmed via the transition
// request itself, or not mechanically enforceable.
default:
// Unknown preconditions are skipped (operator intent overrides).
}
return nil
}
// fetchAttrs loads an entity's JSONB attributes column as a decoded map.
// Missing attributes decode to an empty map (every key absent).
func fetchAttrs(ctx context.Context, tx pgx.Tx, entityID uuid.UUID) (map[string]any, error) {
var raw string
if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&raw); err != nil {
return nil, err
}
var attrs map[string]any
if err := json.Unmarshal([]byte(raw), &attrs); err != nil {
return nil, fmt.Errorf("decode entity attributes: %w", err)
}
if attrs == nil {
attrs = map[string]any{}
}
return attrs, nil
}
// attrTruthy reports whether key is present in attrs with a meaningful value.
// It replaces substring matching on raw JSONB text: a previous strings.Contains
// check treated {"backups_verified": false} as satisfied (the key text was
// present) and bypassed the attributes GIN index. Booleans must be true;
// strings must be non-empty; nil/absent fail.
func attrTruthy(attrs map[string]any, key string) bool {
v, ok := attrs[key]
if !ok || v == nil {
return false
}
switch t := v.(type) {
case bool:
return t
case string:
return t != ""
default:
return true // numbers, objects, arrays count as present
}
}

View File

@@ -0,0 +1,55 @@
package db
import (
"encoding/json"
"testing"
)
// attrTruthy replaces a previous strings.Contains check over raw JSONB text.
// The key regression it guards: a literal attribute like
// {"backups_verified": false} must NOT satisfy the "backups-verified"
// precondition, even though the key text is present in the column.
func TestAttrTruthy(t *testing.T) {
cases := []struct {
name string
attrs map[string]any
key string
want bool
}{
{"absent", map[string]any{}, "backups_verified", false},
{"nil map", nil, "backups_verified", false},
{"explicit nil value", map[string]any{"backups_verified": nil}, "backups_verified", false},
{"bool true", map[string]any{"backups_verified": true}, "backups_verified", true},
{"bool false is the regression case", map[string]any{"backups_verified": false}, "backups_verified", false},
{"nonempty string age pubkey", map[string]any{"age_pubkey": "age1abc"}, "age_pubkey", true},
{"empty string is falsy", map[string]any{"mesh_ip": ""}, "mesh_ip", false},
{"number counts as present", map[string]any{"port": float64(22)}, "port", true},
{"other keys present", map[string]any{"backups_verified": true, "unrelated": "x"}, "backups_verified", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := attrTruthy(tc.attrs, tc.key); got != tc.want {
t.Fatalf("attrTruthy(%v, %q) = %v, want %v", tc.attrs, tc.key, got, tc.want)
}
})
}
}
// fetchAttrs decodes the JSONB column text; verify the decode shape that
// attrTruthy then evaluates (the DB round-trip itself is covered by make test-db).
func TestAttrTruthyAfterDecode(t *testing.T) {
raw := `{"backups_verified": true, "mesh_ip": "10.0.0.5", "secrets_revoked": false}`
var got map[string]any
if err := json.Unmarshal([]byte(raw), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !attrTruthy(got, "backups_verified") {
t.Error("backups_verified should be truthy after decode")
}
if !attrTruthy(got, "mesh_ip") {
t.Error("mesh_ip should be truthy after decode")
}
if attrTruthy(got, "secrets_revoked") {
t.Error("secrets_revoked:false is the regression — must be falsy")
}
}

View File

@@ -0,0 +1,288 @@
package db
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io/fs"
"log/slog"
"sort"
"strings"
"github.com/dtoro/oikos/migrations"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gopkg.in/yaml.v3"
)
// Pool wraps a pgx connection pool.
type Pool struct {
*pgxpool.Pool
}
// New creates a new connection pool.
func New(ctx context.Context, databaseURL string) (*Pool, error) {
cfg, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse database url: %w", err)
}
cfg.MaxConns = 15
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("ping db: %w", err)
}
return &Pool{pool}, nil
}
// migrationLockKey is the advisory-lock key serializing migration runs —
// two concurrent `oikos migrate` invocations must not interleave DDL.
const migrationLockKey = 0x01c05e5
// Migrate runs all embedded forward migrations in order.
// Uses a schema_migrations table to track applied versions. The whole run
// happens on one connection holding a session advisory lock.
func (p *Pool) Migrate(ctx context.Context) error {
conn, err := p.Acquire(ctx)
if err != nil {
return fmt.Errorf("acquire migration conn: %w", err)
}
defer conn.Release()
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil {
return fmt.Errorf("acquire migration lock: %w", err)
}
defer func() {
if _, err := conn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", migrationLockKey); err != nil {
slog.Warn("postgres: release migration lock failed", "error", err)
}
}()
// Create tracking table if not exists
_, err = conn.Exec(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version INT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`)
if err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
// List migration files
entries, err := fs.ReadDir(migrations.FS, ".")
if err != nil {
return fmt.Errorf("read migration fs: %w", err)
}
var files []string
for _, e := range entries {
if !e.IsDir() && hasSuffix(e.Name(), ".up.sql") {
files = append(files, e.Name())
}
}
sort.Strings(files)
for _, fname := range files {
// Extract version number (001, 002, etc.)
var version int
if _, err := fmt.Sscanf(fname, "%03d", &version); err != nil {
continue
}
// Check if already applied
var applied int
err := conn.QueryRow(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = $1", version).Scan(&applied)
if err != nil {
return fmt.Errorf("check migration %d: %w", version, err)
}
if applied > 0 {
continue
}
// Read and execute migration — split into individual statements
// because TimescaleDB CAGGs and some DDL can't run inside a transaction,
// and pgx's multi-statement Exec wraps them implicitly.
content, err := migrations.FS.ReadFile(fname)
if err != nil {
return fmt.Errorf("read %s: %w", fname, err)
}
stmts := splitSQL(string(content))
for i, stmt := range stmts {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
_, err := conn.Exec(ctx, stmt)
if err != nil {
return fmt.Errorf("exec migration %s stmt %d: %w", fname, i+1, err)
}
}
_, err = conn.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", version)
if err != nil {
return fmt.Errorf("record migration %d: %w", version, err)
}
slog.Info("migration applied", "file", fname, "version", version, "statements", len(stmts))
}
return nil
}
// SeedIngest ingests a YAML seed file into the database.
// Idempotent: if the file's content hash matches seed_versions, it's a no-op (A4).
func (p *Pool) SeedIngest(ctx context.Context, filename string, content []byte,
ingestFn func(ctx context.Context, tx pgx.Tx, data map[string]any) error) error {
hash := contentHash(content)
// Check if already applied with same hash
var existing string
err := p.QueryRow(ctx,
"SELECT content_hash FROM seed_versions WHERE file = $1", filename).Scan(&existing)
if err == nil && existing == hash {
return nil // no-op, same content
}
// Parse YAML
var data map[string]any
if err := yaml.Unmarshal(content, &data); err != nil {
return fmt.Errorf("parse %s: %w", filename, err)
}
// Apply in a single transaction
tx, err := p.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() {
if err := tx.Rollback(ctx); err != nil {
slog.Debug("postgres: rollback after failed ingest", "error", err)
}
}()
if err := ingestFn(ctx, tx, data); err != nil {
return fmt.Errorf("ingest %s: %w", filename, err)
}
// Record the seed version
_, err = tx.Exec(ctx,
`INSERT INTO seed_versions (file, content_hash) VALUES ($1, $2)
ON CONFLICT (file) DO UPDATE SET content_hash = $2, applied_at = now()`,
filename, hash)
if err != nil {
return fmt.Errorf("record seed version: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit seed: %w", err)
}
return nil
}
// contentHash returns a SHA-256 hex digest of the content.
func contentHash(content []byte) string {
h := sha256.Sum256(content)
return hex.EncodeToString(h[:])
}
// hasSuffix reports whether the string ends with the given suffix.
func hasSuffix(s, suffix string) bool {
return strings.HasSuffix(s, suffix)
}
// splitSQL splits a SQL string into individual statements.
// Handles $$ ... $$ dollar-quoted blocks, $tag$ ... $tag$ tagged quotes,
// -- line comments, /* ... */ block comments, and '...' string literals
// so that semicolons inside any of these constructs are not treated as
// statement boundaries.
func splitSQL(sql string) []string {
var statements []string
var current strings.Builder
inDollarQuote := false
dollarTag := ""
i := 0
for i < len(sql) {
// Handle line comments (-- to end of line)
if !inDollarQuote && i+1 < len(sql) && sql[i] == '-' && sql[i+1] == '-' {
for i < len(sql) && sql[i] != '\n' {
current.WriteByte(sql[i])
i++
}
continue
}
// Handle block comments (/* ... */)
if !inDollarQuote && i+1 < len(sql) && sql[i] == '/' && sql[i+1] == '*' {
end := strings.Index(sql[i+2:], "*/")
if end >= 0 {
current.WriteString(sql[i : i+end+4])
i += end + 4
continue
}
}
// Handle single-quoted string literals ('...')
if !inDollarQuote && sql[i] == '\'' {
j := i + 1
for j < len(sql) {
if sql[j] == '\'' {
if j+1 < len(sql) && sql[j+1] == '\'' {
j += 2 // skip doubled quote ''
continue
}
break
}
j++
}
current.WriteString(sql[i : j+1])
i = j + 1
continue
}
// Check for dollar-quote start/end
if !inDollarQuote && sql[i] == '$' {
j := i + 1
for j < len(sql) && (sql[j] == '_' || (sql[j] >= 'a' && sql[j] <= 'z') || (sql[j] >= 'A' && sql[j] <= 'Z') || (sql[j] >= '0' && sql[j] <= '9')) {
j++
}
if j < len(sql) && sql[j] == '$' {
dollarTag = sql[i : j+1]
current.WriteString(dollarTag)
inDollarQuote = true
i = j + 1
continue
}
}
if inDollarQuote && strings.HasPrefix(sql[i:], dollarTag) {
current.WriteString(dollarTag)
i += len(dollarTag)
inDollarQuote = false
dollarTag = ""
continue
}
if !inDollarQuote && sql[i] == ';' {
statements = append(statements, current.String())
current.Reset()
i++
continue
}
current.WriteByte(sql[i])
i++
}
if strings.TrimSpace(current.String()) != "" {
statements = append(statements, current.String())
}
return statements
}

View File

@@ -0,0 +1,79 @@
-- Entity read + mutation queries (API paths). Aliased `e` throughout to
-- avoid ambiguity with joined tables.
-- name: GetEntityByID :one
SELECT e.* FROM entities e WHERE e.id = $1;
-- name: GetEntityBySlug :one
SELECT e.* FROM entities e WHERE e.slug = $1;
-- name: ListEntities :many
WITH RECURSIVE tt AS (
SELECT name FROM entity_types WHERE sqlc.narg('type')::text IS NULL OR name = sqlc.narg('type')
UNION
SELECT et.name FROM entity_types et JOIN tt ON et.parent_type = tt.name
WHERE sqlc.narg('type')::text IS NOT NULL
)
SELECT e.* FROM entities e
JOIN entity_types et ON et.name = e.type
WHERE e.type IN (SELECT name FROM tt)
AND (sqlc.narg('state')::text IS NULL OR e.state = sqlc.narg('state'))
AND (sqlc.narg('domain')::text IS NULL OR et.domain = sqlc.narg('domain'))
AND (sqlc.narg('layer')::text IS NULL OR et.layer = sqlc.narg('layer'))
AND (sqlc.narg('q')::text IS NULL
OR e.slug ILIKE '%'||sqlc.narg('q')||'%'
OR e.name ILIKE '%'||sqlc.narg('q')||'%')
AND (sqlc.narg('cursor')::text IS NULL OR e.slug > sqlc.narg('cursor'))
ORDER BY e.slug
LIMIT sqlc.arg('lim');
-- name: InsertEntity :one
INSERT INTO entities (id, slug, type, name, state, attributes)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: UpdateEntity :one
UPDATE entities SET
name = COALESCE(sqlc.narg('name'), name),
state = COALESCE(sqlc.narg('state'), state),
attributes = COALESCE(sqlc.narg('attributes'), attributes),
maintenance_until = CASE WHEN sqlc.arg('set_maintenance')::bool
THEN sqlc.narg('maintenance_until') ELSE maintenance_until END,
version = version + 1,
updated_at = now()
WHERE id = sqlc.arg('id') AND version = sqlc.arg('version')
RETURNING *;
-- name: MergeEntityAttributes :execrows
-- Shallow-merge a JSON patch into an entity's attributes (the
-- update_entity_attributes MCP/HTTP surface). Replaces the raw
-- `attributes = attributes || $2::jsonb` used in entity_tools.go.
UPDATE entities SET
attributes = attributes || sqlc.arg('patch')::jsonb,
updated_at = now()
WHERE slug = sqlc.arg('slug');
-- name: SetEntityState :execrows
-- Set an entity's lifecycle state by id (the set_entity_state surface, run
-- after db.ValidateTransition). Replaces the raw
-- `UPDATE entities SET state = $2 ... WHERE id = $1`.
UPDATE entities SET
state = sqlc.arg('state'),
updated_at = now()
WHERE id = sqlc.arg('id');
-- blast_radius(): the recursive-CTE traversal function's TABLE return type
-- is opaque to sqlc's analyzer — that one query stays hand-written pgx in
-- internal/httpapi (see entities.go GetBlastRadius).
--
-- Deliberate raw-SQL exceptions (plan E2): the httpapi entity *read* handlers
-- (ListEntities/GetEntity/GetGraph/queryEntities) project a fixed
-- `entityCols` column set (entities.* + a LEFT JOIN to entity_status for
-- health/last_check_at) and scan it positionally into the oapi-generated
-- gen.Entity shape. sqlc generates its own row struct per query and cannot
-- emit gen.Entity, so migrating those reads would add a per-call field-by-
-- field mapping with no compile-time gain and real column-order risk. They
-- stay hand-written pgx, like blast_radius and the seed/export bulk paths
-- noted in sqlc.yaml. The mutation/relationship surface (MergeEntityAttributes,
-- SetEntityState, InsertRelationshipIfAbsent, EndCurrentRelationship) IS
-- migrated and is what the entity CRUD tools now call.

View File

@@ -0,0 +1,13 @@
-- name: ListEntityTypes :many
SELECT * FROM entity_types ORDER BY name;
-- name: ListRelationshipTypes :many
SELECT * FROM relationship_types ORDER BY name;
-- name: ListLifecycleDefs :many
SELECT * FROM lifecycle_defs ORDER BY id;
-- name: GetLifecycleForType :one
SELECT ld.* FROM lifecycle_defs ld
JOIN entity_types et ON et.lifecycle_id = ld.id
WHERE et.name = $1;

View File

@@ -0,0 +1,269 @@
-- name: ListSignals :many
SELECT sig.entity_id, se.slug, sig.kind, sig.severity, sig.state,
te.slug AS target_slug, sig.check_id, sig.evidence, sig.likely_cause,
sig.occurrence_count, sig.flap_count, sig.hold_down_until,
sig.mute_until, sig.first_seen_at, sig.last_seen_at
FROM signals sig
JOIN entities se ON se.id = sig.entity_id
LEFT JOIN entities te ON te.id = sig.target_entity_id
WHERE (sqlc.narg('state')::text IS NULL OR sig.state = sqlc.narg('state'))
AND (sqlc.narg('severity')::text IS NULL OR sig.severity = sqlc.narg('severity'))
AND (sqlc.narg('target')::text IS NULL OR te.slug = sqlc.narg('target'))
AND (sqlc.narg('kind')::text IS NULL OR sig.kind = sqlc.narg('kind'))
AND (sqlc.narg('cursor')::text IS NULL OR se.slug > sqlc.narg('cursor'))
ORDER BY se.slug
LIMIT sqlc.arg('lim');
-- name: GetIdempotentResponse :one
SELECT response_code, response_body, request_hash FROM idempotency_keys
WHERE actor = $1 AND key = $2;
-- name: PutIdempotentResponse :exec
INSERT INTO idempotency_keys (actor, key, request_hash, response_code, response_body)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (actor, key) DO NOTHING;
-- name: InsertAuditEntry :exec
INSERT INTO audit_log (actor_type, actor_id, action, entity_id, method, path,
status_code, detail, source_ip, correlation_id, session_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);
-- name: InsertEvent :one
INSERT INTO events (type, entity_id, severity, source, data, correlation_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, ts;
-- name: ListEvents :many
SELECT id, ts, type, entity_id, severity, source, data, correlation_id
FROM events
WHERE (sqlc.narg('type')::text IS NULL OR type = sqlc.narg('type'))
AND (sqlc.narg('entity_id')::uuid IS NULL OR entity_id = sqlc.narg('entity_id'))
AND (sqlc.narg('severity')::text IS NULL OR severity = sqlc.narg('severity'))
AND (sqlc.narg('correlation_id')::text IS NULL OR correlation_id = sqlc.narg('correlation_id'))
AND (sqlc.narg('from_ts')::timestamptz IS NULL OR ts >= sqlc.narg('from_ts'))
AND (sqlc.narg('to_ts')::timestamptz IS NULL OR ts <= sqlc.narg('to_ts'))
AND (sqlc.narg('before_id')::bigint IS NULL OR id < sqlc.narg('before_id'))
ORDER BY id DESC
LIMIT sqlc.arg('lim');
-- name: ListEventsAfter :many
SELECT id, ts, type, entity_id, severity, source, data, correlation_id
FROM events WHERE id > $1 ORDER BY id ASC LIMIT $2;
-- =====================================================================
-- Phase 3 queries
-- =====================================================================
-- name: ListEnabledCheckDefs :many
-- Enabled AND due. interval_s used to be selected but never filtered on, so
-- every check ran on every 30s pass and the declared intervals meant nothing.
-- NULL last_run_at = never run = due now.
SELECT cd.entity_id, cd.target_id, cd.target_type, cd.kind, cd.config,
cd.interval_s, cd.timeout_s, cd.zone, cd.enabled, cd.updated_at,
e.slug AS entity_slug
FROM check_defs cd
JOIN entities e ON e.id = cd.entity_id
LEFT JOIN entities tgt ON tgt.id = cd.target_id
WHERE cd.enabled = true
AND (tgt.id IS NULL OR tgt.state IS NULL OR tgt.state NOT IN ('deprecated', 'destroyed'))
AND (cd.last_run_at IS NULL
OR cd.last_run_at <= now() - make_interval(secs => cd.interval_s));
-- name: MarkCheckRun :exec
UPDATE check_defs SET last_run_at = now(), last_health = $2 WHERE entity_id = $1;
-- name: WorstHealthForTarget :one
-- An entity is as healthy as its unhealthiest check. Checks that have not run
-- yet (last_health IS NULL) are ignored rather than counted as unknown, so a
-- newly added check does not drag a known-good entity down before it has
-- produced a verdict.
SELECT COALESCE(
(SELECT last_health FROM check_defs
WHERE enabled AND target_id = $1 AND last_health IS NOT NULL
ORDER BY CASE last_health
WHEN 'down' THEN 0 WHEN 'degraded' THEN 1 WHEN 'stale' THEN 2
WHEN 'unknown' THEN 3 ELSE 4 END
LIMIT 1),
'unknown')::text AS health;
-- name: GetCheckDef :one
SELECT * FROM check_defs WHERE entity_id = $1;
-- name: InsertCheckDef :exec
INSERT INTO check_defs (entity_id, target_id, target_type, kind, config, interval_s, timeout_s, zone, enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);
-- name: UpdateCheckDef :exec
UPDATE check_defs SET kind = $2, config = $3, interval_s = $4, timeout_s = $5,
target_id = $6, target_type = $7, zone = $8, enabled = $9, updated_at = now()
WHERE entity_id = $1;
-- name: UpsertSignal :one
INSERT INTO signals (entity_id, kind, severity, target_entity_id, check_id, evidence, likely_cause, state)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'raised')
ON CONFLICT (target_entity_id, kind) WHERE state NOT IN ('resolved','failed')
DO UPDATE SET occurrence_count = signals.occurrence_count + 1,
last_seen_at = now(),
evidence = EXCLUDED.evidence,
updated_at = now()
RETURNING *;
-- name: GetOpenSignalsForAutoAct :many
-- Signals with auto-act classifications that haven't been executed yet
SELECT s.*, c.entity_id AS classification_id, c.action, c.risk_class, c.route,
c.blast_radius, c.correlation_id, c.reasoning
FROM classifications c
JOIN signals s ON s.entity_id = c.signal_entity_id
LEFT JOIN executions e ON e.classification_id = c.entity_id
WHERE c.route = 'auto-act'
AND e.entity_id IS NULL
AND (s.hold_down_until IS NULL OR s.hold_down_until < now())
AND (s.mute_until IS NULL OR s.mute_until < now())
ORDER BY s.last_seen_at ASC
LIMIT $1;
-- name: ListClassifications :many
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 AS target_slug
FROM classifications c
JOIN entities e ON e.id = c.target_entity_id
WHERE (sqlc.narg('route')::text IS NULL OR c.route = sqlc.narg('route'))
AND (sqlc.narg('cursor')::text IS NULL OR e.slug > sqlc.narg('cursor'))
ORDER BY e.slug
LIMIT sqlc.arg('lim');
-- name: InsertExecution :exec
INSERT INTO executions (entity_id, classification_id, signal_entity_id,
target_entity_id, action, risk_class, approval_id, agent_id,
skill_id, skill_version, status, correlation_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'proposed', $11);
-- name: UpdateExecutionStatus :exec
UPDATE executions SET status = $2, result = $3, duration_ms = $4,
verified = $5, started_at = COALESCE(started_at, now()),
completed_at = CASE WHEN $2 IN ('completed','failed','cancelled') THEN now() ELSE completed_at END
WHERE entity_id = $1;
-- name: GetExecution :one
SELECT * FROM executions WHERE entity_id = $1;
-- name: ListExecutions :many
SELECT e.entity_id, e.classification_id, e.signal_entity_id, e.target_entity_id,
e.action, e.risk_class, e.approval_id, e.agent_id,
e.skill_id, 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 AS target_slug
FROM executions e
JOIN entities te ON te.id = e.target_entity_id
WHERE (sqlc.narg('status')::text IS NULL OR e.status = sqlc.narg('status'))
AND (sqlc.narg('cursor')::text IS NULL OR te.slug > sqlc.narg('cursor'))
ORDER BY te.slug
LIMIT sqlc.arg('lim');
-- name: GetFeedbackAfterWatermark :many
SELECT f.entity_id, f.execution_id, f.outcome, f.observation, f.lesson,
f.unexpected_side_effects, f.tags, f.created_at,
e.action, e.risk_class, e.target_entity_id,
et.name AS applies_type
FROM feedback f
JOIN executions e ON e.entity_id = f.execution_id
JOIN entities ent ON ent.id = e.target_entity_id
JOIN entity_types et ON et.name = ent.type
WHERE f.created_at > $1
ORDER BY f.created_at ASC;
-- name: UpsertPattern :exec
INSERT INTO patterns (entity_id, applies_type, action, pattern, confidence,
evidence_count, success_count, failure_count, status, version)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'hypothesized', 1)
ON CONFLICT (applies_type, action)
DO UPDATE SET evidence_count = patterns.evidence_count + EXCLUDED.evidence_count,
success_count = patterns.success_count + EXCLUDED.success_count,
failure_count = patterns.failure_count + EXCLUDED.failure_count,
updated_at = now();
-- name: GetPattern :one
SELECT * FROM patterns WHERE applies_type = $1 AND action = $2;
-- name: ListPatterns :many
SELECT p.* FROM patterns p
WHERE (sqlc.narg('status')::text IS NULL OR p.status = sqlc.narg('status'))
ORDER BY p.applies_type, p.action;
-- name: UpdatePatternStatus :exec
UPDATE patterns SET status = $2, version = version + 1,
last_validated_at = CASE WHEN $2 = 'validated' THEN now() ELSE last_validated_at END
WHERE entity_id = $1;
-- name: UpdatePatternQuarantine :exec
UPDATE patterns SET quarantined = $2 WHERE entity_id = $1;
-- name: ListSkills :many
SELECT * FROM skills
WHERE (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
ORDER BY name, version DESC;
-- name: UpdateSkillStatus :exec
UPDATE skills SET status = $2, last_used_at = now() WHERE entity_id = $1 AND version = $2;
-- name: InsertApproval :exec
INSERT INTO approvals (entity_id, subject_entity_id, action, risk_class, kind,
payload, status, token_hash, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7, $8);
-- name: ListApprovals :many
SELECT a.*, e.slug AS subject_slug
FROM approvals a
JOIN entities e ON e.id = a.subject_entity_id
WHERE (sqlc.narg('status')::text IS NULL OR a.status = sqlc.narg('status'))
AND (sqlc.narg('cursor')::text IS NULL OR e.slug > sqlc.narg('cursor'))
ORDER BY e.slug
LIMIT sqlc.arg('lim');
-- name: GetApprovalByID :one
SELECT * FROM approvals WHERE entity_id = $1;
-- name: UpdateApprovalStatus :exec
UPDATE approvals SET status = $2, decided_at = now(), decided_by = $3
WHERE entity_id = $1 AND status = 'pending';
-- name: GetAutonomySetting :one
SELECT value FROM autonomy_settings WHERE key = $1;
-- name: ListRiskClasses :many
SELECT * FROM risk_classes ORDER BY name;
-- name: ListApprovalRules :many
SELECT * FROM approval_rules ORDER BY entity_type, action;
-- name: InsertMetricSample :exec
INSERT INTO metric_samples (entity_id, metric, value, tags, ts)
VALUES ($1, $2, $3, $4, now());
-- name: QueryMetrics :many
SELECT time_bucket(sqlc.arg('bucket_interval')::interval, ts) AS bucket,
entity_id, metric,
ROUND(avg(value)::numeric, 2) AS avg_val,
ROUND(min(value)::numeric, 2) AS min_val,
ROUND(max(value)::numeric, 2) AS max_val
FROM metric_samples
WHERE entity_id = $1
AND metric = $2
AND ts > $3
GROUP BY bucket, entity_id, metric
ORDER BY bucket DESC;
-- name: UpsertEntityStatus :exec
INSERT INTO entity_status (entity_id, health, last_check_at, details)
VALUES ($1, $2, $3, $4)
ON CONFLICT (entity_id)
DO UPDATE SET health = EXCLUDED.health,
last_check_at = EXCLUDED.last_check_at,
details = EXCLUDED.details,
updated_at = now();
-- name: GetEntityStatus :one
SELECT * FROM entity_status WHERE entity_id = $1;

View File

@@ -0,0 +1,42 @@
-- name: ListEntityRelations :many
SELECT se.slug AS source_slug, te.slug AS target_slug, r.type, r.attributes,
r.valid_from, r.valid_to
FROM relationships r
JOIN entities se ON se.id = r.source_id
JOIN entities te ON te.id = r.target_id
WHERE r.valid_to IS NULL
AND ((sqlc.arg('direction')::text IN ('out','both') AND r.source_id = sqlc.arg('id'))
OR (sqlc.arg('direction')::text IN ('in','both') AND r.target_id = sqlc.arg('id')))
AND (sqlc.narg('rel_type')::text IS NULL OR r.type = sqlc.narg('rel_type'))
ORDER BY r.type, se.slug, te.slug;
-- name: ListGraphEdges :many
SELECT se.slug AS source_slug, te.slug AS target_slug, r.type, r.attributes,
r.valid_from, r.valid_to
FROM relationships r
JOIN entities se ON se.id = r.source_id
JOIN entities te ON te.id = r.target_id
WHERE r.valid_to IS NULL
AND r.source_id = ANY(sqlc.arg('ids')::uuid[])
AND r.target_id = ANY(sqlc.arg('ids')::uuid[])
AND (sqlc.narg('rel_types')::text[] IS NULL OR r.type = ANY(sqlc.narg('rel_types')::text[]))
ORDER BY r.type, se.slug, te.slug;
-- name: EndCurrentRelationship :execrows
UPDATE relationships SET valid_to = now()
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL;
-- name: InsertRelationshipIfAbsent :execrows
-- Idempotent relationship insert (the create_relationship surface): no-op if
-- an active edge of the same source/target/type already exists. Replaces the
-- raw INSERT...WHERE NOT EXISTS used in entity_tools.go.
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT sqlc.arg('source_id'), sqlc.arg('target_id'), sqlc.arg('type'),
sqlc.arg('attributes')::jsonb, now()
WHERE NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = sqlc.arg('source_id')
AND target_id = sqlc.arg('target_id')
AND type = sqlc.arg('type')
AND valid_to IS NULL
);

View File

@@ -0,0 +1,486 @@
package db
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/dtoro/oikos/internal/checkdefaults"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// SeedResult holds counts from a seed ingest operation.
type SeedResult struct {
Lifecycles int
EntityTypes int
RelationshipTypes int
Entities int
Relationships int
RiskClasses int
ApprovalRules int
AutonomySettings int
Checks int
}
// IngestOntologySeed ingests seeds/ontology.yaml into the DB.
func IngestOntologySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*SeedResult, error) {
r := &SeedResult{}
// Lifecycles
lifecycles, _ := data["lifecycles"].(map[string]any)
for id, raw := range lifecycles {
lcMap, _ := raw.(map[string]any)
states := toStringSlice(lcMap["states"])
defaultState, _ := lcMap["default_state"].(string)
terminalStates := toStringSlice(lcMap["terminal_states"])
if len(terminalStates) == 0 {
terminalStates = []string{}
}
transitionsBytes, _ := json.Marshal(lcMap["transitions"])
_, err := tx.Exec(ctx,
`INSERT INTO lifecycle_defs (id, states, default_state, terminal_states, transitions)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET states = $2, default_state = $3,
terminal_states = $4, transitions = $5`,
id, states, defaultState, terminalStates, string(transitionsBytes))
if err != nil {
return nil, fmt.Errorf("lifecycle %s: %w", id, err)
}
r.Lifecycles++
}
// Entity types — need to handle parent_type FK, so insert in dependency order
// (types with no parent first, then their children)
types, _ := data["entity_types"].(map[string]any)
if err := insertEntityTypes(ctx, tx, types, r); err != nil {
return nil, err
}
// Relationship types
relTypes, _ := data["relationship_types"].(map[string]any)
for name, raw := range relTypes {
rtMap, _ := raw.(map[string]any)
inverse, _ := rtMap["inverse"].(string)
sourceType, _ := rtMap["source"].(string)
targetType, _ := rtMap["target"].(string)
cardinality, _ := rtMap["cardinality"].(string)
desc, _ := rtMap["description"].(string)
// Which end of the edge depends on the other; drives blast_radius().
// Absent means 'none' — an undeclared edge contributes nothing rather
// than silently producing a wrong dependency answer.
blastDirection, _ := rtMap["blast_direction"].(string)
if blastDirection == "" {
blastDirection = "none"
}
_, err := tx.Exec(ctx,
`INSERT INTO relationship_types (name, inverse, source_type, target_type, cardinality, description, blast_direction)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (name) DO UPDATE SET inverse = $2, source_type = $3,
target_type = $4, cardinality = $5, description = $6,
blast_direction = $7`,
name, nullableStr(inverse), sourceType, targetType, cardinality, desc, blastDirection)
if err != nil {
return nil, fmt.Errorf("relationship_type %s: %w", name, err)
}
r.RelationshipTypes++
}
return r, nil
}
// IngestInventorySeed ingests seeds/inventory.yaml into the DB.
// Every entity and edge is validated against the ontology (abstract types
// rejected, lifecycle states checked, relationship endpoints hierarchy-
// validated, cardinality enforced) — a violating seed rolls back atomically.
func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*SeedResult, error) {
r := &SeedResult{}
tree, err := LoadTypeTree(ctx, tx)
if err != nil {
return nil, fmt.Errorf("load type tree: %w", err)
}
// Entities
entities, _ := data["entities"].([]any)
entityTypes := make(map[string]string) // slug -> type, for edge validation
var pendingChecks []checkdefaults.Target
for _, raw := range entities {
eMap, ok := raw.(map[string]any)
if !ok {
continue
}
slug, _ := eMap["slug"].(string)
typeName, _ := eMap["type"].(string)
name, _ := eMap["name"].(string)
state, _ := eMap["state"].(string)
attrs := eMap["attributes"]
if err := tree.ValidateEntity(typeName, state); err != nil {
return nil, fmt.Errorf("entity %s: %w", slug, err)
}
if state == "" {
state = tree.DefaultState(typeName)
}
entityTypes[slug] = typeName
entityID, err := getOrCreateEntityID(ctx, tx, slug)
if err != nil {
return nil, fmt.Errorf("entity %s: %w", slug, err)
}
attrsBytes, _ := json.Marshal(attrs)
_, err = tx.Exec(ctx,
`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 1, now(), now())
ON CONFLICT (slug) DO UPDATE SET
type = EXCLUDED.type, name = EXCLUDED.name,
attributes = entities.attributes || EXCLUDED.attributes,
updated_at = now()`,
entityID, slug, typeName, name, nullableStr(state), string(attrsBytes))
if err != nil {
return nil, fmt.Errorf("entity %s: %w", slug, err)
}
// Seed initial entity_status row so health queries return
// results even before the scheduler populates check results.
_, err = tx.Exec(ctx,
`INSERT INTO entity_status (entity_id, health, updated_at)
VALUES ($1, 'unknown', now())
ON CONFLICT (entity_id) DO NOTHING`,
entityID)
if err != nil {
return nil, fmt.Errorf("entity_status %s: %w", slug, err)
}
// Default checks are deferred until after relationships are ingested:
// a service has no address of its own and inherits its container's,
// which means the hosting edge has to exist first.
pendingChecks = append(pendingChecks, checkdefaults.Target{
ID: entityID, Slug: slug, Type: typeName, Name: name, Attrs: attrsBytes,
})
r.Entities++
}
// Relationships — upsert against the current-edge partial unique index
// (migration 007) so re-ingest never duplicates edges.
rels, _ := data["relationships"].([]any)
for _, raw := range rels {
relMap, ok := raw.(map[string]any)
if !ok {
continue
}
source, _ := relMap["source"].(string)
target, _ := relMap["target"].(string)
relType, _ := relMap["type"].(string)
attrs := relMap["attributes"]
sourceID, err := getEntityIDBySlug(ctx, tx, source)
if err != nil {
return nil, fmt.Errorf("rel from %s: %w", source, err)
}
targetID, err := getEntityIDBySlug(ctx, tx, target)
if err != nil {
return nil, fmt.Errorf("rel to %s: %w", target, err)
}
srcType := entityTypes[source]
tgtType := entityTypes[target]
if srcType == "" || tgtType == "" { // entity pre-existing in DB, not in this seed
if srcType == "" {
srcType, err = getEntityTypeBySlug(ctx, tx, source)
if err != nil {
return nil, err
}
}
if tgtType == "" {
tgtType, err = getEntityTypeBySlug(ctx, tx, target)
if err != nil {
return nil, err
}
}
}
if err := tree.ValidateEdge(relType, srcType, tgtType); err != nil {
return nil, fmt.Errorf("rel %s→%s: %w", source, target, err)
}
attrsBytes, _ := json.Marshal(attrs)
_, err = tx.Exec(ctx,
`INSERT INTO relationships (source_id, target_id, type, attributes, valid_from, valid_to)
VALUES ($1, $2, $3, $4, now(), NULL)
ON CONFLICT (source_id, target_id, type) WHERE valid_to IS NULL
DO UPDATE SET attributes = EXCLUDED.attributes`,
sourceID, targetID, relType, string(attrsBytes))
if err != nil {
return nil, fmt.Errorf("rel %s→%s %s: %w", source, target, relType, err)
}
r.Relationships++
}
if err := ValidateCardinality(ctx, tx); err != nil {
return nil, err
}
// Default checks, now that hosting edges exist. Errors here are fatal:
// swallowing them is what let a foreign-key violation abort the ingest
// transaction while surfacing as an unrelated failure several entities
// later.
for _, target := range pendingChecks {
res, err := checkdefaults.Ensure(ctx, tx, tree, target)
if err != nil {
return nil, fmt.Errorf("default checks for %s: %w", target.Slug, err)
}
checkdefaults.LogResult(target.Slug, target.Type, res)
r.Checks += res.Created
}
return r, nil
}
// IngestPolicySeed ingests seeds/policy.yaml into the DB.
func IngestPolicySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*SeedResult, error) {
r := &SeedResult{}
// Risk classes
riskClasses, _ := data["risk_classes"].(map[string]any)
for name, raw := range riskClasses {
rcMap, _ := raw.(map[string]any)
desc, _ := rcMap["description"].(string)
approval, _ := rcMap["approval_required"].(string)
autonomy, _ := rcMap["autonomy_allowed"].(bool)
_, err := tx.Exec(ctx,
`INSERT INTO risk_classes (name, description, approval_required, autonomy_allowed)
VALUES ($1, $2, $3, $4)
ON CONFLICT (name) DO UPDATE SET description = $2, approval_required = $3, autonomy_allowed = $4`,
name, desc, approval, autonomy)
if err != nil {
return nil, fmt.Errorf("risk_class %s: %w", name, err)
}
r.RiskClasses++
}
// Approval rules
rules, _ := data["approval_rules"].([]any)
for _, raw := range rules {
ruleMap, ok := raw.(map[string]any)
if !ok {
continue
}
entityType, _ := ruleMap["entity_type"].(string)
action, _ := ruleMap["action"].(string)
riskClass, _ := ruleMap["risk_class"].(string)
autonomy, _ := ruleMap["autonomy_level"].(string)
scopeEntity, _ := ruleMap["scope_entity"].(string)
var scopeID any
if scopeEntity != "" {
id, err := getEntityIDBySlug(ctx, tx, scopeEntity)
if err == nil {
scopeID = id
}
}
ruleID := uuid.New()
_, err := tx.Exec(ctx,
`INSERT INTO approval_rules (id, entity_type, action, risk_class, autonomy_level, scope_entity, version, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 1, now())
ON CONFLICT (entity_type, action, scope_entity)
DO UPDATE SET risk_class = $4, autonomy_level = $5, scope_entity = $6, updated_at = now()`,
ruleID, nullableStr(entityType), action, riskClass, autonomy, scopeID)
if err != nil {
return nil, fmt.Errorf("approval_rule %s/%s: %w", entityType, action, err)
}
r.ApprovalRules++
}
// Autonomy settings
settings, _ := data["autonomy_settings"].(map[string]any)
for key, raw := range settings {
val, _ := raw.(string)
_, 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 = $2, updated_at = now()`,
key, val)
if err != nil {
return nil, fmt.Errorf("autonomy_setting %s: %w", key, err)
}
r.AutonomySettings++
}
return r, nil
}
// --- Helpers ---
// getOrCreateEntityID returns the UUID for a slug, generating a new
// time-ordered UUIDv7 if the slug doesn't exist yet (ADR-0005).
func getOrCreateEntityID(ctx context.Context, tx pgx.Tx, slug string) (uuid.UUID, error) {
var id uuid.UUID
err := tx.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&id)
switch {
case err == nil:
return id, nil
case errors.Is(err, pgx.ErrNoRows):
return uuid.NewV7()
default:
return uuid.Nil, fmt.Errorf("lookup slug %s: %w", slug, err)
}
}
// getEntityTypeBySlug resolves a slug to its entity type name.
func getEntityTypeBySlug(ctx context.Context, tx pgx.Tx, slug string) (string, error) {
var t string
err := tx.QueryRow(ctx, "SELECT type FROM entities WHERE slug = $1", slug).Scan(&t)
if err != nil {
return "", fmt.Errorf("resolve type of %s: %w", slug, err)
}
return t, nil
}
// getEntityIDBySlug resolves a slug to its UUID.
func getEntityIDBySlug(ctx context.Context, tx pgx.Tx, slug string) (uuid.UUID, error) {
var id uuid.UUID
err := tx.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&id)
if err != nil {
return uuid.Nil, fmt.Errorf("resolve slug %s: %w", slug, err)
}
return id, nil
}
// insertEntityTypes inserts entity types in dependency order (parents before children).
func insertEntityTypes(ctx context.Context, tx pgx.Tx, types map[string]any, r *SeedResult) error {
// Build a dependency graph and insert in topological order
// Simple approach: insert types with no parent first, then iterate
inserted := make(map[string]bool)
remaining := make(map[string]map[string]any)
for name, raw := range types {
tMap, _ := raw.(map[string]any)
remaining[name] = tMap
}
maxPasses := 10
for pass := 0; pass < maxPasses && len(remaining) > 0; pass++ {
for name, tMap := range remaining {
parent, _ := tMap["parent"].(string)
if parent == "" || inserted[parent] {
if err := insertOneEntityType(ctx, tx, name, tMap); err != nil {
return err
}
inserted[name] = true
delete(remaining, name)
r.EntityTypes++
}
}
}
if len(remaining) > 0 {
return fmt.Errorf("circular or missing parent in entity types: %v", keysOf(remaining))
}
return nil
}
func insertOneEntityType(ctx context.Context, tx pgx.Tx, name string, tMap map[string]any) error {
parent, _ := tMap["parent"].(string)
isAbstract, _ := tMap["abstract"].(bool)
domain, _ := tMap["domain"].(string)
layer, _ := tMap["layer"].(string)
desc, _ := tMap["description"].(string)
lifecycleID, _ := tMap["lifecycle"].(string)
// seeds/ontology.yaml spells this `attributes:`. Reading it as
// "attribute_schema" silently marshalled nil to the JSON literal `null`
// for every type, so no attribute schema was ever ingested — the API and
// `oikos export` returned null for all 60 types.
attrSchema := tMap["attributes"]
_, err := tx.Exec(ctx,
`INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, description,
lifecycle_id, attribute_schema, monitoring_spec, schema_version, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 1, 'active', now(), now())
ON CONFLICT (name) DO UPDATE SET parent_type = $2, is_abstract = $3, domain = $4,
layer = $5, description = $6, lifecycle_id = $7, attribute_schema = $8,
monitoring_spec = $9, updated_at = now()`,
name, nullableStr(parent), isAbstract, domain, layer, desc, nullableStr(lifecycleID),
attributeSchemaJSON(attrSchema), monitoringSpecJSON(tMap["monitoring"]))
return err
}
// attributeSchemaJSON marshals a type's `attributes:` block for storage,
// mapping "the type declares no schema" to SQL NULL rather than to the JSON
// literal `null`. Both readers already treat a JSON `null` as absent, but a
// real NULL is what `attribute_schema IS NULL` expects and is what the column
// meant all along.
func attributeSchemaJSON(v any) any {
if v == nil {
return nil
}
b, err := json.Marshal(v)
if err != nil {
return nil
}
return string(b)
}
// monitoringSpecJSON normalises an entity type's `monitoring:` declaration into
// the JSONB stored in entity_types.monitoring_spec. Three outcomes, and the
// difference between the last two is load-bearing for coverage signalling:
//
// absent → nil (SQL NULL) — undeclared, an ontology gap
// none | [] → "[]" — explicitly unmonitorable, by design
// [http, resource]→ '["http","resource"]'
//
// `monitoring: none` is accepted as a more legible spelling of `[]`; YAML
// parses the bare word as the string "none", not as null.
func monitoringSpecJSON(v any) any {
switch spec := v.(type) {
case nil:
return nil
case string:
if spec == "none" {
return "[]"
}
// A single kind written unquoted, e.g. `monitoring: http`.
b, _ := json.Marshal([]string{spec})
return string(b)
case []any:
b, _ := json.Marshal(toStringSlice(spec))
return string(b)
}
return nil
}
func toStringSlice(v any) []string {
if v == nil {
return nil
}
switch s := v.(type) {
case []string:
return s
case []any:
out := make([]string, 0, len(s))
for _, item := range s {
if str, ok := item.(string); ok {
out = append(out, str)
}
}
return out
}
return nil
}
func nullableStr(s string) any {
if s == "" {
return nil
}
return s
}
func keysOf(m map[string]map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}

View File

@@ -0,0 +1,107 @@
package db
import (
"strings"
"testing"
)
func nonEmpty(stmts []string) []string {
var out []string
for _, s := range stmts {
if strings.TrimSpace(s) != "" {
out = append(out, s)
}
}
return out
}
func TestSplitSQLBasic(t *testing.T) {
stmts := nonEmpty(splitSQL("CREATE TABLE a (id int); CREATE TABLE b (id int);"))
if len(stmts) != 2 {
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
}
}
func TestSplitSQLDollarQuotedFunction(t *testing.T) {
sql := `CREATE FUNCTION f() RETURNS int AS $$
SELECT 1; SELECT 2;
$$ LANGUAGE sql;
CREATE TABLE t (id int);`
stmts := nonEmpty(splitSQL(sql))
if len(stmts) != 2 {
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
}
if !strings.Contains(stmts[0], "SELECT 1; SELECT 2;") {
t.Errorf("dollar-quoted body was split: %q", stmts[0])
}
}
func TestSplitSQLTaggedDollarQuote(t *testing.T) {
sql := `DO $body$ BEGIN PERFORM 1; END $body$;SELECT 1;`
stmts := nonEmpty(splitSQL(sql))
if len(stmts) != 2 {
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
}
}
func TestSplitSQLSemicolonInComment(t *testing.T) {
sql := "-- comment with ; semicolon\nCREATE TABLE t (id int); -- trailing; note\nSELECT 1;"
stmts := nonEmpty(splitSQL(sql))
if len(stmts) != 2 {
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
}
}
func TestSplitSQLSemicolonInStringLiteral(t *testing.T) {
sql := `SELECT 'hello; world'; INSERT INTO t VALUES (1);`
stmts := nonEmpty(splitSQL(sql))
if len(stmts) != 2 {
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
}
}
func TestSplitSQLDollarSignInStringLiteral(t *testing.T) {
sql := `SELECT '$100'; SELECT 2;`
stmts := nonEmpty(splitSQL(sql))
if len(stmts) != 2 {
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
}
}
func TestSplitSQLBlockComment(t *testing.T) {
sql := `SELECT 1; /* block; with; semicolons */ SELECT 2;`
stmts := nonEmpty(splitSQL(sql))
if len(stmts) != 2 {
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
}
}
func TestSplitSQLBlockCommentWithDollarQuote(t *testing.T) {
sql := `/* $$ not a dollar quote */ SELECT 1;`
stmts := nonEmpty(splitSQL(sql))
if len(stmts) != 1 {
t.Fatalf("got %d statements, want 1: %#v", len(stmts), stmts)
}
}
func TestSplitSQLDoubledQuoteInString(t *testing.T) {
sql := `SELECT 'O''Brien'; SELECT 2;`
stmts := nonEmpty(splitSQL(sql))
if len(stmts) != 2 {
t.Fatalf("got %d statements, want 2: %#v", len(stmts), stmts)
}
}
func TestSplitSQLEmptyInput(t *testing.T) {
stmts := nonEmpty(splitSQL(""))
if len(stmts) != 0 {
t.Fatalf("got %d statements, want 0", len(stmts))
}
}
func TestSplitSQLNoSemicolon(t *testing.T) {
stmts := nonEmpty(splitSQL("SELECT 1"))
if len(stmts) != 1 {
t.Fatalf("got %d statements, want 1", len(stmts))
}
}

View File

@@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package sqlcgen
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}

View File

@@ -0,0 +1,275 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: entities.sql
package sqlcgen
import (
"context"
"time"
"github.com/google/uuid"
)
const getEntityByID = `-- name: GetEntityByID :one
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at, e.enrolled_at, e.enrolled_by FROM entities e WHERE e.id = $1
`
// Entity read + mutation queries (API paths). Aliased `e` throughout to
// avoid ambiguity with joined tables.
func (q *Queries) GetEntityByID(ctx context.Context, id uuid.UUID) (Entity, error) {
row := q.db.QueryRow(ctx, getEntityByID, id)
var i Entity
err := row.Scan(
&i.ID,
&i.Slug,
&i.Type,
&i.Name,
&i.State,
&i.Attributes,
&i.MaintenanceUntil,
&i.Version,
&i.CreatedAt,
&i.UpdatedAt,
&i.EnrolledAt,
&i.EnrolledBy,
)
return i, err
}
const getEntityBySlug = `-- name: GetEntityBySlug :one
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at, e.enrolled_at, e.enrolled_by FROM entities e WHERE e.slug = $1
`
func (q *Queries) GetEntityBySlug(ctx context.Context, slug string) (Entity, error) {
row := q.db.QueryRow(ctx, getEntityBySlug, slug)
var i Entity
err := row.Scan(
&i.ID,
&i.Slug,
&i.Type,
&i.Name,
&i.State,
&i.Attributes,
&i.MaintenanceUntil,
&i.Version,
&i.CreatedAt,
&i.UpdatedAt,
&i.EnrolledAt,
&i.EnrolledBy,
)
return i, err
}
const insertEntity = `-- name: InsertEntity :one
INSERT INTO entities (id, slug, type, name, state, attributes)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, slug, type, name, state, attributes, maintenance_until, version, created_at, updated_at, enrolled_at, enrolled_by
`
type InsertEntityParams struct {
ID uuid.UUID
Slug string
Type string
Name string
State *string
Attributes []byte
}
func (q *Queries) InsertEntity(ctx context.Context, arg InsertEntityParams) (Entity, error) {
row := q.db.QueryRow(ctx, insertEntity,
arg.ID,
arg.Slug,
arg.Type,
arg.Name,
arg.State,
arg.Attributes,
)
var i Entity
err := row.Scan(
&i.ID,
&i.Slug,
&i.Type,
&i.Name,
&i.State,
&i.Attributes,
&i.MaintenanceUntil,
&i.Version,
&i.CreatedAt,
&i.UpdatedAt,
&i.EnrolledAt,
&i.EnrolledBy,
)
return i, err
}
const listEntities = `-- name: ListEntities :many
WITH RECURSIVE tt AS (
SELECT name FROM entity_types WHERE $7::text IS NULL OR name = $7
UNION
SELECT et.name FROM entity_types et JOIN tt ON et.parent_type = tt.name
WHERE $7::text IS NOT NULL
)
SELECT e.id, e.slug, e.type, e.name, e.state, e.attributes, e.maintenance_until, e.version, e.created_at, e.updated_at, e.enrolled_at, e.enrolled_by FROM entities e
JOIN entity_types et ON et.name = e.type
WHERE e.type IN (SELECT name FROM tt)
AND ($1::text IS NULL OR e.state = $1)
AND ($2::text IS NULL OR et.domain = $2)
AND ($3::text IS NULL OR et.layer = $3)
AND ($4::text IS NULL
OR e.slug ILIKE '%'||$4||'%'
OR e.name ILIKE '%'||$4||'%')
AND ($5::text IS NULL OR e.slug > $5)
ORDER BY e.slug
LIMIT $6
`
type ListEntitiesParams struct {
State *string
Domain *string
Layer *string
Q *string
Cursor *string
Lim int32
Type *string
}
func (q *Queries) ListEntities(ctx context.Context, arg ListEntitiesParams) ([]Entity, error) {
rows, err := q.db.Query(ctx, listEntities,
arg.State,
arg.Domain,
arg.Layer,
arg.Q,
arg.Cursor,
arg.Lim,
arg.Type,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Entity
for rows.Next() {
var i Entity
if err := rows.Scan(
&i.ID,
&i.Slug,
&i.Type,
&i.Name,
&i.State,
&i.Attributes,
&i.MaintenanceUntil,
&i.Version,
&i.CreatedAt,
&i.UpdatedAt,
&i.EnrolledAt,
&i.EnrolledBy,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const mergeEntityAttributes = `-- name: MergeEntityAttributes :execrows
UPDATE entities SET
attributes = attributes || $1::jsonb,
updated_at = now()
WHERE slug = $2
`
type MergeEntityAttributesParams struct {
Patch []byte
Slug string
}
// Shallow-merge a JSON patch into an entity's attributes (the
// update_entity_attributes MCP/HTTP surface). Replaces the raw
// `attributes = attributes || $2::jsonb` used in entity_tools.go.
func (q *Queries) MergeEntityAttributes(ctx context.Context, arg MergeEntityAttributesParams) (int64, error) {
result, err := q.db.Exec(ctx, mergeEntityAttributes, arg.Patch, arg.Slug)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const setEntityState = `-- name: SetEntityState :execrows
UPDATE entities SET
state = $1,
updated_at = now()
WHERE id = $2
`
type SetEntityStateParams struct {
State *string
ID uuid.UUID
}
// Set an entity's lifecycle state by id (the set_entity_state surface, run
// after db.ValidateTransition). Replaces the raw
// `UPDATE entities SET state = $2 ... WHERE id = $1`.
func (q *Queries) SetEntityState(ctx context.Context, arg SetEntityStateParams) (int64, error) {
result, err := q.db.Exec(ctx, setEntityState, arg.State, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const updateEntity = `-- name: UpdateEntity :one
UPDATE entities SET
name = COALESCE($1, name),
state = COALESCE($2, state),
attributes = COALESCE($3, attributes),
maintenance_until = CASE WHEN $4::bool
THEN $5 ELSE maintenance_until END,
version = version + 1,
updated_at = now()
WHERE id = $6 AND version = $7
RETURNING id, slug, type, name, state, attributes, maintenance_until, version, created_at, updated_at, enrolled_at, enrolled_by
`
type UpdateEntityParams struct {
Name *string
State *string
Attributes []byte
SetMaintenance bool
MaintenanceUntil *time.Time
ID uuid.UUID
Version int32
}
func (q *Queries) UpdateEntity(ctx context.Context, arg UpdateEntityParams) (Entity, error) {
row := q.db.QueryRow(ctx, updateEntity,
arg.Name,
arg.State,
arg.Attributes,
arg.SetMaintenance,
arg.MaintenanceUntil,
arg.ID,
arg.Version,
)
var i Entity
err := row.Scan(
&i.ID,
&i.Slug,
&i.Type,
&i.Name,
&i.State,
&i.Attributes,
&i.MaintenanceUntil,
&i.Version,
&i.CreatedAt,
&i.UpdatedAt,
&i.EnrolledAt,
&i.EnrolledBy,
)
return i, err
}

View File

@@ -0,0 +1,460 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package sqlcgen
import (
"time"
"github.com/google/uuid"
)
type AgentActivity struct {
ID int64
Ts time.Time
AgentID uuid.UUID
SessionID *string
ActivityType string
ToolName *string
EntityID *uuid.UUID
InputSummary *string
OutputSummary *string
DurationMs *int32
TokenCount *int32
Success *bool
CorrelationID *string
}
type AgentMessage struct {
ID uuid.UUID
SessionID uuid.UUID
Role string
Content []byte
CreatedAt time.Time
}
type AgentSession struct {
ID uuid.UUID
Title string
Actor string
CreatedAt time.Time
LastActiveAt time.Time
Goal string
Status string
Outcome *string
Summary string
EntityID *uuid.UUID
CompletionNudges int32
Blocker string
ClosedAt *time.Time
}
type Approval struct {
EntityID uuid.UUID
SubjectEntityID *uuid.UUID
Action string
RiskClass string
Kind string
Payload []byte
Status string
TokenHash *string
ExpiresAt time.Time
DecidedAt *time.Time
DecidedBy *uuid.UUID
CreatedAt time.Time
MatrixEventID *string
AlertSentAt *time.Time
}
type ApprovalRule struct {
ID uuid.UUID
EntityType *string
Action string
RiskClass string
AutonomyLevel string
ScopeEntity *uuid.UUID
Version int32
UpdatedAt time.Time
}
type AuditLog struct {
ID int64
Ts time.Time
ActorType string
ActorID *uuid.UUID
Action string
EntityID *uuid.UUID
Method *string
Path *string
StatusCode *int32
Detail []byte
SourceIp *string
CorrelationID *string
SessionID *uuid.UUID
}
type AutonomySetting struct {
Key string
Value string
Version int32
UpdatedAt time.Time
}
type CheckDef struct {
EntityID uuid.UUID
TargetID *uuid.UUID
TargetType *string
Kind string
Config []byte
IntervalS int32
TimeoutS int32
Zone *string
Enabled bool
UpdatedAt time.Time
// When this check last executed. NULL = never, due immediately. Compared against interval_s to decide due-ness.
LastRunAt *time.Time
// This check's own most recent verdict (healthy/degraded/down/unknown). entity_status.health is the worst of these across the target's enabled checks.
LastHealth *string
}
type Classification struct {
EntityID uuid.UUID
SignalEntityID *uuid.UUID
TargetEntityID *uuid.UUID
Action string
RecommendedAction []byte
RiskClass string
Route string
BlastRadius []uuid.UUID
PatternConfidence *float32
SkillID *uuid.UUID
AutonomyCheck *string
Reasoning []byte
CorrelationID string
CreatedAt time.Time
}
type ContextFile struct {
Path string
Hash string
LastChanged time.Time
}
type ContextVersion struct {
Singleton bool
Version int64
UpdatedAt time.Time
}
type Entity struct {
ID uuid.UUID
Slug string
Type string
Name string
State *string
Attributes []byte
MaintenanceUntil *time.Time
Version int32
CreatedAt time.Time
UpdatedAt time.Time
EnrolledAt *time.Time
EnrolledBy *uuid.UUID
}
type EntityStatus struct {
EntityID uuid.UUID
Health string
LastCheckAt *time.Time
Details []byte
UpdatedAt time.Time
}
type EntityType struct {
Name string
ParentType *string
IsAbstract bool
Domain string
Layer string
Description *string
LifecycleID *string
AttributeSchema []byte
SchemaVersion int32
Status string
CreatedAt time.Time
UpdatedAt time.Time
// Check kinds this type warrants, resolved through parent_type. NULL means undeclared (an ontology gap), [] means explicitly unmonitorable, ["http","resource"] means declared kinds. Populated from seeds/ontology.yaml.
MonitoringSpec []byte
}
type Event struct {
ID int64
Ts time.Time
Type string
EntityID *uuid.UUID
Severity string
Source string
Data []byte
CorrelationID *string
}
type Execution struct {
EntityID uuid.UUID
ClassificationID *uuid.UUID
SignalEntityID *uuid.UUID
TargetEntityID *uuid.UUID
Action string
RiskClass string
ApprovalID *uuid.UUID
AgentID *uuid.UUID
SkillID *uuid.UUID
SkillVersion *int32
Status string
Result []byte
DurationMs *int32
Verified bool
CorrelationID string
StartedAt *time.Time
CompletedAt *time.Time
CreatedAt time.Time
}
type ExecutionLog struct {
ExecutionID uuid.UUID
Ts time.Time
Seq int32
Stream string
Chunk string
}
type Feedback struct {
EntityID uuid.UUID
ExecutionID uuid.UUID
Outcome string
Observation *string
Lesson *string
UnexpectedSideEffects []string
Tags []string
CreatedAt time.Time
}
type IdempotencyKey struct {
Key string
Actor string
RequestHash string
ResponseCode *int32
ResponseBody []byte
CreatedAt time.Time
}
type KnowledgeEntity struct {
EntityID uuid.UUID
Title string
Content string
Source *string
Tags []string
CreatedAt time.Time
UpdatedAt time.Time
ContentHash *string
Search interface{}
EditedBy string
DeletedAt *time.Time
}
type KnowledgeRevision struct {
ID int64
EntityID uuid.UUID
Title string
Content string
Source *string
Tags []string
EditedBy string
VersionAt time.Time
RevisedAt time.Time
}
type Ledger struct {
Ts time.Time
ExecutionID uuid.UUID
TargetEntityID *uuid.UUID
Action string
RiskClass string
Status string
Verified bool
Route *string
Reasoning []byte
ApprovalStatus *string
DecidedBy *uuid.UUID
AgentID *uuid.UUID
CorrelationID string
}
type LifecycleDef struct {
ID string
States []string
DefaultState string
TerminalStates []string
Transitions []byte
CreatedAt time.Time
}
type MetricRollups1d struct {
Bucket interface{}
EntityID uuid.UUID
Metric string
AvgValue float64
MinValue interface{}
MaxValue interface{}
SampleCount int64
}
type MetricRollups1h struct {
Bucket interface{}
EntityID uuid.UUID
Metric string
AvgValue float64
MinValue interface{}
MaxValue interface{}
SampleCount int64
}
type MetricSample struct {
Ts time.Time
EntityID uuid.UUID
Metric string
Value float64
Tags []byte
}
type NomosPlanExecution struct {
ExecutionID uuid.UUID
SessionID uuid.UUID
ContinuedAt *time.Time
CreatedAt time.Time
}
type Pattern struct {
EntityID uuid.UUID
AppliesType string
Action string
Pattern string
Confidence float32
EvidenceCount int32
SuccessCount int32
FailureCount int32
Status string
Quarantined bool
Version int32
LastValidatedAt *time.Time
CreatedAt time.Time
}
type ProvisioningStep struct {
ID uuid.UUID
EntityID uuid.UUID
ExecutionID uuid.UUID
StepOrder int32
StepName string
Status string
StartedAt *time.Time
FinishedAt *time.Time
ErrorMessage *string
CreatedAt time.Time
UpdatedAt time.Time
}
type Relationship struct {
SourceID uuid.UUID
TargetID uuid.UUID
Type string
Attributes []byte
ValidFrom time.Time
ValidTo *time.Time
}
type RelationshipType struct {
Name string
Inverse *string
SourceType string
TargetType string
Cardinality string
Description *string
CreatedAt time.Time
// Which end of this edge depends on the other. forward = target depends on source. backward = source depends on target. none = no runtime dependency. Drives blast_radius().
BlastDirection string
}
type RiskClass struct {
Name string
Description *string
ApprovalRequired string
AutonomyAllowed bool
}
type SeedVersion struct {
File string
ContentHash string
AppliedAt time.Time
}
type SessionPlanStep struct {
ID uuid.UUID
SessionID uuid.UUID
Seq int32
Title string
Detail string
Status string
ExecutionID *uuid.UUID
TargetSlug *string
StartedAt *time.Time
FinishedAt *time.Time
CreatedAt time.Time
Generation int32
ReplacedReason *string
}
type SessionQuestion struct {
ID uuid.UUID
SessionID uuid.UUID
Prompt string
Context []byte
Status string
Answer *string
CreatedAt time.Time
AnsweredAt *time.Time
}
type Signal struct {
EntityID uuid.UUID
Kind string
Severity string
TargetEntityID *uuid.UUID
CheckID *uuid.UUID
Evidence *string
LikelyCause *string
State string
OccurrenceCount int32
FirstSeenAt time.Time
LastSeenAt time.Time
FlapCount int32
HoldDownUntil *time.Time
MuteUntil *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type Skill struct {
EntityID uuid.UUID
Version int32
Name string
Procedure []byte
AppliesType *string
Action string
PatternIds []uuid.UUID
Status string
SuccessRate *float32
ChangedBy *uuid.UUID
ChangeReason *string
LastUsedAt *time.Time
CreatedAt time.Time
}

View File

@@ -0,0 +1,132 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: ontology.sql
package sqlcgen
import (
"context"
)
const getLifecycleForType = `-- name: GetLifecycleForType :one
SELECT ld.id, ld.states, ld.default_state, ld.terminal_states, ld.transitions, ld.created_at FROM lifecycle_defs ld
JOIN entity_types et ON et.lifecycle_id = ld.id
WHERE et.name = $1
`
func (q *Queries) GetLifecycleForType(ctx context.Context, name string) (LifecycleDef, error) {
row := q.db.QueryRow(ctx, getLifecycleForType, name)
var i LifecycleDef
err := row.Scan(
&i.ID,
&i.States,
&i.DefaultState,
&i.TerminalStates,
&i.Transitions,
&i.CreatedAt,
)
return i, err
}
const listEntityTypes = `-- name: ListEntityTypes :many
SELECT name, parent_type, is_abstract, domain, layer, description, lifecycle_id, attribute_schema, schema_version, status, created_at, updated_at, monitoring_spec FROM entity_types ORDER BY name
`
func (q *Queries) ListEntityTypes(ctx context.Context) ([]EntityType, error) {
rows, err := q.db.Query(ctx, listEntityTypes)
if err != nil {
return nil, err
}
defer rows.Close()
var items []EntityType
for rows.Next() {
var i EntityType
if err := rows.Scan(
&i.Name,
&i.ParentType,
&i.IsAbstract,
&i.Domain,
&i.Layer,
&i.Description,
&i.LifecycleID,
&i.AttributeSchema,
&i.SchemaVersion,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
&i.MonitoringSpec,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listLifecycleDefs = `-- name: ListLifecycleDefs :many
SELECT id, states, default_state, terminal_states, transitions, created_at FROM lifecycle_defs ORDER BY id
`
func (q *Queries) ListLifecycleDefs(ctx context.Context) ([]LifecycleDef, error) {
rows, err := q.db.Query(ctx, listLifecycleDefs)
if err != nil {
return nil, err
}
defer rows.Close()
var items []LifecycleDef
for rows.Next() {
var i LifecycleDef
if err := rows.Scan(
&i.ID,
&i.States,
&i.DefaultState,
&i.TerminalStates,
&i.Transitions,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRelationshipTypes = `-- name: ListRelationshipTypes :many
SELECT name, inverse, source_type, target_type, cardinality, description, created_at, blast_direction FROM relationship_types ORDER BY name
`
func (q *Queries) ListRelationshipTypes(ctx context.Context) ([]RelationshipType, error) {
rows, err := q.db.Query(ctx, listRelationshipTypes)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RelationshipType
for rows.Next() {
var i RelationshipType
if err := rows.Scan(
&i.Name,
&i.Inverse,
&i.SourceType,
&i.TargetType,
&i.Cardinality,
&i.Description,
&i.CreatedAt,
&i.BlastDirection,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,177 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: relationships.sql
package sqlcgen
import (
"context"
"time"
"github.com/google/uuid"
)
const endCurrentRelationship = `-- name: EndCurrentRelationship :execrows
UPDATE relationships SET valid_to = now()
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL
`
type EndCurrentRelationshipParams struct {
SourceID uuid.UUID
TargetID uuid.UUID
Type string
}
func (q *Queries) EndCurrentRelationship(ctx context.Context, arg EndCurrentRelationshipParams) (int64, error) {
result, err := q.db.Exec(ctx, endCurrentRelationship, arg.SourceID, arg.TargetID, arg.Type)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const insertRelationshipIfAbsent = `-- name: InsertRelationshipIfAbsent :execrows
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
SELECT $1, $2, $3,
$4::jsonb, now()
WHERE NOT EXISTS (
SELECT 1 FROM relationships
WHERE source_id = $1
AND target_id = $2
AND type = $3
AND valid_to IS NULL
)
`
type InsertRelationshipIfAbsentParams struct {
SourceID uuid.UUID
TargetID uuid.UUID
Type string
Attributes []byte
}
// Idempotent relationship insert (the create_relationship surface): no-op if
// an active edge of the same source/target/type already exists. Replaces the
// raw INSERT...WHERE NOT EXISTS used in entity_tools.go.
func (q *Queries) InsertRelationshipIfAbsent(ctx context.Context, arg InsertRelationshipIfAbsentParams) (int64, error) {
result, err := q.db.Exec(ctx, insertRelationshipIfAbsent,
arg.SourceID,
arg.TargetID,
arg.Type,
arg.Attributes,
)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const listEntityRelations = `-- name: ListEntityRelations :many
SELECT se.slug AS source_slug, te.slug AS target_slug, r.type, r.attributes,
r.valid_from, r.valid_to
FROM relationships r
JOIN entities se ON se.id = r.source_id
JOIN entities te ON te.id = r.target_id
WHERE r.valid_to IS NULL
AND (($1::text IN ('out','both') AND r.source_id = $2)
OR ($1::text IN ('in','both') AND r.target_id = $2))
AND ($3::text IS NULL OR r.type = $3)
ORDER BY r.type, se.slug, te.slug
`
type ListEntityRelationsParams struct {
Direction string
ID uuid.UUID
RelType *string
}
type ListEntityRelationsRow struct {
SourceSlug string
TargetSlug string
Type string
Attributes []byte
ValidFrom time.Time
ValidTo *time.Time
}
func (q *Queries) ListEntityRelations(ctx context.Context, arg ListEntityRelationsParams) ([]ListEntityRelationsRow, error) {
rows, err := q.db.Query(ctx, listEntityRelations, arg.Direction, arg.ID, arg.RelType)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListEntityRelationsRow
for rows.Next() {
var i ListEntityRelationsRow
if err := rows.Scan(
&i.SourceSlug,
&i.TargetSlug,
&i.Type,
&i.Attributes,
&i.ValidFrom,
&i.ValidTo,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listGraphEdges = `-- name: ListGraphEdges :many
SELECT se.slug AS source_slug, te.slug AS target_slug, r.type, r.attributes,
r.valid_from, r.valid_to
FROM relationships r
JOIN entities se ON se.id = r.source_id
JOIN entities te ON te.id = r.target_id
WHERE r.valid_to IS NULL
AND r.source_id = ANY($1::uuid[])
AND r.target_id = ANY($1::uuid[])
AND ($2::text[] IS NULL OR r.type = ANY($2::text[]))
ORDER BY r.type, se.slug, te.slug
`
type ListGraphEdgesParams struct {
Ids []uuid.UUID
RelTypes []string
}
type ListGraphEdgesRow struct {
SourceSlug string
TargetSlug string
Type string
Attributes []byte
ValidFrom time.Time
ValidTo *time.Time
}
func (q *Queries) ListGraphEdges(ctx context.Context, arg ListGraphEdgesParams) ([]ListGraphEdgesRow, error) {
rows, err := q.db.Query(ctx, listGraphEdges, arg.Ids, arg.RelTypes)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListGraphEdgesRow
for rows.Next() {
var i ListGraphEdgesRow
if err := rows.Scan(
&i.SourceSlug,
&i.TargetSlug,
&i.Type,
&i.Attributes,
&i.ValidFrom,
&i.ValidTo,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@@ -0,0 +1,150 @@
package db
import (
"context"
"fmt"
"github.com/dtoro/oikos/internal/ontology"
"github.com/jackc/pgx/v5"
)
// LoadTypeTree loads the ontology meta-schema from the DB for validation.
// Called within the same transaction as an ingest so it sees just-ingested
// types.
func LoadTypeTree(ctx context.Context, tx pgx.Tx) (*ontology.TypeTree, error) {
t := &ontology.TypeTree{
Types: map[string]ontology.TypeInfo{},
RelTypes: map[string]ontology.RelTypeInfo{},
Lifecycles: map[string]ontology.LifecycleInfo{},
}
rows, err := tx.Query(ctx,
`SELECT name, COALESCE(parent_type,''), is_abstract, COALESCE(lifecycle_id,''),
layer, monitoring_spec
FROM entity_types`)
if err != nil {
return nil, fmt.Errorf("load entity_types: %w", err)
}
for rows.Next() {
var name string
var info ontology.TypeInfo
// NULL monitoring_spec means the type declared nothing; '[]' means it
// declared "explicitly unmonitorable". Scanning through a pointer is
// what keeps those two apart — see ontology.TypeTree.Monitoring.
var monitoring *[]string
if err := rows.Scan(&name, &info.Parent, &info.IsAbstract, &info.LifecycleID,
&info.Layer, &monitoring); err != nil {
rows.Close()
return nil, err
}
info.Monitoring = monitoring
t.Types[name] = info
}
rows.Close()
if rows.Err() != nil {
return nil, rows.Err()
}
rows, err = tx.Query(ctx,
`SELECT name, source_type, target_type, cardinality FROM relationship_types`)
if err != nil {
return nil, fmt.Errorf("load relationship_types: %w", err)
}
for rows.Next() {
var name string
var info ontology.RelTypeInfo
if err := rows.Scan(&name, &info.SourceType, &info.TargetType, &info.Cardinality); err != nil {
rows.Close()
return nil, err
}
t.RelTypes[name] = info
}
rows.Close()
if rows.Err() != nil {
return nil, rows.Err()
}
rows, err = tx.Query(ctx,
`SELECT id, states, default_state FROM lifecycle_defs`)
if err != nil {
return nil, fmt.Errorf("load lifecycle_defs: %w", err)
}
for rows.Next() {
var id, def string
var states []string
if err := rows.Scan(&id, &states, &def); err != nil {
rows.Close()
return nil, err
}
set := make(map[string]bool, len(states))
for _, s := range states {
set[s] = true
}
t.Lifecycles[id] = ontology.LifecycleInfo{States: set, DefaultState: def}
}
rows.Close()
if rows.Err() != nil {
return nil, rows.Err()
}
return t, nil
}
// ValidateCardinality checks all CURRENT edges against their relationship
// type's declared cardinality. Runs inside the ingest transaction so a
// violating seed rolls back atomically.
func ValidateCardinality(ctx context.Context, tx pgx.Tx) error {
// one-to-one / many-to-one: a source may have at most one outgoing
// current edge of the type.
rows, err := tx.Query(ctx, `
SELECT r.type, se.slug, count(*)
FROM relationships r
JOIN relationship_types rt ON rt.name = r.type
JOIN entities se ON se.id = r.source_id
WHERE r.valid_to IS NULL AND rt.cardinality IN ('one-to-one','many-to-one')
GROUP BY r.type, se.slug HAVING count(*) > 1`)
if err != nil {
return err
}
violations, err := collectViolations(rows, "source")
if err != nil {
return err
}
// one-to-one / one-to-many: a target may have at most one incoming
// current edge of the type.
rows, err = tx.Query(ctx, `
SELECT r.type, te.slug, count(*)
FROM relationships r
JOIN relationship_types rt ON rt.name = r.type
JOIN entities te ON te.id = r.target_id
WHERE r.valid_to IS NULL AND rt.cardinality IN ('one-to-one','one-to-many')
GROUP BY r.type, te.slug HAVING count(*) > 1`)
if err != nil {
return err
}
v2, err := collectViolations(rows, "target")
if err != nil {
return err
}
violations = append(violations, v2...)
if len(violations) > 0 {
return fmt.Errorf("cardinality violations: %v", violations)
}
return nil
}
func collectViolations(rows pgx.Rows, side string) ([]string, error) {
defer rows.Close()
var out []string
for rows.Next() {
var relType, slug string
var n int
if err := rows.Scan(&relType, &slug, &n); err != nil {
return nil, err
}
out = append(out, fmt.Sprintf("%s %s=%s (%d edges)", relType, side, slug, n))
}
return out, rows.Err()
}