# Oikos — System Model A single Model-Based Systems Engineering (MBSE) view of Oikos, structured after *Systems Engineering Demystified* (2nd ed., Jon Holt): one underlying system — the Oikos entity graph, its OODA control loop, and the services that implement it — represented here through a sequence of **views**, each a projection of that same model for a different concern (mission, structure, behavior, requirements, risk, ...). They are kept in one document deliberately: a change that matters (a new gating rule, a retired tool) is rarely relevant to only one view, and putting them back-to-back makes that cross-view impact visible on read, not something you have to jump files to notice. This document **is the Model** in Holt's sense — Ontology + Viewpoints already applied, producing Views. The template that generated it — the Ontology, the Viewpoint definitions, and why each View below earns its place — lives separately in **[framework.md](framework.md)**. Read that first if you're deciding whether to add a View, extending the Ontology, or just want to understand why the documentation is shaped the way it is rather than what it currently says. The nine Views below cut across the system by *concern* (requirements, behavior, risk...). Two companion cuts exist alongside it: for a per-component cut — one View per running part of the system, going one layer deeper into its own internal structure — see **[components.md](components.md)**; for the domain-specific language every View here is written in — the full entity/relationship/lifecycle definitions, not the condensed sketch in framework.md §3 — see **[ontology.md](ontology.md)**. Findings below are graded by confidence. **Verified** means checked directly against source during this documentation pass (`grep`/`Read`, cross-checked in more than one place). **Per research pass** means gathered by parallel research agents feeding this document and not independently re-confirmed line by line — high-confidence, not certain. This distinction matters most in the [Risk & Safety](#8-risk--safety) and [Roadmap & Traceability](#9-roadmap--traceability) views, so it's called out again there. **Read this if:** you want to understand what Oikos *is* and *why* it's built the way it is — as opposed to the [ADRs](../adr/) (why one specific decision was made) or [plans/](../../plans/) (what's being built right now, task by task). This is a synthesis across both, cross-checked directly against the running code as of **2026-07-16**. ## Views in this model | # | View | Concern it addresses | |---|---|---| | [1](#1-mission-stakeholders--system-context) | Mission, Stakeholders & System Context | Why does this exist, who is it for, what's inside vs. outside the boundary? | | [2](#2-requirements) | Requirements | What must the system do, traced to source and implementation status? | | [3](#3-functional-architecture) | Functional Architecture | What functions does the system perform, and which component actually performs each one? | | [4](#4-physical-architecture) | Physical Architecture | What physically runs, where, on what ports, talking to what? | | [5](#5-interfaces-icd) | Interfaces (ICD) | Every MCP tool, REST group, SSE event, external protocol | | [6](#6-behavior) | Behavior | How a task, a signal, an execution, an approval moves through time | | [7](#7-verification--validation) | Verification & Validation | How do we know it works, and what isn't checked yet? | | [8](#8-risk--safety) | Risk & Safety | What actually stops this system from doing something dangerous? | | [9](#9-roadmap--traceability) | Roadmap & Traceability | What's code-real vs. dead code vs. schema-only, and where is this headed? | If you only read one section: [§8 Risk & Safety](#8-risk--safety) — it contains the single most consequential finding from this documentation pass (the policy kill-switch doesn't reach the live execution path) and is the shortest path to understanding what "gated" actually means in this system today, versus what the schema implies. **What this model corrects.** [ADR-0014](../adr/0014-entity-model.md) already distinguished "code-real" from "schema-only" for the entity model and OODA loop as of 2026-07-08 — excellent discipline, extended here to what changed since: the `general-gated-execution` rebuild (the `run` primitive, command classifier, assent/destructive windows) and the goal-oriented Task model (plan steps, auto-continuation, the knowledge loop). It also corrects one specific claim: ADR-0014 and `OIKOS.md` describe Phase 3 (control loop — scheduler, actuator, learning, classifier, notifier) as fully "DONE." This pass found that **the actuator and learning packages are not started by any running process**, and that the classifier described in the schema (`ClassifySignal`) is dead code — the actually-live classifier (`ClassifyCommand`) was added later by a different plan and lives in a different function. Full evidence in [§9.2](#92-code-real--dead-code--schema-only-matrix). --- ## 1. Mission, Stakeholders & System Context *Concern: why does Oikos exist, who does it serve, what's inside vs. outside the boundary? This is the Concept-of-Operations / Mission-Analysis view — stakeholders → needs → mission → boundary → context.* ### 1.1 Mission statement > **Oikos** (Greek: *household*) is an agentic operating system for the > **hubris** homelab. It observes the fleet, orients findings against a > typed ontology graph, decides what to do under an enforced risk policy, > acts through a single gated execution primitive, and learns from the > outcome — so that the homelab is administered with the same rigor as a > managed production environment, without requiring the operator to be > present for every routine decision. The name is deliberate: Oikos is not "a monitoring tool" or "a chatbot for servers." It is the **household's operating system** — the substrate every other agent (human or AI) reads state from and requests action through. ### 1.2 Stakeholders | Stakeholder | Role | What they need from Oikos | |---|---|---| | **Operator** (dtoro) | Owner, sole human decision-maker | Situational awareness without babysitting; a single approval gate for risky actions, not a flood of pings; confidence that destructive actions cannot happen silently | | **Nomos** (AI agent) | Primary autonomous actor, MCP client | A safe, complete, well-typed action surface (`run` + read tools) it cannot bypass; enough context (ontology, knowledge, history) to act correctly without re-discovering facts every session | | **Enrolled clients** (hosts, LXCs, VMs, workstations) | Managed infrastructure, also *sources* of signals | Correct classification of their state (health, lifecycle); truthful, current inventory; checks/agents deployed via the auto-setup mechanism | | **Future agents / delegates** | Not yet built (§9) | A ledger and identity model that supports multi-agent delegation | | **Control-room web UI** | The operator's window into the system | Real-time visibility (SSE) into fleet health, tasks, approvals, activity — without being the system of record itself | | **The system itself (future sessions of Nomos/Claude)** | Re-orients from persisted state, not memory | DB-as-source-of-truth, knowledge that compounds task over task | ### 1.3 Mission drivers These recur through every other view below and explain design choices that would otherwise look arbitrary: 1. **The homelab is unattended most of the time.** A human cannot review every restart or disk check. The system must be able to act — but only within a bound it cannot exceed on its own (§8). 2. **Autonomy must be legible, not implicit.** Every mutation traces to a risk class, an approval (or an explicit auto-act rule), and a ledger entry. "Why did the agent do that?" must always be answerable from the DB, not from a chat transcript that might scroll away. 3. **The agent should get smarter without getting less safe.** Learning (patterns, knowledge) is explicitly **proposal-only** — see [ADR-0006](../adr/0006-learning-proposal-only.md) — it can suggest, never self-authorize. 4. **One approval, not one-per-step.** The operator's stated pain point (see [plans/done/2026-07-11-goal-oriented-chat-control-panel.md](../../plans/done/2026-07-11-goal-oriented-chat-control-panel.md)) was re-approving every step of a multi-step task. The system evolved from "approve every command" to "approve a plan once, then the system is the event loop, not the human" (auto-continuation, §6.5). 5. **General gated execution over a fixed action enum.** Early design used named RPCs per action type (`restart_service`, `apt_upgrade`, …). The system moved to **one primitive (`run`) that is classified by what it actually does**, not by what it's called — see [plans/2026-07-10-general-gated-execution.md](../../plans/2026-07-10-general-gated-execution.md) and the north-star note in memory: unlimited actions, classifier-gated, not a whitelist of verbs. Full status in §9. ### 1.4 System boundary ```mermaid flowchart TB subgraph boundary["Oikos system boundary"] API["oikos api\n(REST + MCP, :8090)"] SCHED["oikos scheduler\n(observe loop)"] NOTIFY["oikos notifier\n(approvals, Matrix)"] NOMOS["nomos serve\n(MCP gateway, :8092)"] DB[("PostgreSQL/TimescaleDB")] WEB["control-room SPA\n(Svelte 5)"] end OPERATOR["Operator (human)"] LLM["LLM provider\n(OpenRouter — Nomos's model)"] MATRIX["Matrix homeserver\n(approval alerts)"] INFISICAL["Infisical\n(secrets, primary)"] SOPS["SOPS+age\n(secrets, DR fallback)"] FLEET["Managed fleet\nhosts, LXCs, VMs\n(SSH targets)"] GITEA["Gitea\n(git remote, CI, deploy webhook)"] OPERATOR <-->|chat, approvals, console| WEB OPERATOR -->|✅/❌ reactions| MATRIX WEB -->|bearer token, REST + SSE| API NOMOS -->|MCP tool calls| API NOMOS <-->|completions| LLM API --> DB SCHED --> DB SCHED -->|SSH probes| FLEET NOTIFY --> DB NOTIFY -->|alerts| MATRIX API -->|secrets read| INFISICAL API -.fallback.-> SOPS API -->|SSH exec, run tool| FLEET GITEA -->|push webhook| API GITEA -->|pull| FLEET style boundary fill:#e8f5e9,stroke:#2e7d32 ``` **Inside the boundary:** the `oikos` binary's four roles (api, scheduler, notifier — plus `all`), the `nomos` binary, the Postgres/TimescaleDB instance, the control-room SPA, and the webhook receiver (`cmd/webhook`). All are versioned in this repo and deployed together on the mac-mini. **Outside the boundary** (external systems Oikos depends on but does not own): | External system | Purpose | Coupling | |---|---|---| | LLM provider (OpenRouter, model configurable) | Nomos's reasoning engine | Nomos calls out per turn; no LLM = no natural-language agent, but MCP tools + REST API still work headless | | Matrix homeserver | Approval alerts, ✅/❌ reactions | Notifier pushes; loss of Matrix degrades to console-only approval | | Infisical (+ SOPS/age fallback) | Secret storage | `internal/secrets` abstracts both; see [ADR-0010](../adr/0010-infisical-with-sops-fallback.md) | | Gitea | Git remote, CI (`.gitea/workflows/ci.yml`), push-to-deploy webhook | Deploy trigger only; not a runtime dependency | | The fleet itself (hosts/LXCs/VMs) | What's being managed | SSH is the only actuation channel — "never ad-hoc SSH" outside the actuator, see [OIKOS.md](../../.agents/OIKOS.md) | ### 1.5 Operational concept — the OODA loop Every unit of work in Oikos — a scheduled probe, an operator's chat message, a signal reacting to drift — is one pass through **Observe → Orient → Decide → Act (→ Learn)**. This is the system's one behavioral pattern, applied at every scale from "check disk usage" to "operator asks for a fleet-wide update audit." ```mermaid flowchart LR O1["OBSERVE\nprobes, drift, agent findings\n→ Signals"] --> O2["ORIENT\nontology graph walk,\nblast radius, history"] O2 --> O3["DECIDE\nrisk class × blast radius\n× confidence → route"] O3 --> O4["ACT\nrun primitive → SSH exec\n→ verify → ledger"] O4 --> O5["LEARN\nfeedback → pattern → skill\n(proposal-only)"] O5 -.-> O1 ``` The full functional decomposition of each phase is §3; the concrete state machines and sequence traces are §6. **The unit of autonomous work: the Task.** As of the 2026-07-11 redesign, the operator-facing unit of the OODA loop is not a raw chat session but a **Task**: a goal, a proposed plan, a single approval gate, autonomous execution across every step, and a knowledge deposit at the end (see [plans/done/2026-07-11-goal-oriented-chat-control-panel.md](../../plans/done/2026-07-11-goal-oriented-chat-control-panel.md)). This is *how* the OODA loop is packaged for a human to supervise without narrating every step — detailed in §3.4 and §6.5–6.6. --- ## 2. Requirements *Concern: what must the system do, traced to source and implementation status? Stakeholder needs from §1.2 are decomposed here into verifiable requirements.* Legend: ✅ implemented and live · ⚠ implemented but not wired into the running system · ❌ schema/policy only, no code path · 🔜 planned. ### 2.1 Observe | ID | Requirement | Source | Status | |---|---|---|---| | REQ-OBS-1 | The system shall continuously probe the fleet on a fixed interval without operator intervention. | [OIKOS.md](../../.agents/OIKOS.md) §OODA | ✅ `scheduler.go` — 30s default tick | | REQ-OBS-2 | Probes shall support at least: HTTP reachability, TCP reachability, disk usage, TLS certificate expiry, ICMP ping, and arbitrary SSH-delivered health scripts. | [ADR-0013](../adr/0013-signal-triggers.md) | ✅ 6 check kinds, `scheduler.go:executeCheck` | | REQ-OBS-3 | An unhealthy probe result shall raise a deduplicated Signal; a healthy result shall resolve any open Signal for that check. | [ADR-0014](../adr/0014-entity-model.md) §5 | ✅ `UpsertSignal`/`resolveSignal`, unique partial index on open signals | | REQ-OBS-4 | Entities whose checks have gone silent beyond a multiple of their expected interval shall be marked `stale`, not silently reported healthy. | — | ✅ `staleSweep`, 3× fastest interval, 5 min floor | | REQ-OBS-5 | New entities shall receive a sane default check set at creation time, without manual check authoring. | — | ✅ `internal/checkdefaults` | ### 2.2 Orient | ID | Requirement | Source | Status | |---|---|---|---| | REQ-ORI-1 | All infrastructure shall be represented as typed entities connected by typed, validated relationships ("rule of completeness": if it can break, change, or hold data, it has an entity). | [OIKOS.md](../../.agents/OIKOS.md), [seeds/ontology.yaml](../../seeds/ontology.yaml) | ✅ 60 entity types, 47 relationship types | | REQ-ORI-2 | The system shall answer "what breaks if X goes down" as a graph query, not a document search. | README §Architecture | ✅ `blast_radius()` recursive CTE | | REQ-ORI-3 | Narrative knowledge (docs, runbooks, investigations) shall be queryable by full-text search and linkable to the entities they concern. | AGENTS.md §5 | ✅ `knowledge_entities` + FTS (migration 011) | | REQ-ORI-4 | An agent orienting on a task shall be able to retrieve prior successful/failed attempts on the same entity before acting. | [goal-oriented-chat-control-panel](../../plans/done/2026-07-11-goal-oriented-chat-control-panel.md) | ✅ `get_entity_knowledge`, outcome-tagged | ### 2.3 Decide | ID | Requirement | Source | Status | |---|---|---|---| | REQ-DEC-1 | Every state-changing action shall be classified into exactly one risk class (`read_only`, `reversible_low`, `config_mutation`, `destructive`) before it is allowed to run. | [seeds/policy.yaml](../../seeds/policy.yaml), [general-gated-execution](../../plans/2026-07-10-general-gated-execution.md) | ✅ `policy.ClassifyCommand` inside `classifyAndGate`, `internal/mcp/server.go` | | REQ-DEC-2 | A classifier shall be able to raise the required risk class above what the caller declares, but never lower it. | plan `general-gated-execution` | ✅ `riskRank` max() in `command.go` | | REQ-DEC-3 | `config_mutation` actions shall require operator approval — either a console/Matrix decision or in-chat assent to an already-approved plan. | seeds/policy.yaml, plan `general-gated-execution` | ✅ approval row + assent window | | REQ-DEC-4 | `destructive` actions shall require a typed confirmation phrase distinct from ordinary assent, scoped narrowly (target + session), even inside an approved plan. | plan `general-gated-execution` | ✅ destructive window, `internal/mcp/server.go:1526` | | REQ-DEC-5 | A global kill-switch and per-entity kill-switches shall be able to force escalation regardless of the computed risk class. | seeds/policy.yaml `autonomy_settings` | ❌ **Not enforced on the live path.** Only checked by the dead `ClassifySignal` and the unwired `actuator` package — see [§8.1](#81-the-kill-switch-gap-verified-most-important-finding-in-this-model). | | REQ-DEC-6 | Lifecycle state shall modify approval requirements (`provisioning` nodes cheaper to mutate; `deprecated`/`destroyed` nodes refuse new dependents/actions). | [OIKOS.md](../../.agents/OIKOS.md) | ✅ policy.yaml lifecycle overrides (ingested; enforcement scope not independently re-verified this pass) | ### 2.4 Act | ID | Requirement | Source | Status | |---|---|---|---| | REQ-ACT-1 | The system shall expose exactly one execution primitive for mutating the fleet (`run`), classified by what the command actually does, not by a fixed named-action enum. | plan `general-gated-execution` | ✅ `request_execution` retired 2026-07-14; `run` is sole mutation tool | | REQ-ACT-2 | Execution shall be auditable: every mutation records who/what/when/risk class/result. | [ADR-0007](../adr/0007-threat-model.md) | ✅ `audit_log` hypertable, `executions` rows | | REQ-ACT-3 | Long-running or asynchronous actions shall be pollable for status without blocking the caller. | AGENTS.md §3 | ✅ `get_execution_status` | | REQ-ACT-4 | A signal that crosses a risk threshold shall be able to trigger autonomous action without an operator initiating a chat turn. | [OIKOS.md](../../.agents/OIKOS.md) §OODA "auto-act" | ❌ **Not implemented.** `internal/actuator` is a stub and is not started by any binary — see §9. Only operator/agent-initiated `run` calls execute today. | | REQ-ACT-5 | An operator shall approve a multi-step plan once, and the system — not the human — shall drive subsequent steps to completion. | [goal-oriented-chat-control-panel](../../plans/done/2026-07-11-goal-oriented-chat-control-panel.md) | ✅ assent window + auto-continuation worker (`nomos_plan_executions`) | ### 2.5 Learn | ID | Requirement | Source | Status | |---|---|---|---| | REQ-LRN-1 | Execution outcomes shall be aggregated into confidence-scored patterns per (entity type, action). | [ADR-0006](../adr/0006-learning-proposal-only.md) | ⚠ Algorithm implemented (Wilson lower bound, evidence≥5 ∧ confidence≥0.7 → `validated`) but `learning.Run` is never started — dormant | | REQ-LRN-2 | Learning shall be structurally incapable of expanding its own autonomy — it may propose, never self-authorize. | ADR-0006 | ⚠ Consistent by design (no governance-table writes in `learning.go`) but currently moot since the loop never runs | | REQ-LRN-3 | A burst of same-key feedback in a single aggregation pass shall be quarantined rather than trusted. | ADR-0006 | ⚠ Implemented (`total > 10` per group ⇒ quarantine) but dormant, same as LRN-1 | | REQ-LRN-4 | On task completion, the agent shall deposit a knowledge note linked to the entities involved and to the task itself, tagged with outcome. | goal-oriented-chat-control-panel | ✅ `upsert_knowledge` + `about`/`involves` relationships; SOUL-nudged, partially enforced server-side | ### 2.6 Non-functional requirements | ID | Requirement | Rationale | Status | |---|---|---|---| | REQ-NFR-1 (Security) | Every API/MCP route except health check and enrollment shall require a real bearer credential — no dev-open bypass. | [ADR-0015](../adr/0015-api-bearer-auth-client-server-split.md) closed a production hole where `docker-compose.yml` ran with auth disabled | ✅ `combinedAuth`, both static-token and OIDC JWT paths | | REQ-NFR-2 (Security) | The Nomos agent gateway (`:8092`) shall require authentication before accepting chat/session/approval-assent requests. | Anyone on the LAN/mesh can currently chat as the agent and grant approvals via assent | ❌ Open gap (C1), deferred by explicit operator decision — see §8 | | REQ-NFR-3 (Security) | Secrets shall never be hardcoded; they shall be resolved from a managed secret store with a documented fallback. | ADR-0010 | ✅ Infisical primary, SOPS+age DR fallback | | REQ-NFR-4 (Security) | Destructive shell patterns (`rm -rf`, `dd of=`, `mkfs`, `DROP TABLE`, credential-file reads, fork bombs, …) shall be denylisted independent of what the caller declares. | plan `general-gated-execution` | ✅ `destructivePatterns` regex list, `command.go` | | REQ-NFR-5 (Reliability) | A failed deploy shall be automatically health-checked and the operator shall have a scripted, tested path to roll back to the previous known-good state. | [docs/operations/rollback.md](../operations/rollback.md) | ✅ `scripts/deploy.sh` health poll + `scripts/rollback.sh` (manual trigger, not automatic) | | REQ-NFR-6 (Reliability) | Database schema changes shall be forward-only and idempotent so that redeploys never require manual intervention. | [ADR-0008](../adr/0008-forward-only-migrations.md) | ✅ 20 migrations, `IF NOT EXISTS` throughout | | REQ-NFR-7 (Observability) | Operators shall see fleet state, pending approvals, and agent activity in real time without polling. | plan `control-room-webui` | ✅ SSE via Postgres `LISTEN/NOTIFY` fan-out | | REQ-NFR-8 (Observability) | Every mutation and every agent tool call shall be traceable to the session/task that caused it. | plan `session-reliability-and-ux-audit` | ✅ `audit_log.session_id` (migration 020), `agent_activity` correlation | | REQ-NFR-9 (Data) | Time-series telemetry shall be retained with automatic rollup/expiry rather than growing unbounded. | ADR-0002 | ✅ TimescaleDB continuous aggregates + retention policies | | REQ-NFR-10 (Versioning) | Every change to `main` shall bump a semver-ish version visible in the UI, so operators can correlate behavior with a build. | AGENTS.md §9 | ✅ `VERSION` file, enforced by convention not CI gate | ### 2.7 Explicitly out of scope - **Perfect classification of arbitrary shell.** The general `run` primitive accepts free-form commands; the plan authoring the design ([general-gated-execution](../../plans/2026-07-10-general-gated-execution.md)) states outright that perfect static classification of shell is impossible and commits instead to default-escalate + a hard denylist + always-on audit as the mitigation, not a guarantee of catching every dangerous command shape. - **Per-client bearer tokens.** All enrolled clients currently share one `OIKOS_MCP_BEARER_TOKEN`; per-client token issuance is acknowledged as future work (AGENTS.md §4). - **Multi-agent delegation.** Blocked on a ledger identity field ([OIKOS.md](../../.agents/OIKOS.md) backlog). ### 2.8 Requirements not yet written down anywhere formal Two observations from this pass that aren't captured as a requirement in any plan/ADR but should be, given what the code does today: - There is no requirement anywhere stating **"the kill-switch shall apply to operator/agent-initiated `run` calls, not just autonomous auto-act."** The policy schema implies it should (`seeds/policy.yaml` calls `never_auto_act.service:caddy` a "hard block"), but REQ-DEC-5's status shows the code doesn't honor that today for the primary execution path. Either a formal requirement + fix, or an ADR narrowing the kill-switch's documented scope to match reality, would close this. - There is no requirement governing **circuit-breaking of repeated failed executions against the same target** on the live `run` path. A real circuit breaker exists (`internal/actuator/actuator.go:156-202`) but guards only the unused auto-act loop. --- ## 3. Functional Architecture *Concern: what functions does the system perform, and which component actually performs each one? Functional architecture is drawn before physical architecture (§4) deliberately, so a function's allocation can be called out when it diverges from where the schema/ontology implies it lives — which happens more than once below.* ### 3.1 Top-level function decomposition ```mermaid flowchart TB F0["F0 Operate the homelab autonomously\n(the mission function)"] F0 --> F1["F1 Observe"] F0 --> F2["F2 Orient"] F0 --> F3["F3 Decide"] F0 --> F4["F4 Act"] F0 --> F5["F5 Learn"] F0 --> F6["F6 Communicate with the operator"] F1 --> F1a["F1.1 Run scheduled probes"] F1 --> F1b["F1.2 Deduplicate & lifecycle-manage signals"] F1 --> F1c["F1.3 Detect staleness"] F2 --> F2a["F2.1 Maintain the ontology graph"] F2 --> F2b["F2.2 Compute blast radius"] F2 --> F2c["F2.3 Retrieve prior knowledge for a target"] F2 --> F2d["F2.4 Track what a task has touched"] F3 --> F3a["F3.1 Classify a command's risk"] F3 --> F3b["F3.2 Gate on plan-first / dedup / anti-flood"] F3 --> F3c["F3.3 Grant/consume approval or assent"] F3 --> F3d["F3.4 Enforce kill-switches"] F4 --> F4a["F4.1 Execute over SSH"] F4 --> F4b["F4.2 Verify & record outcome"] F4 --> F4c["F4.3 Auto-continue a plan across steps"] F5 --> F5a["F5.1 Extract patterns from feedback"] F5 --> F5b["F5.2 Quarantine anomalous bursts"] F5 --> F5c["F5.3 Deposit task-outcome knowledge"] F6 --> F6a["F6.1 Render live fleet/task state"] F6 --> F6b["F6.2 Deliver approval prompts (Matrix)"] F6 --> F6c["F6.3 Stream real-time events (SSE)"] ``` ### 3.2 Function allocation matrix The "expected owner" column is what the schema/ontology/ADRs imply; the "actual owner" column is what this pass verified in the running system (grep + direct read, corroborated by four independent research passes — see §9). Where they differ, the function is marked **misallocated / orphaned**. | Function | Expected owner | Actual owner (live) | Note | |---|---|---|---| | F1.1 Run scheduled probes | `internal/scheduler` | `internal/scheduler/scheduler.go` | ✅ matches | | F1.2 Signal dedup/lifecycle | `internal/scheduler` + `internal/domain.Signal` state machine | `scheduler.go` raw SQL (`UpsertSignal`, `resolveSignal`) | ⚠ `resolveSignal` updates `raised→resolved` directly without calling `Signal.CanTransition` — the one enforced state machine in the domain layer is bypassed by its own primary caller | | F2.2 Compute blast radius | policy/ontology layer | Postgres `blast_radius()` SQL function, called from both `internal/policy/classify.go` (dead) and `internal/mcp/server.go` (live, `get_blast_radius` tool) | ✅ the SQL function itself is shared and correct; only the *classifier* caller is dead | | **F3.1 Classify a command's risk** | `internal/policy` (one classifier) | **Two independent classifiers exist.** `policy.ClassifySignal` (signal-oriented, DB-rule driven) is **dead code — zero callers**. `policy.ClassifyCommand` (regex allow/deny-list on the literal shell command) is the one actually invoked, from `internal/mcp/server.go:classifyAndGate`. | 🔴 misallocated — the documented/schema-implied classifier is not the one running | | F3.2 Plan-first/dedup/anti-flood gates | — (not modeled in ontology at all) | `internal/mcp/server.go:classifyAndGate` (P1, dedup, P5) | New function, added by the `general-gated-execution` plan; not yet reflected in `seeds/ontology.yaml` | | F3.3 Approval / assent | `internal/notifier` (token issuance) + operator decision | `internal/notifier/notifier.go` (Matrix leg) **and** a second, independent inline reimplementation of token verification in `internal/httpapi/phase3.go:DecideApproval` | ⚠ `notifier.VerifyApprovalToken` is dead code; the live verification path duplicates its logic rather than calling it | | **F3.4 Enforce kill-switches** | `internal/policy` + `autonomy_settings` (`global.auto_act`, `never_auto_act.*`) | Checked only in `internal/policy/classify.go` (dead) and `internal/actuator/actuator.go` (never started) | 🔴 **not enforced anywhere in the live request path.** See [§8.1](#81-the-kill-switch-gap-verified-most-important-finding-in-this-model) | | **F4.1 Execute over SSH** | `internal/actuator` (`ExecuteProcedure`, with SSH error classification, circuit breaker, retry budget) | `internal/httpapi/phase3.go:sshExec` and an equivalent in `internal/mcp/server.go` — a simpler, independently-written single-command executor | 🔴 `internal/actuator/ssh.go`'s well-built procedure runner has **zero callers**; the live executor is a separate, less defended implementation (no circuit breaker) | | F4.2 Verify & record outcome | `internal/actuator` + `executions` state machine | `internal/httpapi/phase3.go` (`executeApprovedAction`) using ad-hoc string statuses, not the `domain.Execution` constants | ⚠ works, but the domain-layer state machine is descriptive only — no `CanTransition`-equivalent guards it | | F4.3 Auto-continue a plan | `cmd/nomos` continuation worker | `cmd/nomos/continue.go` polling `nomos_plan_executions` | ✅ matches, and is the mechanism behind REQ-ACT-5 ("the system is the event loop, not the human") | | F5.1–F5.2 Learning | `internal/learning` | `internal/learning/learning.go` — algorithm is real (Wilson bound, quarantine) | 🔴 **never runs.** Not started by `cmd/oikos/main.go` or any `docker-compose.yml` service. Feedback accumulates; nothing processes it | | F5.3 Knowledge deposit | Nomos SOUL + `upsert_knowledge` | `cmd/nomos` (SOUL-nudged) + `internal/mcp/server.go:upsert_knowledge` | ✅ matches, partially enforced (see §9 — a 348-tool-call audited session made zero `update_entity_attributes`/`create_relationship` calls despite discovered drift) | | F6.3 SSE stream | `internal/httpapi/sse.go` | same | ✅ Postgres `LISTEN/NOTIFY` → in-memory ring buffer → per-connection fan-out | **Reading this table honestly:** F1 (Observe) and F6 (Communicate) are solidly allocated to where you'd expect. F3 (Decide) and F4 (Act) — the two functions that actually gate and perform mutation — are where the implementation diverged furthest from the schema/ontology's implied design, because they were rebuilt under the `general-gated-execution` plan *alongside*, not *instead of*, the original `internal/policy` / `internal/actuator` packages. The old packages were never deleted, so they read as "the" implementation to anyone navigating by package name alone. ### 3.3 Functional flow — the `run` gate (F3 in detail) This is the most safety-relevant function in the system and the one worth seeing end to end. It supersedes the classifier flow drawn in [ADR-0014](../adr/0014-entity-model.md) §3, which predates the command classifier. ```mermaid flowchart TD START(["run(target, command, purpose, declared_risk)\ncalled by Nomos, session-scoped"]) --> P1{"P1: does this session\nhave a plan?"} P1 -->|no, and _session_id set| REJECT1["reject — propose_plan first"] P1 -->|"yes, or no session (direct call)"| DEDUP{"identical pending-approval\ncommand already queued\nfor this target?"} DEDUP -->|yes| REJECT2["reject — dedup"] DEDUP -->|no| FLOOD{"P5: another config_mutation\nalready pending for\nthis session?"} FLOOD -->|yes| REJECT3["reject — anti-flood"] FLOOD -->|no| CLASSIFY["ClassifyCommand(command, declared_risk)\ndenylist → destructive\nallowlist-only-segments → read_only\notherwise → config_mutation\nfinal = max(computed, declared)"] CLASSIFY -->|read_only| EXEC["execute immediately over SSH"] CLASSIFY -->|config_mutation| ASSENT{"assent window\nactive for this\nagent+session?"} ASSENT -->|yes| EXEC ASSENT -->|no| QUEUE1["queue pending_approval\n+ approvals row\n+ approval.created SSE event"] CLASSIFY -->|destructive| DWIN{"destructive window\nactive (typed 'I confirm'\nfor this target+session)?"} DWIN -->|yes| EXEC DWIN -->|no| QUEUE2["queue pending_approval\nmarked DESTRUCTIVE"] EXEC --> RECORD["insert execution entity +\nexecutions row + targets/involves\nrelationships"] QUEUE1 --> WAIT["operator decides via\nconsole or Matrix ✅/❌"] QUEUE2 --> WAIT WAIT -->|approved| EXEC WAIT -->|denied/expired| END(["terminal, no execution"]) RECORD --> POLL["get_execution_status\npollable"] ``` **What is conspicuously absent from this diagram**, verified directly against `internal/mcp/server.go`: a check against `autonomy_settings` (`global.auto_act`, `never_auto_act.`). Compare against [ADR-0014](../adr/0014-entity-model.md) §3's OODA diagram, which shows the kill-switch gating the *orient→decide* transition — that gate exists in code (`internal/policy/classify.go`), but on a function (`ClassifySignal`) nothing calls. See [§8.1](#81-the-kill-switch-gap-verified-most-important-finding-in-this-model) for the safety implication. ### 3.4 Functional flow — the Task lifecycle (F3/F4/F5 packaged for a human) The Task is the unit the operator actually supervises (§1.5). It packages one pass of Decide→Act→Learn behind a single approval: ```mermaid stateDiagram-v2 [*] --> planning: goal set via set_goal planning --> awaiting_approval: propose_plan awaiting_approval --> executing: operator approves,\nopens assent window executing --> executing: update_plan_step per step,\npending to running to done executing --> awaiting_input: ask_operator,\nreal decision needed awaiting_input --> executing: operator answers executing --> done: complete_task, outcome success or partial executing --> failed: complete_task, outcome failure done --> [*] failed --> [*] ``` Approving the plan once authorizes every `read_only` and `config_mutation` step already named in it — this is F3.3 folded into a single grant rather than one grant per `run` call. `destructive` steps still require the typed confirmation phrase regardless of plan approval (F3.4's *intended* design, not the missing kill-switch check above — these are different mechanisms: the destructive-window requirement is enforced in `classifyAndGate`; the policy-level kill-switch is not). **Structural gates, not prose reminders.** An earlier version of this lifecycle relied on the model's system prompt (SOUL.md) to avoid re-proposing a plan or double-completing a task. A 2026-07-14 incident chain (empty LLM response → misclassified as disconnect → generic reconnect note → agent re-proposed the plan) led to a deliberate architecture shift: SOUL prose was demoted from primary safeguard to instruction, and `propose_plan`/`complete_task` themselves now refuse invalid calls server-side (`plans/done/2026-07-14-post-fix-session-remainders.md`). This is a general pattern worth carrying forward: **anywhere the model's correctness depends on a system-prompt instruction rather than a tool-level guard, treat it as a latent bug, not a solved problem.** --- ## 4. Physical Architecture *Concern: what physically runs, where, on what ports, talking to what? Where §3 asked "what does the system do and who actually does it," this asks "what actually runs." This is the Block Definition Diagram / deployment-diagram layer, expressed as containers, processes, and ports — there is no SysML tool in this repo's toolchain, so Mermaid stands in.* ### 4.1 Component block diagram ```mermaid flowchart TB subgraph binary["cmd/oikos — single Go binary, role-selected by CLI arg"] API["api role\nREST :8090 + MCP\ninternal/httpapi, internal/mcp"] SCHED["scheduler role\ninternal/scheduler"] NOTIFY["notifier role\ninternal/notifier"] ORPHAN1["internal/actuator\n(package exists, NO role\nstarts it — dead weight)"] ORPHAN2["internal/learning\n(package exists, NO role\nstarts it — dead weight)"] end NOMOSBIN["cmd/nomos — separate binary\nserve :8092\nagent gateway + task tools"] WEBHOOK["cmd/webhook\n:9797\nGitea deploy receiver"] DESKTOP["cmd/desktop\nWails-wrapped SPA\n(distributed as .app, not a server role)"] WEB["web/ — Svelte 5 SPA\nstatic, served by Caddy container\nnot embedded in cmd/oikos"] DB[("postgres 16\n+ TimescaleDB 2.17.2\n:5432")] binary --- DB NOMOSBIN -->|MCP over HTTP| API WEBHOOK -->|"exec scripts/deploy.sh"| binary WEB -->|REST + SSE, bearer/OIDC| API WEB -.optional desktop shell.- DESKTOP style ORPHAN1 fill:#ffebee,stroke:#c62828,stroke-dasharray: 5 5 style ORPHAN2 fill:#ffebee,stroke:#c62828,stroke-dasharray: 5 5 ``` The dashed red boxes are drawn *inside* the binary because they compile into it, but no `cmd/oikos` role wires their `Run()` loop — see §3.2 and §9 for the full evidence trail. Physically they are dead code shipped in the binary, not a running process. ### 4.2 Deployment topology (mac-mini, Docker Compose) ```mermaid flowchart TB subgraph host["mac-mini — Docker host"] subgraph profile_dev["profile: dev"] PG[("postgres:5432")] MIG["migrate\n(one-shot, exits)"] SEED["seed\n(one-shot, exits)"] APID["api :8090"] SCHEDD["scheduler"] NOTIFYD["notifier"] end subgraph profile_full["profile: full (adds)"] NOMOSD["nomos :8092"] WEBD["web :8091\n(Caddy static)"] end subgraph profile_infisical["profile: infisical (adds)"] REDIS["redis :6379"] INFISICALD["infisical :8080"] end CADDY["Caddy (external repo\ndtoro/caddy-conf, LXC 121)\nnot in this compose file"] end MIG --> PG SEED -->|after migrate succeeds| PG APID --> PG SCHEDD --> PG NOTIFYD --> PG NOMOSD -->|MCP calls, bearer token| APID WEBD -->|REST + SSE| APID APID -.secrets.-> INFISICALD CADDY -->|"oikos.hubris.network → :8091 (browser)\n/api,/mcp,/agent → :8090 direct"| APID CADDY -->|"mcp.hubris.network → :8090"| APID CADDY -->|"nomos.hubris.network → :8092\n(NO auth of its own)"| NOMOSD CADDY --> WEBD ``` | Service | Port | Profile(s) | Notes | |---|---|---|---| | `postgres` | 5432 | all | TimescaleDB 2.17.2 / PG16, healthcheck-gated startup | | `migrate` | — | all | one-shot, `compose/oikos/Dockerfile … migrate`, exits after applying `migrations/` | | `seed` | — | all | one-shot, runs only after `migrate` succeeds; ingests `seeds/*.yaml` | | `api` | 8090 | dev, full | REST + MCP; **every route requires a real bearer token**, no dev-open bypass ([ADR-0015](../adr/0015-api-bearer-auth-client-server-split.md)); mounts an SSH key read-only for the actuation path | | `scheduler` | — | dev, full | observe loop, `NET_RAW` capability for ping checks, mounts the same SSH key | | `notifier` | — | dev, full | Matrix alerts + approval token issuance, needs `OIKOS_APPROVAL_HMAC_SECRET` | | `nomos` | 8092 | full only | agent gateway, calls back into `api` over MCP with the shared bearer token; talks to OpenRouter for completions | | `web` | 8091 (host) → 80 (container) | dev, full | Svelte 5 SPA, multi-stage build (`node:22-alpine` → `caddy:2-alpine` static serve); **standalone**, deployed and versioned independently of the Go binary | | `redis`, `infisical` | 6379, 8080 | infisical, full | secrets backend; SOPS+age is the DR fallback if this stack is down | **Reference vs. live Caddy config.** `compose/caddy/Caddyfile.oikos` in this repo is a *reference copy* — the config actually enforced in production lives in a separate `dtoro/caddy-conf` repo deployed to LXC 121 and is kept in sync manually. As of the last verified check (`plans/done/2026-07-12-wails-desktop-app.md` completion note), `oikos.hubris.network` had **no live site block yet** in the real Caddy config — the routing table above is the *designed* topology, not a confirmed-live one for that specific hostname. `mcp.hubris.network` and `nomos.hubris.network` route directly to their respective ports with no browser-session layer in front — `nomos.hubris.network` in particular has **no auth of its own** at the Caddy layer or the Nomos process layer (§8). ### 4.3 Physical/network trust zones Per [ADR-0007](../adr/0007-threat-model.md), the system's trust boundary is expressed as Docker networks rather than host firewalling: | Zone | Contains | Trust level | |---|---|---| | `net-front` | Caddy, web SPA | Internet/mesh-facing, TLS terminated here | | `net-data` | postgres, redis | No direct external exposure; only backend services reach it | | `net-ops` | api, scheduler, notifier, nomos | Holds the SSH key and secret-store credentials; the actual actuation surface | The agent (Nomos) itself holds **no SSH key** — `SOUL.md` states this explicitly, and it's structurally true: the key lives only inside the `api` container's environment (`OIKOS_SSH_KEY_PATH`, default `/etc/oikos/ssh_key`), read by `internal/mcp/server.go`'s `initSSH()`. Nomos can only ever reach a host by asking `api` to run something through the gated `run` tool — it cannot open its own SSH session even if compromised, short of getting `api` to do it for it. ### 4.4 External systems and their coupling | External system | Reached from | Failure mode if unavailable | |---|---|---| | OpenRouter (LLM provider) | `cmd/nomos` only | Natural-language chat stops; MCP tools + REST API remain fully usable headless | | Matrix homeserver | `internal/notifier` | Approvals fall back to console-only decision; no ✅/❌ reaction path | | Infisical | `internal/secrets` (used from `api`) | Falls back to SOPS+age files on disk ([ADR-0010](../adr/0010-infisical-with-sops-fallback.md)) | | Gitea | `cmd/webhook`, CI (`.gitea/workflows/`), the deploy poller | Push-to-deploy webhook stops firing; the 2-minute `launchd` poller (`scripts/deploy/network.hubris.oikos-deploy-poller.plist`) is the redundant fallback path | | The fleet itself | `internal/scheduler` (probes), `api`'s `sshExec` (actuation) | No live health data; no new mutations possible, but read paths served from last-known DB state still work | ### 4.5 Build & release artifacts | Artifact | Built by | Distributed as | |---|---|---| | `oikos` binary | `.gitea/workflows/ci.yml` build-test job (vet/lint/vulncheck/build/test), `compose/oikos/Dockerfile` for the runtime image | Docker image, rebuilt on the deploy host from source (no registry push) | | `nomos` binary | same CI job, `compose/nomos/Dockerfile` | Docker image, same local-rebuild model | | `web` SPA | `compose/web/Dockerfile` (multi-stage) | Docker image serving static assets via Caddy, **or** bundled into the desktop app below | | Desktop app (`cmd/desktop`) | `.gitea/workflows/desktop.yml`, CGO-enabled Wails build, Linux amd64 currently | Tarball attached to a Gitea release on tag push; installed manually to `/Applications` on the operator's Mac as of 2026-07-14 | Note the asymmetry: server-side services are deployed via **push-to-main → local rebuild on mac-mini** (§4.2), while the desktop app is deployed via **explicit version tags → release artifact → manual install**. These are two different release cadences for the same frontend codebase (`web/`) — worth knowing before assuming "deployed" means the same thing for both. ### 4.6 Physical architecture vs. functional allocation — the gap in one sentence The functional architecture (§3) shows F3 (Decide) and F4 (Act) allocated to `internal/mcp` and `internal/httpapi`; this section's block diagram shows *why* that's surprising to someone reading package names cold — `internal/actuator` and `internal/policy`'s signal classifier look, from directory structure alone, like they should be the answer. They compile, they're well-tested in isolation in places, and they are not wired into anything `cmd/oikos` starts. Anyone extending the execution or classification logic should extend `internal/mcp/server.go` and `internal/policy/command.go` — not `internal/actuator` or `internal/policy/classify.go` — until/unless a deliberate decision is made to consolidate (tracked as an open question in §9.4). --- ## 5. Interfaces (ICD) *Concern: every interface a component in §4 exposes to another component or to the operator, with enough detail to integrate against without reading the source.* ### 5.1 Authentication (applies to every interface below) `internal/httpapi/server.go`'s `combinedAuth` middleware wraps every route except `GET /healthz`, `POST /api/v1/clients/enroll`, `GET /api/v1/auth/oidc-config`, `POST /api/v1/auth/oidc-token`, and `GET /oidc-callback`. It tries, in order: 1. **OIDC JWT** — if `OIDCIssuer`/`OIDCClientID` are configured, validates against a cached JWKS fetched from the issuer's `.well-known/openid-configuration`, checking issuer + audience, RS/ES algorithms only. 2. **Static bearer token** — `OIKOS_API_TOKEN` (operator/SPA) or `OIKOS_MCP_BEARER_TOKEN` (agent), compared in constant time. There is deliberately **no dev-open bypass** — this was a real production gap closed by [ADR-0015](../adr/0015-api-bearer-auth-client-server-split.md). The one interface in this document not covered by this middleware is the Nomos gateway itself (§5.4) — see §8. The SSE endpoint accepts the token as a query parameter (`?token=…`, `allowQueryToken=true`) because browser `EventSource` cannot set custom headers. ### 5.2 MCP tool catalog (`internal/mcp/server.go`) — 33 tools This is the agent-facing action surface — every tool an LLM-driven agent (Nomos or any other MCP client) can call. Every call is wrapped in `withActivityLogging`, so every row below produces an `agent_activity` audit entry regardless of outcome. **Context — observe & orient** (read-only, no approval): | Tool | Purpose | |---|---| | `get_entity(slug)` | Fetch one entity by slug/UUID | | `list_entities(type, limit, cursor)` | Cursor-paginated entity listing | | `get_relations(entity)` | Relationships for an entity | | `get_blast_radius(entity)` | Downstream-impact graph walk (`blast_radius()` SQL fn) | | `search_knowledge(query)` | Full-text search over documents/investigations/runbooks | | `get_entity_knowledge(entity_slug)` | Every knowledge item linked to one entity, one call, outcome-tagged | | `get_knowledge_content(id)` | Full markdown body of one knowledge item | | `get_patterns(status, entity_type, action)` | Learned action patterns (dormant source — §9) | | `get_skills(status)` | Automation skill catalog | | `http_get(url)` | Public-web fetch, SSRF-guarded (`isPrivateHost`), ~16KB cap, HTTP/HTTPS only | **Management — live state** (read, but some do live SSH reads): | Tool | Purpose | Live SSH? | |---|---|---| | `get_service_status(service_slug)` | `systemctl is-active` on target host | yes | | `tail_log(service_slug, lines=200)` | `journalctl` tail | yes | | `list_lxcs()` | All LXC containers, ID/host/IP/health | no (DB) | | `get_lxc_state(lxc_slug)` | `pct status` from the Proxmox host | yes | | `ping_service(service_slug)` | HTTP reachability from `entity_status` | no (DB) | | `list_my_secrets(caller_pubkey)` | Secrets reachable by this client's age pubkey | no (DB) — see §8 re: scoping gap | **Oikos — decisions:** | Tool | Purpose | |---|---| | `explain(service_slug)` | Compact context card: type, state, health, relations | | `preflight(service_slug, action)` | **Static** risk-class lookup by action string — not the real command classifier, see caution below | | `whoami(hostname)` | Entity record, peers, health for a client | | `get_change_history(entity_slug, limit=20)` | Recent audit-log entries for one entity | | `get_state_snapshot()` | Fleet health, disk, drift count | > **Caution:** `preflight` looks up a risk class from `approval_rules` by > `(entity_type, action)` — the same table the *dead* `ClassifySignal` > reads. It does **not** run `ClassifyCommand`'s regex classifier and does > **not** reflect what will actually happen when `run` is called with a > specific shell command. Treat its output as advisory, not authoritative. **Operations — observe & act:** | Tool | Purpose | |---|---| | `get_health_summary()` | Fleet health counts (healthy/degraded/down/unknown) | | `get_signal_history(entity_slug, state, limit)` | Open + recent signals | | `get_audit_trail(entity_id)` | Audit log filter + browse | | `get_agent_activity(limit)` | Agent self-inspection of its own activity log | | `query_metrics(hours=24)` | Time-bucketed metric averages (TimescaleDB) | | `get_trend(entity_id, days=7)` | Metric slope over time | | `get_event_timeline(severity, entity_slug, limit)` | Recent events | **Knowledge — write** (no approval required; updates the graph, not live infra): | Tool | Purpose | |---|---| | `upsert_knowledge(title, content)` | Record a learning; the only way anything persists past a session | | `update_entity_attributes(slug, attributes)` | Shallow-merge a discovered fact into an entity | | `create_relationship(source, target, type)` | Idempotent edge insert between two entities | **Execution — mutating the live infrastructure:** | Tool | Purpose | |---|---| | `run(target, command, purpose, declared_risk)` | **The sole mutation primitive.** Classified, gated, audited — full flow in §3.3 | | `get_execution_status(execution_id)` | Poll an execution by UUID or slug prefix | **Retired:** `request_execution` (the old fixed action-type enum: `restart`/`systemctl`/`pct_exec`/`apt_upgrade`/`pct_create`) was retired 2026-07-14 (commit `60effcb`). `internal/mcp/server.go:360` carries an explicit comment: "legacy fixed enum … DO NOT re-register this tool" — the old handlers remain as dead reference code, not a live interface. **Task tools — Nomos-local, not on the shared MCP server.** `cmd/nomos/tasks.go` defines five additional tools handled **in-process by the gateway**, not forwarded to `api`'s MCP server (because the shared server has no session concept): `set_goal(goal)`, `propose_plan(steps:[{title,detail?,target_slug?}])`, `update_plan_step(seq,status,execution_id?)`, `ask_operator(prompt,options?,context_entities?,why?)`, `complete_task(outcome,summary)`. These are what an agent connecting directly to `api`'s `/mcp` endpoint (bypassing Nomos) would **not** have access to. ### 5.3 REST API (`api/openapi.yaml`, contract-first — [ADR-0004](../adr/0004-openapi-first.md)) Top-level resource groups (`GET /api/v1/...` unless noted): | Group | Examples | |---|---| | Entities & graph | `entities`, `relationships`, `graph` (+ `?format=mermaid`), `blast-radius` | | Client lifecycle | `clients/enroll` (unauthenticated, IP-gated), client secrets, provisioning | | Ontology | entity types, relationship types, lifecycle defs | | Signals | list, ack, resolve, mute | | Checks | check_defs CRUD | | Executions | list, get, **cancel** | | Classifications | list (writes are dead-path, see §9) | | Approvals | list, **decision** (approve/deny) | | Patterns / Skills | list, patch (status/quarantine), skill versions | | Policy | risk-classes, approval-rules, autonomy (read + write — autonomy write is how an operator would toggle the kill-switch, see §8 for why that toggle doesn't reach the live `run` path today) | | Knowledge | search, `/knowledge/recent`, `/knowledge/content/{id}` (non-OpenAPI, added directly on the chi router) | | Metrics | `query`, `trend` | | Audit | `audit` | | Events | `events`, `events/stream` (SSE, §5.5) | | Agent activity | `activity/recent`, `activity/session/{id}` (non-OpenAPI) | | Learning | `learning/timeline`, `learning/trend` (non-OpenAPI) | | Health / Dashboard | `healthz` (unauthenticated), `dashboard/summary` | | Export | full DB state → YAML (regenerates `seeds/*.yaml` for DR) | Cross-cutting conventions ([ADR-0004](../adr/0004-openapi-first.md), [ADR-0009](../adr/0009-sse-over-websocket.md)): RFC 9457 `problem+json` errors, cursor pagination, `If-Match`/ETag optimistic concurrency, idempotency keys on mutating POSTs. ### 5.4 Nomos HTTP interface (`cmd/nomos`, port 8092) | Route | Purpose | Auth | |---|---|---| | `GET /healthz` | Liveness | none | | `POST /query` | Structured tool call (`{"tool":..., "args":...}`) — bypasses the LLM entirely; or `{"query":...}`, which returns a pointer to `/chat` rather than answering | **none** | | `POST /chat` (SSE) | The real agentic loop — natural language in, streamed tool calls + tokens out | **none** | | `GET/POST /sessions`, `/sessions/{id}` | Session (Task) CRUD | **none** | This entire interface is unauthenticated today — see §8 for the risk statement; it is a known, tracked, and explicitly deferred gap (`C1` in `plans/2026-07-11-nomos-agent-code-review.md`), not an oversight this document is newly discovering. Nomos's own outbound interface to `api` is a hand-rolled Streamable-HTTP MCP JSON-RPC client (`cmd/nomos/main.go:583-810`): `initialize` → session ID → `tools/call`/`tools/list`, pooled **per chat session** (not globally shared) specifically so one task's long-running `run` call (up to a 10-minute timeout) doesn't serialize every other task behind one mutex. ### 5.5 Server-Sent Events (`internal/httpapi/sse.go`) Delivery mechanism: Postgres `LISTEN/NOTIFY`, not polling. A dedicated pooled connection issues `LISTEN oikos_events`; on notify, the full row is fetched and fanned out to all live subscribers over non-blocking channel sends (slow subscribers drop frames, recovered via `Last-Event-ID` replay against a 10,000-entry in-memory ring buffer, falling back to a full DB query if the buffer doesn't cover the gap). **Deliberate protocol choice:** the SSE `event:` field is *never* set — all events arrive on the generic `onmessage` handler with `type` embedded in the JSON payload, because a named `event:` field only fires `addEventListener(type)` listeners and the frontend listens uniformly via `onmessage`. There is no fixed enum of event types in `sse.go` itself; types are whatever gets written to the `events` table. Task-relevant types introduced by the goal-oriented redesign: | Type | Payload | |---|---| | `task.status` | `{ status, outcome?, summary? }` | | `goal.set` | `{ goal }` | | `plan.proposed` | `{ steps:[{seq,title,detail,target_slug}] }` | | `plan.step.started` / `plan.step.finished` | `{ step_id, seq, status, execution_id? }` | | `question.raised` / `question.answered` | `{ question_id, prompt?, context?, answer? }` | | `entity.touched` | `{ slug, tool }` — emitted for every tool call via `withActivityLogging`, zero agent-side effort | | `knowledge.recorded` | `{ title, about, outcome }` | | `approval.created` | emitted from `classifyAndGate` when a `run` call queues for approval | | `health.changed` | entity health transition | ### 5.6 Web SPA → API interface `web/src/lib/config.ts`'s `fetchWithAuth` is the single wrapper every API call in the frontend goes through: resolves an OIDC token first (refreshing if expired), falls back to the static bearer token in `localStorage` (`oikos_token`), injects `Authorization: Bearer …`. A 401 on an OIDC-authenticated request triggers one forced `oidcLogout()` before falling back. Config (API URL + token) is resolved **per request**, not memoized at import time, so the same bundle works same-origin (production, Vite dev proxy) or cross-origin (the Wails desktop webview). ### 5.7 External interfaces | Interface | Protocol | Direction | Notes | |---|---|---|---| | OpenRouter | HTTPS, OpenAI-compatible chat completions | Nomos → OpenRouter | `data_collection: deny` pinned; model configurable via `NOMOS_MODEL`, default `deepseek/deepseek-v4-pro` | | Matrix homeserver | Matrix client-server API (`PUT /rooms/.../send/m.room.message`, `GET /relations/.../m.annotation`) | notifier ↔ Matrix | ✅/❌ (and 👍/👎, "approve"/"deny") reactions turned into a normal `POST /api/v1/approvals/{id}/decision` call — same code path as a console click | | Infisical | Infisical SDK v0.8.0, UniversalAuth machine identity | `api` → Infisical | see [ADR-0010](../adr/0010-infisical-with-sops-fallback.md) | | Gitea webhook | `POST /deploy`, `X-Hub-Signature-256` HMAC-SHA256 | Gitea → `cmd/webhook` (:9797) | verified with `hmac.Equal`; triggers `scripts/deploy.sh` asynchronously, responds `202` immediately | | SSH | standard SSH protocol, key-based | `api` (scheduler + actuation) → fleet hosts/LXCs | **host key verification is disabled** (`InsecureIgnoreHostKey`) — flagged in §8 | --- ## 6. Behavior *Concern: how does a task, a signal, an execution, an approval move through time? This does not repeat [ADR-0014](../adr/0014-entity-model.md)'s entity-type hierarchy, full relationship catalog, or ERD — those are unchanged and still authoritative. What follows is the behavioral layer: state machines and sequence/interaction diagrams, refreshed against what this pass verified in the running code as of 2026-07-16, plus two sequences ADR-0014 doesn't cover.* ### 6.1 Signal state machine — the one domain type with an enforced guard ```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 --> [*] ``` `internal/domain/signal.go` is the **only** domain type with a real guard: `ValidSignalTransitions` (a map of allowed next-states) backs a `(*Signal) CanTransition(to string) bool` method. Every other domain type with a documented lifecycle (`Execution`, `Approval`, `Pattern`, `Skill`) has the states defined as constants but **no code enforces the arrows** — the state machine is descriptive, not guarded, and call sites are trusted to only make legal moves. **Even the one guarded type is bypassed by its main caller.** The scheduler's `resolveSignal` (`scheduler.go:206-225`) runs `UPDATE signals SET state='resolved' WHERE state='raised'` directly — it never calls `Signal.CanTransition`. In this specific case the transition (`raised → resolved`) is one the map does allow, so no invalid state is reached today, but the guard providing zero actual protection is worth knowing before relying on it as a safety net for a future code change that adds an illegal transition elsewhere. **Dedup mechanism** (unchanged from ADR-0014): a partial unique index — `UNIQUE (target_entity_id, kind) WHERE state NOT IN ('resolved','failed')` — enforces at most one open signal per (entity, kind) at the database level, independent of the Go-level state machine. ### 6.2 Execution state machine — schema-defined, convention-enforced ```mermaid stateDiagram-v2 [*] --> proposed proposed --> approved proposed --> auto_approved proposed --> denied approved --> executing approved --> expired auto_approved --> executing executing --> verified executing --> failed executing --> timed_out executing --> cancelled timed_out --> verifying verifying --> verified verifying --> failed failed --> rolled_back failed --> rollback_failed verified --> [*] denied --> [*] expired --> [*] cancelled --> [*] rolled_back --> [*] rollback_failed --> [*] ``` Corrected against a direct read of `seeds/ontology.yaml`'s registered `execution` lifecycle (see [ontology.md §11c](ontology.md#11c-lifecycle-state-machines) for the full state machine with its named `requires:` gates) — the earlier version of this diagram omitted `verifying` as a state distinct from `executing`, and the `timed_out → verifying` recovery path ("check if the command completed anyway"). `domain.Execution`'s constants (`proposed/approved/executing/verified/ failed/timed-out/rolled-back/cancelled/expired`) are never actually written by the live path — `internal/httpapi/phase3.go`'s `executeApprovedAction` writes ad-hoc SQL string statuses (`'running'`, `'completed'`, `'failed'`, `'pending_approval'`, `'cancelled'`) that overlap but don't map 1:1 onto the domain constants (`'completed'`/`'running'` vs. `verified`/`executing`). Treat the diagram above as the *intended* shape, and the actual column values as whatever `phase3.go` and `mcp/server.go`'s call sites happen to write — there's no single source of truth enforcing agreement between them. ### 6.3 Approval state machine ```mermaid stateDiagram-v2 [*] --> pending pending --> approved pending --> denied approved --> revoked approved --> expired denied --> [*] revoked --> [*] expired --> [*] ``` Unchanged from ADR-0014 and still accurate — `internal/httpapi/phase3.go: DecideApproval` is the single write path for `pending → approved/denied`, and the expiry sweep in `internal/notifier/notifier.go:90-93` handles `pending → expired` inline during its poll pass. ### 6.4 Sequence — the `run` primitive, end to end Supersedes ADR-0014 §3's OODA sequence, which predates the command classifier and the `request_execution` retirement. This is the live path as of 2026-07-16, for a `config_mutation` command with no pre-existing assent window (the "operator must approve" case — the assent-window already-granted case is the same minus the queue/wait steps): ```mermaid sequenceDiagram participant Op as Operator participant N as Nomos agent participant API as api on 8090 participant DB as Postgres participant Mx as Matrix participant H as Target host Op->>N: chat: "restart caddy" N->>API: MCP run(target=lxc:caddy, command="systemctl restart caddy", purpose=...) API->>API: classifyAndGate: P1 plan-first check API->>API: dedup check, P5 anti-flood check API->>API: policy.ClassifyCommand → config_mutation\n(scope override: service:caddy always escalates) API->>DB: no assent window active → INSERT executions (pending_approval)\nINSERT approvals (pending) API-->>N: "queued for approval, execution_id=..." N-->>Op: "queued — needs your approval" par Matrix path API->>DB: (notifier poll) SELECT pending approvals DB-->>API: approval row, no alert_sent_at yet API->>Mx: PUT room message: risk_class=config_mutation, ✅/❌ and Console path Op->>API: GET /api/v1/approvals (control-room UI) end Op->>Mx: reacts ✅ Mx-->>API: (notifier poll) GET relations → match ✅ API->>API: POST /api/v1/approvals/{id}/decision (self-call, same code as console) API->>DB: UPDATE approvals SET status=approved API->>H: SSH exec systemctl restart caddy H-->>API: exit 0 API->>DB: UPDATE executions SET status=completed API->>DB: INSERT audit_log API-->>N: (auto-continuation worker polls nomos_plan_executions) execution result N-->>Op: "caddy restarted, verified healthy" ``` Note the **dual path to the same decision endpoint**: a Matrix ✅ reaction and a console click both terminate in the identical `POST /api/v1/approvals/{id}/decision` call — the notifier's `callDecideApproval` (`notifier.go:204-228`) makes an HTTP call to itself rather than reimplementing the decision logic, so there is exactly one place that decides what "approved" does. ### 6.5 Sequence — plan auto-continuation ("the system is the event loop") This is the mechanism behind REQ-ACT-5: after a plan is approved once, the operator is not required to say "continue" after every asynchronous step. ```mermaid sequenceDiagram participant N as Nomos agent loop participant W as continuation worker participant DB as Postgres participant API as api N->>API: run(...) for plan step 2 API-->>N: execution_id=E2, status=pending (async) N->>DB: INSERT nomos_plan_executions(execution_id=E2, session_id=S, continued_at=NULL) N-->>N: turn ends (nothing left to say yet) loop worker poll W->>DB: SELECT nomos_plan_executions WHERE continued_at IS NULL DB-->>W: E2 still pending end API->>DB: (async, elsewhere) executions.status → completed for E2 W->>DB: SELECT nomos_plan_executions WHERE continued_at IS NULL DB-->>W: E2 now terminal W->>N: re-invoke agent loop for session S with E2's result N->>DB: UPDATE nomos_plan_executions SET continued_at=now() N->>API: run(...) for plan step 3 (no operator input needed) ``` The `nomos_plan_executions` table (migration 017) deliberately has **no hard foreign key** on `execution_id` — the header comment explains this is to avoid a race between the API creating the execution entity and Nomos recording the link breaking the insert. This is a documented, intentional looseness, not an oversight. ### 6.6 Sequence — a Task from goal to knowledge deposit ```mermaid sequenceDiagram participant Op as Operator participant N as Nomos participant DB as Postgres Op->>N: "install jellyfin on strong" N->>DB: get_entity_knowledge(lxc:strong) — prior learnings first N->>N: set_goal("install jellyfin on strong") N->>DB: session.status = planning N->>N: propose_plan([{seq:1,...}, {seq:2,...}, ...]) N->>DB: session.status = awaiting_approval Op->>N: approves plan (opens assent window) N->>DB: session.status = executing loop each step N->>N: run(...) or ask_operator(...) if a real decision is needed N->>DB: update_plan_step(seq, status) end N->>DB: upsert_knowledge("jellyfin install on strong: gotcha X") N->>DB: link knowledge —about→ lxc:strong, —outcome_of→ task:S\n(tagged success/failure) N->>N: complete_task(outcome=success, summary=...) N->>DB: session.status = done ``` This is the mechanism behind REQ-LRN-4 — the compounding knowledge loop is real and structurally wired (unlike the pattern-learning pipeline in §3.2/§9, which is algorithmically real but never executes). Its weak point, per an audited session referenced in §9, is compliance: the loop depends on the agent actually calling `upsert_knowledge`/`update_entity_attributes` at the end of a task, which is SOUL-nudged rather than structurally enforced the way `propose_plan`'s gating is. ### 6.7 Infrastructure lifecycle The 8-state `planned → provisioning → active → migrating → deprecated → destroyed` machine (with `failed` as a recovery/write-off branch) and its precondition table are unchanged from [ADR-0014](../adr/0014-entity-model.md) §5 and not reproduced here. --- ## 7. Verification & Validation *Concern: how do we know it works, and what isn't checked yet? MBSE distinguishes **verification** ("did we build the system right" — does it meet its specified requirements) from **validation** ("did we build the right system" — does it do what the operator actually needs).* ### 7.1 Verification — automated **CI pipeline** (`.gitea/workflows/ci.yml`), triggered on push to `main` and on PRs. One job, `build-test`: 1. Spin up a TimescaleDB service container. 2. `go vet ./...` 3. `golangci-lint` — **advisory** (`continue-on-error: true`) 4. `govulncheck` — **advisory** 5. Generated-code drift check (`make generate-check` — catches stale `oapi-codegen`/`sqlc` output committed out of sync with its source) 6. `go build ./...` 7. `go test -race -covermode=atomic ./...` with a coverage report A second job, `docker-build`, verifies `compose/oikos/Dockerfile` builds (no registry push — see §4.5 for why deploy doesn't need one). **Coverage is measured, not gated.** The report is informational; an 80%-on-`policy`/`learning`-packages enforcement gate is noted as deferred ("Phase 3") rather than active. This means REQ-DEC-1/REQ-DEC-2 (classifier correctness) currently rely on `internal/policy`'s own test file plus the eval suite (below) for confidence, not a CI-enforced coverage floor. A separate workflow, `desktop.yml`, builds and (on tag push) releases the Wails desktop binary — see §4.5. **Health checks as continuous verification.** The scheduler's 6 probe kinds (`http`, `tcp`, `disk`, `cert-expiry`, `ping`, `ssh-script`) are, functionally, a continuously-running verification suite against the *deployed fleet* rather than the codebase — every 30s, every enrolled entity is re-verified against its expected health, independent of whether any code changed. The 18 scripts under `checks/` (`cpu_check.sh`, `disk_usage_check.sh`, `docker_health_check.sh`, `zfs_check.sh`, …) are the concrete verification procedures for the `ssh-script` kind, auto-deployed to every enrolled client via `tools/setup-checks.sh` (AGENTS.md §8). **Agent evals** (`evals/*.yaml`, `cmd/nomos/eval`) — golden-conversation evals for the Nomos agent loop, **structural** assertions only (tool-call sequences, plan steps, writeback, completion); text quality is explicitly out of scope since it's model-dependent. Each run costs real OpenRouter credits (~$0.01–0.05/conversation), so these are not part of CI — they're an operator-invoked regression suite: ``` go run ./cmd/nomos/eval -gateway http://localhost:8092 -manifest evals/*.yaml ``` | Eval | What it guards against | |---|---| | `golden.yaml: trivial_readonly` | Ceremony creep — a one-line question shouldn't trigger a plan or a `run` call | | `golden.yaml: plan_advances_on_proceed` | The original operator-reported bug: re-proposing a plan instead of advancing it on "proceed" | | `golden.yaml: ui_complaint_no_rerun` | Approval-vocabulary ("go ahead") correctly recognized as assent, not misfired re-execution | | `golden.yaml: knowledge_preferred_over_rerun` | REQ-ORI-4/REQ-LRN-4 in practice — the agent must call `search_knowledge` before re-probing 20 LXCs it already has a same-day audit for | | `iteration-followup.yaml`, `iteration-readonly.yaml`, `plan-always-readonly.yaml`, `no-plan-no-run.yaml` | Additional plan/iteration-boundary regressions | This is the closest thing in the repo to **validation** of REQ-ACT-5 ("one approval → autonomous execution") and REQ-DEC-1-adjacent agent behavior — it tests the *agent's* use of the gated primitives, not the primitives themselves. **Database migration safety.** [ADR-0008](../adr/0008-forward-only-migrations.md): all 20 migrations are forward-only and idempotent (`IF NOT EXISTS`, `ON CONFLICT DO NOTHING` throughout), verified implicitly by every `migrate` one-shot container run on every deploy (§7.2) — a migration that isn't idempotent fails loudly on the next deploy rather than silently corrupting state. ### 7.2 Verification — deployment-time ```mermaid flowchart LR PUSH["push to main\n(webhook or 2-min poller)"] --> DUMP["pg_dump\npre-deploy-SHA.sql\n(best-effort, warns not fails)"] DUMP --> PULL["git pull origin main"] PULL --> SANITY["sanity-check last commit\nhas a message"] SANITY --> BUILD["docker compose --profile full build"] BUILD --> UP["docker compose --profile full up -d\n--remove-orphans (rolling restart)"] UP --> POLL["poll /healthz\nup to 60s (default)"] POLL -->|healthy| DONE(["deploy complete"]) POLL -->|never healthy| FAIL(["exit non-zero\nNO automatic rollback"]) ``` **There is no automatic rollback on health-check failure.** `scripts/deploy.sh` exits non-zero if `/healthz` never returns healthy within the retry budget, but reverting to the previous SHA + restoring the pre-deploy dump is a **manual, operator-invoked** step via `scripts/rollback.sh ` (documented in [docs/operations/rollback.md](../operations/rollback.md)). This is a deliberate scope boundary, not a gap — REQ-NFR-5 requires the *path* to exist and be tested, not that it fire automatically. `scripts/watchdog.sh` (separate 5-minute cron, outside the Docker stack — "watching the watcher") probes `/healthz` on two paths (LAN direct IP and NetBird mesh IP) and only pages via Matrix if **both** fail for 3 consecutive runs — a single-path failure is logged but not alerted, treating it as a probable transient NetBird/Caddy issue rather than a real outage. ### 7.3 Verification — what's not covered - **No test coverage identified for `internal/actuator` or `internal/learning`'s integration into a running process** — because there is no such integration to test (§3.2). Any unit tests on these packages verify the algorithms in isolation, not that the system behaves as the ADRs claim end-to-end. - **No automated check that `policy.ClassifyCommand`'s denylist actually blocks every pattern the plan (`general-gated-execution`) claims it blocks** — the regex list in `internal/policy/command.go` is the authority; there's no eval or test enumerated in this pass that walks the full pattern list and asserts each one classifies as `destructive`. This would be a cheap, high-value addition (§9.4). - **No automated check that `autonomy_settings` kill-switches are honored** — appropriate, since (per §8) they currently aren't, on the live path. A test asserting they *are* honored would currently fail correctly, which is itself informative. - **CI coverage gate is informational, not enforced** (§7.1). ### 7.4 Validation — does the system do what the operator needs The clearest validation evidence in the repo is the trail of operator-reported problems → shipped fixes in `plans/done/` and the active `plans/` UX/reliability audits — e.g. the original "approve every step" pain point directly drove the Task/plan-approval redesign (§1.3 item 4), and a specific audited session (348 tool calls, zero `update_entity_attributes`/`create_relationship` calls despite discovered drift) directly drove scrutiny of the knowledge-loop's compliance (§6.6). This is validation by **operator-in-the-loop iteration**, not a formal acceptance-test suite — consistent with a single-operator homelab system, but worth naming explicitly as the validation methodology rather than implying a UAT-style process that doesn't exist here. --- ## 8. Risk & Safety *Concern: what actually stops this system from doing something dangerous, and where does that break down? Oikos exists to act autonomously on real infrastructure with no human in the loop for most decisions — this section is the honest account of what keeps that safe today, versus what the schema/ADRs describe as the design intent. Findings marked **verified** were checked directly against source during this pass (grep + read); findings marked **per research pass** were not independently re-confirmed line by line — high-confidence, not certain.* ### 8.1 The kill-switch gap (verified, most important finding in this model) `seeds/policy.yaml` defines a global autonomy toggle (`global.auto_act: "off"` at cold start) and per-entity hard blocks (`never_auto_act.service:caddy/dns/authentik`, `never_auto_act.host:hubris/strong` — described in the policy file itself as "hard blocks"). These are read from the `autonomy_settings` table by exactly two code paths: - `internal/policy/classify.go` (`ClassifySignal`) — **dead code, zero callers anywhere in the codebase.** - `internal/actuator/actuator.go` — **never started.** No `cmd/oikos` role and no `docker-compose.yml` service runs `actuator.Run()`. The live, actually-executing gate for every mutation — `internal/mcp/server.go`'s `classifyAndGate`, invoked by the `run` MCP tool — reads `autonomy_settings` too, but **only** for its own session-scoped `assent_window.*` / `destructive_window.*` keys (per-task approval grants), never for `global.auto_act` or `never_auto_act.*`. **Practical consequence:** an operator who sets `never_auto_act.service: caddy = true`, believing it hard-blocks any action against `service:caddy`, gets no such protection against an agent-initiated `run` call today. The only things actually standing between an operator's chat message and a command running against `service:caddy` are: 1. the plan-first gate (a plan must exist), 2. `ClassifyCommand`'s regex classification of the *literal command text*, 3. the approval/assent-window requirement for anything above `read_only`, 4. the `scope_entity` override in `seeds/policy.yaml` that escalates `caddy`/`dns`/`authentik` restarts to `config_mutation` regardless of the generic per-action rule. Layer 4 means an ordinary `systemctl restart caddy` still requires operator approval today — so the practical exposure is narrower than "the kill switch does nothing" — but it is exposure the policy schema's own language ("hard block", "never") does not accurately describe, and it is exposure that widens for any action *not* covered by a `scope_entity` override. **This should be treated as a real defect, not a documentation nuance** — either wire `global.auto_act`/`never_auto_act.*` into `classifyAndGate`, or rewrite `seeds/policy.yaml`'s language and the operator-facing UI to state plainly that these settings currently gate only the (unused) autonomous auto-act pipeline. ### 8.2 Defense in depth that *is* live on the `run` path Layered, in the order a `run` call actually passes through them (`internal/mcp/server.go:classifyAndGate`, per research pass, corroborated by direct grep of `command.go`'s pattern list): 1. **Plan-first gate (P1).** A session-scoped call to `run` is refused unless the session already has a proposed plan — closes the original gap where `pct_exec` ran ad-hoc SSH with zero classification (the incident that motivated the whole `general-gated-execution` redesign). Does **not** apply to direct/scripted MCP calls with no `_session_id`. 2. **Dedup.** An identical pending-approval command already queued for the same target is refused rather than double-queued. 3. **Anti-flood (P5).** A second `config_mutation` approval cannot be queued while one is already pending for the session — bounds how much an operator can be asked to approve at once. 4. **Command classification** (`internal/policy/command.go`, verified by direct read): a **denylist** of destructive shell patterns (`rm -rf`/`rm -fr`, `dd of=`, `mkfs`, `wipefs`, `shred`, `pct destroy`/`qm destroy`, `zpool destroy`, `lvremove`/`vgremove`/ `pvremove`, `DROP TABLE`/`TRUNCATE TABLE`, redirects to raw block devices, `shutdown`/`reboot`/`halt`/`poweroff`, fork bombs, `chmod -R 000`, `iptables -F`/`ufw disable`, and reads of credential files `id_rsa`/`id_ed25519`/`*.pem`/`shadow`/`*.age`) forces `destructive` regardless of what the caller declares; an **allowlist** of read-only lead verbs (`cat`, `ls`, `ps`, `docker ps/inspect/logs`, `systemctl status`, `git status/log/diff`, `curl -I`, …) permits `read_only` only if *every* segment of a compound command matches it; everything else defaults to `config_mutation` ("when in doubt, escalate"). - Documented deliberate omission: `curl|sh` was removed from the denylist because it's a common legitimate install pattern (get.docker.com); it falls to `config_mutation` gating instead of an outright block. 5. **Assent / destructive windows.** `config_mutation` auto-runs only inside an assent window opened by the operator approving the containing plan; `destructive` auto-runs only inside a narrower, target-and-session-scoped window opened by a **typed confirmation phrase** distinct from ordinary assent — and this requirement holds even for steps inside an already-approved plan. 6. **Risk can only go up, never down.** `riskRank(computed, declared)` takes the max — a model that under-declares risk on a call cannot talk its way past the classifier's own assessment. 7. **Audit trail.** Every `run` call, whatever the outcome, produces an `execution` entity, an `executions` row, and (via `withActivityLogging`) an `agent_activity` row — nothing routes around the ledger. This is a genuinely defensible design *for the threat model it targets* (an LLM agent making mistakes or being subtly misled by ambiguous instructions). It is explicitly **not** designed to catch perfectly laundered malicious shell — the plan authoring this design says so outright: perfect static classification of arbitrary shell is acknowledged as impossible, and default-escalate + denylist + always-on audit is the stated mitigation, not a claim of completeness. ### 8.3 Threat model & trust zones ([ADR-0007](../adr/0007-threat-model.md)) - Trust boundaries are Docker networks (`net-front`/`net-data`/`net-ops` — §4.3), not host-level firewalling. - The agent (Nomos) holds no SSH key — verified structurally: the key lives only in the `api` container's environment, read by `internal/mcp/server.go`'s `initSSH()`. Nomos can only reach a host by asking `api` to run something through the gated `run` tool. - Caddy is a trust root for browser traffic but the API independently validates OIDC JWTs — defense in depth, not sole reliance on the edge proxy. - Documented residual risks in the ADR: a LAN break-glass path carries plaintext, and there's a shared-Postgres single point of failure across every service. - **Stale terminology note:** ADR-0007 refers to the agent as "Hermes" throughout ("Hermes holds no SSH keys", "the Hermes gateway remains mesh-only") — this predates the Hermes→Nomos rename (migration `014_rename_agent_hermes_to_nomos.up.sql`). The architectural claims are still accurate; the name is not. Flagged here rather than silently "corrected," since the ADR itself should not be edited after acceptance (repo convention, see [docs/adr/README.md](../adr/README.md)). ### 8.4 Known open security gaps These come from `plans/2026-07-08-oikos-gaps-and-improvements.md` and `plans/2026-07-11-nomos-agent-code-review.md` (per research pass — not independently re-verified in this pass beyond what's cross-checked elsewhere in this document). Status reflects each plan's own last-updated header. | ID | Gap | Status | |---|---|---| | **C1** | Nomos's own HTTP gateway (`:8092`) has **zero authentication** — `/chat`, `/query`, `/sessions` are open to anyone on the LAN/mesh, including the ability to grant chat-assent approvals with no credential check at all. | **Open, explicitly deferred** by operator instruction ("leave auth out for this round of fixes") — a scoping decision, not an oversight, but still a live gap. See §5.4. | | B1 | Unauthenticated enrollment can leak a newly-enrolled node's age private key. | Open | | B2 | Fake/placeholder Infisical credentials returned to enrollees. | Open | | B3 | Nomos's `mesh_only: true` config exists but is never enforced. | Open | | B4 | SSH host key verification disabled (`InsecureIgnoreHostKey`) for actuation SSH. | Open | | B5 | `list_my_secrets` enumerates all node pubkeys without scoping to the calling client. | Open | | A2 | Matrix notifier can flood: `alert_sent_at` guard is written *after* send, so a failed UPDATE re-sends on retry; no approval dedup; unbounded reaction-polling API calls. | Open | Prioritized fix order per the gaps plan: A1 (fixed) → A2 → B1/B2/B3 → D (mostly resolved) → remaining items. ### 8.5 What safety machinery exists but doesn't run Cross-reference to §3.2 for the full allocation table; the safety-relevant subset: - **Circuit breaker** (`internal/actuator/actuator.go:156-202`) — threshold + cooldown logic to stop hammering a target after repeated failures. Fully implemented, guards only the unused auto-act loop. **The live `run` path has no equivalent** — nothing currently stops an agent from retrying a failing command against the same target in a tight loop beyond the dedup/anti-flood gates in §8.2, which guard *queueing*, not *repeated execution* of already-approved or auto-run commands. - **SSH error classification & context-aware timeouts** (`internal/actuator/ssh.go`) — network/auth/timeout/remote error classification, per-step timeout defaulting. Zero callers; the live SSH executors (`internal/httpapi/phase3.go:sshExec` and its `mcp/server.go` counterpart) are simpler, independently-written implementations with a flat 10-minute hard cap and less granular error classification. - **Learning's anomaly quarantine** (§3.2, F5.2) — a same-pass burst of >10 identical-key feedback events gets quarantined rather than trusted. Algorithmically real, never executes (learning loop not started). ### 8.6 Safety properties that *are* structurally guaranteed Worth stating plainly, not just gaps: - **Learning cannot self-authorize.** [ADR-0006](../adr/0006-learning-proposal-only.md): the learning engine's DB role has no grants on governance/autonomy tables — a structural (role-permission), not merely logical, guarantee. Currently moot in practice since the engine doesn't run (§8.5), but the guarantee would hold the moment it's wired back in, without further code changes, per the ADR's design. - **Destructive actions require an explicit typed phrase, always**, independent of plan approval — verified in §8.2 item 5, this is not bypassable by pre-authorizing a plan that merely *lists* a destructive step. - **The agent cannot hold an SSH key even if compromised** (§4.3) — the blast radius of a fully-compromised Nomos process is bounded by what the gated `run` tool will classify and allow, not by what SSH access the process itself has (it has none). - **Every mutation is audited whether it succeeds, fails, or is denied** — there is no silent-failure path in `classifyAndGate` that skips the ledger. ### 8.7 Recommended reading order if triaging this for real 1. §8.1 (kill-switch gap) — highest-severity, cheapest to reason about, not yet fixed. 2. §8.4 table — known, triaged, prioritized elsewhere; don't re-discover these, just track them. 3. §8.5 — lower urgency than §8.1/§8.4 (the missing circuit breaker matters only once the auto-act path or a much higher-volume agent workload exists), but worth deciding *deliberately* whether to port the existing `actuator/ssh.go` machinery into the live path or delete it, rather than letting it silently rot as an attractive nuisance for the next person who assumes it's load-bearing. --- ## 9. Roadmap & Traceability *Concern: what's code-real vs. dead-code vs. schema-only, and where is this headed? Two things live here: the authoritative status matrix every other section points back to, and the north-star direction. This supersedes the equivalent table in [ADR-0014](../adr/0014-entity-model.md) §6, which predates the `general-gated-execution` rebuild and the discovery that `actuator`/`learning` are unwired.* ### 9.1 The north star: general gated execution Direction: **"general gated execution (unlimited actions, classifier-gated), not a fixed action enum."** `plans/2026-07-10-general-gated-execution.md` is the design document and describes four layers: | Layer | Description | Status | |---|---|---| | **Layer 0** | The general gated `run` primitive itself | ✅ Done — `ClassifyCommand`, `run` MCP tool, chat-assent, `request_execution` retired (commit `60effcb`, 2026-07-14) | | **Layer 1** | Runbooks as *executable data* in the knowledge DB, not prose | ⚠ Mostly unimplemented — "the knowledge is inert w.r.t. action." `skill` entities carry a JSON `procedure` field; no execution engine reads or runs it | | **Layer 2** | Learning closes the loop — successful ad-hoc sequences get promoted into runbooks | ⚠ Partial — the pattern-extraction algorithm exists (`internal/learning/learning.go`) and is faithful to [ADR-0006](../adr/0006-learning-proposal-only.md)'s design, but is never started by any process (§9.2) | | **Layer 3** | Observability — live action feed, session digest, learning view, global ledger | ✅ Largely built (SSE event types, Activity timeline, Learning page) | **The one item the plan's own header still marks open:** "revive auto-act" — the Observe→Act autonomous direction (a signal triggering action without an operator initiating a chat turn) is unimplemented in practice, because `internal/actuator.Run()` is a literal `{"success": true, "message": "stub execution"}` stub *and* is never started. Only operator/agent-initiated `run` calls execute end-to-end today. Reaching the north star requires either completing this stub and wiring it into a `cmd/oikos` role, or making an explicit decision that auto-act is out of scope and updating the plan/policy language accordingly (§8.1 — this decision also has to resolve the kill-switch question, since `actuator.go` is where that check currently lives). ### 9.2 Code-real / dead-code / schema-only matrix Legend: ✅ live in production · ⚠ implemented, not wired in · 🔴 dead code (zero callers) · ❌ schema/ontology only, no Go implementation. | Component | Status | Evidence | |---|---|---| | Entity CRUD, relationships, ontology validation | ✅ | `internal/httpapi/impl.go`, unchanged from ADR-0014 | | Lifecycle transitions + preconditions | ✅ | `impl.go`, unchanged from ADR-0014 | | Scheduler observe loop (6 probe kinds, dedup, entity_status, stale sweep) | ✅ | `internal/scheduler/scheduler.go` | | **`policy.ClassifySignal`** (signal → risk class, DB-rule driven) | 🔴 | Zero callers anywhere in the codebase — verified | | **`policy.ClassifyCommand`** (command text → risk class, regex driven) | ✅ | Called from `internal/mcp/server.go:classifyAndGate` — verified, the actually-live classifier | | `run` MCP tool + gating (plan-first, dedup, anti-flood, assent/destructive windows) | ✅ | `internal/mcp/server.go` | | `request_execution` (old fixed-enum mutation tool) | 🔴 | Retired 2026-07-14, explicit "DO NOT re-register" guard left in place | | **`internal/actuator.Run`** (auto-act loop, circuit breaker, kill-switch check) | 🔴 | Not started by any `cmd/oikos` role or compose service; its own executor is a hardcoded stub result | | **`internal/actuator.ExecuteProcedure`** (SSH procedure runner, error classification) | 🔴 | Zero callers; live SSH execution is a separate, simpler implementation in `httpapi/phase3.go` / `mcp/server.go` | | Live SSH execution (`sshExec` in `phase3.go` and `mcp/server.go`) | ✅ | Actual executor for approved/auto-run commands — duplicated across two files, no shared implementation | | Approvals (create, decide, expire) | ✅ | `internal/httpapi/phase3.go` | | `notifier.VerifyApprovalToken` | 🔴 | Zero callers; `phase3.go:DecideApproval` reimplements the same check inline | | Notifier (Matrix alerts, reaction polling, token issuance) | ✅ | `internal/notifier/notifier.go` | | **`internal/learning.Run`** (pattern extraction, Wilson bound, quarantine) | ⚠ | Algorithm faithful to ADR-0006; never started by any process — feedback accumulates, nothing processes it | | Patterns/Skills list + patch endpoints | ✅ | `internal/httpapi/phase3.go` (read/admin surface exists independent of whether learning produces new rows) | | `domain.Execution` state machine (constants) | ❌ (descriptive only) | No `CanTransition`-equivalent guard; live code writes ad-hoc string statuses that don't map 1:1 | | `domain.Signal` state machine | ✅ (guarded) but bypassed | `CanTransition` exists and is real; `scheduler.go:resolveSignal` bypasses it with raw SQL (transition happens to be legal today) | | `autonomy_settings` kill-switch (`global.auto_act`, `never_auto_act.*`) | 🔴 on the live path | Only read by `ClassifySignal` (dead) and `actuator` (unstarted) — §8.1 | | `autonomy_settings` assent/destructive windows | ✅ | Different keys, same table, read/written live by `classifyAndGate` | | Task/plan-step/question schema (migrations 018–020) | ✅ | Fully wired: `set_goal`/`propose_plan`/`update_plan_step`/`ask_operator`/`complete_task`, SSE event types, `TaskContextPanel.svelte` | | Plan auto-continuation worker | ✅ | `cmd/nomos/continue.go`, polls `nomos_plan_executions` | | Knowledge write loop (`upsert_knowledge`, `about`/`involves` relationships) | ✅ (mechanism) / ⚠ (compliance) | Mechanism fully wired; a 348-tool-call audited session made zero `update_entity_attributes`/`create_relationship` calls despite discovered drift — compliance is SOUL-nudged, not enforced | | SSE event stream (Postgres `LISTEN/NOTIFY` fan-out) | ✅ | `internal/httpapi/sse.go` | | Blast radius (`blast_radius()` SQL fn) | ✅ | Called from both the dead classifier and the live `get_blast_radius` MCP tool — the SQL itself is shared and correct | | Bearer/OIDC auth on `api` | ✅ | `combinedAuth`, no dev-open bypass (ADR-0015) | | Auth on Nomos gateway (`:8092`) | 🔴 | None — open, deferred gap (C1) | ### 9.3 Documentation/code divergences found during this pass | Divergence | Where | Recommendation | |---|---|---| | ADR-0007 calls the agent "Hermes" throughout | `docs/adr/0007-threat-model.md` | Leave as-is (ADRs aren't edited post-acceptance per repo convention) but don't cite it without noting the rename | | `nomos/config.yaml` says `max_iterations: 15`; the Go constant actually enforced is `40` (`cmd/nomos/agent.go:24`) | config vs. code | Either wire the config value through or delete it so it stops implying control it doesn't have | | Dual `agent_activity` logging — both `cmd/nomos/store.go`'s `logActivity` and `internal/mcp/server.go`'s `withActivityLogging` log the same forwarded tool call | per research pass, not independently re-verified this pass | Confirm whether this is intentional dual-audit-trail (nomos-side + api-side) or accidental duplication before relying on `agent_activity` row counts for anything quantitative | | `plans/2026-07-14-activity-gaps.md` and `plans/2026-07-14-activity-timeline.md` still say `Status: Planned` in their own headers but are **not listed** in `plans/index.md`'s active table at all (unlike four sibling 07-14 plans, which are listed and marked Done) | plans/ | Likely superseded/absorbed by `post-fix-session-remainders.md` and the tool-timeline/unified-indicator work — worth an explicit note in `plans/index.md` either way so the ambiguity doesn't recur | | `internal/ontology`/`seeds/ontology.yaml` mention relationship types `powers`/`monitors` and entity type `certificate` with no concrete instances or, in the `powers`/`monitors` case, no `relationship_types` row | ADR-0014 §6 (still accurate, unchanged) | Low priority — cosmetic ontology completeness | ### 9.4 Suggested next steps (informational — not a commitment, not a plan) These are documentation-pass observations, offered for the operator to triage into an actual plan if desired — not a proposal for unilateral action: 1. **Decide the fate of `internal/actuator` and `internal/learning`.** Either finish wiring them in (which would also require resolving the kill-switch gap, since `actuator.go` is where that check currently lives) or delete them and update ADR-0014/OIKOS.md's "Phase 3 — DONE" claim, which currently overstates what's actually running. 2. **Fix the kill-switch gap** (§8.1) — either wire `autonomy_settings.global.auto_act`/`never_auto_act.*` into `classifyAndGate`, or rewrite the policy schema's "hard block" language to describe what it actually gates today. 3. **Consolidate the two SSH executors** (`internal/actuator/ssh.go` vs. `httpapi/phase3.go`'s `sshExec`) — the unused one has better error classification and timeout handling; the live one is simpler. Picking one and deleting the other removes a maintenance trap. 4. **Add a test enumerating `command.go`'s destructive-pattern list** against a fixture of known-dangerous commands, closing the verification gap named in §7.3. 5. **Resolve C1** (Nomos gateway auth) when the operator is ready to pick it back up — currently a deliberate, tracked deferral, not a forgotten item. ### 9.5 Traceability summary Every requirement in §2 carries a Status column that points into §9.2's matrix by implication (✅/⚠/❌ map directly). Every function in §3.2's allocation table cites the same evidence. §9.2 is the place to update first when code changes — every other section's status markers should be treated as **derived from**, not independent of, that matrix. ## Keeping this model current This is a snapshot, not a live view. When you change: - **What a component does** → update §3.2's allocation table and, if it changes what's live vs. dormant, §9.2's matrix. - **A gating/approval rule** → update §8 and re-check whether §8.1's kill-switch finding is still accurate. - **An MCP tool, REST route, or SSE event type** → update §5. - **A state machine or the sequence of a core flow** → update §6. If a change resolves one of the divergences or gaps named in §9.3–9.4, remove it from that list rather than leaving it to rot as a stale finding — the whole value of this model depends on it staying honest about current state, the same discipline ADR-0014 modeled first.