phase 1: Go foundation — module, migrations, domain, seed ingest
Core deliverables: - Go module github.com/dtoro/oikos (Go 1.26.3) - cmd/oikos: single binary with role subcommands (migrate, seed, export) - 6 SQL migrations: ontology meta-schema, entity instances (UUID+slug, blast_radius recursive function), operations (signals/checks/approvals), cognition (classifications/executions/feedback/patterns/skills), policy, observability (TimescaleDB hypertables + CAGGs + retention) - Domain layer: entity, signal, execution, classification, pattern, skill, approval, check types + 11 sentinel errors + lifecycle state machines - DB layer: pgx pool, SQL splitter (handles 94436 and -- comments), migration runner, seed ingest (ontology+inventory+policy) with content-hash dedup - Config: env-based with defaults, secrets redaction - Observability: slog JSON logger with debug mode - Infrastructure: Makefile, docker-compose.yml, multi-stage Dockerfile (distroless, CGO_ENABLED=0) Verified end-to-end against timescale/timescaledb:2.17.2-pg16: - 6 migrations applied (65 SQL statements) - Seeds ingested: 6 lifecycles, 59 entity types, 46 relationship types, 111 entities, 144 relationships, 4 risk classes, 27 approval rules, 9 autonomy settings - Idempotent: second seed run is a no-op (content hash matches) Bugs fixed during implementation: - TimescaleDB CAGGs can't run in a transaction -> splitSQL() executes statements individually - Semicolons in -- comments treated as separators -> comment handling - YAML keys source/target didn't match code's source_type/target_type - yaml.Marshal produced YAML for JSONB columns -> json.Marshal
This commit is contained in:
36
Makefile
Normal file
36
Makefile
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
.PHONY: build test lint generate dev migrate seed export clean
|
||||||
|
|
||||||
|
BINARY := oikos
|
||||||
|
GO := /opt/homebrew/bin/go
|
||||||
|
|
||||||
|
build:
|
||||||
|
$(GO) build -o $(BINARY) -tags timetzdata ./cmd/oikos
|
||||||
|
|
||||||
|
test:
|
||||||
|
$(GO) test -race -cover ./...
|
||||||
|
|
||||||
|
lint:
|
||||||
|
$(GO) vet ./...
|
||||||
|
@command -v golangci-lint >/dev/null 2>&1 && golangci-lint run || echo "golangci-lint not installed, skipping"
|
||||||
|
|
||||||
|
generate:
|
||||||
|
@echo "TODO: oapi-codegen + sqlc generation"
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
$(GO) run ./cmd/oikos migrate
|
||||||
|
|
||||||
|
seed:
|
||||||
|
$(GO) run ./cmd/oikos seed
|
||||||
|
|
||||||
|
export:
|
||||||
|
$(GO) run ./cmd/oikos export
|
||||||
|
|
||||||
|
dev:
|
||||||
|
docker compose --profile dev up -d
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(BINARY)
|
||||||
|
$(GO) clean -testcache
|
||||||
|
|
||||||
|
tidy:
|
||||||
|
$(GO) mod tidy
|
||||||
217
cmd/oikos/main.go
Normal file
217
cmd/oikos/main.go
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/config"
|
||||||
|
"github.com/dtoro/oikos/internal/db"
|
||||||
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
role := os.Args[1]
|
||||||
|
cfg := config.FromEnv()
|
||||||
|
|
||||||
|
// Structured logging (slog)
|
||||||
|
logger := observability.NewLogger(cfg.Debug)
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
slog.Info("starting oikos", "role", role, "config", cfg)
|
||||||
|
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(),
|
||||||
|
syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
switch role {
|
||||||
|
case "migrate":
|
||||||
|
if err := runMigrate(ctx, cfg); err != nil {
|
||||||
|
slog.Error("migrate failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
case "seed":
|
||||||
|
if err := runSeed(ctx, cfg); err != nil {
|
||||||
|
slog.Error("seed failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
case "export":
|
||||||
|
if err := runExport(ctx, cfg); err != nil {
|
||||||
|
slog.Error("export failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
case "api":
|
||||||
|
slog.Info("api role not yet implemented (Phase 2)")
|
||||||
|
os.Exit(1)
|
||||||
|
case "scheduler":
|
||||||
|
slog.Info("scheduler role not yet implemented (Phase 3)")
|
||||||
|
os.Exit(1)
|
||||||
|
case "notifier":
|
||||||
|
slog.Info("notifier role not yet implemented (Phase 3)")
|
||||||
|
os.Exit(1)
|
||||||
|
case "all":
|
||||||
|
slog.Info("all role not yet implemented (runs api + scheduler + notifier in one process)")
|
||||||
|
os.Exit(1)
|
||||||
|
case "version":
|
||||||
|
fmt.Println("oikos dev (Phase 1)")
|
||||||
|
case "help", "--help", "-h":
|
||||||
|
usage()
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown role: %s\n", role)
|
||||||
|
usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func usage() {
|
||||||
|
fmt.Println(`oikos — the homelab OS
|
||||||
|
|
||||||
|
Usage: oikos <role> [flags]
|
||||||
|
|
||||||
|
Roles:
|
||||||
|
migrate Run database migrations (forward-only, idempotent)
|
||||||
|
seed Ingest seed YAML files into the database
|
||||||
|
export Export DB state back to seed YAMLs (DR / version control)
|
||||||
|
api Run the REST + MCP API server (Phase 2)
|
||||||
|
scheduler Run the observe + act loop (Phase 3)
|
||||||
|
notifier Run the notification service (Phase 3)
|
||||||
|
all Run all roles in one process (dev mode)
|
||||||
|
version Print version info
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
OIKOS_DATABASE_URL Postgres connection string
|
||||||
|
OIKOS_API_LISTEN API listen address (default :8090)
|
||||||
|
OIKOS_ENV Environment (dev, prod)
|
||||||
|
OIKOS_DEBUG Enable verbose logging (true/1)
|
||||||
|
OIKOS_SEEDS_DIR Path to seeds directory (default: seeds)
|
||||||
|
OIKOS_MCP_BEARER_TOKEN Shared secret for MCP auth`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMigrate(ctx context.Context, cfg config.Config) error {
|
||||||
|
pool, err := db.New(ctx, cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
slog.Info("running migrations")
|
||||||
|
if err := pool.Migrate(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Info("migrations complete")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSeed(ctx context.Context, cfg config.Config) error {
|
||||||
|
pool, err := db.New(ctx, cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
// Ensure migrations are applied first
|
||||||
|
if err := pool.Migrate(ctx); err != nil {
|
||||||
|
return fmt.Errorf("migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seedsDir := cfg.SeedsDir
|
||||||
|
if seedsDir == "" {
|
||||||
|
seedsDir = "seeds"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingest ontology seed
|
||||||
|
ontoContent, err := os.ReadFile(seedsDir + "/ontology.yaml")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read ontology seed: %w", err)
|
||||||
|
}
|
||||||
|
err = pool.SeedIngest(ctx, "ontology.yaml", ontoContent,
|
||||||
|
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||||
|
r, err := db.IngestOntologySeed(ctx, tx, data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Info("ontology ingested",
|
||||||
|
"lifecycles", r.Lifecycles,
|
||||||
|
"entity_types", r.EntityTypes,
|
||||||
|
"relationship_types", r.RelationshipTypes)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingest inventory seed
|
||||||
|
invContent, err := os.ReadFile(seedsDir + "/inventory.yaml")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read inventory seed: %w", err)
|
||||||
|
}
|
||||||
|
err = pool.SeedIngest(ctx, "inventory.yaml", invContent,
|
||||||
|
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||||
|
r, err := db.IngestInventorySeed(ctx, tx, data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Info("inventory ingested",
|
||||||
|
"entities", r.Entities,
|
||||||
|
"relationships", r.Relationships)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingest policy seed
|
||||||
|
polContent, err := os.ReadFile(seedsDir + "/policy.yaml")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read policy seed: %w", err)
|
||||||
|
}
|
||||||
|
err = pool.SeedIngest(ctx, "policy.yaml", polContent,
|
||||||
|
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
|
||||||
|
r, err := db.IngestPolicySeed(ctx, tx, data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slog.Info("policy ingested",
|
||||||
|
"risk_classes", r.RiskClasses,
|
||||||
|
"approval_rules", r.ApprovalRules,
|
||||||
|
"autonomy_settings", r.AutonomySettings)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("seed ingest complete")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runExport(ctx context.Context, cfg config.Config) error {
|
||||||
|
pool, err := db.New(ctx, cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
exports, err := db.ExportToYAML(ctx, pool)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, content := range exports {
|
||||||
|
path := cfg.SeedsDir + "/" + name
|
||||||
|
if err := os.WriteFile(path, content, 0644); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
slog.Info("exported", "file", path, "bytes", len(content))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
21
compose/oikos/Dockerfile
Normal file
21
compose/oikos/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Multi-stage Dockerfile for Oikos (ADR 0001: single binary)
|
||||||
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 go build -o /oikos -tags timetzdata -ldflags="-s -w" ./cmd/oikos
|
||||||
|
|
||||||
|
# --- Runtime: distroless static ---
|
||||||
|
FROM gcr.io/distroless/static:nonroot
|
||||||
|
|
||||||
|
COPY --from=builder /oikos /oikos
|
||||||
|
COPY --from=builder /build/seeds /seeds
|
||||||
|
COPY --from=builder /build/migrations /migrations
|
||||||
|
|
||||||
|
ENTRYPOINT ["/oikos"]
|
||||||
70
docker-compose.yml
Normal file
70
docker-compose.yml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Docker Compose for Oikos development
|
||||||
|
# Usage: docker compose up -d postgres (just the DB)
|
||||||
|
# make dev (full dev stack)
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: timescale/timescaledb:2.17.2-pg16
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: oikos
|
||||||
|
POSTGRES_USER: oikos
|
||||||
|
POSTGRES_PASSWORD: oikos_dev
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pg-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "pg_isready", "-U", "oikos"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# One-shot: run migrations then exit
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: compose/oikos/Dockerfile
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
OIKOS_DATABASE_URL: postgres://oikos:***@postgres:5432/oikos?sslmode=disable
|
||||||
|
command: ["migrate"]
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
# One-shot: ingest seeds then exit
|
||||||
|
seed:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: compose/oikos/Dockerfile
|
||||||
|
depends_on:
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
OIKOS_DATABASE_URL: postgres://oikos:***@postgres:5432/oikos?sslmode=disable
|
||||||
|
OIKOS_SEEDS_DIR: /app/seeds
|
||||||
|
command: ["seed"]
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
# API server (Phase 2)
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: compose/oikos/Dockerfile
|
||||||
|
profiles: ["dev", "full"]
|
||||||
|
depends_on:
|
||||||
|
seed:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
OIKOS_DATABASE_URL: postgres://oikos:***@postgres:5432/oikos?sslmode=disable
|
||||||
|
OIKOS_API_LISTEN: ":8090"
|
||||||
|
OIKOS_ENV: dev
|
||||||
|
OIKOS_DEBUG: "true"
|
||||||
|
ports:
|
||||||
|
- "8090:8090"
|
||||||
|
command: ["api"]
|
||||||
|
stop_signal: SIGTERM
|
||||||
|
stop_grace_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg-data:
|
||||||
19
go.mod
Normal file
19
go.mod
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
module github.com/dtoro/oikos
|
||||||
|
|
||||||
|
go 1.26.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
)
|
||||||
37
go.sum
Normal file
37
go.sum
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
81
internal/config/config.go
Normal file
81
internal/config/config.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds all runtime configuration for an Oikos role.
|
||||||
|
// Hierarchy: compiled defaults → config file → env vars → Infisical (secrets only).
|
||||||
|
type Config struct {
|
||||||
|
// Database
|
||||||
|
DatabaseURL string // postgres://user:pass@host:5432/oikos?sslmode=disable
|
||||||
|
|
||||||
|
// API
|
||||||
|
APIListen string // :8090
|
||||||
|
APIEnv string // dev, prod
|
||||||
|
|
||||||
|
// MCP
|
||||||
|
MCPBearerToken string // shared secret for Hermes→API MCP calls
|
||||||
|
|
||||||
|
// Observability
|
||||||
|
Debug bool // verbose logging, probe payloads, SQL
|
||||||
|
|
||||||
|
// Seeds directory (for ingest/export)
|
||||||
|
SeedsDir string
|
||||||
|
|
||||||
|
// Migrations directory (embedded at build time, but path for fallback)
|
||||||
|
MigrationsDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default returns a Config with compiled defaults.
|
||||||
|
func Default() Config {
|
||||||
|
return Config{
|
||||||
|
DatabaseURL: "postgres://oikos:oikos@localhost:5432/oikos?sslmode=disable",
|
||||||
|
APIListen: ":8090",
|
||||||
|
APIEnv: "dev",
|
||||||
|
SeedsDir: "seeds",
|
||||||
|
MigrationsDir: "migrations",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromEnv loads config from environment variables, overlaying defaults.
|
||||||
|
func FromEnv() Config {
|
||||||
|
c := Default()
|
||||||
|
|
||||||
|
if v := os.Getenv("OIKOS_DATABASE_URL"); v != "" {
|
||||||
|
c.DatabaseURL = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_API_LISTEN"); v != "" {
|
||||||
|
c.APIListen = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_ENV"); v != "" {
|
||||||
|
c.APIEnv = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_MCP_BEARER_TOKEN"); v != "" {
|
||||||
|
c.MCPBearerToken = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIKOS_SEEDS_DIR"); v != "" {
|
||||||
|
c.SeedsDir = v
|
||||||
|
}
|
||||||
|
c.Debug = os.Getenv("OIKOS_DEBUG") == "true" || os.Getenv("OIKOS_DEBUG") == "1"
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns a human-safe representation (secrets redacted).
|
||||||
|
func (c Config) String() string {
|
||||||
|
dbURL := c.DatabaseURL
|
||||||
|
if i := strings.Index(dbURL, "@"); i >= 0 {
|
||||||
|
if j := strings.Index(dbURL, "://"); j >= 0 && j < i {
|
||||||
|
dbURL = dbURL[:j+3] + "***" + dbURL[i:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token := ""
|
||||||
|
if c.MCPBearerToken != "" {
|
||||||
|
token = "***"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Config{DB=%s Listen=%s Env=%s Debug=%v MCPToken=%s SeedsDir=%s}",
|
||||||
|
dbURL, c.APIListen, c.APIEnv, c.Debug, token, c.SeedsDir)
|
||||||
|
}
|
||||||
234
internal/db/pool.go
Normal file
234
internal/db/pool.go
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/migrations"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pool wraps a pgx connection pool.
|
||||||
|
type Pool struct {
|
||||||
|
*pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new connection pool.
|
||||||
|
func New(ctx context.Context, databaseURL string) (*Pool, error) {
|
||||||
|
cfg, err := pgxpool.ParseConfig(databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse database url: %w", err)
|
||||||
|
}
|
||||||
|
cfg.MaxConns = 15
|
||||||
|
|
||||||
|
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create pool: %w", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("ping db: %w", err)
|
||||||
|
}
|
||||||
|
return &Pool{pool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate runs all embedded forward migrations in order.
|
||||||
|
// Uses a schema_migrations table to track applied versions.
|
||||||
|
func (p *Pool) Migrate(ctx context.Context) error {
|
||||||
|
// Create tracking table if not exists
|
||||||
|
_, err := p.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INT PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List migration files
|
||||||
|
entries, err := fs.ReadDir(migrations.FS, ".")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migration fs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && hasSuffix(e.Name(), ".up.sql") {
|
||||||
|
files = append(files, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
|
||||||
|
for _, fname := range files {
|
||||||
|
// Extract version number (001, 002, etc.)
|
||||||
|
var version int
|
||||||
|
if _, err := fmt.Sscanf(fname, "%03d", &version); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already applied
|
||||||
|
var applied int
|
||||||
|
err := p.QueryRow(ctx,
|
||||||
|
"SELECT COUNT(*) FROM schema_migrations WHERE version = $1", version).Scan(&applied)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check migration %d: %w", version, err)
|
||||||
|
}
|
||||||
|
if applied > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and execute migration — split into individual statements
|
||||||
|
// because TimescaleDB CAGGs and some DDL can't run inside a transaction,
|
||||||
|
// and pgx's multi-statement Exec wraps them implicitly.
|
||||||
|
content, err := migrations.FS.ReadFile(fname)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read %s: %w", fname, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stmts := splitSQL(string(content))
|
||||||
|
for i, stmt := range stmts {
|
||||||
|
stmt = strings.TrimSpace(stmt)
|
||||||
|
if stmt == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, err := p.Exec(ctx, stmt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("exec migration %s stmt %d: %w", fname, i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = p.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", version)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("record migration %d: %w", version, err)
|
||||||
|
}
|
||||||
|
slog.Info("migration applied", "file", fname, "version", version, "statements", len(stmts))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedIngest ingests a YAML seed file into the database.
|
||||||
|
// Idempotent: if the file's content hash matches seed_versions, it's a no-op (A4).
|
||||||
|
func (p *Pool) SeedIngest(ctx context.Context, filename string, content []byte,
|
||||||
|
ingestFn func(ctx context.Context, tx pgx.Tx, data map[string]any) error) error {
|
||||||
|
|
||||||
|
hash := contentHash(content)
|
||||||
|
|
||||||
|
// Check if already applied with same hash
|
||||||
|
var existing string
|
||||||
|
err := p.QueryRow(ctx,
|
||||||
|
"SELECT content_hash FROM seed_versions WHERE file = $1", filename).Scan(&existing)
|
||||||
|
if err == nil && existing == hash {
|
||||||
|
return nil // no-op, same content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse YAML
|
||||||
|
var data map[string]any
|
||||||
|
if err := yaml.Unmarshal(content, &data); err != nil {
|
||||||
|
return fmt.Errorf("parse %s: %w", filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply in a single transaction
|
||||||
|
tx, err := p.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if err := ingestFn(ctx, tx, data); err != nil {
|
||||||
|
return fmt.Errorf("ingest %s: %w", filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the seed version
|
||||||
|
_, err = tx.Exec(ctx,
|
||||||
|
`INSERT INTO seed_versions (file, content_hash) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (file) DO UPDATE SET content_hash = $2, applied_at = now()`,
|
||||||
|
filename, hash)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("record seed version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("commit seed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentHash returns a SHA-256 hex digest of the content.
|
||||||
|
func contentHash(content []byte) string {
|
||||||
|
h := sha256.Sum256(content)
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasSuffix reports whether the string ends with the given suffix.
|
||||||
|
func hasSuffix(s, suffix string) bool {
|
||||||
|
return strings.HasSuffix(s, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitSQL splits a SQL string into individual statements.
|
||||||
|
// Handles $$ ... $$ dollar-quoted blocks and -- line comments.
|
||||||
|
func splitSQL(sql string) []string {
|
||||||
|
var statements []string
|
||||||
|
var current strings.Builder
|
||||||
|
inDollarQuote := false
|
||||||
|
dollarTag := ""
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for i < len(sql) {
|
||||||
|
// Handle line comments (-- to end of line)
|
||||||
|
if !inDollarQuote && i+1 < len(sql) && sql[i] == '-' && sql[i+1] == '-' {
|
||||||
|
// Skip to end of line
|
||||||
|
for i < len(sql) && sql[i] != '\n' {
|
||||||
|
current.WriteByte(sql[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for dollar-quote start/end
|
||||||
|
if !inDollarQuote && sql[i] == '$' {
|
||||||
|
j := i + 1
|
||||||
|
for j < len(sql) && (sql[j] == '_' || (sql[j] >= 'a' && sql[j] <= 'z') || (sql[j] >= 'A' && sql[j] <= 'Z') || (sql[j] >= '0' && sql[j] <= '9')) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
if j < len(sql) && sql[j] == '$' {
|
||||||
|
dollarTag = sql[i : j+1]
|
||||||
|
current.WriteString(dollarTag)
|
||||||
|
inDollarQuote = true
|
||||||
|
i = j + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inDollarQuote && strings.HasPrefix(sql[i:], dollarTag) {
|
||||||
|
current.WriteString(dollarTag)
|
||||||
|
i += len(dollarTag)
|
||||||
|
inDollarQuote = false
|
||||||
|
dollarTag = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !inDollarQuote && sql[i] == ';' {
|
||||||
|
statements = append(statements, current.String())
|
||||||
|
current.Reset()
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
current.WriteByte(sql[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(current.String()) != "" {
|
||||||
|
statements = append(statements, current.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return statements
|
||||||
|
}
|
||||||
386
internal/db/seed.go
Normal file
386
internal/db/seed.go
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"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.
|
||||||
|
type SeedResult struct {
|
||||||
|
Lifecycles int
|
||||||
|
EntityTypes int
|
||||||
|
RelationshipTypes int
|
||||||
|
Entities int
|
||||||
|
Relationships int
|
||||||
|
RiskClasses int
|
||||||
|
ApprovalRules int
|
||||||
|
AutonomySettings int
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngestOntologySeed ingests seeds/ontology.yaml into the DB.
|
||||||
|
func IngestOntologySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*SeedResult, error) {
|
||||||
|
r := &SeedResult{}
|
||||||
|
|
||||||
|
// Lifecycles
|
||||||
|
lifecycles, _ := data["lifecycles"].(map[string]any)
|
||||||
|
for id, raw := range lifecycles {
|
||||||
|
lcMap, _ := raw.(map[string]any)
|
||||||
|
states := toStringSlice(lcMap["states"])
|
||||||
|
defaultState, _ := lcMap["default_state"].(string)
|
||||||
|
terminalStates := toStringSlice(lcMap["terminal_states"])
|
||||||
|
if len(terminalStates) == 0 {
|
||||||
|
terminalStates = []string{}
|
||||||
|
}
|
||||||
|
transitionsBytes, _ := json.Marshal(lcMap["transitions"])
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO lifecycle_defs (id, states, default_state, terminal_states, transitions)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET states = $2, default_state = $3,
|
||||||
|
terminal_states = $4, transitions = $5`,
|
||||||
|
id, states, defaultState, terminalStates, string(transitionsBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("lifecycle %s: %w", id, err)
|
||||||
|
}
|
||||||
|
r.Lifecycles++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entity types — need to handle parent_type FK, so insert in dependency order
|
||||||
|
// (types with no parent first, then their children)
|
||||||
|
types, _ := data["entity_types"].(map[string]any)
|
||||||
|
if err := insertEntityTypes(ctx, tx, types, r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relationship types
|
||||||
|
relTypes, _ := data["relationship_types"].(map[string]any)
|
||||||
|
for name, raw := range relTypes {
|
||||||
|
rtMap, _ := raw.(map[string]any)
|
||||||
|
inverse, _ := rtMap["inverse"].(string)
|
||||||
|
sourceType, _ := rtMap["source"].(string)
|
||||||
|
targetType, _ := rtMap["target"].(string)
|
||||||
|
cardinality, _ := rtMap["cardinality"].(string)
|
||||||
|
desc, _ := rtMap["description"].(string)
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO relationship_types (name, inverse, source_type, target_type, cardinality, description)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET inverse = $2, source_type = $3,
|
||||||
|
target_type = $4, cardinality = $5, description = $6`,
|
||||||
|
name, nullableStr(inverse), sourceType, targetType, cardinality, desc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("relationship_type %s: %w", name, err)
|
||||||
|
}
|
||||||
|
r.RelationshipTypes++
|
||||||
|
}
|
||||||
|
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngestInventorySeed ingests seeds/inventory.yaml into the DB.
|
||||||
|
func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*SeedResult, error) {
|
||||||
|
r := &SeedResult{}
|
||||||
|
|
||||||
|
// Entities
|
||||||
|
entities, _ := data["entities"].([]any)
|
||||||
|
for _, raw := range entities {
|
||||||
|
eMap, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slug, _ := eMap["slug"].(string)
|
||||||
|
typeName, _ := eMap["type"].(string)
|
||||||
|
name, _ := eMap["name"].(string)
|
||||||
|
state, _ := eMap["state"].(string)
|
||||||
|
attrs := eMap["attributes"]
|
||||||
|
|
||||||
|
// Generate UUIDv7 for new entities, or find existing by slug
|
||||||
|
entityID, err := getOrCreateEntityID(ctx, tx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("entity %s: %w", slug, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
attrsBytes, _ := json.Marshal(attrs)
|
||||||
|
_, err = tx.Exec(ctx,
|
||||||
|
`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 1, now(), now())
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET type = $3, name = $4, state = $5,
|
||||||
|
attributes = $6, updated_at = now()`,
|
||||||
|
entityID, slug, typeName, name, nullableStr(state), string(attrsBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("entity %s: %w", slug, err)
|
||||||
|
}
|
||||||
|
r.Entities++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
rels, _ := data["relationships"].([]any)
|
||||||
|
for _, raw := range rels {
|
||||||
|
relMap, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
source, _ := relMap["source"].(string)
|
||||||
|
target, _ := relMap["target"].(string)
|
||||||
|
relType, _ := relMap["type"].(string)
|
||||||
|
attrs := relMap["attributes"]
|
||||||
|
|
||||||
|
sourceID, err := getEntityIDBySlug(ctx, tx, source)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rel from %s: %w", source, err)
|
||||||
|
}
|
||||||
|
targetID, err := getEntityIDBySlug(ctx, tx, target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rel to %s: %w", 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`,
|
||||||
|
sourceID, targetID, relType, string(attrsBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rel %s→%s %s: %w", source, target, relType, err)
|
||||||
|
}
|
||||||
|
r.Relationships++
|
||||||
|
}
|
||||||
|
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngestPolicySeed ingests seeds/policy.yaml into the DB.
|
||||||
|
func IngestPolicySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*SeedResult, error) {
|
||||||
|
r := &SeedResult{}
|
||||||
|
|
||||||
|
// Risk classes
|
||||||
|
riskClasses, _ := data["risk_classes"].(map[string]any)
|
||||||
|
for name, raw := range riskClasses {
|
||||||
|
rcMap, _ := raw.(map[string]any)
|
||||||
|
desc, _ := rcMap["description"].(string)
|
||||||
|
approval, _ := rcMap["approval_required"].(string)
|
||||||
|
autonomy, _ := rcMap["autonomy_allowed"].(bool)
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO risk_classes (name, description, approval_required, autonomy_allowed)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET description = $2, approval_required = $3, autonomy_allowed = $4`,
|
||||||
|
name, desc, approval, autonomy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("risk_class %s: %w", name, err)
|
||||||
|
}
|
||||||
|
r.RiskClasses++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approval rules
|
||||||
|
rules, _ := data["approval_rules"].([]any)
|
||||||
|
for _, raw := range rules {
|
||||||
|
ruleMap, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entityType, _ := ruleMap["entity_type"].(string)
|
||||||
|
action, _ := ruleMap["action"].(string)
|
||||||
|
riskClass, _ := ruleMap["risk_class"].(string)
|
||||||
|
autonomy, _ := ruleMap["autonomy_level"].(string)
|
||||||
|
scopeEntity, _ := ruleMap["scope_entity"].(string)
|
||||||
|
|
||||||
|
var scopeID any
|
||||||
|
if scopeEntity != "" {
|
||||||
|
id, err := getEntityIDBySlug(ctx, tx, scopeEntity)
|
||||||
|
if err == nil {
|
||||||
|
scopeID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleID := uuid.New()
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO approval_rules (id, entity_type, action, risk_class, autonomy_level, scope_entity, version, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 1, now())
|
||||||
|
ON CONFLICT (entity_type, action, scope_entity)
|
||||||
|
DO UPDATE SET risk_class = $4, autonomy_level = $5, scope_entity = $6, updated_at = now()`,
|
||||||
|
ruleID, nullableStr(entityType), action, riskClass, autonomy, scopeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("approval_rule %s/%s: %w", entityType, action, err)
|
||||||
|
}
|
||||||
|
r.ApprovalRules++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autonomy settings
|
||||||
|
settings, _ := data["autonomy_settings"].(map[string]any)
|
||||||
|
for key, raw := range settings {
|
||||||
|
val, _ := raw.(string)
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO autonomy_settings (key, value, version, updated_at)
|
||||||
|
VALUES ($1, $2, 1, now())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = now()`,
|
||||||
|
key, val)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("autonomy_setting %s: %w", key, err)
|
||||||
|
}
|
||||||
|
r.AutonomySettings++
|
||||||
|
}
|
||||||
|
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
// getOrCreateEntityID returns the UUID for a slug, generating a new UUIDv7 if not found.
|
||||||
|
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 {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
// Generate a time-ordered UUID (using uuid.New for now; UUIDv7 in production)
|
||||||
|
return uuid.New(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEntityIDBySlug resolves a slug to its UUID.
|
||||||
|
func getEntityIDBySlug(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 {
|
||||||
|
return uuid.Nil, fmt.Errorf("resolve slug %s: %w", slug, err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertEntityTypes inserts entity types in dependency order (parents before children).
|
||||||
|
func insertEntityTypes(ctx context.Context, tx pgx.Tx, types map[string]any, r *SeedResult) error {
|
||||||
|
// Build a dependency graph and insert in topological order
|
||||||
|
// Simple approach: insert types with no parent first, then iterate
|
||||||
|
inserted := make(map[string]bool)
|
||||||
|
remaining := make(map[string]map[string]any)
|
||||||
|
for name, raw := range types {
|
||||||
|
tMap, _ := raw.(map[string]any)
|
||||||
|
remaining[name] = tMap
|
||||||
|
}
|
||||||
|
|
||||||
|
maxPasses := 10
|
||||||
|
for pass := 0; pass < maxPasses && len(remaining) > 0; pass++ {
|
||||||
|
for name, tMap := range remaining {
|
||||||
|
parent, _ := tMap["parent"].(string)
|
||||||
|
if parent == "" || inserted[parent] {
|
||||||
|
if err := insertOneEntityType(ctx, tx, name, tMap); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
inserted[name] = true
|
||||||
|
delete(remaining, name)
|
||||||
|
r.EntityTypes++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(remaining) > 0 {
|
||||||
|
return fmt.Errorf("circular or missing parent in entity types: %v", keysOf(remaining))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertOneEntityType(ctx context.Context, tx pgx.Tx, name string, tMap map[string]any) error {
|
||||||
|
parent, _ := tMap["parent"].(string)
|
||||||
|
isAbstract, _ := tMap["abstract"].(bool)
|
||||||
|
domain, _ := tMap["domain"].(string)
|
||||||
|
layer, _ := tMap["layer"].(string)
|
||||||
|
desc, _ := tMap["description"].(string)
|
||||||
|
lifecycleID, _ := tMap["lifecycle"].(string)
|
||||||
|
attrSchema := tMap["attribute_schema"]
|
||||||
|
|
||||||
|
schemaBytes, _ := json.Marshal(attrSchema)
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO entity_types (name, parent_type, is_abstract, domain, layer, description,
|
||||||
|
lifecycle_id, attribute_schema, schema_version, status, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, 'active', now(), now())
|
||||||
|
ON CONFLICT (name) DO UPDATE SET parent_type = $2, is_abstract = $3, domain = $4,
|
||||||
|
layer = $5, description = $6, lifecycle_id = $7, attribute_schema = $8, updated_at = now()`,
|
||||||
|
name, nullableStr(parent), isAbstract, domain, layer, desc, nullableStr(lifecycleID), nullableStr(string(schemaBytes)))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func toStringSlice(v any) []string {
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch s := v.(type) {
|
||||||
|
case []string:
|
||||||
|
return s
|
||||||
|
case []any:
|
||||||
|
out := make([]string, 0, len(s))
|
||||||
|
for _, item := range s {
|
||||||
|
if str, ok := item.(string); ok {
|
||||||
|
out = append(out, str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableStr(s string) any {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func keysOf(m map[string]map[string]any) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
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
|
||||||
110
internal/domain/approval.go
Normal file
110
internal/domain/approval.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Approval is a short-TTL signed grant for a gated action.
|
||||||
|
type Approval struct {
|
||||||
|
EntityID UUID
|
||||||
|
SubjectEntityID UUID
|
||||||
|
Action string
|
||||||
|
RiskClass string
|
||||||
|
Kind string
|
||||||
|
Payload map[string]any
|
||||||
|
Status string
|
||||||
|
TokenHash string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
DecidedAt *time.Time
|
||||||
|
DecidedBy UUID
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approval statuses.
|
||||||
|
const (
|
||||||
|
ApprovalPending = "pending"
|
||||||
|
ApprovalApproved = "approved"
|
||||||
|
ApprovalDenied = "denied"
|
||||||
|
ApprovalExpired = "expired"
|
||||||
|
ApprovalRevoked = "revoked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Approval kinds.
|
||||||
|
const (
|
||||||
|
ApprovalKindExecution = "execution"
|
||||||
|
ApprovalKindPolicyChange = "policy-change"
|
||||||
|
ApprovalKindPatternActivation = "pattern-activation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CheckDef defines a probe (R3-7: probes as data, not code).
|
||||||
|
type CheckDef struct {
|
||||||
|
EntityID UUID
|
||||||
|
TargetID UUID
|
||||||
|
TargetType string
|
||||||
|
Kind string
|
||||||
|
Config map[string]any
|
||||||
|
IntervalS int
|
||||||
|
TimeoutS int
|
||||||
|
Zone string
|
||||||
|
Enabled bool
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check kinds.
|
||||||
|
const (
|
||||||
|
CheckHTTP = "http"
|
||||||
|
CheckTCP = "tcp"
|
||||||
|
CheckDisk = "disk"
|
||||||
|
CheckCertExpiry = "cert-expiry"
|
||||||
|
CheckDrift = "drift"
|
||||||
|
CheckSSHScript = "ssh-script"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EntityStatus is the current health of an entity (R3-6: replaces state_snapshots).
|
||||||
|
type EntityStatus struct {
|
||||||
|
EntityID UUID
|
||||||
|
Health string
|
||||||
|
LastCheckAt *time.Time
|
||||||
|
Details map[string]any
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health values.
|
||||||
|
const (
|
||||||
|
HealthHealthy = "healthy"
|
||||||
|
HealthDegraded = "degraded"
|
||||||
|
HealthDown = "down"
|
||||||
|
HealthUnknown = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RiskClass is the four-level safety model.
|
||||||
|
type RiskClass struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
ApprovalRequired string
|
||||||
|
AutonomyAllowed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Risk class names.
|
||||||
|
const (
|
||||||
|
RiskReadOnly = "read_only"
|
||||||
|
RiskReversibleLow = "reversible_low"
|
||||||
|
RiskConfigMutation = "config_mutation"
|
||||||
|
RiskDestructive = "destructive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApprovalRule maps (entity_type, action) → risk_class + autonomy.
|
||||||
|
type ApprovalRule struct {
|
||||||
|
ID UUID
|
||||||
|
EntityType string
|
||||||
|
Action string
|
||||||
|
RiskClass string
|
||||||
|
AutonomyLevel string
|
||||||
|
ScopeEntity UUID
|
||||||
|
Version int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autonomy levels.
|
||||||
|
const (
|
||||||
|
AutonomyAuto = "auto"
|
||||||
|
AutonomyEscalate = "escalate"
|
||||||
|
AutonomyNever = "never"
|
||||||
|
)
|
||||||
76
internal/domain/entity.go
Normal file
76
internal/domain/entity.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entity is the core graph node — every object in the OS is an entity.
|
||||||
|
// Typed tables (signals, executions, etc.) reference entities(id) for
|
||||||
|
// indexed querying; graph edges live in the relationships table.
|
||||||
|
type Entity struct {
|
||||||
|
ID UUID
|
||||||
|
Slug string
|
||||||
|
Type string
|
||||||
|
Name string
|
||||||
|
State string
|
||||||
|
Attributes map[string]any
|
||||||
|
MaintenanceUntil *time.Time
|
||||||
|
Version int
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntityType is the meta-schema entry defining what entities can exist.
|
||||||
|
type EntityType struct {
|
||||||
|
Name string
|
||||||
|
ParentType string
|
||||||
|
IsAbstract bool
|
||||||
|
Domain string
|
||||||
|
Layer string
|
||||||
|
Description string
|
||||||
|
LifecycleID string
|
||||||
|
AttributeSchema map[string]any
|
||||||
|
SchemaVersion int
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelationshipType defines a typed edge between entity types.
|
||||||
|
type RelationshipType struct {
|
||||||
|
Name string
|
||||||
|
Inverse string
|
||||||
|
SourceType string
|
||||||
|
TargetType string
|
||||||
|
Cardinality string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LifecycleDef is the state machine for an entity type.
|
||||||
|
type LifecycleDef struct {
|
||||||
|
ID string
|
||||||
|
States []string
|
||||||
|
DefaultState string
|
||||||
|
TerminalStates []string
|
||||||
|
Transitions map[string]map[string]TransitionReq
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransitionReq holds the named preconditions for a lifecycle transition.
|
||||||
|
type TransitionReq struct {
|
||||||
|
Requires []string `json:"requires"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relationship is a typed edge between two entities.
|
||||||
|
type Relationship struct {
|
||||||
|
SourceID UUID
|
||||||
|
TargetID UUID
|
||||||
|
Type string
|
||||||
|
Attributes map[string]any
|
||||||
|
ValidFrom time.Time
|
||||||
|
ValidTo *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// UUID is a type alias for UUID values. Using string for simplicity;
|
||||||
|
// the DB layer uses pgx's UUID type. Conversion happens at the boundary.
|
||||||
|
type UUID string
|
||||||
|
|
||||||
|
// IsNil returns true if the UUID is empty.
|
||||||
|
func (u UUID) IsNil() bool { return u == "" }
|
||||||
21
internal/domain/errors.go
Normal file
21
internal/domain/errors.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// Sentinel errors. Used throughout the codebase for typed error handling.
|
||||||
|
// The API middleware maps these to HTTP status codes (SG11).
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("entity not found")
|
||||||
|
ErrInvalidTransition = errors.New("invalid lifecycle transition")
|
||||||
|
ErrApprovalRequired = errors.New("operator approval required")
|
||||||
|
ErrAutonomyBlocked = errors.New("autonomy policy blocks this action")
|
||||||
|
ErrConflict = errors.New("concurrent modification conflict")
|
||||||
|
ErrCircuitOpen = errors.New("circuit breaker open for target")
|
||||||
|
ErrAbstractType = errors.New("cannot instantiate abstract entity type")
|
||||||
|
ErrInvalidEdge = errors.New("relationship endpoint type mismatch")
|
||||||
|
ErrCardinality = errors.New("relationship cardinality violation")
|
||||||
|
ErrSeedHashMismatch = errors.New("seed content hash mismatch")
|
||||||
|
ErrAlreadyExists = errors.New("entity already exists")
|
||||||
|
ErrQuarantined = errors.New("pattern is quarantined")
|
||||||
|
ErrSkillDeprecated = errors.New("skill is deprecated")
|
||||||
|
)
|
||||||
64
internal/domain/execution.go
Normal file
64
internal/domain/execution.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Classification persists every autonomous decision the classifier makes (SA5).
|
||||||
|
// This is the audit trail for "why did the OS auto-act / escalate?"
|
||||||
|
type Classification struct {
|
||||||
|
EntityID UUID
|
||||||
|
SignalEntityID UUID
|
||||||
|
TargetEntityID UUID
|
||||||
|
Action string
|
||||||
|
RecommendedAction map[string]any
|
||||||
|
RiskClass string
|
||||||
|
Route string
|
||||||
|
BlastRadius []UUID
|
||||||
|
PatternConfidence float64
|
||||||
|
SkillID UUID
|
||||||
|
AutonomyCheck string
|
||||||
|
Reasoning map[string]any
|
||||||
|
CorrelationID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classification routes.
|
||||||
|
const (
|
||||||
|
RouteAutoAct = "auto-act"
|
||||||
|
RouteEscalate = "escalate"
|
||||||
|
RouteHold = "hold"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Execution is a detailed record of one action the OS performed.
|
||||||
|
type Execution struct {
|
||||||
|
EntityID UUID
|
||||||
|
ClassificationID UUID
|
||||||
|
SignalEntityID UUID
|
||||||
|
TargetEntityID UUID
|
||||||
|
Action string
|
||||||
|
RiskClass string
|
||||||
|
ApprovalID UUID
|
||||||
|
AgentID UUID
|
||||||
|
SkillID UUID
|
||||||
|
SkillVersion int
|
||||||
|
Status string
|
||||||
|
Result map[string]any
|
||||||
|
DurationMs int
|
||||||
|
Verified bool
|
||||||
|
CorrelationID string
|
||||||
|
StartedAt *time.Time
|
||||||
|
CompletedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execution lifecycle states.
|
||||||
|
const (
|
||||||
|
ExecProposed = "proposed"
|
||||||
|
ExecApproved = "approved"
|
||||||
|
ExecExecuting = "executing"
|
||||||
|
ExecVerified = "verified"
|
||||||
|
ExecFailed = "failed"
|
||||||
|
ExecTimedOut = "timed-out"
|
||||||
|
ExecRolledBack = "rolled-back"
|
||||||
|
ExecCancelled = "cancelled"
|
||||||
|
ExecExpired = "expired"
|
||||||
|
)
|
||||||
76
internal/domain/pattern.go
Normal file
76
internal/domain/pattern.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Feedback records what was learned from an execution.
|
||||||
|
type Feedback struct {
|
||||||
|
EntityID UUID
|
||||||
|
ExecutionID UUID
|
||||||
|
Outcome string
|
||||||
|
Observation string
|
||||||
|
Lesson string
|
||||||
|
UnexpectedSideEffects []string
|
||||||
|
Tags []string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feedback outcomes.
|
||||||
|
const (
|
||||||
|
OutcomeSuccess = "success"
|
||||||
|
OutcomeFailure = "failure"
|
||||||
|
OutcomePartial = "partial"
|
||||||
|
OutcomeUnexpected = "unexpected"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pattern is a generalized rule extracted from accumulated feedback.
|
||||||
|
type Pattern struct {
|
||||||
|
EntityID UUID
|
||||||
|
AppliesType string
|
||||||
|
Action string
|
||||||
|
Pattern string
|
||||||
|
Confidence float64
|
||||||
|
EvidenceCount int
|
||||||
|
SuccessCount int
|
||||||
|
FailureCount int
|
||||||
|
Status string
|
||||||
|
Quarantined bool
|
||||||
|
Version int
|
||||||
|
LastValidatedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern lifecycle states.
|
||||||
|
const (
|
||||||
|
PatternHypothesized = "hypothesized"
|
||||||
|
PatternValidated = "validated"
|
||||||
|
PatternActive = "active"
|
||||||
|
PatternDeprecated = "deprecated"
|
||||||
|
PatternInvalidated = "invalidated"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Skill is a codified procedure refined through validated patterns.
|
||||||
|
type Skill struct {
|
||||||
|
EntityID UUID
|
||||||
|
Version int
|
||||||
|
Name string
|
||||||
|
Procedure map[string]any
|
||||||
|
AppliesType string
|
||||||
|
Action string
|
||||||
|
PatternIDs []UUID
|
||||||
|
Status string
|
||||||
|
SuccessRate float64
|
||||||
|
ChangedBy UUID
|
||||||
|
ChangeReason string
|
||||||
|
LastUsedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skill lifecycle states.
|
||||||
|
const (
|
||||||
|
SkillDrafted = "drafted"
|
||||||
|
SkillTested = "tested"
|
||||||
|
SkillActive = "active"
|
||||||
|
SkillRefined = "refined"
|
||||||
|
SkillDeprecated = "deprecated"
|
||||||
|
SkillFailed = "failed"
|
||||||
|
)
|
||||||
65
internal/domain/signal.go
Normal file
65
internal/domain/signal.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Signal is an attention record — something the lab noticed that needs
|
||||||
|
// attention and possibly action. Dual entity: has an entities row + a
|
||||||
|
// signals table row for indexed querying.
|
||||||
|
type Signal struct {
|
||||||
|
EntityID UUID
|
||||||
|
Kind string
|
||||||
|
Severity string
|
||||||
|
TargetEntityID UUID
|
||||||
|
CheckID UUID
|
||||||
|
Evidence string
|
||||||
|
LikelyCause string
|
||||||
|
State string
|
||||||
|
OccurrenceCount int
|
||||||
|
FirstSeenAt time.Time
|
||||||
|
LastSeenAt time.Time
|
||||||
|
FlapCount int
|
||||||
|
HoldDownUntil *time.Time
|
||||||
|
MuteUntil *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal lifecycle states (see lifecycle_defs in seeds/ontology.yaml).
|
||||||
|
const (
|
||||||
|
SignalRaised = "raised"
|
||||||
|
SignalAcknowledged = "acknowledged"
|
||||||
|
SignalActing = "acting"
|
||||||
|
SignalMuted = "muted"
|
||||||
|
SignalResolved = "resolved"
|
||||||
|
SignalFailed = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Signal severities.
|
||||||
|
const (
|
||||||
|
SeverityInfo = "info"
|
||||||
|
SeverityWarning = "warning"
|
||||||
|
SeverityCritical = "critical"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidSignalTransitions defines legal state transitions.
|
||||||
|
var ValidSignalTransitions = map[string][]string{
|
||||||
|
SignalRaised: {SignalAcknowledged, SignalMuted, SignalResolved},
|
||||||
|
SignalAcknowledged: {SignalActing, SignalResolved, SignalMuted},
|
||||||
|
SignalActing: {SignalResolved, SignalRaised, SignalFailed},
|
||||||
|
SignalFailed: {SignalAcknowledged},
|
||||||
|
SignalMuted: {SignalRaised},
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanTransition returns true if from→to is a legal signal state transition.
|
||||||
|
func (s *Signal) CanTransition(to string) bool {
|
||||||
|
allowed, ok := ValidSignalTransitions[s.State]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, a := range allowed {
|
||||||
|
if a == to {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
23
internal/observability/logging.go
Normal file
23
internal/observability/logging.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewLogger creates a structured JSON logger writing to stdout.
|
||||||
|
// In debug mode, it enables verbose probe payloads, SQL queries, and
|
||||||
|
// classification reasoning.
|
||||||
|
func NewLogger(debug bool) *slog.Logger {
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if debug {
|
||||||
|
level = slog.LevelDebug
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: level,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger := slog.New(handler).With("service", "oikos")
|
||||||
|
return logger
|
||||||
|
}
|
||||||
43
migrations/001_ontology.up.sql
Normal file
43
migrations/001_ontology.up.sql
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
-- Migration 001: Ontology meta-schema (with inheritance, R3-1)
|
||||||
|
-- Defines entity types, relationship types, lifecycle definitions, and seed versioning.
|
||||||
|
|
||||||
|
CREATE TABLE lifecycle_defs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
states TEXT[] NOT NULL,
|
||||||
|
default_state TEXT NOT NULL,
|
||||||
|
terminal_states TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
transitions JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE entity_types (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
parent_type TEXT REFERENCES entity_types(name),
|
||||||
|
is_abstract BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
layer TEXT NOT NULL CHECK (layer IN ('meta','infrastructure','governance','cognition')),
|
||||||
|
description TEXT,
|
||||||
|
lifecycle_id TEXT REFERENCES lifecycle_defs(id),
|
||||||
|
attribute_schema JSONB,
|
||||||
|
schema_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE relationship_types (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
inverse TEXT,
|
||||||
|
source_type TEXT NOT NULL REFERENCES entity_types(name),
|
||||||
|
target_type TEXT NOT NULL REFERENCES entity_types(name),
|
||||||
|
cardinality TEXT NOT NULL CHECK (cardinality IN
|
||||||
|
('one-to-one','one-to-many','many-to-one','many-to-many')),
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE seed_versions (
|
||||||
|
file TEXT PRIMARY KEY,
|
||||||
|
content_hash TEXT NOT NULL,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
50
migrations/002_entities.up.sql
Normal file
50
migrations/002_entities.up.sql
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
-- Migration 002: Entity instances (UUIDv7 + slug, R3-5/D1)
|
||||||
|
-- The inventory graph: entities + typed relationships + blast_radius function.
|
||||||
|
|
||||||
|
CREATE TABLE entities (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
type TEXT NOT NULL REFERENCES entity_types(name),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
state TEXT,
|
||||||
|
attributes JSONB NOT NULL DEFAULT '{}',
|
||||||
|
maintenance_until TIMESTAMPTZ,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (type, name)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_entities_type ON entities(type);
|
||||||
|
CREATE INDEX idx_entities_state ON entities(state);
|
||||||
|
CREATE INDEX idx_entities_attrs ON entities USING GIN(attributes);
|
||||||
|
|
||||||
|
CREATE TABLE relationships (
|
||||||
|
source_id UUID NOT NULL REFERENCES entities(id) ON DELETE RESTRICT,
|
||||||
|
target_id UUID NOT NULL REFERENCES entities(id) ON DELETE RESTRICT,
|
||||||
|
type TEXT NOT NULL REFERENCES relationship_types(name),
|
||||||
|
attributes JSONB,
|
||||||
|
valid_from TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
valid_to TIMESTAMPTZ,
|
||||||
|
PRIMARY KEY (source_id, target_id, type, valid_from)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_rel_source ON relationships(source_id) WHERE valid_to IS NULL;
|
||||||
|
CREATE INDEX idx_rel_target ON relationships(target_id) WHERE valid_to IS NULL;
|
||||||
|
CREATE INDEX idx_rel_type ON relationships(type) WHERE valid_to IS NULL;
|
||||||
|
|
||||||
|
-- Cycle-safe traversal (P1): path accumulator prevents revisits; depth capped.
|
||||||
|
CREATE OR REPLACE FUNCTION blast_radius(start_id UUID, max_depth INT DEFAULT 3,
|
||||||
|
rel_types TEXT[] DEFAULT NULL)
|
||||||
|
RETURNS TABLE(entity_id UUID, depth INT) AS $$
|
||||||
|
WITH RECURSIVE walk AS (
|
||||||
|
SELECT start_id AS entity_id, 0 AS depth, ARRAY[start_id] AS path
|
||||||
|
UNION ALL
|
||||||
|
SELECT r.target_id, w.depth + 1, w.path || r.target_id
|
||||||
|
FROM relationships r
|
||||||
|
JOIN walk w ON r.source_id = w.entity_id
|
||||||
|
WHERE w.depth < LEAST(max_depth, 5)
|
||||||
|
AND r.valid_to IS NULL
|
||||||
|
AND NOT r.target_id = ANY(w.path)
|
||||||
|
AND (rel_types IS NULL OR r.type = ANY(rel_types))
|
||||||
|
)
|
||||||
|
SELECT entity_id, MIN(depth) FROM walk GROUP BY entity_id;
|
||||||
|
$$ LANGUAGE sql STABLE;
|
||||||
71
migrations/003_operations.up.sql
Normal file
71
migrations/003_operations.up.sql
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
-- Migration 003: Operations (signals, checks, approvals, status)
|
||||||
|
-- Signals are dual entities (entities row + signals table for indexed querying).
|
||||||
|
|
||||||
|
CREATE TABLE check_defs (
|
||||||
|
entity_id UUID PRIMARY KEY REFERENCES entities(id),
|
||||||
|
target_id UUID REFERENCES entities(id),
|
||||||
|
target_type TEXT REFERENCES entity_types(name),
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
config JSONB NOT NULL DEFAULT '{}',
|
||||||
|
interval_s INTEGER NOT NULL DEFAULT 600,
|
||||||
|
timeout_s INTEGER NOT NULL DEFAULT 10,
|
||||||
|
zone TEXT,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE signals (
|
||||||
|
entity_id UUID PRIMARY KEY REFERENCES entities(id),
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL CHECK (severity IN ('info','warning','critical')),
|
||||||
|
target_entity_id UUID REFERENCES entities(id),
|
||||||
|
check_id UUID REFERENCES check_defs(entity_id),
|
||||||
|
evidence TEXT,
|
||||||
|
likely_cause TEXT,
|
||||||
|
state TEXT NOT NULL DEFAULT 'raised',
|
||||||
|
occurrence_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
flap_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hold_down_until TIMESTAMPTZ,
|
||||||
|
mute_until TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
-- At most ONE open signal per (target, kind) — repeats update the open row
|
||||||
|
CREATE UNIQUE INDEX uq_signals_open ON signals(target_entity_id, kind)
|
||||||
|
WHERE state NOT IN ('resolved','failed');
|
||||||
|
CREATE INDEX idx_signals_state ON signals(state);
|
||||||
|
|
||||||
|
CREATE TABLE approvals (
|
||||||
|
entity_id UUID PRIMARY KEY REFERENCES entities(id),
|
||||||
|
subject_entity_id UUID REFERENCES entities(id),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
risk_class TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'execution',
|
||||||
|
payload JSONB,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
token_hash TEXT,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
decided_at TIMESTAMPTZ,
|
||||||
|
decided_by UUID REFERENCES entities(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE entity_status (
|
||||||
|
entity_id UUID PRIMARY KEY REFERENCES entities(id),
|
||||||
|
health TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
last_check_at TIMESTAMPTZ,
|
||||||
|
details JSONB NOT NULL DEFAULT '{}',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE idempotency_keys (
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
request_hash TEXT NOT NULL,
|
||||||
|
response_code INTEGER,
|
||||||
|
response_body JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (actor, key)
|
||||||
|
);
|
||||||
91
migrations/004_cognition.up.sql
Normal file
91
migrations/004_cognition.up.sql
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
-- Migration 004: Cognition (classifications, executions, learning)
|
||||||
|
-- All cognition objects are dual entities (entities row + typed table).
|
||||||
|
-- Classifications persist every autonomous decision (SA5).
|
||||||
|
|
||||||
|
CREATE TABLE classifications (
|
||||||
|
entity_id UUID PRIMARY KEY REFERENCES entities(id),
|
||||||
|
signal_entity_id UUID REFERENCES signals(entity_id),
|
||||||
|
target_entity_id UUID REFERENCES entities(id),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
recommended_action JSONB,
|
||||||
|
risk_class TEXT NOT NULL,
|
||||||
|
route TEXT NOT NULL CHECK (route IN ('auto-act','escalate','hold')),
|
||||||
|
blast_radius UUID[],
|
||||||
|
pattern_confidence REAL,
|
||||||
|
skill_id UUID,
|
||||||
|
autonomy_check TEXT,
|
||||||
|
reasoning JSONB NOT NULL,
|
||||||
|
correlation_id TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_class_signal ON classifications(signal_entity_id);
|
||||||
|
CREATE INDEX idx_class_entity ON classifications(target_entity_id);
|
||||||
|
|
||||||
|
CREATE TABLE executions (
|
||||||
|
entity_id UUID PRIMARY KEY REFERENCES entities(id),
|
||||||
|
classification_id UUID REFERENCES classifications(entity_id),
|
||||||
|
signal_entity_id UUID REFERENCES signals(entity_id),
|
||||||
|
target_entity_id UUID REFERENCES entities(id),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
risk_class TEXT NOT NULL,
|
||||||
|
approval_id UUID REFERENCES approvals(entity_id),
|
||||||
|
agent_id UUID REFERENCES entities(id),
|
||||||
|
skill_id UUID,
|
||||||
|
skill_version INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'proposed',
|
||||||
|
result JSONB,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
verified BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
correlation_id TEXT NOT NULL,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_exec_target ON executions(target_entity_id);
|
||||||
|
CREATE INDEX idx_exec_status ON executions(status);
|
||||||
|
|
||||||
|
CREATE TABLE feedback (
|
||||||
|
entity_id UUID PRIMARY KEY REFERENCES entities(id),
|
||||||
|
execution_id UUID NOT NULL REFERENCES executions(entity_id),
|
||||||
|
outcome TEXT NOT NULL CHECK (outcome IN ('success','failure','partial','unexpected')),
|
||||||
|
observation TEXT,
|
||||||
|
lesson TEXT,
|
||||||
|
unexpected_side_effects TEXT[],
|
||||||
|
tags TEXT[],
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_feedback_ts ON feedback(created_at);
|
||||||
|
|
||||||
|
CREATE TABLE patterns (
|
||||||
|
entity_id UUID PRIMARY KEY REFERENCES entities(id),
|
||||||
|
applies_type TEXT NOT NULL REFERENCES entity_types(name),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
pattern TEXT NOT NULL,
|
||||||
|
confidence REAL NOT NULL DEFAULT 0,
|
||||||
|
evidence_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'hypothesized',
|
||||||
|
quarantined BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_validated_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (applies_type, action)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE skills (
|
||||||
|
entity_id UUID NOT NULL REFERENCES entities(id),
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
procedure JSONB NOT NULL,
|
||||||
|
applies_type TEXT REFERENCES entity_types(name),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
pattern_ids UUID[],
|
||||||
|
status TEXT NOT NULL DEFAULT 'drafted',
|
||||||
|
success_rate REAL,
|
||||||
|
changed_by UUID,
|
||||||
|
change_reason TEXT,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (entity_id, version)
|
||||||
|
);
|
||||||
28
migrations/005_policy.up.sql
Normal file
28
migrations/005_policy.up.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- Migration 005: Policy (risk classes, approval rules, autonomy settings)
|
||||||
|
|
||||||
|
CREATE TABLE risk_classes (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
description TEXT,
|
||||||
|
approval_required TEXT NOT NULL DEFAULT 'none',
|
||||||
|
autonomy_allowed BOOLEAN NOT NULL DEFAULT false
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE approval_rules (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
entity_type TEXT REFERENCES entity_types(name),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
risk_class TEXT NOT NULL REFERENCES risk_classes(name),
|
||||||
|
autonomy_level TEXT NOT NULL DEFAULT 'escalate' CHECK
|
||||||
|
(autonomy_level IN ('auto','escalate','never')),
|
||||||
|
scope_entity UUID REFERENCES entities(id),
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (entity_type, action, scope_entity)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE autonomy_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
138
migrations/006_observability.up.sql
Normal file
138
migrations/006_observability.up.sql
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
-- Migration 006: Observability (TimescaleDB)
|
||||||
|
-- Hypertable PKs include time column (SG1); idempotent DDL (SG3); no array_agg in CAGGs (SG2).
|
||||||
|
|
||||||
|
-- Enable TimescaleDB extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||||
|
|
||||||
|
-- ─── Time-series metrics ──────────────────────────────────────────────
|
||||||
|
CREATE TABLE metric_samples (
|
||||||
|
ts TIMESTAMPTZ NOT NULL,
|
||||||
|
entity_id UUID NOT NULL,
|
||||||
|
metric TEXT NOT NULL,
|
||||||
|
value DOUBLE PRECISION NOT NULL,
|
||||||
|
tags JSONB NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
SELECT create_hypertable('metric_samples', 'ts',
|
||||||
|
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
|
||||||
|
CREATE INDEX idx_metrics_entity_ts ON metric_samples(entity_id, ts DESC);
|
||||||
|
CREATE INDEX idx_metrics_metric_ts ON metric_samples(metric, ts DESC);
|
||||||
|
DO $$ BEGIN
|
||||||
|
PERFORM add_retention_policy('metric_samples', INTERVAL '90 days');
|
||||||
|
EXCEPTION WHEN OTHERS THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 1-hour rollups
|
||||||
|
CREATE MATERIALIZED VIEW metric_rollups_1h WITH (timescaledb.continuous) AS
|
||||||
|
SELECT time_bucket('1 hour', ts) AS bucket, entity_id, metric,
|
||||||
|
avg(value) AS avg_value, min(value) AS min_value,
|
||||||
|
max(value) AS max_value, count(*) AS sample_count
|
||||||
|
FROM metric_samples GROUP BY bucket, entity_id, metric;
|
||||||
|
DO $$ BEGIN
|
||||||
|
PERFORM add_continuous_aggregate_policy('metric_rollups_1h',
|
||||||
|
start_offset => INTERVAL '2 hours',
|
||||||
|
end_offset => INTERVAL '5 minutes',
|
||||||
|
schedule_interval => INTERVAL '1 hour');
|
||||||
|
EXCEPTION WHEN OTHERS THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 1-day rollups
|
||||||
|
CREATE MATERIALIZED VIEW metric_rollups_1d WITH (timescaledb.continuous) AS
|
||||||
|
SELECT time_bucket('1 day', ts) AS bucket, entity_id, metric,
|
||||||
|
avg(value) AS avg_value, min(value) AS min_value,
|
||||||
|
max(value) AS max_value, count(*) AS sample_count
|
||||||
|
FROM metric_samples GROUP BY bucket, entity_id, metric;
|
||||||
|
DO $$ BEGIN
|
||||||
|
PERFORM add_continuous_aggregate_policy('metric_rollups_1d',
|
||||||
|
start_offset => INTERVAL '2 days',
|
||||||
|
end_offset => INTERVAL '1 hour',
|
||||||
|
schedule_interval => INTERVAL '1 day');
|
||||||
|
EXCEPTION WHEN OTHERS THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ─── Audit log ────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||||
|
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
actor_type TEXT NOT NULL,
|
||||||
|
actor_id UUID,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
entity_id UUID,
|
||||||
|
method TEXT,
|
||||||
|
path TEXT,
|
||||||
|
status_code INTEGER,
|
||||||
|
detail JSONB NOT NULL DEFAULT '{}',
|
||||||
|
source_ip TEXT,
|
||||||
|
correlation_id TEXT,
|
||||||
|
PRIMARY KEY (id, ts)
|
||||||
|
);
|
||||||
|
SELECT create_hypertable('audit_log', 'ts',
|
||||||
|
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
|
||||||
|
CREATE INDEX idx_audit_actor ON audit_log(actor_type, actor_id, ts DESC);
|
||||||
|
CREATE INDEX idx_audit_entity ON audit_log(entity_id, ts DESC);
|
||||||
|
CREATE INDEX idx_audit_action ON audit_log(action, ts DESC);
|
||||||
|
CREATE INDEX idx_audit_correlation ON audit_log(correlation_id);
|
||||||
|
DO $$ BEGIN
|
||||||
|
PERFORM add_retention_policy('audit_log', INTERVAL '365 days');
|
||||||
|
EXCEPTION WHEN OTHERS THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ─── Event log ────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE events (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||||
|
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
entity_id UUID,
|
||||||
|
severity TEXT NOT NULL DEFAULT 'info',
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
data JSONB NOT NULL DEFAULT '{}',
|
||||||
|
correlation_id TEXT,
|
||||||
|
PRIMARY KEY (id, ts)
|
||||||
|
);
|
||||||
|
SELECT create_hypertable('events', 'ts',
|
||||||
|
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
|
||||||
|
CREATE INDEX idx_events_type_ts ON events(type, ts DESC);
|
||||||
|
CREATE INDEX idx_events_entity_ts ON events(entity_id, ts DESC);
|
||||||
|
CREATE INDEX idx_events_severity_ts ON events(severity, ts DESC);
|
||||||
|
CREATE INDEX idx_events_correlation ON events(correlation_id);
|
||||||
|
DO $$ BEGIN
|
||||||
|
PERFORM add_retention_policy('events', INTERVAL '90 days');
|
||||||
|
EXCEPTION WHEN OTHERS THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ─── Agent activity ──────────────────────────────────────────────────
|
||||||
|
CREATE TABLE agent_activity (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||||
|
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
agent_id UUID NOT NULL,
|
||||||
|
session_id TEXT,
|
||||||
|
activity_type TEXT NOT NULL,
|
||||||
|
tool_name TEXT,
|
||||||
|
entity_id UUID,
|
||||||
|
input_summary TEXT,
|
||||||
|
output_summary TEXT,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
token_count INTEGER,
|
||||||
|
success BOOLEAN,
|
||||||
|
correlation_id TEXT,
|
||||||
|
PRIMARY KEY (id, ts)
|
||||||
|
);
|
||||||
|
SELECT create_hypertable('agent_activity', 'ts',
|
||||||
|
chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
|
||||||
|
CREATE INDEX idx_agent_activity_agent_ts ON agent_activity(agent_id, ts DESC);
|
||||||
|
CREATE INDEX idx_agent_activity_type_ts ON agent_activity(activity_type, ts DESC);
|
||||||
|
CREATE INDEX idx_agent_activity_entity ON agent_activity(entity_id, ts DESC);
|
||||||
|
CREATE INDEX idx_agent_activity_correlation ON agent_activity(correlation_id);
|
||||||
|
DO $$ BEGIN
|
||||||
|
PERFORM add_retention_policy('agent_activity', INTERVAL '90 days');
|
||||||
|
EXCEPTION WHEN OTHERS THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ─── Ledger view (R3-11: not a fourth write path) ────────────────────
|
||||||
|
CREATE VIEW ledger AS
|
||||||
|
SELECT e.created_at AS ts, e.entity_id AS execution_id, e.target_entity_id,
|
||||||
|
e.action, e.risk_class, e.status, e.verified,
|
||||||
|
c.route, c.reasoning, a.status AS approval_status, a.decided_by,
|
||||||
|
e.agent_id, e.correlation_id
|
||||||
|
FROM executions e
|
||||||
|
LEFT JOIN classifications c ON c.entity_id = e.classification_id
|
||||||
|
LEFT JOIN approvals a ON a.entity_id = e.approval_id;
|
||||||
7
migrations/embed.go
Normal file
7
migrations/embed.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Package migrations embeds SQL migration files for use by the db package.
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed *.up.sql
|
||||||
|
var FS embed.FS
|
||||||
Reference in New Issue
Block a user