Files
oikos/docs/adr/0014-entity-model.md
dtoro d80a394b7f
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
docs: fix plan/repo drift, retire dead Goose+Nomos and Caveman tooling
Documentation and repo-hygiene pass following the client/server split:

Plan drift (audited all other active plans against current code):
- oikos-gaps-and-improvements.md: mark Section C and D.5 resolved (both
  described cmd/hermes, renamed to cmd/nomos with a real LLM loop since);
  refresh ~10 stale file:line citations; fix tool-count (33, not 28).
- liveness-drift-and-ux-cohesion.md: fix stale default-model claim (now
  deepseek-v4-pro since 2026-07-10) and "not yet deployed" status.
- nomos-agent-code-review.md: fix C1's citation (one unauthenticated route
  to nomos now, not two, after the client/server split).
- wails-desktop-app.md: record the production deploy outcome.

Repo structure: added missing directories to README/CONTRIBUTING layout
tables (checks/, tools/, cmd/webhook/, docs/operations/), fixed a broken
link, added ADR 0015 documenting the auth/CORS/client-split model (there
wasn't one despite CONTRIBUTING's own process requiring it), normalized
ADR 0013/0014's format drift, added an Authentication section to
AGENTS.md/CLIENTS.md (every example call was missing the now-required
bearer header).

Retired the Goose+Nomos workstation flow (bootstrap.sh --with-nomos,
tools/setup-nomos-soul.sh, .agents/operations/nomos-agent.md) and the
Caveman auto-install tooling (tools/setup-caveman.sh, tools/caveman/) —
both superseded by the production containerized Nomos agent, which has
never used either. Kept .agents/shared/caveman.md itself (the terse
writing-style convention agents still follow by reading it).

Deleted the orphaned legacy Python oikos/ directory — nothing imports it,
and bin/homelab (the CLI it was kept for) no longer exists in the repo.

Rewrote .agents/operations/agent-enrollment.md (365 -> ~110 lines) and
commands.md to match the current architecture instead of the retired
`homelab` CLI; migrated the still-true networking prerequisites (Netbird,
split-horizon DNS, SSH key distribution) into the knowledge base as a
runbook via upsert_knowledge rather than duplicating them in markdown.
Updated all 10 .agents/skills/ runbooks referencing the dead CLI with
their real MCP tool / REST API equivalents, or flagged them as needing
verification where no equivalent is confirmed yet.

Two real bugs found and fixed, not just docs:
- The tools/setup-*.sh auto-setup glob was tools/*.setup.sh in THREE
  places (tools/post-pull.sh, bootstrap.sh, and internal/httpapi/impl.go's
  GetClientContext handler) since the mechanism's introduction on
  2026-06-02 — never matched any real filename, so no client has ever
  picked up an auto-setup script via git-pull or the context-poller sync.
  Fixed all three; the Go server-side fix is the one that actually matters
  since it's what the current context-poller mechanism depends on.
- bootstrap.sh removed dead vestigial --gitea-token/--gitea-user flags
  (parsed, never consumed) left over from an earlier clone-based model.

Also flagged, not fixed (documented as an open gap in
client-enrollment/SKILL.md): bootstrap.sh tells a freshly-enrolled client
to call POST /api/v1/clients/{slug}/activate to finish enrollment, but
that route doesn't exist in api/openapi.yaml — EnrollClient sets entities
to provisioning and nothing currently transitions them to active.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 18:19:41 +02:00

20 KiB
Raw Permalink Blame History

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)

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

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)

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

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

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)

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)

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

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)

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

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

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

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

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

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