From 55781984c72e9255161374dd052139296d8b9aa3 Mon Sep 17 00:00:00 2001 From: dtoro Date: Fri, 17 Jul 2026 21:00:14 +0200 Subject: [PATCH] docs(mbse): add MBSE system model, framework, component and ontology views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four cross-linked documents under docs/mbse/, structured after Jon Holt's Systems Engineering Demystified (2nd ed.): Framework = Ontology + Viewpoints, producing a Model made of Views. - framework.md — the Ontology (SE meta-concepts + Oikos's domain ontology) and an 11-entry Viewpoint catalog (two repeating: Component, Ontology). - README.md — the Model's 9 concern-based Views (mission, requirements, functional/physical architecture, interfaces, behavior, V&V, risk, roadmap). - components.md — 8 per-component Views going one layer deeper into each running part of the system's own internal structure. - ontology.md — 4 Views on the domain ontology itself: entity type hierarchy (split into 9 digestible per-domain diagrams), full relationship catalog, lifecycle state machines with their requires: gates, and concrete population. Grounded in direct verification against source (grep/read), not just existing docs — every finding is graded verified vs. per-research-pass. Surfaced several real, previously undocumented findings along the way: the policy kill-switch (global.auto_act/never_auto_act) is checked only by dead code and an unstarted actuator package, so it doesn't gate the live run path; internal/actuator and internal/learning are compiled but never started by any process; the relationship catalog grew from 34 to 47 types since ADR-0014; and task has no registered lifecycle_defs entry despite having a documented, code-enforced state machine. Co-Authored-By: Claude Sonnet 5 --- docs/mbse/README.md | 1610 +++++++++++++++++++++++++++++++++++++++ docs/mbse/components.md | 509 +++++++++++++ docs/mbse/framework.md | 414 ++++++++++ docs/mbse/ontology.md | 444 +++++++++++ 4 files changed, 2977 insertions(+) create mode 100644 docs/mbse/README.md create mode 100644 docs/mbse/components.md create mode 100644 docs/mbse/framework.md create mode 100644 docs/mbse/ontology.md diff --git a/docs/mbse/README.md b/docs/mbse/README.md new file mode 100644 index 0000000..bf6b95c --- /dev/null +++ b/docs/mbse/README.md @@ -0,0 +1,1610 @@ +# 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. diff --git a/docs/mbse/components.md b/docs/mbse/components.md new file mode 100644 index 0000000..da6cd73 --- /dev/null +++ b/docs/mbse/components.md @@ -0,0 +1,509 @@ +# Oikos — Component Views + +> Companion to [the Model](README.md) and [the Framework](framework.md). +> Where README.md's nine Views cut across the whole system by *concern* +> (requirements, behavior, risk...), this document cuts across it by +> *component* — one View per running part of the System, going one layer +> deeper into its own internal structure than the whole-system Views do. +> Per [framework.md](framework.md) §4, each section below is still a View +> and must still answer Holt's three questions; they're stated once per +> section rather than as a separate table, since here the Stakeholder is +> almost always the same ("an engineer about to change this component") +> and the Notation is the same (prose + Mermaid) throughout. + +**How to use this alongside the other two documents:** if you're deciding +*whether something belongs in the Model*, read [framework.md](framework.md). +If you're asking *what does the system do and why*, read +[README.md](README.md). If you're about to **change code in a specific +package** and want to know its internal shape, its own state, and what's +already known to be broken or dormant inside it before you touch it, read +the relevant section here. + +## Contents + +| Component | Path | Status | +|---|---|---| +| [1. oikos api](#1-oikos-api) | `internal/httpapi`, `internal/mcp`, `internal/policy` | ✅ live — the decision/execution gate | +| [2. oikos scheduler](#2-oikos-scheduler) | `internal/scheduler`, `internal/checkdefaults` | ✅ live — the observe loop | +| [3. oikos notifier](#3-oikos-notifier) | `internal/notifier` | ✅ live — approval delivery | +| [4. nomos](#4-nomos-agent-gateway) | `cmd/nomos` | ✅ live — the agent, unauthenticated gateway | +| [5. web control room](#5-web-control-room) | `web/src` | ✅ live — standalone SPA | +| [6. PostgreSQL/TimescaleDB](#6-postgresqltimescaledb) | `migrations/`, `seeds/` | ✅ live — the System's own source of truth | +| [7. Dormant components](#7-dormant-components) | `internal/actuator`, `internal/learning` | 🔴 compiled, never started | +| [8. Auxiliary components](#8-auxiliary-components) | `cmd/webhook`, `cmd/desktop` | ✅ live — deploy + packaging, not decision logic | + +--- + +## 1. oikos api + +**Stakeholders:** engineers extending the MCP tool surface, the `run` gate, +or REST endpoints; anyone debugging why a specific command was or wasn't +classified the way they expected. **Why this View earns its place:** this +is the single component where the highest-consequence findings in +[Risk & Safety](README.md#8-risk--safety) live — extending it without +knowing its internal shape is how the kill-switch gap and the +two-classifiers problem happened in the first place. + +### oikos api — Internal structure + +| File | Lines | Role | +|---|---|---| +| `internal/httpapi/server.go` | 842 | `NewHandler` (routing entry, L75), `combinedAuth` (L229), OIDC JWKS discovery/fetch/validate (L337-524), `GetActor` (L526), OIDC config/token/callback handlers (L575-712), `ListenAndServe` (L811) | +| `internal/httpapi/impl.go` | 1,639 | Entity CRUD, lifecycle transitions + preconditions (per [ADR-0014](../adr/0014-entity-model.md)) | +| `internal/httpapi/phase3.go` | 2,627 | Executions, approvals (`DecideApproval`), `sshExec`, `executeApprovedAction`, autonomy-settings read/write endpoints — the **largest single file in the component** | +| `internal/httpapi/sse.go` | 366 | `LISTEN/NOTIFY` fan-out, ring-buffer replay | +| `internal/httpapi/activity.go` | 215 | `agent_activity` read endpoints | +| `internal/httpapi/knowledge.go` | 286 | Knowledge search/content endpoints | +| `internal/httpapi/dashboard.go` | 172 | `dashboard/summary` | +| `internal/httpapi/learning_view.go` | 129 | `learning/timeline`, `learning/trend` | +| `internal/httpapi/problem.go` | 71 | RFC 9457 `problem+json` error envelope | +| `internal/httpapi/default_checks.go` | 13 | Thin wrapper calling `internal/checkdefaults` on entity creation | +| `internal/mcp/server.go` | 1,691 | All 33 MCP tool registrations (`get_entity` at L76 through `list_my_secrets` at L753), `sshExec` (L1031), `resolveExecTarget` (L1223), **`classifyAndGate`** (L1264-1417) | +| `internal/policy/command.go` | 174 | `ClassifyCommand` (L108) — the **live** classifier, `computeCommandRisk` (L127), `allSegmentsReadOnly` (L157), `riskRank` (L26) | +| `internal/policy/classify.go` | 157 | `ClassifySignal` (L46) — **dead code, zero callers** (see [Roadmap §9.2](README.md#92-code-real--dead-code--schema-only-matrix)) | + +### oikos api — internal call structure: the `run` gate, by file + +README.md's [§3.3](README.md#3-functional-architecture) shows the *decision +logic* of the `run` gate. This shows the *code path* — which file hands off +to which — because they're not the same question: the decision flowchart +tells you what happens, this tells you where to go fix it. + +```mermaid +flowchart LR + MCP["mcp/server.go\nrun tool handler, L366"] --> GATE["mcp/server.go\nclassifyAndGate, L1264"] + GATE --> RESOLVE["mcp/server.go\nresolveExecTarget, L1223"] + GATE --> CLASSIFY["policy/command.go\nClassifyCommand, L108"] + CLASSIFY --> RISK["policy/command.go\ncomputeCommandRisk, L127\nallSegmentsReadOnly, L157"] + GATE -->|read_only or window open| EXEC["mcp/server.go\nsshExec, L1031"] + GATE -->|otherwise| APPROVAL["phase3.go\ncreateApproval"] + APPROVAL -->|operator decides| DECIDE["phase3.go\nDecideApproval"] + DECIDE --> EXEC2["phase3.go\nsshExec\n(separate implementation)"] + + style CLASSIFY fill:#e8f5e9,stroke:#2e7d32 + style EXEC fill:#fff3e0,stroke:#e65100 + style EXEC2 fill:#fff3e0,stroke:#e65100 +``` + +The two orange boxes are the same finding stated visually: `mcp/server.go` +and `phase3.go` each have **their own `sshExec`**, independently written, +not sharing an implementation. Fix one path's SSH handling and the other is +untouched — verified during the Roadmap audit, not assumed. + +### oikos api — Interfaces this component owns + +Full catalogs live in [README.md §5](README.md#5-interfaces-icd) (33 MCP +tools, REST groups, SSE event types) — not repeated here. What's specific +to *this* component's internal ownership: `internal/mcp/server.go` owns +every MCP tool; `internal/httpapi/{impl,phase3,sse,activity,knowledge, +dashboard,learning_view}.go` own the REST surface between them, split by +resource area rather than by file size; `internal/policy/command.go` is a +pure function library with no HTTP surface of its own, called only from +`classifyAndGate`. + +### oikos api — Status and known issues + +All of the following are detailed with evidence in +[Roadmap & Traceability](README.md#9-roadmap--traceability) and +[Risk & Safety](README.md#8-risk--safety) — cross-referenced here so an +engineer opening this specific package sees them before making a change, +not after: + +- `policy.ClassifySignal` (in this component) is dead code; the schema it + reads (`autonomy_settings.global.auto_act`, `never_auto_act.*`) is + therefore not enforced by anything in the live request path — [§8.1](README.md#81-the-kill-switch-gap-verified-most-important-finding-in-this-model). +- `notifier.VerifyApprovalToken` (a different component, §3 below) is dead; + `phase3.go:DecideApproval` reimplements token verification inline instead + of calling it. +- `domain.Execution`'s state constants are descriptive only — `phase3.go` + writes ad-hoc SQL string statuses that don't map 1:1 onto them. +- SSH host key verification is disabled (`InsecureIgnoreHostKey`) on the + actuation path — open gap B4. + +--- + +## 2. oikos scheduler + +**Stakeholders:** engineers adding a new probe kind or debugging why a +signal did or didn't fire. **Why this View earns its place:** the +scheduler is the only component that runs unattended on a fixed interval +with no operator or agent triggering it — its failure modes look different +from every request-driven component above. + +### oikos scheduler — Internal structure + +| File | Lines | Role | +|---|---|---| +| `internal/scheduler/scheduler.go` | 761 | Everything — no sub-packages | +| `internal/scheduler/init.go` | 13 | `RunnerForMain()` — the only thing `cmd/oikos`'s `scheduler` role calls | +| `internal/checkdefaults/defaults.go` | — | `ForEntityType` (L60-123), `Ensure` (L144-204), `DefaultInterval` (L133-142) — default check provisioning on entity creation | + +Key functions inside `scheduler.go`: `Run` (L36-64, the tick loop, default +30s), `runCheckPass` (L67-94, loads `check_defs`, dispatches with a +10-worker `errgroup` limit), `runCheck` (L104-186), `executeCheck` (L237-254, +the kind dispatcher), `resolveSignal` (L206-225), `evaluateSeverity` +(L737-760), `staleSweep` (L286-333, 3× fastest interval / 5 min floor). + +### oikos scheduler — behavior specific to this component: the probe dispatch + +```mermaid +flowchart TB + TICK["Run tick, every 30s"] --> LOAD["ListEnabledCheckDefs"] + LOAD --> DISPATCH["executeCheck: dispatch by kind"] + DISPATCH --> HTTP["checkHTTP, L336"] + DISPATCH --> TCP["checkTCP, L393"] + DISPATCH --> DISK["checkDisk, L424"] + DISPATCH --> CERT["checkCertExpiry, L474"] + DISPATCH --> PING["checkPing, L545"] + DISPATCH --> SSH["checkSSHScript, L613"] + HTTP & TCP & DISK & CERT & PING & SSH --> RESULT["checkResult struct\nhealth, signalKind, evidence, metrics"] + RESULT -->|healthy| RESOLVE["resolveSignal\nraw SQL, bypasses Signal.CanTransition"] + RESULT -->|unhealthy| UPSERT["UpsertSignal\ndedup by target+kind"] + RESULT --> METRICS["INSERT metric_samples"] + RESULT --> STATUS["UpsertEntityStatus"] +``` + +`checkSSHScript` (L613-724) is the odd one out: it shells out to the system +`ssh` binary directly (`BatchMode=yes`, `StrictHostKeyChecking=no`) rather +than using a Go SSH library, restricted to scripts matching +`^[a-z][a-z0-9_-]+\.sh$` at a fixed path `/opt/oikos/checks/