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>
20 KiB
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()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 → 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: 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.
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 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 hypertables:
metric_samples,events,audit_log,agent_activity— 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
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):
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