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:
112
internal/ontology/validate.go
Normal file
112
internal/ontology/validate.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Package ontology implements the meta-schema logic: the entity-type
|
||||
// hierarchy (is-a with abstract types), relationship endpoint validation,
|
||||
// cardinality enforcement, and lifecycle state checks. Both the seed
|
||||
// ingest and the API mutation paths validate through this package so the
|
||||
// graph can never violate the ontology (plan R3-1).
|
||||
package ontology
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/dtoro/oikos/internal/domain"
|
||||
)
|
||||
|
||||
// TypeInfo is the subset of an entity type the validator needs.
|
||||
type TypeInfo struct {
|
||||
Parent string
|
||||
IsAbstract bool
|
||||
LifecycleID string
|
||||
}
|
||||
|
||||
// RelTypeInfo is the subset of a relationship type the validator needs.
|
||||
type RelTypeInfo struct {
|
||||
SourceType string
|
||||
TargetType string
|
||||
Cardinality string
|
||||
}
|
||||
|
||||
// LifecycleInfo is the subset of a lifecycle the validator needs.
|
||||
type LifecycleInfo struct {
|
||||
States map[string]bool
|
||||
DefaultState string
|
||||
}
|
||||
|
||||
// TypeTree holds the loaded ontology meta-schema for validation.
|
||||
type TypeTree struct {
|
||||
Types map[string]TypeInfo
|
||||
RelTypes map[string]RelTypeInfo
|
||||
Lifecycles map[string]LifecycleInfo
|
||||
}
|
||||
|
||||
// IsA reports whether typ is target or a descendant of it.
|
||||
func (t *TypeTree) IsA(typ, target string) bool {
|
||||
seen := map[string]bool{}
|
||||
for cur := typ; cur != ""; cur = t.Types[cur].Parent {
|
||||
if cur == target {
|
||||
return true
|
||||
}
|
||||
if seen[cur] {
|
||||
return false // cycle guard — ingest rejects cycles, belt and braces
|
||||
}
|
||||
seen[cur] = true
|
||||
if _, ok := t.Types[cur]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateEntity checks that typ exists, is not abstract, and that state
|
||||
// (if set) is legal for the type's lifecycle.
|
||||
func (t *TypeTree) ValidateEntity(typ, state string) error {
|
||||
info, ok := t.Types[typ]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: entity type %q", domain.ErrNotFound, typ)
|
||||
}
|
||||
if info.IsAbstract {
|
||||
return fmt.Errorf("%w: %q", domain.ErrAbstractType, typ)
|
||||
}
|
||||
if state == "" {
|
||||
return nil
|
||||
}
|
||||
if info.LifecycleID == "" {
|
||||
return fmt.Errorf("%w: type %q has no lifecycle but state %q given",
|
||||
domain.ErrInvalidTransition, typ, state)
|
||||
}
|
||||
lc, ok := t.Lifecycles[info.LifecycleID]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: lifecycle %q", domain.ErrNotFound, info.LifecycleID)
|
||||
}
|
||||
if !lc.States[state] {
|
||||
return fmt.Errorf("%w: state %q not in lifecycle %q",
|
||||
domain.ErrInvalidTransition, state, info.LifecycleID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateEdge checks that relType exists and that the endpoint entity
|
||||
// types are the declared source/target types or descendants of them.
|
||||
func (t *TypeTree) ValidateEdge(relType, sourceEntityType, targetEntityType string) error {
|
||||
rt, ok := t.RelTypes[relType]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: relationship type %q", domain.ErrNotFound, relType)
|
||||
}
|
||||
if !t.IsA(sourceEntityType, rt.SourceType) {
|
||||
return fmt.Errorf("%w: %s source %q is not a %q",
|
||||
domain.ErrInvalidEdge, relType, sourceEntityType, rt.SourceType)
|
||||
}
|
||||
if !t.IsA(targetEntityType, rt.TargetType) {
|
||||
return fmt.Errorf("%w: %s target %q is not a %q",
|
||||
domain.ErrInvalidEdge, relType, targetEntityType, rt.TargetType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultState returns the default lifecycle state for a type ("" if none).
|
||||
func (t *TypeTree) DefaultState(typ string) string {
|
||||
info, ok := t.Types[typ]
|
||||
if !ok || info.LifecycleID == "" {
|
||||
return ""
|
||||
}
|
||||
return t.Lifecycles[info.LifecycleID].DefaultState
|
||||
}
|
||||
120
internal/ontology/validate_test.go
Normal file
120
internal/ontology/validate_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package ontology
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/dtoro/oikos/internal/domain"
|
||||
)
|
||||
|
||||
func fixtureTree() *TypeTree {
|
||||
return &TypeTree{
|
||||
Types: map[string]TypeInfo{
|
||||
"entity": {IsAbstract: true},
|
||||
"compute-entity": {Parent: "entity", IsAbstract: true},
|
||||
"machine": {Parent: "compute-entity", IsAbstract: true},
|
||||
"proxmox-host": {Parent: "machine", LifecycleID: "infrastructure"},
|
||||
"lxc": {Parent: "compute-entity", LifecycleID: "infrastructure"},
|
||||
"service": {Parent: "entity", LifecycleID: "infrastructure"},
|
||||
"document": {Parent: "entity"}, // no lifecycle
|
||||
},
|
||||
RelTypes: map[string]RelTypeInfo{
|
||||
"hosts": {SourceType: "machine", TargetType: "compute-entity", Cardinality: "one-to-many"},
|
||||
"provides": {SourceType: "compute-entity", TargetType: "service", Cardinality: "one-to-many"},
|
||||
"depends-on": {SourceType: "service", TargetType: "service", Cardinality: "many-to-many"},
|
||||
"documents": {SourceType: "document", TargetType: "entity", Cardinality: "many-to-one"},
|
||||
},
|
||||
Lifecycles: map[string]LifecycleInfo{
|
||||
"infrastructure": {
|
||||
States: map[string]bool{"planned": true, "active": true, "destroyed": true},
|
||||
DefaultState: "active",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAWalksHierarchy(t *testing.T) {
|
||||
tree := fixtureTree()
|
||||
cases := []struct {
|
||||
typ, target string
|
||||
want bool
|
||||
}{
|
||||
{"proxmox-host", "machine", true},
|
||||
{"proxmox-host", "compute-entity", true},
|
||||
{"proxmox-host", "entity", true},
|
||||
{"proxmox-host", "proxmox-host", true},
|
||||
{"lxc", "machine", false},
|
||||
{"service", "compute-entity", false},
|
||||
{"nonexistent", "entity", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := tree.IsA(c.typ, c.target); got != c.want {
|
||||
t.Errorf("IsA(%q, %q) = %v, want %v", c.typ, c.target, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEntityRejectsAbstract(t *testing.T) {
|
||||
tree := fixtureTree()
|
||||
for _, abstract := range []string{"entity", "compute-entity", "machine"} {
|
||||
if err := tree.ValidateEntity(abstract, ""); !errors.Is(err, domain.ErrAbstractType) {
|
||||
t.Errorf("ValidateEntity(%q) = %v, want ErrAbstractType", abstract, err)
|
||||
}
|
||||
}
|
||||
if err := tree.ValidateEntity("lxc", "active"); err != nil {
|
||||
t.Errorf("ValidateEntity(lxc, active) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEntityStates(t *testing.T) {
|
||||
tree := fixtureTree()
|
||||
if err := tree.ValidateEntity("lxc", "flying"); !errors.Is(err, domain.ErrInvalidTransition) {
|
||||
t.Errorf("bad state = %v, want ErrInvalidTransition", err)
|
||||
}
|
||||
// state on a type without a lifecycle is rejected
|
||||
if err := tree.ValidateEntity("document", "active"); !errors.Is(err, domain.ErrInvalidTransition) {
|
||||
t.Errorf("state without lifecycle = %v, want ErrInvalidTransition", err)
|
||||
}
|
||||
// unknown type
|
||||
if err := tree.ValidateEntity("ghost", ""); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("unknown type = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEdgeHonorsInheritance(t *testing.T) {
|
||||
tree := fixtureTree()
|
||||
// proxmox-host is-a machine; lxc is-a compute-entity → valid
|
||||
if err := tree.ValidateEdge("hosts", "proxmox-host", "lxc"); err != nil {
|
||||
t.Errorf("hosts(proxmox-host→lxc) = %v, want nil", err)
|
||||
}
|
||||
// abstract endpoint declared, concrete descendant offered → valid
|
||||
if err := tree.ValidateEdge("provides", "lxc", "service"); err != nil {
|
||||
t.Errorf("provides(lxc→service) = %v, want nil", err)
|
||||
}
|
||||
// documents targets the root abstract 'entity' → anything is valid
|
||||
if err := tree.ValidateEdge("documents", "document", "proxmox-host"); err != nil {
|
||||
t.Errorf("documents(document→proxmox-host) = %v, want nil", err)
|
||||
}
|
||||
// service is not a machine → invalid source
|
||||
if err := tree.ValidateEdge("hosts", "service", "lxc"); !errors.Is(err, domain.ErrInvalidEdge) {
|
||||
t.Errorf("hosts(service→lxc) = %v, want ErrInvalidEdge", err)
|
||||
}
|
||||
// lxc is not a service → invalid target
|
||||
if err := tree.ValidateEdge("depends-on", "service", "lxc"); !errors.Is(err, domain.ErrInvalidEdge) {
|
||||
t.Errorf("depends-on(service→lxc) = %v, want ErrInvalidEdge", err)
|
||||
}
|
||||
// unknown relationship type
|
||||
if err := tree.ValidateEdge("teleports", "lxc", "service"); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("unknown rel type = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultState(t *testing.T) {
|
||||
tree := fixtureTree()
|
||||
if got := tree.DefaultState("lxc"); got != "active" {
|
||||
t.Errorf("DefaultState(lxc) = %q, want active", got)
|
||||
}
|
||||
if got := tree.DefaultState("document"); got != "" {
|
||||
t.Errorf("DefaultState(document) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user