// Package ontology implements the meta-schema logic: the entity-type // hierarchy (is-a with abstract types), relationship endpoint validation, // cardinality enforcement, lifecycle state checks, and transition // requirement enforcement. 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 ( "context" "fmt" "github.com/dtoro/oikos/internal/domain" "github.com/jackc/pgx/v5/pgxpool" "github.com/google/uuid" ) // TypeInfo is the subset of an entity type the validator needs. type TypeInfo struct { Parent string IsAbstract bool LifecycleID string Layer string // Monitoring is this type's own `monitoring:` declaration, or nil if it // declared nothing (in which case the answer comes from an ancestor, or // from the layer default). A non-nil pointer to an empty slice means // "explicitly unmonitorable" — see TypeTree.Monitoring. Monitoring *[]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 } // ─── Transition check enforcement ──────────────────────────────────── // Validated transitions in seeds/ontology.yaml carry a "requires:" list // of named checks. Each check name maps to one of the functions below. // The Go runtime enforces these before allowing a state transition. // CheckFn validates a single named transition requirement. type CheckFn func(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error // TransitionChecks maps named check IDs to their implementation. var TransitionChecks = map[string]CheckFn{ "age-key-enrolled-if-needed": checkAgeKeyEnrolled, "mesh-joined-if-needed": checkMeshJoined, "health-check-answering": checkHealthCheckAnswering, "no-inbound-edges": checkNoInboundEdges, "secrets-revoked-and-rekeyed": checkSecretsRevoked, "backups-verified": checkBackupsVerified, "ingress-and-dns-removed": checkIngressDNSRemoved, "doc-page-complete": checkDocPageComplete, } func checkAgeKeyEnrolled(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error { // Workstations and servers need age keys; compute entities (LXC/VM) don't. if entityType == "lxc" || entityType == "vm" || entityType == "docker-container" { return nil } if _, ok := attrs["age_pubkey"]; !ok { return fmt.Errorf("%w: age_pubkey not set", domain.ErrInvalidTransition) } return nil } func checkMeshJoined(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error { if entityType == "lxc" || entityType == "vm" || entityType == "docker-container" { return nil } if _, ok := attrs["mesh_ip"]; !ok { return fmt.Errorf("%w: mesh_ip not set", domain.ErrInvalidTransition) } return nil } func checkHealthCheckAnswering(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error { var health string err := pool.QueryRow(ctx, "SELECT COALESCE(health, 'unknown') FROM entity_status WHERE entity_id = $1", entityID).Scan(&health) if err != nil { return nil // entity_status row may not exist yet; non-blocking } if health == "down" { return fmt.Errorf("%w: health is down", domain.ErrInvalidTransition) } return nil } func checkNoInboundEdges(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error { var count int err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM relationships WHERE target_id = $1 AND valid_to IS NULL AND type IN ('depends-on', 'hosts', 'provides', 'mounts', 'routes-to', 'stores-on')`, entityID).Scan(&count) if err != nil { return err } if count > 0 { return fmt.Errorf("%w: %d inbound edges still exist", domain.ErrInvalidTransition, count) } return nil } func checkSecretsRevoked(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error { // Application-level check: the API caller must have already revoked // Infisical identity and removed age key. We verify age_pubkey is // still present as a guard — if it's gone, secrets were revoked. if _, ok := attrs["age_pubkey"]; ok { return fmt.Errorf("%w: age_pubkey still present; revoke secrets first", domain.ErrInvalidTransition) } return nil } func checkBackupsVerified(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error { // Check for a recent audit log entry confirming backup verification. var count int err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM audit_log WHERE entity_id = $1 AND action = 'backup-verified' AND timestamp > NOW() - INTERVAL '30 days'`, entityID).Scan(&count) if err != nil { return err } if count == 0 { return fmt.Errorf("%w: backup not verified in last 30 days", domain.ErrInvalidTransition) } return nil } func checkIngressDNSRemoved(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error { // Check for remaining ingress or DNS relationships. var count int err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM relationships WHERE source_id = $1 AND valid_to IS NULL AND type IN ('routes-to', 'provides', 'hosts')`, entityID).Scan(&count) if err != nil { return err } if count > 0 { return fmt.Errorf("%w: %d ingress/DNS relationships still exist", domain.ErrInvalidTransition, count) } return nil } func checkDocPageComplete(ctx context.Context, pool *pgxpool.Pool, entityID uuid.UUID, entityType string, attrs map[string]any) error { var count int err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM relationships WHERE (source_id = $1 OR target_id = $1) AND valid_to IS NULL AND type = 'documents'`, entityID).Scan(&count) if err != nil { return err } if count == 0 { return fmt.Errorf("%w: no document edge found", domain.ErrInvalidTransition) } return nil }