# 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
