adr: move to docs/adr/, renumber 0013 + 0014, update README index
This commit is contained in:
206
docs/adr/0013-signal-triggers.md
Normal file
206
docs/adr/0013-signal-triggers.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# Signal Trigger Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
When Nomos is asked "what are the thermals of hubris?", here is exactly what happens:
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Nomos │ │ Oikos │ │Scheduler │ │ Hubris │
|
||||
│ (Agent) │ │ API │ │ (Docker) │ │(Proxmox) │
|
||||
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
|
||||
│ │ │ │
|
||||
│ query_metrics │ │ │
|
||||
│────────────────>│ │ │
|
||||
│ │ │ │
|
||||
│ ← cpu_pct=2.5 │ SELECT FROM │ │
|
||||
│ cpu_temp=48 │ metric_samples│ │
|
||||
│<────────────────│ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
══════ Every 60s (autonomous loop) ══════ │
|
||||
│ │ │ │
|
||||
│ │ │ SSH exec │
|
||||
│ │ │─────────────────>│
|
||||
│ │ │ /opt/oikos/ │
|
||||
│ │ │ checks/ │
|
||||
│ │ │ cpu_check.sh │
|
||||
│ │ │ │
|
||||
│ │ │ {"health":"ok", │
|
||||
│ │ │ "metrics": │
|
||||
│ │ │ {"cpu_pct":2.5, │
|
||||
│ │ │ "cpu_temp":48}}│
|
||||
│ │ │<─────────────────│
|
||||
│ │ │ │
|
||||
│ │ │ INSERT │
|
||||
│ │ │ metric_samples │
|
||||
│ │ │ │
|
||||
│ │ │ UPSERT signal │
|
||||
│ │ │ (dedup) │
|
||||
│ │ │ │
|
||||
```
|
||||
|
||||
## Two Paths
|
||||
|
||||
### Path A — Autonomous Collection (Scheduler)
|
||||
|
||||
1. Operator creates a check via REST API: `POST /api/v1/checks`
|
||||
2. Scheduler loads enabled checks every 30s from `check_defs` table
|
||||
3. For `ssh-script` checks, scheduler SSHs to target host and runs `/opt/oikos/checks/<script>.sh`
|
||||
4. Script returns JSON with `health`, `signalKind`, `evidence`, and `metrics`
|
||||
5. Metrics written to TimescaleDB `metric_samples` table **every cycle** (healthy or not)
|
||||
6. If unhealthy: a signal is raised (deduplicated by target_entity_id + kind)
|
||||
7. If healthy again: the signal is auto-resolved, entity_status health updated
|
||||
8. All state changes emit SSE events for real-time UI updates
|
||||
|
||||
### Path B — Query (Nomos via MCP)
|
||||
|
||||
1. Nomos calls `query_metrics(metric=["cpu_pct","cpu_temp"])` MCP tool
|
||||
2. API runs time-bucketed aggregation over `metric_samples`
|
||||
3. Returns latest readings with avg/min/max per bucket
|
||||
4. Nomos formats them and presents to the user
|
||||
|
||||
## Check Kinds
|
||||
|
||||
| Kind | Where it runs | Protocol | Example |
|
||||
|------|--------------|----------|---------|
|
||||
| `ping` | Scheduler container | ICMP (`ping` binary) | Reachability + latency |
|
||||
| `http` | Scheduler container | HTTP GET | Service endpoint health |
|
||||
| `tcp` | Scheduler container | TCP dial | Port open check |
|
||||
| `disk` | Scheduler container | `unix.Statfs` | Local disk usage + inodes |
|
||||
| `cert-expiry` | Scheduler container | TLS dial | Certificate days remaining |
|
||||
| `ssh-script` | Remote target via SSH | SSH exec + JSON | Any script in `/opt/oikos/checks/` |
|
||||
|
||||
## Available Check Scripts
|
||||
|
||||
All scripts live in `/opt/oikos/checks/` on target hosts. They output JSON:
|
||||
|
||||
```json
|
||||
{"health":"healthy","metrics":{"cpu_pct":2.5,"cpu_temp":48.0}}
|
||||
```
|
||||
or on failure:
|
||||
```json
|
||||
{"health":"degraded","signalKind":"disk-smart-fail","evidence":"SMART failed for Samsung 990"}
|
||||
```
|
||||
|
||||
| Script | Metrics | Signal (on failure) |
|
||||
|--------|---------|---------------------|
|
||||
| `cpu_check.sh` | `cpu_pct`, `cpu_temp` | threshold-based |
|
||||
| `memory_check.sh` | `mem_pct` | threshold-based |
|
||||
| `load_check.sh` | `load1`, `cores` | threshold-based |
|
||||
| `swap_check.sh` | `swap_pct` | threshold-based |
|
||||
| `disk_usage_check.sh` | `disk_*_pct`, `inode_*_pct` | threshold-based |
|
||||
| `disk_smart_check.sh` | — | `disk-smart-fail` |
|
||||
| `updates_check.sh` | `security_updates`, `reboot_required` | threshold-based |
|
||||
| `zfs_check.sh` | — | `zfs-degraded`, `zfs-scrub-overdue` |
|
||||
| `process_check.sh` | — | `<service-name>` |
|
||||
| `uptime_check.sh` | `uptime_seconds` | threshold-based |
|
||||
| `oom_check.sh` | `oom_count` | `oom-kills` |
|
||||
| `journal_check.sh` | `journal_errors` | `journal-errors` |
|
||||
| `time_check.sh` | `clock_drift_s` | `time-drift` |
|
||||
| `fd_check.sh` | `fd_pct` | threshold-based |
|
||||
| `docker_health_check.sh` | `docker_unhealthy`, `docker_total` | `docker-unhealthy` |
|
||||
| `caddy_error_rate.sh` | `caddy_5xx_rate`, `caddy_requests`, `caddy_5xx` | `caddy-errors` |
|
||||
| `backup_freshness.sh` | — | `backup-stale` |
|
||||
|
||||
## Script Deployment
|
||||
|
||||
```
|
||||
Git Push Sync Timer (5min) Target Host
|
||||
┌────────┐ ┌────────────────┐ ┌──────────┐
|
||||
│ git push│ │ git pull │ │ │
|
||||
│ origin │───────────────>│ homelab-context│ │ │
|
||||
│ main │ │ │ │ │
|
||||
└────────┘ │ post-pull.sh │ │ │
|
||||
│ → tools/ │ │ │
|
||||
│ setup- │ │ │
|
||||
│ checks.sh │ │ │
|
||||
│ → checks/ │ │ │
|
||||
│ install.sh│ │ │
|
||||
│ │──cp *.sh ─>│ /opt/ │
|
||||
│ │ │ oikos/ │
|
||||
│ │ │ checks/ │
|
||||
└────────────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Defining a Check
|
||||
|
||||
```bash
|
||||
curl -X POST http://oikos:8090/api/v1/checks \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"kind": "ssh-script",
|
||||
"target": "host:hubris",
|
||||
"config": {
|
||||
"host": "192.168.8.77",
|
||||
"script": "cpu_check.sh",
|
||||
"thresholds": {
|
||||
"cpu_pct": {"warn": 90, "crit": 95},
|
||||
"cpu_temp": {"crit": 85}
|
||||
}
|
||||
},
|
||||
"interval_s": 60
|
||||
}'
|
||||
```
|
||||
|
||||
## Signal Lifecycle
|
||||
|
||||
```
|
||||
raised ──> acknowledged ──> acting ──> resolved
|
||||
│ │ │
|
||||
├── muted ├── muted ├── raised (retry)
|
||||
│ │ │
|
||||
└── resolved └── resolved └── failed
|
||||
```
|
||||
|
||||
Signals deduplicate: **one open signal per (target_entity_id, kind)**.
|
||||
Repeated failures increment `occurrence_count` instead of creating duplicates.
|
||||
|
||||
## Threshold Evaluation
|
||||
|
||||
Each check config can define per-metric thresholds in the `config` JSONB:
|
||||
|
||||
```json
|
||||
{
|
||||
"thresholds": {
|
||||
"cpu_temp": {"crit": 85},
|
||||
"cpu_pct": {"warn": 90, "crit": 95}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Severity mapping:
|
||||
- metric >= `crit` → severity = `critical`
|
||||
- metric >= `warn` → severity = `warning`
|
||||
- `health == "down"` with no thresholds → severity = `critical`
|
||||
- Otherwise → severity = `warning`
|
||||
|
||||
## Data Flow (DB Tables)
|
||||
|
||||
```
|
||||
check_defs ──(scheduler reads)──> executeCheck()
|
||||
│ │
|
||||
│ ├── healthy? → resolve signal, upsert entity_status
|
||||
│ │
|
||||
│ └── unhealthy? → UpsertSignal(), insert metric_samples
|
||||
│
|
||||
▼
|
||||
signals ◄──── UpsertSignal (dedup by target+kind)
|
||||
│
|
||||
▼
|
||||
entity_status ◄── upsert (health, last_check_at)
|
||||
|
||||
metric_samples ◄── INSERT (every cycle, healthy or not)
|
||||
│
|
||||
▼
|
||||
metric_rollups_1h ◄── continuous aggregate
|
||||
metric_rollups_1d ◄── continuous aggregate
|
||||
```
|
||||
|
||||
## Prerequisites for SSH Checks
|
||||
|
||||
1. **Key**: SSH private key mounted at `/etc/oikos/ssh_key` in the scheduler container
|
||||
2. **User**: `OIKOS_SSH_USER=root` (or set `"user"` in check config)
|
||||
3. **Scripts**: Deployed on target host at `/opt/oikos/checks/`
|
||||
4. **Network**: Scheduler container must reach target host (bridge → LAN works)
|
||||
5. **Container**: Scheduler needs `openssh-client` (alpine base) + `CAP_NET_RAW` (for ping)
|
||||
486
docs/adr/0014-entity-model.md
Normal file
486
docs/adr/0014-entity-model.md
Normal file
@@ -0,0 +1,486 @@
|
||||
# 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()` 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)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 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`: 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.
|
||||
|
||||
---
|
||||
|
||||
## 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 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:** 4 hypertables 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
|
||||
|
||||
```
|
||||
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):**
|
||||
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
|
||||
@@ -5,7 +5,7 @@ after acceptance — superseding decisions get a new ADR that links back.
|
||||
Statuses: proposed | accepted | superseded-by-NNNN.
|
||||
|
||||
| ADR | Title |
|
||||
|---|---|
|
||||
|---|---|---|
|
||||
| [0001](0001-go-single-binary.md) | Go with single-binary role packaging |
|
||||
| [0002](0002-postgres-timescale-only-datastore.md) | PostgreSQL + TimescaleDB as the only datastore |
|
||||
| [0003](0003-db-native-ontology-yaml-seeds.md) | DB-native ontology with YAML seed manifests |
|
||||
@@ -16,3 +16,7 @@ Statuses: proposed | accepted | superseded-by-NNNN.
|
||||
| [0008](0008-forward-only-migrations.md) | Forward-only migrations |
|
||||
| [0009](0009-sse-over-websocket.md) | SSE over WebSocket for the event stream |
|
||||
| [0010](0010-infisical-with-sops-fallback.md) | Infisical secrets with SOPS DR fallback |
|
||||
| [0011](0011-client-lifecycle-flows.md) | Client lifecycle flows — enrollment, bootstrap, sync |
|
||||
| [0012](0012-hermes-oikos-interactions.md) | Hermes–Oikos interactions — agent/OS contract |
|
||||
| [0013](0013-signal-triggers.md) | Signal triggers — host health checks via scheduler |
|
||||
| [0014](0014-entity-model.md) | Entity model — types, relationships, state machines, OODA loop |
|
||||
|
||||
Reference in New Issue
Block a user