Files
oikos/internal/db/export.go
dtoro 1b04683639 phase 1 review fixes: dedup edges, real export, validation, tests
Review of aa2ca0a found and fixed:
- re-ingest duplicated ALL edges (upsert conflicted on valid_from=now(),
  never fired) — migration 007 dedupes + partial unique index on current
  edges; upsert now targets it. Regression-tested.
- export was a stub that overwrote seeds/*.yaml with 11-byte "version: 1"
  files — implemented real deterministic export (ontology/inventory/policy,
  cognition-layer excluded); round-trip is byte-stable (tested)
- DB password leaked in startup logs (slog JSON bypasses String()) —
  Config now implements slog.LogValuer; regression-tested
- docker-compose had literal '***' as DB password — env-interpolated
- uuid.New() (v4) → uuid.NewV7() per ADR-0005
- no ontology validation on ingest — internal/ontology TypeTree: abstract
  instantiation rejected, relationship endpoints hierarchy-validated,
  cardinality enforced in-transaction, lifecycle states checked, default
  state applied (Phase 1 gate items, R3-1)
- getOrCreateEntityID swallowed non-ErrNoRows errors
- migration runner now holds a session advisory lock on one connection
- Makefile: hardcoded /opt/homebrew/bin/go → go; test-db target

Tests: 4 unit suites + 7 integration tests (env-guarded, throwaway DB per
run): migrate idempotent, seed idempotent + no dup edges, abstract/edge/
cardinality rejection, blast_radius cycle termination, export round-trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 08:11:01 +02:00

301 lines
7.6 KiB
Go

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
}