Files
oikos/internal/ontology/validate.go
dtoro e074f04bdf feat: Phase 0 of hexagonal refactor — ADR 0016, core scaffold, depguard rules
Problem: the hexagonal-architecture plan (plans/2026-08-15-hexagonal-
architecture.md) needs its foundation — an accepted ADR, the target
directory tree, and machine-checked dependency rules — before any
service extraction starts. Also folds the four outstanding review
findings (F3.1/F5/F6/F7) into the plan: ObservationService owns the
bounded probe-concurrency contract (scheduler.go:133), Phase 9 gates
ExecutionService+PolicyService ≥ 90% with a gating-matrix test,
per-phase abort criteria, and the §3.2 internal/config note.

Change:
- docs/adr/0016-hexagonal-ports-adapters.md records context, decision,
  and consequences of the ports & adapters migration.
- internal/domain → internal/core/domain (mechanical import rewrite,
  20 files), new internal/core/{ports,app}, internal/adapters trees
  with package docs.
- .golangci.yml: depguard rules for §3.1 (core purity, no agent-client
  tech in core, nomos isolation — the nomos rules self-activate when
  internal/nomos exists in Phase 8). Config migrated to golangci-lint
  v2 format so it loads at all (the v1 config errored under v2, masked
  by CI's advisory continue-on-error). Verified depguard fires on a
  planted openai-go import in internal/core/app.
- CONTRIBUTING.md layout section now shows the core/adapters tree.

Risk: import path churn is mechanical and tests pass unchanged; the
lint config migration surfaces the pre-existing 400-issue baseline
(advisory in CI, unchanged policy) — new/moved packages lint clean.

Verification: go vet ./..., make test (race, core/domain at 100%
coverage), make generate-check, golangci-lint on internal/core/... and
internal/adapters/... — 0 issues; depguard violation probe confirmed.
2026-08-15 22:09:19 +02:00

254 lines
8.6 KiB
Go

// 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/core/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
}