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>
This commit is contained in:
2026-07-07 08:11:01 +02:00
parent aa2ca0ae6f
commit 1b04683639
13 changed files with 1250 additions and 70 deletions

View File

@@ -3,13 +3,11 @@ package db
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/dtoro/oikos/internal/domain"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"gopkg.in/yaml.v3"
)
// SeedResult holds counts from a seed ingest operation.
@@ -83,11 +81,20 @@ func IngestOntologySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*S
}
// 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
for _, raw := range entities {
eMap, ok := raw.(map[string]any)
if !ok {
@@ -99,7 +106,14 @@ func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*
state, _ := eMap["state"].(string)
attrs := eMap["attributes"]
// Generate UUIDv7 for new entities, or find existing by slug
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)
@@ -118,7 +132,8 @@ func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*
r.Entities++
}
// Relationships
// 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)
@@ -139,12 +154,32 @@ func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*
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, valid_from)
DO UPDATE SET attributes = $4`,
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)
@@ -152,6 +187,10 @@ func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*
r.Relationships++
}
if err := ValidateCardinality(ctx, tx); err != nil {
return nil, err
}
return r, nil
}
@@ -231,15 +270,29 @@ func IngestPolicySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*See
// --- Helpers ---
// getOrCreateEntityID returns the UUID for a slug, generating a new UUIDv7 if not found.
// 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)
if err == nil {
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)
}
// Generate a time-ordered UUID (using uuid.New for now; UUIDv7 in production)
return uuid.New(), nil
}
// 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.
@@ -338,49 +391,3 @@ func keysOf(m map[string]map[string]any) []string {
return keys
}
// ExportToYAML regenerates the three seed YAMLs from the DB (for DR / version control, D6).
func ExportToYAML(ctx context.Context, pool *Pool) (map[string][]byte, error) {
result := make(map[string][]byte)
// Export ontology
onto, err := exportOntology(ctx, pool)
if err != nil {
return nil, fmt.Errorf("export ontology: %w", err)
}
result["ontology.yaml"], _ = yaml.Marshal(onto)
// Export inventory
inv, err := exportInventory(ctx, pool)
if err != nil {
return nil, fmt.Errorf("export inventory: %w", err)
}
result["inventory.yaml"], _ = yaml.Marshal(inv)
// Export policy
pol, err := exportPolicy(ctx, pool)
if err != nil {
return nil, fmt.Errorf("export policy: %w", err)
}
result["policy.yaml"], _ = yaml.Marshal(pol)
return result, nil
}
func exportOntology(ctx context.Context, pool *Pool) (map[string]any, error) {
// TODO: implement full export from DB
return map[string]any{"version": 1}, nil
}
func exportInventory(ctx context.Context, pool *Pool) (map[string]any, error) {
// TODO: implement full export from DB
return map[string]any{"version": 1}, nil
}
func exportPolicy(ctx context.Context, pool *Pool) (map[string]any, error) {
// TODO: implement full export from DB
return map[string]any{"version": 1}, nil
}
// Unused import suppression for domain (will be needed when we add more logic)
var _ = domain.Entity{}
var _ = time.Now