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:
2026-07-07 01:07:26 +02:00
parent 55710bd254
commit aa2ca0ae6f
23 changed files with 1964 additions and 0 deletions

View 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()
);

View 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;

View 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)
);

View 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)
);

View 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()
);

View 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
View 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