30 KiB
Oikos Entity Model — Types, Relationships & Interactions
Status: Adopted
Date: 2026-07-08
Scope: Full inventory of every entity type, relationship, state machine, and
cognition pipeline — with clear markers for what is code-real vs schema-only.
1. Entity Type Hierarchy (56 types)
layer: meta
entity ★ (abstract root)
layer: infrastructure ──────────────────────────────────────────────────
domain: physical
site ups sensor peripheral
domain: compute
compute-entity ★ (abstract)
machine ★ (abstract)
proxmox-host standalone-server workstation appliance
vm
container ★ (abstract)
lxc docker-container
hypervisor
domain: network
network ★ (abstract)
lan mesh vlan
network-interface dns-zone dns-record
ingress-route certificate firewall-rule
domain: storage
storage-pool volume backup-target dataset
domain: software
service application config-repo deploy-pipeline
package-set cluster compose-stack
domain: external
domain-registration cloud-service isp-link vendor-dependency
layer: governance ──────────────────────────────────────────────────────
domain: identity
person agent identity-provider account
secret key access-grant
layer: cognition ── the OODA loop ──────────────────────────────────────
domain: cognition
check signal classification execution feedback
pattern skill approval
document runbook investigation
★ = abstract (cannot be instantiated; acts as polymorphic target for relationships)
Concrete instances (88 active entities)
| Type | Count | Examples |
|---|---|---|
lxc |
19 | jellyfin, caddy, dns, gitea, nextcloud, matrix, arriman… |
service |
25 | caddy, authentik, dns, jellyfin, paperless, matrix… |
ingress-route |
21 | *.hubris.network |
config-repo |
6 | caddy-conf, gitea-customizations, mule-image… |
proxmox-host |
2 | hubris, strong |
workstation |
2 | mac-mini, republic-laptop |
standalone-server |
1 | netbird-vps |
vm |
2 | zimaos, haos |
storage-pool |
3 | local-lvm-hubris, library-hubris, ludo-lvm |
volume |
2 | library, media-local |
| + sites, networks, agents, documents, destroyed… |
2. Core Sequence: Machine Onboarding
Operator Oikos API DB Scheduler Target Machine
┌────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ POST │ │ │ │ │ │ │ │ │
│/entities│────>│Create │ │ │ │ │ │ │
│ │ │Entity() │ │ │ │ │ │ │
│ │ │ │────>│INSERT │ │ │ │ │
│ │ │ │ │entities │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ensure │ │ │ │ │ │ │
│ │ │Default │────>│INSERT │ │ │ │ │
│ │ │Checks() │ │check_defs│ │ │ │ │
│ │ │→ ping │ │×6 │ │ │ │ │
│ │ │→ cpu │ │ │ │ │ │ │
│ │ │→ memory │ │ │ │ │ │ │
│ │ │→ load │ │(target_id│ │ │ │ │
│ │ │→ disk │ │ set) │ │ │ │ │
│ │ │→ updates │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │<────│201 │ │ │ │ │ │ │
│ │ │Created │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
│ │ │ │ │ │ ── 30s tick ─>│ │ │
│ │ │ │ │ │ loads │ │ │
│ │ │ │ │ │ check_defs │ │ │
│ │ │ │ │ │ │──SSH────>│ │
│ │ │ │ │ │ │ /opt/ │ │
│ │ │ │ │ │ │ oikos/ │ │
│ │ │ │ │ │ │ checks/ │ │
│ │ │ │ │ │ │ cpu.sh │ │
│ │ │ │ │ │ │<──JSON───│ │
│ │ │ │ │<─────────│INSERT │ │ │
│ │ │ │ │metric │metric_samples │ │ │
│ │ │ │ │samples │ │ │ │
│ │ │ │ │ │ │ │ │
│ │ │ │ │<─────────│UPSERT │ │ │
│ │ │ │ │entity │entity_status │ │ │
│ │ │ │ │status │(health) │ │ │
│ │ │ │ │ │ │ │ │
What's code-real here:
CreateEntity()atinternal/httpapi/impl.go:811— handles POST, validates type, callsensureDefaultChecks()ensureDefaultChecks()→internal/checkdefaults/defaults.go:144— resolves host IP, SSH user, creates 6 check_defs rows with target_id- Scheduler at
internal/scheduler/scheduler.go:26— loadsListEnabledCheckDefs, dispatches by kind, writes metrics + signals
3. Core Sequence: The OODA Loop (observe → orient → decide → act)
┌─────────────────────────────────────────────────────────────────────┐
│ OBSERVE (Scheduler) │
│ │
│ Every 30s: │
│ ┌──────────┐ ListEnabledCheckDefs ┌──────────┐ │
│ │scheduler │─────────────────────────>│ Postgres │ │
│ │.go:54 │ │ │ │
│ └──────────┘ └──────────┘ │
│ │ │
│ ├── ping ──> exec.Command("ping", host) │
│ ├── http ──> http.Get(url) │
│ ├── tcp ──> net.DialTimeout("tcp", addr) │
│ ├── disk ──> unix.Statfs(path) │
│ ├── cert-expiry ──> tls.Dial + cert.NotAfter │
│ └── ssh-script ──> exec.Command("ssh", host, script) │
│ │ │
│ ┌───────┘ │
│ ▼ │
│ ┌─────────────┐ │
│ │ checkResult │ {health, signalKind, evidence, metrics}│
│ └─────────────┘ │
│ │ │
│ ┌──────────┼──────────┐ │
│ ▼ ▼ ▼ │
│ metric_samples signals entity_status │
│ INSERT UPSERT UPSERT │
│ (every cycle) (dedup by (health + last_check_at) │
│ target+kind) │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ ORIENT (Classification) │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ For each open signal: │ │
│ │ │ │
│ │ classify_by_policy(signal, entity, blast_radius) │ │
│ │ │ │ │
│ │ ├── read_only ───────────> route: auto_act │ │
│ │ ├── reversible_low ──────> route: auto_act │ │
│ │ │ (if global.auto_act=on + not in never_auto_act)│ │
│ │ ├── config_mutation ─────> route: escalate │ │
│ │ └── destructive ─────────> route: hold │ │
│ │ │ │
│ │ INSERT INTO classifications │ │
│ │ edge: classifies → signal │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ⚠ classification creation: schema defined, NOT yet wired │
│ (policy.ClassifySignal exists but scheduler doesn't call it) │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ DECIDE (Approval Gate) │
│ │
│ For route=auto_act: │
│ skip approval, execute immediately │
│ │
│ For route=escalate (config_mutation): │
│ POST /api/v1/executions ──> INSERT approval (status=pending) │
│ notifier.go sends Matrix alert with HMAC token │
│ operator replies ✅ or ❌ │
│ DecideApproval() → systemctl restart / apt upgrade │
│ │
│ For route=hold (destructive): │
│ queued for operator, requires explicit confirmation │
│ (never auto-executed even with global.auto_act=on) │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ ACT (Execution) │
│ │
│ ┌──────────┐ request_execution ┌──────────┐ │
│ │ Nomos │───────────────────────>│ MCP tool │ │
│ │ (agent) │ │ server.go │ │
│ └──────────┘ └──────────┘ │
│ │ │
│ ┌───────────┼───────────┐ │
│ ▼ ▼ ▼ │
│ reversible config_ destructive │
│ _low mutation │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ immediate approval hold │
│ execute queue (never auto) │
│ │ │ │
│ ▼ ▼ │
│ actuator. Matrix │
│ Execute() alert → │
│ (SSH exec) operator │
│ → approves │
│ → actuator.Execute() │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ LEARN (Patterns + Skills) │
│ │
│ execution ──produces──> feedback ──contributes-to──> pattern │
│ │ │
│ informs │
│ ▼ │
│ skill │
│ │
│ ⚠ Schema defined, NOT yet wired: │
│ - No code writes feedback records │
│ - No code transitions patterns hypothesized→validated │
│ - Skill execution against JSON procedure definitions not built │
└─────────────────────────────────────────────────────────────────────┘
What's code-real in the OODA loop
| Phase | Table | Code | Status |
|---|---|---|---|
| Observe | check_defs, metric_samples |
scheduler.go:26-215 |
✅ fully wired, 6 probe kinds |
| Observe → Orient | signals |
scheduler.go:130-141 (UpsertSignal) |
✅ dedup, severity, events |
| Orient | classifications |
policy/classify.go (function exists) |
⚠ function defined but scheduler never calls it |
| Decide | approvals |
server.go:311-367 (request_execution) |
✅ escalation gate works |
| Act | executions |
actuator/exec.go (SSH exec) |
✅ systemctl, apt, pct |
| Learn | feedback, patterns, skills |
tables + list endpoints only | ⚠ schema only, no write path |
4. Relationship Types — The Edge Catalog (34 edges)
Infrastructure Topology
host:hubris ──hosts──> lxc:jellyfin, lxc:caddy, lxc:dns, ... (machine provisions LXCs)
host:strong ──hosts──> lxc:jellyfin, lxc:arriman, ... (migrated LXCs)
host:hubris ──member-of──> cluster:homelab
host:strong ──member-of──> cluster:homelab
lxc:caddy ──provides──> service:caddy
lxc:gitea ──provides──> service:gitea
lxc:dns ──provides──> service:dns
host:hubris ──mounts──> volume:library (attrs: mount_point=/mnt/library)
host:hubris ──stores-on──> pool:library-hubris
Network
ingress:paperless.hubris.network ──routes-to──> service:paperless
ingress:paperless.hubris.network ──secured-by──> idp:authentik
ingress:paperless.hubris.network ──uses-certificate──> cert:*.hubris.network
service:jellyfin ──authenticates-via──> idp:authentik (OIDC)
dns:paperless ──in-zone──> zone:hubris.network
dns:paperless ──resolves-to──> lxc:caddy (caddy terminates)
host:hubris ──connects-via──> lan:lab
host:strong ──connects-via──> lan:household
Service Dependencies
service:jellyfin ──depends-on──> service:authentik (OIDC auth)
service:paperless ──depends-on──> service:authentik
service:arr-stack ──depends-on──> service:jellyfin
(depends-on edges feed blast_radius() — recursive CTE)
Cognition (OODA edges)
check:ssh-script:d419257d ──checks──> host:hubris
check:ssh-script:d419257d ──raises──> signal:cpu-pressure (when unhealthy)
signal:cpu-pressure ──about──> host:hubris
classification:xyz ──classifies──> signal:cpu-pressure
classification:xyz ──precedes──> execution:restart-xyz
execution:restart-xyz ──targets──> host:hubris
execution:restart-xyz ──performs──> agent:nomos
Governance
person:dtoro ──owns──> agent:nomos
person:dtoro ──decides──> approval:xyz
idp:authentik ──authenticates──> person:dtoro
5. Lifecycle State Machines
Infrastructure (15 concrete types use this)
planned ──> provisioning ──> active ──> migrating ──> active
│ │ │ │
│ │ └── failed ──┘
│ │ └── deprecated ──> destroyed
│ │
│ └── failed ──> active (recovery)
│
└── destroyed (cancelled)
Terminal: [destroyed]
Default: active
Real precondition checks (code in impl.go:1494-1579):
| Transition | Precondition | How it's checked |
|---|---|---|
| provisioning→active | health-check-answering |
SELECT health FROM entity_status WHERE entity_id=$1 — must be healthy |
| provisioning→active | age-key-enrolled-if-needed |
Checks attributes->>'age_pubkey' (workstation only) |
| provisioning→active | mesh-joined-if-needed |
Checks attributes->>'mesh_ip' (workstation only) |
| provisioning→active | doc-page-complete |
SELECT count(*) FROM relationships WHERE target_id=$1 AND type='documents' |
| deprecated→destroyed | no-inbound-edges |
SELECT count(*) FROM relationships WHERE target_id=$1 AND valid_to IS NULL |
| any → terminated | backups-verified |
Checks flag in entity attributes |
| any → terminated | secrets-revoked |
Checks flag in entity attributes |
Soft preconditions (always pass — operator-confirmed): inventory-entry, ip-reserved, preflight-passed, backup-verified, replacement-live, caddy-backends-checked, un-deprecate-note, etc.
Signal
raised ──> acknowledged ──> acting ──> resolved
│ │ │
├── muted ├── muted ├── raised (retry budget)
│ │ │
└── resolved└── resolved └── failed ──> acknowledged (operator-retry)
Terminal: [resolved]
Default: raised
Implemented preconditions:
raised → muted: requiresmute_untilset (MuteSignal handler,impl.go:615-689)acting → resolved: requiresverification-passed(soft — operator confirms)
Dedup mechanism: UNIQUE INDEX uq_signals_open ON signals(target_entity_id, kind) WHERE state NOT IN ('resolved','failed') — at most one open signal per (entity, kind). Repeated failures call UpsertSignal which increments occurrence_count on the existing row.
6. What's Code-Real vs Schema-Only
✅ Fully Implemented (code exists, running in production)
| Component | File(s) | What it does |
|---|---|---|
| Entity CRUD | impl.go:811-966 |
Create, read, patch, list entities |
| Lifecycle transitions | impl.go:1494-1579 |
Precondition checks + state transitions |
| Relationship management | seed.go (ingest) |
Create edges with valid_from/valid_to |
| Client enrollment | impl.go:1134-1236 |
POST /clients/enroll — age keypair, Infisical, state: provisioning |
| Check definitions | phase3.go:265-376 |
CreateCheck, ListChecks, PatchCheck |
| Scheduler observe | scheduler.go:26-215 |
6 probe kinds, metric_samples, signals, entity_status |
| Signals | scheduler.go:81-161 |
UpsertSignal (dedup), ResolveSignal, severity evaluation |
| Executions | server.go:286-411 |
request_execution MCP tool — reversible_low/config_mutation/destructive |
| Approvals | server.go:970-1052 |
createApproval, DecideApproval → executeApprovedAction |
| Notifier | notifier/notifier.go |
Matrix alerts for pending approvals |
| Patterns | phase3.go:1100+ |
ListPatterns, PatchPattern (status/quarantine) |
| Skills | phase3.go:1300+ |
ListSkills, PatchSkill, ListSkillVersions |
| Default checks | checkdefaults/defaults.go |
Auto-create checks on entity creation/enrollment/seed |
| TimescaleDB metrics | metric_samples table |
Hypertable with 1h/1d continuous aggregates, 90-day retention |
| Events + SSE | events table + pg_notify |
Real-time UI updates via SSE endpoint |
| Audit log | audit_log hypertable |
Every mutation with actor + action |
| Knowledge entities | knowledge_entities |
Documents, runbooks, investigations with FTS |
| MCP tools | server.go |
24 tools for observe/orient/decide/act |
| Blast radius | blast_radius() fn |
Recursive CTE — depends-on + hosts + routes-to edges |
⚠ Schema Defined, Not Yet Wired (table exists, no active code path creates rows)
| Component | What's Missing |
|---|---|
classifications auto-creation |
policy.ClassifySignal() exists but scheduler never calls it. Signals are raised but never automatically classified. The GetOpenSignalsForAutoAct query would return signals with auto-act classification, but the classify step is manual-only. |
feedback records |
No code writes to the feedback table. Execution results are not analyzed for patterns. |
| Pattern auto-learning | No code transitions patterns from hypothesized → validated. The lifecycle requires evidence-count≥5 + confidence≥0.7 but no aggregation runs. |
| Skill execution | Skill entities carry a JSON procedure field but no execution engine reads or runs it. |
drift check kind |
Defined in OpenAPI and check_defs.kind enum, but no scheduler implementation exists. |
📋 Defined in Seeds Only (ontology.yaml references, no DB schema)
| Item | Notes |
|---|---|
Relationship type cluster |
Mentioned in inventory but not in ontology relationship_types |
certificate entity type |
Referenced in uses-certificate edges but no concrete certificates in inventory |
Relationship type powers / monitors |
Not defined in relationship_types |
7. Database Physical Schema (Key Tables)
entity_types ──FK──> lifecycle_defs
│
│ FK (entities.type)
▼
entities ──FK──> entity_types
│
├──FK──> entity_status (dual)
├──FK──> check_defs (dual; check_defs.target_id → entities)
├──FK──> signals (dual; signals.target_entity_id → entities)
├──FK──> classifications (dual)
├──FK──> executions (dual; executions.target_entity_id → entities)
├──FK──> feedback (dual)
├──FK──> patterns (dual)
├──FK──> skills (dual)
├──FK──> approvals (dual; approvals.subject_entity_id → entities)
├──FK──> knowledge_entities (dual)
└──>→ relationships (source_id, target_id → entities)
relationship_types ──FK──> entity_types (source_type, target_type)
│
│ FK (relationships.type)
▼
relationships ──FK──> entities (source_id, target_id)
│
└── unique index: (source_id, target_id, type) WHERE valid_to IS NULL
approval_rules ──FK──> entity_types (entity_type)
autonomy_settings (key/value, no FKs)
risk_classes (standalone)
metric_samples (TimescaleDB hypertable — ts dimension)
events (TimescaleDB hypertable — ts dimension, pg_notify trigger for SSE)
audit_log (TimescaleDB hypertable — ts dimension)
agent_activity (TimescaleDB hypertable — ts dimension)
Key architectural patterns:
- Dual entities:
check_defs,signals,classifications,executions,feedback,patterns,skills,approvals,knowledge_entities— all haveentity_id UUID PK REFERENCES entities(id). Every row is also an entity. - Partial unique indexes:
relationships(current edges),signals(open signals),patterns(per-type action) — all useWHEREclauses for snapshot semantics. - TimescaleDB: 4 hypertables with continuous aggregates and retention policies.
- SSE fan-out:
pg_notify('oikos_events', ...)trigger oneventsINSERT → Go listener fan-out → SSE connections.
8. How Nomos Queries Thermals — End-to-End Trace
User: "what are the thermals of hubris?"
│
▼
Nomos calls MCP: query_metrics(metric=["cpu_pct","cpu_temp"])
│
▼
server.go:getMetricHistory()
│
▼
SELECT time_bucket('1h', ts) AS bucket,
avg(value), min(value), max(value)
FROM metric_samples
WHERE metric IN ('cpu_pct', 'cpu_temp')
AND entity_id = (SELECT id FROM entities WHERE slug = 'host:hubris')
GROUP BY bucket
│
▼
Returns: cpu_pct ≈ 15%, cpu_temp ≈ 48°C (from TimescaleDB continuous aggregate)
│
▼
Nomos formats and presents results to user
What made this possible (chronologically):
scheduler.gorefactored to return metrics map →checkResult{metrics}ssh-scriptcheck kind implemented → SSH exec to remote hostcpu_check.shdeployed to hubris → returns{"metrics":{"cpu_pct":2.5,"cpu_temp":48}}- Check created:
POST /checks {"kind":"ssh-script","target":"host:hubris","config":{"host":"192.168.8.77","script":"cpu_check.sh"}} - Fixed:
InsertMetricSamplemissingtscolumn →now()literal - Fixed: SSH port/user parsing bugs
- Fixed: SSH warnings polluting JSON output
- Scheduler loop → metrics written to TimescaleDB every 60s
- MCP
query_metricsreads from TimescaleDB → Nomos gets live data