# ADR 0014 — Entity model: types, relationships & interactions **Status:** Accepted **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) ```mermaid graph TD subgraph meta["layer: meta"] entity["★ entity"] end subgraph infrastructure["layer: infrastructure"] subgraph physical["domain: physical"] site ups sensor peripheral end subgraph compute["domain: compute"] ce["★ compute-entity"] machine["★ machine"] proxmox-host standalone-server workstation appliance vm container["★ container"] lxc docker-container hypervisor end subgraph network["domain: network"] net["★ network"] lan mesh vlan network-interface dns-zone dns-record ingress-route certificate firewall-rule end subgraph storage["domain: storage"] storage-pool volume backup-target dataset end subgraph software["domain: software"] service application config-repo deploy-pipeline package-set cluster compose-stack end subgraph external["domain: external"] domain-registration cloud-service isp-link vendor-dependency end end subgraph governance["layer: governance"] subgraph identity["domain: identity"] person agent identity-provider account secret key access-grant end end subgraph cognition["layer: cognition"] check signal classification execution feedback pattern skill approval document runbook investigation end entity --> ce entity --> machine entity --> net entity --> container entity --> site entity --> ups entity --> sensor entity --> peripheral entity --> vm entity --> hypervisor entity --> network-interface entity --> dns-zone entity --> dns-record entity --> ingress-route entity --> certificate entity --> firewall-rule entity --> storage-pool entity --> volume entity --> backup-target entity --> dataset entity --> service entity --> application entity --> config-repo entity --> deploy-pipeline entity --> package-set entity --> cluster entity --> compose-stack entity --> domain-registration entity --> cloud-service entity --> isp-link entity --> vendor-dependency entity --> person entity --> agent entity --> identity-provider entity --> account entity --> secret entity --> key entity --> access-grant entity --> check entity --> signal entity --> classification entity --> execution entity --> feedback entity --> pattern entity --> skill entity --> approval entity --> document entity --> runbook entity --> investigation ce --> machine ce --> container machine --> proxmox-host machine --> standalone-server machine --> workstation machine --> appliance container --> lxc container --> docker-container net --> lan net --> mesh net --> vlan ``` ★ = abstract (cannot be instantiated; 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 ```mermaid sequenceDiagram participant O as Operator participant A as Oikos API participant D as DB participant S as Scheduler participant T as Target Machine O->>A: POST /api/v1/entities {type:proxmox-host, slug:host:new, …} A->>D: INSERT INTO entities A->>A: ensureDefaultChecks().resolveHost() → lan_ip A->>D: INSERT check_defs × 6 (target_id set) A-->>O: 201 Created Note over S,T: 30s scheduler tick S->>D: ListEnabledCheckDefs S->>T: SSH exec /opt/oikos/checks/cpu_check.sh T-->>S: {"health":"ok","metrics":{"cpu_pct":2.5,"cpu_temp":48}} S->>D: INSERT metric_samples S->>D: UPSERT entity_status (health) ``` **What's code-real here:** - `CreateEntity()` at `internal/httpapi/impl.go:811` — handles POST, validates type, calls `ensureDefaultChecks()` - `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` — loads `ListEnabledCheckDefs`, dispatches by kind, writes metrics + signals --- ## 3. Core Sequence: The OODA Loop (observe → orient → decide → act → learn) ```mermaid flowchart TB subgraph OBSERVE["🔍 OBSERVE (Scheduler every 30s)"] direction TB S1["ListEnabledCheckDefs"] S2["ping → exec.Command(ping, host)"] S3["http → http.Get(url)"] S4["tcp → net.DialTimeout(tcp, addr)"] S5["disk → unix.Statfs(path)"] S6["cert-expiry → tls.Dial + cert.NotAfter"] S7["ssh-script → exec.Command(ssh, host, script)"] S8["checkResult{health, signalKind, evidence, metrics}"] S1 --> S2 & S3 & S4 & S5 & S6 & S7 S2 & S3 & S4 & S5 & S6 & S7 --> S8 S8 -->|INSERT| MS[("metric_samples")] S8 -->|UPSERT| SG["signals (dedup by target+kind)"] S8 -->|UPSERT| ES["entity_status (health)"] end subgraph ORIENT["🧭 ORIENT (Classification)"] direction TB C1["policy.ClassifySignal(signal, entity, blast_radius)"] C2["read_only → route: auto_act"] C3["reversible_low → route: auto_act"] C4["config_mutation → route: escalate"] C5["destructive → route: hold"] C1 --> C2 & C3 & C4 & C5 end subgraph DECIDE["⚖️ DECIDE (Approval Gate)"] direction TB D1["auto_act → execute immediately"] D2["escalate → INSERT approval (pending)"] D3["notifier → Matrix alert + HMAC token"] D4["operator replies ✅ or ❌"] D5["hold → queued, never auto-executed"] D2 --> D3 --> D4 end subgraph ACT["⚡ ACT (Execution)"] direction TB A1["Nomos → MCP request_execution"] A2["actuator.Execute() → SSH exec"] A3["systemctl restart / apt upgrade / pct exec"] A1 --> A2 --> A3 end subgraph LEARN["🧠 LEARN (Patterns + Skills)"] direction TB L1["execution → produces → feedback"] L2["feedback → contributes-to → pattern"] L3["pattern → informs → skill"] L1 --> L2 --> L3 end SG --> ORIENT ORIENT --> DECIDE DECIDE --> ACT ACT --> LEARN style OBSERVE fill:#e3f2fd style ORIENT fill:#fff3e0 style DECIDE fill:#fce4ec style ACT fill:#e8f5e9 style LEARN fill:#f3e5f5 ``` ### 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 ```mermaid graph LR HH["host:hubris"] -->|hosts| LX1["lxc:jellyfin"] HH -->|hosts| LX2["lxc:caddy"] HH -->|hosts| LX3["lxc:dns"] HH -->|hosts| LX4["lxc:gitea"] HH -->|hosts| LX5["lxc:…"] HS["host:strong"] -->|hosts| LX6["lxc:jellyfin"] HS -->|hosts| LX7["lxc:arriman"] HH -->|member-of| CL["cluster:homelab"] HS -->|member-of| CL LX2 -->|provides| SV1["service:caddy"] LX4 -->|provides| SV2["service:gitea"] LX3 -->|provides| SV3["service:dns"] HH -->|mounts| VL["volume:library"] HH -->|stores-on| PL["pool:library-hubris"] ``` ### Network ```mermaid graph LR IG["ingress:paperless.hubris.network"] -->|routes-to| SP["service:paperless"] IG -->|secured-by| IDP["idp:authentik"] IG -->|uses-certificate| CRT["cert:*.hubris.network"] SJ["service:jellyfin"] -->|authenticates-via| IDP DNS["dns:paperless"] -->|in-zone| ZN["zone:hubris.network"] DNS -->|resolves-to| LX["lxc:caddy"] HH["host:hubris"] -->|connects-via| LL["lan:lab"] HS["host:strong"] -->|connects-via| LH["lan:household"] ``` ### Service Dependencies (feeds blast_radius CTE) ```mermaid graph LR JF["service:jellyfin"] -->|depends-on| AK["service:authentik"] PP["service:paperless"] -->|depends-on| AK AR["service:arr-stack"] -->|depends-on| JF AK -->|depends-on| CD["service:caddy"] AK -->|depends-on| DNS["service:dns"] ``` ### Cognition (OODA edges) ```mermaid graph LR CK["check:ssh-script:d419257d"] -->|checks| HH["host:hubris"] CK -->|raises| SG["signal:cpu-pressure"] SG -->|about| HH CL["classification:xyz"] -->|classifies| SG CL -->|precedes| EX["execution:restart-xyz"] EX -->|targets| HH EX -->|performs| AG["agent:nomos"] ``` ### Governance ```mermaid graph LR DT["person:dtoro"] -->|owns| NO["agent:nomos"] DT -->|decides| AP["approval:xyz"] AK["idp:authentik"] -->|authenticates| DT ``` --- ## 5. Lifecycle State Machines ### Infrastructure (15 concrete types use this) ```mermaid stateDiagram-v2 [*] --> planned planned --> provisioning planned --> destroyed: cancelled provisioning --> active provisioning --> failed active --> migrating active --> failed active --> deprecated migrating --> active: post-verify migrating --> failed failed --> active: recovery verified failed --> deprecated: write-off deprecated --> active: un-deprecate deprecated --> destroyed destroyed --> [*] ``` **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 ```mermaid stateDiagram-v2 [*] --> raised raised --> acknowledged raised --> muted: mute_until set raised --> resolved: condition cleared acknowledged --> acting: classification exists acknowledged --> muted acknowledged --> resolved acting --> resolved: verification passed acting --> raised: retry budget acting --> failed failed --> acknowledged: operator retry muted --> raised: mute_until expired resolved --> [*] ``` **Implemented preconditions:** - `raised → muted`: requires `mute_until` set (MuteSignal handler, `impl.go:615-689`) - `acting → resolved`: requires `verification-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. ### Execution ```mermaid stateDiagram-v2 [*] --> proposed proposed --> approved proposed --> auto_approved proposed --> denied proposed --> expired approved --> executing auto_approved --> executing expired --> [*] denied --> [*] executing --> verified executing --> failed executing --> timed_out failed --> rolled_back rolled_back --> verified rolled_back --> rollback_failed verified --> [*] rollback_failed --> [*] timed_out --> [*] ``` ### Approval ```mermaid stateDiagram-v2 [*] --> pending pending --> approved pending --> denied approved --> revoked approved --> expired denied --> [*] revoked --> [*] expired --> [*] ``` --- ## 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. | | `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`. 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 ```mermaid erDiagram lifecycle_defs ||--o{ entity_types : "lifecycle_id FK" entity_types ||--o{ entities : "type FK" entity_types ||--o| entity_types : "parent_type FK (self-ref)" entities ||--o| entity_status : "dual entity (shared PK)" entities ||--o| check_defs : "dual entity (shared PK)" entities ||--o| signals : "dual entity (shared PK)" entities ||--o| classifications : "dual entity (shared PK)" entities ||--o| executions : "dual entity (shared PK)" entities ||--o| feedback : "dual entity (shared PK)" entities ||--o| patterns : "dual entity (shared PK)" entities ||--o| skills : "dual entity (shared PK)" entities ||--o| approvals : "dual entity (shared PK)" entities ||--o| knowledge_entities : "dual entity (shared PK)" entities ||--o{ relationships : "source_id FK" entities ||--o{ relationships : "target_id FK" check_defs }o--|| entities : "target_id FK" signals }o--|| entities : "target_entity_id FK" executions }o--|| entities : "target_entity_id FK" approvals }o--|| entities : "subject_entity_id FK" entity_types ||--o{ relationship_types : "source_type FK" entity_types ||--o{ relationship_types : "target_type FK" relationship_types ||--o{ relationships : "type FK" entity_types ||--o{ approval_rules : "entity_type FK" signals ||--o| check_defs : "check_id FK" ``` **Key architectural patterns:** - **Dual entities:** `check_defs`, `signals`, `classifications`, `executions`, `feedback`, `patterns`, `skills`, `approvals`, `knowledge_entities` — all have `entity_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 use `WHERE` clauses for snapshot semantics. - **TimescaleDB hypertables:** `metric_samples`, `events`, `audit_log`, `agent_activity` — with continuous aggregates and retention policies. - **SSE fan-out:** `pg_notify('oikos_events', ...)` trigger on `events` INSERT → Go listener fan-out → SSE connections. --- ## 8. How Nomos Queries Thermals — End-to-End Trace ```mermaid sequenceDiagram participant U as User participant N as Nomos (Agent) participant A as Oikos API participant T as TimescaleDB participant S as Scheduler participant H as Hubris Note over S,H: Autonomous collection (every 60s) S->>H: SSH exec cpu_check.sh H-->>S: {"metrics":{"cpu_pct":15,"cpu_temp":48}} S->>T: INSERT metric_samples (cpu_pct, cpu_temp) Note over U,N: User asks question U->>N: "what are the thermals of hubris?" N->>A: MCP query_metrics(metric=["cpu_pct","cpu_temp"]) A->>T: SELECT time_bucket('1h', ts) … FROM metric_samples T-->>A: {avg:15, min:2, max:40} (cpu_pct) T-->>A: {avg:48, min:42, max:85} (cpu_temp) A-->>N: time-bucketed metrics N-->>U: "CPU at 15%, temp 48°C — normal range" ``` **What made this possible (chronologically):** 1. `scheduler.go` refactored to return metrics map → `checkResult{metrics}` 2. `ssh-script` check kind implemented → SSH exec to remote host 3. `cpu_check.sh` deployed to hubris → returns `{"metrics":{"cpu_pct":2.5,"cpu_temp":48}}` 4. Check created: `POST /checks {"kind":"ssh-script","target":"host:hubris","config":{"host":"192.168.8.77","script":"cpu_check.sh"}}` 5. Fixed: `InsertMetricSample` missing `ts` column → `now()` literal 6. Fixed: SSH port/user parsing bugs 7. Fixed: SSH warnings polluting JSON output 8. Scheduler loop → metrics written to TimescaleDB every 60s 9. MCP `query_metrics` reads from TimescaleDB → Nomos gets live data