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