Files
oikos/docs/mbse/components.md
dtoro 482c7f3448
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
feat(web): app-registry architecture — OS + Apps, lazy loading, installable apps
Problem: the frontend had an implicit OS+Apps metaphor (desktop, floating
windows, an app registry) but the contract was informal — the mascot was
hardcoded into the shell, all apps were statically imported into one
800KB bundle, and there was no install/uninstall path.

Change: three phases landed.
- Phase 1 (contract + docked kind): AppDef extended with docked/noIcon
  and optional geometry; the mascot registered as a docked app via a
  generic DockedLayer that replaces the hardcoded <MascotLayer />;
  openAppWindow branches on docked → toggleDocked; persisted docked
  visibility store (absent key = visible, no APPS import to avoid a
  static cycle).
- Phase 2 (lazy loading): AppDef.component is now a dynamic-import
  loader; LazyApp renders with a loading skeleton; Vite code-splits
  each app (main bundle 800KB→485KB); the LazyMascot wrapper is gone
  since the lazy loader breaks the import cycle directly.
- Phase 3 (installable apps, local bundles): AppManifest + catalog +
  installApp/uninstallApp + localStorage persistence; reactive apps
  store (built-in + installed) and derived appById; App Store page;
  Notes demo app; icons.ts and WindowLayer's orphan-close react to
  registration so installs appear without a reload.
- Structure: data-table casing unified to PascalCase; the mislabeled
  DataTable.svelte.ts (pure types, not runes) renamed to types.ts;
  LazyApp colocated with its desktop-shell consumers; app-store moved
  under lib/ so the dependency direction is consistent.

Risk: the app registry is now a reactive store, not a static array, so
every consumer (Desktop, DockedLayer, Taskbar, icons, windows) reads
from derived stores. Two static-cycle traps are documented in
docs/mbse/components.md §9: docked.ts must not import APPS (it would
fire a TDZ at init via the apps.ts→pages→windows.ts→here path), and
apps.ts must not statically import the mascot (the lazy loader defers
its module graph). Remote bundle loading, the /api/v1/apps endpoint,
and permission enforcement are deliberately NOT in this commit — they
are security-critical and deferred to Phase 4 with an ADR.

Verification: vitest 38/38; svelte-check + tsc clean for changed files;
eslint clean; vite build green; runtime smoke confirmed (install
Notes → icon appears → open → uninstall → icon + window gone; survives
reload). docs/mbse/components.md Component 9 and the plan updated.

Plan: plans/2026-07-21-frontend-os-apps-architecture.md
2026-07-21 14:37:36 +02:00

686 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
| [9. web control room — App architecture](#9-web-control-room--app-architecture) | `web/src/lib/apps.ts`, `web/src/lib/stores/windows.ts`, `web/src/lib/stores/docked.ts`, `web/src/lib/components/desktop-shell/` | ✅ live — the OS + Apps shell contract |
---
## 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/<script>`. The
18 scripts it can run (`cpu_check.sh`, `disk_usage_check.sh`,
`docker_health_check.sh`, `zfs_check.sh`, …) live in `checks/` in this repo
and are auto-deployed to every enrolled client by `tools/setup-checks.sh`
(per AGENTS.md §8) — this component's actual probe logic is split between
Go code here and shell scripts version-controlled elsewhere in the repo.
### oikos scheduler — Interfaces this component owns
No external API — this is the one component with no inbound interface at
all, only outbound: SSH to the fleet (probes), and writes to
`metric_samples`/`signals`/`entity_status`/`events` that every other
component reads. It is a pure producer.
### oikos scheduler — Status and known issues
- Never calls `policy.ClassifySignal` — signals it raises sit as
`state='raised'` with no automatic classification; whatever consumes
them downstream (the agent, the console) does its own interpretation.
- `resolveSignal` updates `raised → resolved` via raw SQL, bypassing the
one enforced state machine in the domain layer
(`domain.Signal.CanTransition`) — the specific transition happens to be
legal today, but nothing would stop a future change from making it not.
---
## 3. oikos notifier
**Stakeholders:** engineers debugging a missed or duplicate Matrix alert,
or extending the approval-delivery mechanism to a new channel.
**Why this View earns its place:** this is the one component whose entire
job is bridging an asynchronous human decision into the same-shaped
synchronous decision every other component expects — worth understanding
in isolation before assuming "approval" means one simple thing.
### oikos notifier — Internal structure
All in `internal/notifier/notifier.go` (305 lines, one file, no
sub-packages): `Run` (L25-47, two tickers — 15s for pending approvals, 30s
for reaction polling), `processPendingApprovals` (L65-115, generates the
token/hash lazily on first pass), `generateApprovalToken` (L275-283,
HMAC-SHA256 over approval ID + nanosecond timestamp), `hashToken`
(L302-305, only the hash is stored), `sendMatrixAlert` (L231-272),
`pollReactions`/`checkReaction` (L118-201), `callDecideApproval`
(L204-228), `VerifyApprovalToken` (L286-300, **dead code**).
### oikos notifier — Behavior specific to this component
The full sequence (Matrix + console paths converging on one decision
endpoint) is in [README.md §6.4](README.md#64-sequence--the-run-primitive-end-to-end).
Specific to this component in isolation: it never calls into
`internal/httpapi` directly except through one HTTP call
(`callDecideApproval`, an ordinary client request to
`POST /api/v1/approvals/{id}/decision`) — the notifier and the API process
communicate **only through the database and one HTTP endpoint**, never
through shared Go state, which is why the header comment in `notifier.go`
calls this a "DB rendezvous pattern."
### oikos notifier — Interfaces this component owns
Outbound only: the Matrix client-server API
(`PUT /rooms/.../send/m.room.message`, `GET /relations/.../m.annotation`)
and one outbound call to the API's own approval-decision endpoint. No
inbound interface — nothing calls into the notifier process.
### oikos notifier — Status and known issues
- `VerifyApprovalToken` is dead code; `phase3.go:DecideApproval` (a
different component, §1 above) reimplements the same hash-compare logic
inline rather than calling it — a single source of truth for token
verification does not currently exist.
- Open gap A2: `alert_sent_at` is written *after* the send attempt, so a
failed UPDATE re-sends the alert on the next poll; no dedup beyond that,
and reaction-polling API calls are unbounded.
---
## 4. nomos (agent gateway)
**Stakeholders:** engineers changing agent behavior, adding a task tool, or
investigating a stuck/duplicated task. **Why this View earns its place:**
this is the largest component by line count (4,681 lines across six files)
and the one with the most active recent bug-fix history
(`plans/2026-07-11-nomos-agent-code-review.md`,
`plans/done/2026-07-14-post-fix-session-remainders.md`) — its internal
shape is not obvious from outside.
### nomos — Internal structure
| File | Lines | Role |
|---|---|---|
| `cmd/nomos/main.go` | 914 | Gateway HTTP server (`:8092`), `/query`/`/chat`/`/sessions` routes, the hand-rolled Streamable-HTTP MCP client (`mcpClient`, per-session pooled) |
| `cmd/nomos/store.go` | 1,472 | Persistence — sessions, messages, `logActivity` |
| `cmd/nomos/agent.go` | 861 | The agentic loop itself; model config (L62-120); `maxIterations = 40` (L24, a **hard-coded constant**, not read from `nomos/config.yaml`'s `max_iterations: 15` — the two disagree, see status below) |
| `cmd/nomos/tasks.go` | 416 | The five nomos-local task tools: `set_goal`, `propose_plan`, `update_plan_step`, `ask_operator`, `complete_task` — handled in-process, never forwarded to `internal/mcp` |
| `cmd/nomos/continue.go` | 347 | The auto-continuation worker — polls `nomos_plan_executions` |
| `cmd/nomos/assent.go` | 183 | `isAssent`/`isTypedConfirmation` — regex word-boundary matching (fixed 2026-07-11 after a false-positive bug where "yesterday" matched "yes") |
### nomos — Behavior specific to this component
The Task lifecycle and auto-continuation sequences are in
[README.md §3.4](README.md#34-functional-flow--the-task-lifecycle-f3f4f5-packaged-for-a-human)
and [§6.5](README.md#65-sequence--plan-auto-continuation-the-system-is-the-event-loop).
Specific to this component: the LLM sees a **union of two tool sources**
the 33 tools fetched live from `api`'s `/mcp` endpoint via `tools/list`,
plus the 5 local task tools in `tasks.go` — and `agent.go`'s per-call
routing decides in-process versus forwarded with no visible seam to the
model itself. A hidden `_session_id` is injected into forwarded calls on
the wire (never in the model-visible arguments) so `internal/mcp/server.go`
can scope assent/destructive windows per task.
### nomos — Interfaces this component owns
| Route | Auth |
|---|---|
| `GET /healthz` | none |
| `POST /query` (structured tool call or a pointer to `/chat`) | **none** |
| `POST /chat` (SSE, the real agentic loop) | **none** |
| `GET/POST /sessions`, `/sessions/{id}` | **none** |
This entire interface is unauthenticated — full detail in
[README.md §5.4](README.md#54-nomos-http-interface-cmdnomos-port-8092).
Outbound: a pooled MCP client to `api`, and chat completions to OpenRouter
(`data_collection: deny` pinned, default model `deepseek/deepseek-v4-pro`).
### nomos — Status and known issues
- **C1, the most consequential open gap involving this component**: zero
authentication on the entire gateway, including the ability to grant
chat-assent approvals with no credential check. Explicitly deferred by
operator instruction, not an oversight — see
[README.md §8.4](README.md#84-known-open-security-gaps).
- `nomos/config.yaml`'s `max_iterations: 15` does not match the enforced
Go constant (`40`) — one of the two is stale.
- Dual `agent_activity` logging: both this component's `store.logActivity`
and `internal/mcp/server.go`'s `withActivityLogging` (a different
component) log the same forwarded tool call. Not confirmed whether this
is an intentional two-sided audit trail or accidental duplication.
---
## 5. web control room
**Stakeholders:** the operator, directly; engineers changing the UI's data
model or adding a page. **Why this View earns its place:** this is the only
component with no server-side logic of its own — understanding it means
understanding what it *doesn't* do (it is not the system of record for
anything) as much as what it does.
### web control room — Internal structure
Nine pages under `web/src/pages/` (Svelte 5, hash-based routing, no router
library):
| Page | Lines | Shows |
|---|---|---|
| `Overview.svelte` | 262 | Task dashboard — fleet/health/signal summary cards, the task list, entry point for launching a new chat |
| `Chat.svelte` | 410 | The conversation UI — streaming, `TaskContextPanel`, tool-call rendering, inline approvals |
| `Ops.svelte` | 240 | Approvals queue, recent activity/execution feed, approve/deny/cancel |
| `Signals.svelte` | 171 | Alert/signal triage — severity filter, ack/resolve/mute |
| `KnowledgeBase.svelte` | 263 | Entity browser — force-directed graph view and table view |
| `Knowledge.svelte` | 186 | Free-text knowledge search |
| `Learning.svelte` | 174 | Pattern/skill telemetry — the one page whose backing data source
(`internal/learning`) is dormant per §7 below, so this page currently shows
whatever accumulated before the engine stopped being called, not a live
feed |
| `Config.svelte` | 202 | Auth/connection screen — static bearer token or OIDC login |
| `EntityDetail.svelte` | 7 | Thin wrapper, deep-link target |
Shared logic under `web/src/lib/`: `config.ts` (`fetchWithAuth`, the single
wrapper every API call goes through), `oidc.ts`, `api.ts`, `tasks.ts`,
`stores/events.ts` (the always-on SSE connection), plus task-specific
components (`TaskContextPanel.svelte`, `GoalHeader.svelte`,
`PlanProgress.svelte`, `OperatorQuestion.svelte`, `SessionGraph.svelte`).
### web control room — Behavior specific to this component
Two data-flow patterns, not one: most pages fetch REST on mount and
re-fetch on a relevant SSE event; `Chat.svelte`'s `TaskContextPanel` is
driven by the **always-on global event stream**
(`web/src/lib/stores/events.ts`), not the per-turn chat SSE connection —
deliberately, so the live context panel stays populated during
server-side auto-continuation (§4 above) when no chat turn is actually
open, and survives a tab reload.
### web control room — Interfaces this component owns
None inbound — it is a pure consumer of `internal/httpapi`'s REST and SSE
interfaces (full catalog: [README.md §5](README.md#5-interfaces-icd)).
`fetchWithAuth` resolves config per request rather than at import time, so
the same build works same-origin (production, Vite dev proxy) or
cross-origin (the Wails desktop webview, §8).
### web control room — Status and known issues
Standalone deploy, versioned and released independently of the `oikos`
binary — see [README.md §4.5](README.md#45-build--release-artifacts) for
why "deployed" means two different release cadences depending on whether
you mean the container or the desktop app. The shell-level architecture
(window manager, app registry, docked layer) is documented separately as
[§9 below](#9-web-control-room--app-architecture); this section covers
the page-level concerns, §9 covers the OS + Apps contract the pages hang
off.
---
## 6. PostgreSQL/TimescaleDB
**Stakeholders:** anyone writing a migration, or reasoning about what
"the system's source of truth" actually means (see
[framework.md §2](framework.md#2-the-goal--system-and-model-made-concrete)
for why that phrase needs disambiguating from the engineering Model).
**Why this View earns its place:** every other component in this document
either reads from or writes to this one; it is the only component every
other component has in common.
### PostgreSQL/TimescaleDB — Internal structure
20 forward-only, idempotent migrations
(`001_ontology.up.sql``020_session_reliability.up.sql`,
[ADR-0008](../adr/0008-forward-only-migrations.md)). Four TimescaleDB
hypertables, all created in `006_observability.up.sql`:
`metric_samples`, `audit_log`, `events`, `agent_activity` — each with
continuous aggregates and retention policies.
The recurring structural pattern across this schema, per
[ADR-0014](../adr/0014-entity-model.md) §7: **dual entities**
`check_defs`, `signals`, `classifications`, `executions`, `feedback`,
`patterns`, `skills`, `approvals`, `knowledge_entities`, and (as of
migration 018) `agent_sessions`-as-`task` all have an
`entity_id UUID PK REFERENCES entities(id)`, meaning every specialized row
is simultaneously a node in the general entity graph — this is what lets
`get_relations`/`get_blast_radius` work uniformly over signals, tasks, and
infrastructure alike without a special case for each.
Partial unique indexes provide snapshot semantics without an application-
level lock: `relationships` (current edges only), `signals` (one open
signal per entity+kind), `patterns` (one per type+action).
### PostgreSQL/TimescaleDB — Behavior specific to this component
`008_event_notify.up.sql`'s `pg_notify` trigger on `events` INSERT is the
entire mechanism behind [README.md's SSE interface](README.md#55-server-sent-events-internalhttpapissego)
— the database, not the API process, is what decides an event happened;
the API process is just a fan-out listener.
### PostgreSQL/TimescaleDB — Interfaces this component owns
Every Go component in this document connects directly (via `sqlc`-generated
queries, `internal/db`) — there is no ORM abstraction layer and no
component-specific access restriction beyond what each service's own
Postgres role grants (notably: the learning engine's DB role has no grants
on governance/autonomy tables, per
[ADR-0006](../adr/0006-learning-proposal-only.md) — a structural guarantee
that would matter the moment §7's dormant learning engine is wired back in).
`seeds/*.yaml` + `oikos seed`/`oikos export` form the bootstrap/DR
interface — the database can be regenerated from seeds, and seeds can be
regenerated from the database.
### PostgreSQL/TimescaleDB — Status and known issues
A shared-Postgres single point of failure across every service is a
documented residual risk in [ADR-0007](../adr/0007-threat-model.md), not a
newly discovered one.
---
## 7. Dormant components
**Stakeholders:** anyone deciding whether to revive auto-act, or tempted to
extend `internal/actuator`/`internal/learning` believing them to be the
live implementation. **Why this View earns its place:** these are the two
components most likely to mislead an engineer navigating by package name —
both are substantial, well-written, and compile cleanly into the `oikos`
binary, and neither runs.
### Dormant components — Internal structure
| File | Lines | Role, and why it's dormant |
|---|---|---|
| `internal/actuator/actuator.go` | 505 | `Run` (L24-41, 10s ticker), `processAutoActSignals`, kill-switch checks (L47, L69 — `getAutonomySetting` for `global.auto_act` and `never_auto_act.<slug>`), a real circuit breaker (L156-202, threshold + cooldown), advisory locking (L87-99) — **but the executor itself is a hardcoded stub**, `{"success": true, "message": "stub execution"}` (L124-137), and `Run()` is never called by `cmd/oikos/main.go` or any `docker-compose.yml` service |
| `internal/actuator/ssh.go` | 291 | `ExecuteProcedure` (L126-230) — a fully-built step-by-step SSH runner with `classifySSHError` (L77-108: network/auth/timeout/remote), per-step timeouts, verify-step semantics. **Zero callers anywhere in the codebase.** |
| `internal/learning/learning.go` | 183 | `Run` (L20-41, hourly ticker), `extractPatterns` (L45-81), `processGroup` (L83-169, Wilson lower-bound confidence, evidence≥5 ∧ confidence≥0.7 → `validated`, anomaly quarantine at >10 same-key events per pass) — algorithmically faithful to [ADR-0006](../adr/0006-learning-proposal-only.md), **never started by any process** |
### Why this matters more than "unused code"
`internal/actuator/actuator.go` is where the policy kill-switch
(`global.auto_act`, `never_auto_act.*`) is actually checked in Go — the
*only* other place is the dead `policy.ClassifySignal`. Reviving auto-act
and fixing the kill-switch gap
([README.md §8.1](README.md#81-the-kill-switch-gap-verified-most-important-finding-in-this-model))
are, structurally, **the same piece of work** — whoever picks up
`internal/actuator/actuator.go:47-69` is the person who also resolves
REQ-DEC-5. This is stated explicitly here because it is not obvious from
reading the Risk & Safety or Roadmap views in isolation; it only becomes
visible once you've read this component's code.
### Dormant components — Status and known issues
This entire section *is* the known issue — see
[README.md §9.1](README.md#91-the-north-star-general-gated-execution)
("revive auto-act," the one item the `general-gated-execution` plan's own
header still marks open) and
[§9.4](README.md#94-suggested-next-steps-informational--not-a-commitment-not-a-plan)
item 1 and 3 for the two decisions this leaves open: wire these back in, or
delete them and correct the "Phase 3 — DONE" claim in
`OIKOS.md`/ADR-0014 that currently overstates what's running.
---
## 8. Auxiliary components
Two small, single-purpose components that support deployment and
packaging rather than decision logic — given lighter treatment here
deliberately, since no Stakeholder identified in this document's other
sections needs their internals to make a change elsewhere.
**`cmd/webhook`** (96 lines, one file) — the Gitea push-to-deploy receiver.
Verifies `X-Hub-Signature-256` HMAC-SHA256 against `WEBHOOK_HMAC_SECRET`
using `hmac.Equal`, responds `202` immediately, then runs
`scripts/deploy.sh` asynchronously. Full deploy sequence:
[README.md §4.2](README.md#42-deployment-topology-mac-mini-docker-compose)
and §7.2.
**`cmd/desktop`** (784 lines) — the Wails-wrapped desktop shell. Bundles
`web/dist` into a native binary, adds an OIDC login flow through a local
HTTP server + system browser, and a `SaveConfig` bridge the web bundle
calls when running inside the desktop webview (`web/src/lib/config.ts`'s
`saveToDesktop()`). Contains no decision logic of its own — it is
packaging for component 5 (§5 above), not a new component in the
functional sense.
---
## 9. web control room — App architecture
**Stakeholders:** anyone adding a page, adding a desktop overlay, or
planning dynamic/third-party app installation. **Why this View earns its
place:** §5 documents the *pages*; this View documents the *shell* they
hang off — and the shell is the part whose contract a new app has to
satisfy. It is also the layer where the "Oikos-as-OS" metaphor
(desktop, icons, floating windows, a tamagotchi-style resident
creature) is actually implemented, so the boundary between "Base OS" and
"App" has to be explicit here or it doesn't exist anywhere.
### App architecture — Internal structure
| File | Role |
|---|---|
| `web/src/lib/apps.ts` | The App registry. Two layers: `builtinApps` (static, always installed) + `installedAppIds` (persisted, from the App Store). The public `apps` store is derived (built-in + installed); `appById` is a derived Map. `installApp`/`uninstallApp` mutate the installed set. Window-id helpers (`appWindowId`, `appIdFromWindowId`) unchanged. |
| `web/src/app-store/catalog.ts` | The installable-app catalog: `AppManifest` (persistable metadata) + `CatalogEntry` (manifest + Lucide icon + dynamic-import loader). Static in Phase 3 (apps ship with the build); Phase 4 swaps this for a fetched `/api/v1/apps` endpoint. Declares `AppPermission` (enforcement is Phase 4). |
| `web/src/app-store/apps/Notes.svelte` | Demo installable app — a localStorage-backed scratchpad proving the install→icon→window→uninstall lifecycle end-to-end. |
| `web/src/lib/stores/windows.ts` | The wmkit window manager singleton + the `openAppWindow` / `openEntityWindow` / `openTaskWindow` primitives. `openAppWindow` branches on `docked` (toggles visibility) vs windowed (`wm.open`); resolves the app via `get(appById)`. |
| `web/src/lib/stores/docked.ts` | Persisted visibility for docked apps. Absent key = visible (default-on); store holds only overrides. Deliberately does **not** import `APPS` — doing so would create a static cycle (`apps.ts` → pages → `windows.ts` → here → `apps.ts`) and fire a TDZ on `APPS` at init. |
| `web/src/lib/stores/icons.ts` | Desktop icon grid: column/row positions, drag-to-reorder, localStorage persistence. Reactive to the `apps` store — a newly-installed app gets a free cell on the next emission; `resetIconLayout` re-seeds from the live registry, not a static snapshot. |
| `web/src/lib/components/LazyApp.svelte` | Renders an app's lazily-loaded component (`AppDef.component` is a dynamic-import loader, not the component). Shows the shared spinner while the chunk fetches; used by both WindowLayer and DockedLayer so the loading state is uniform across app kinds. Vite's module cache makes repeat opens resolve from cache. |
| `web/src/lib/components/desktop-shell/Desktop.svelte` | Full-viewport surface: background, icons, task launcher, `<WindowLayer />`, `<DockedLayer />`, taskbar. Reads `$apps` (the derived store) so installs reflect immediately. |
| `web/src/lib/components/desktop-shell/WindowLayer.svelte` | Floating-window stack (z-40). Resolves window id → content component; renders shared titlebar chrome. The orphan-close `$effect` is reactive on `$appById` — reinstalling an app revives its persisted window, uninstalling closes it. |
| `web/src/lib/components/desktop-shell/DockedLayer.svelte` | Docked-app overlay (z-45). Renders `$apps.filter(a => a.docked)` gated on `dockedVisibility`. Replaces the previously-hardcoded `<MascotLayer />`. |
| `web/src/lib/components/desktop-shell/Taskbar.svelte` | Window buttons + tray. Renders from `wmState.order`; resolves icons via `$appById`. |
| `web/src/pages/AppStore.svelte` | The App Store — lists the catalog, shows install state, install/uninstall. Installing makes the app appear on the desktop immediately (no reload) via the reactive `apps` store; uninstalling closes any open window for that app via WindowLayer's orphan-close effect. |
### App architecture — The App contract
```typescript
interface AppDef {
id: string // unique; window IDs are "app:<id>"
title: string // desktop icon label + window titlebar
icon: Component // Lucide icon (desktop icon + taskbar)
component: () => Promise<{ default: Component }> // dynamic-import loader
docked?: boolean // true = Docked Layer app, no window
noIcon?: boolean // true = registered but no desktop icon
width?: number; height?: number; minWidth?: number; minHeight?: number
// required for windowed, forbidden for docked
badge?: (s: DashboardSummary | null) => number
}
```
`component` is a dynamic-import loader (`() => import('../pages/X.svelte')`),
not the component itself. Desktop icons render from metadata alone (id,
title, icon — all static), the component chunk fetches on first window
open, and Vite code-splits each app into its own chunk (Phase 2). The
mascot uses the same path — `() => import('./mascot/MascotLayer.svelte')`
— which also defers the mascot's module graph until after `apps.ts` has
finished initializing, breaking what would otherwise be a static cycle
(`apps.ts``MascotLayer``Mascot.svelte``icons.ts``apps.ts`).
Two app kinds, picked by one flag:
| Kind | Window | Titlebar | Taskbar | Opened by |
|---|---|---|---|---|
| **Windowed** (default) | wmkit floating window | yes | yes | `openAppWindow``wm.open` |
| **Docked** (`docked: true`) | none — renders on the Docked Layer | no | no | `openAppWindow``toggleDocked` |
Apps receive **no props** from the shell. They import the OS-service
surface (below) directly. The shell→app edge is one-way.
### App architecture — The OS-service surface (AppOS)
The stable set of `$lib` exports an App may import. Everything else in
`$lib` is shell-internal and may change without notice. This is a
**documentation contract** today (apps are compiled in); it becomes an
**enforced sandbox boundary** the moment third-party app installation
(Phase 3 in [the plan](../../plans/2026-07-21-frontend-os-apps-architecture.md)) lands.
| Service | Import |
|---|---|
| Open an app window | `openAppWindow(id)` from `$lib/stores/windows` |
| Open an entity window | `openEntityWindow(slug)` from `$lib/stores/windows` |
| Open a task window | `openTaskWindow(sessionId, title)` from `$lib/stores/windows` |
| Dashboard summary | `summary`, `subscribeContext` from `$lib/stores/context` |
| Live events | `subscribeEvents` from `$lib/stores/events` |
| Per-session chat / workspace / activity | `chatFor`, `workspaceFor`, `activityLogFor` from `$lib/stores/{chat,workspace,activity}` |
| REST API | `$lib/api` (generated from OpenAPI, [ADR-0004](../adr/0004-openapi-first.md)) |
| UI primitives | `$lib/components/ui/*` |
| Theme | `getTheme`, `setTheme` from `$lib/stores/theme.svelte` |
### App architecture — Content resolution
Window ids are namespaced so the window layer resolves content purely
from the id, with no extra bookkeeping — which is also why persisted
windows hydrate correctly across reloads:
| Id shape | Renders |
|---|---|
| `app:<id>` | the registry app's component (`appById.get(id).component`) |
| `session:<id>` | `SessionChatWindow` (per-session chat) |
| `new-task` | `NewTaskChat` (singleton compose) |
| bare slug (`type:identifier`) | `EntityDetailContent` (fallback) |
A hydrated `app:<id>` window whose id no longer matches a registry entry
(an app removed since the layout was persisted) self-closes — the
orphan-close `$effect` in `WindowLayer.svelte` sweeps it on mount.
### App architecture — Current population
Seven windowed apps + one docked app:
| App | Kind | Badge |
|---|---|---|
| `tasks` | windowed | — |
| `kb` | windowed | — |
| `ops` | windowed | `approvals_pending` |
| `signals` | windowed | open signal count |
| `knowledge` | windowed | — |
| `learning` | windowed | — |
| `settings` | windowed | — |
| `mascot` (Cluck) | **docked** | — |
The mascot is the first docked app and the reason the docked kind
exists; before this View it was a hardcoded `<MascotLayer />` in
`Desktop.svelte`, not a registry entry. Its persistent model
(`web/src/lib/mascot/state.svelte.ts`, localStorage) and sprite cache
(`sprites.ts`) are module-scoped, so toggling visibility (unmount) and
restoring (remount) loses no state — this is why `docked` visibility is
a plain `{#if}` gate rather than a `keepAlive` mechanism.
### App architecture — Designed extension points (documented, not built)
| Extension | Mechanism when built | Trigger |
|---|---|---|
| Titlebar actions | `titlebarActions?: Component` on `AppDef`, rendered left of min/max/close | First app that needs one |
| App-scoped state | `state?: () => Record<string, unknown>` on `AppDef` | First app with cross-mount state that isn't module-scoped |
| `onRegister` handshake | called with a scoped AppOS capability object | Phase 3 (dynamic install) |
| Third-party manifests | `AppManifest` JSON + `/api/v1/apps` + permission model | Phase 3 |
Documenting these now prevents the current contract from painting itself
into a corner; building them now would be speculative. (Lazy-loaded
components were on this list and shipped in Phase 2 — `component` is now
`() => Promise<{ default: Component }>` and Vite code-splits each app.)
### App architecture — Status and known issues
Phase 1 (the docked kind, mascot-as-app, the docked visibility store) and
Phase 2 (lazy component loading — `component` as dynamic-import loader,
`LazyApp.svelte` for uniform loading state, per-app code-splitting) have
landed. Open items, by phase:
- **Phase 3 (dynamic install):** the AppOS table above becomes a real
injected capability object, not a documentation table; permissions
enforced at the store-access boundary; `AppManifest` format +
`/api/v1/apps` endpoint + install flow.
- **Late-registering apps (Phase 3 prerequisite):** `icons.ts:48` builds
`appIds` once at module load to validate persisted positions — fine
today (all apps are in the static `APPS` array; only their components
are lazy), fragile the moment apps register post-load. When dynamic
registration lands, revalidate against the live registry, not the
import-time snapshot. Likewise `WindowLayer`'s orphan-close `$effect`
must be gated on registry-ready so a not-yet-loaded app's persisted
window isn't killed on hydration.
The static-cycle trap that bit this View during Phase 1 implementation is
now resolved by Phase 2's lazy loading — recording it for context:
- `apps.ts` no longer statically imports any page or the mascot (they're
all `() => import(...)`), so there's no static edge from `apps.ts` into
the mascot/page module graph to cycle through `icons.ts` back to `APPS`.
The earlier `LazyMascot.svelte` wrapper (Phase 1's cycle break) was
deleted in Phase 2 — the lazy loader in the registry replaces it.
`docked.ts` still must not import `APPS` (it's reached from `apps.ts`'s
graph via `windows.ts`), and doesn't — defaults are implicit
(absent key = visible).
---
## Keeping this document current
The same discipline as README.md's closing note applies here, scoped to
components: when a file listed in a "Internal structure" table is renamed,
split, or gains a new responsibility, update that row. When a "Status and
known issues" bullet is resolved, remove it — and check whether removing it
also resolves an entry in
[README.md §9](README.md#9-roadmap--traceability), since most of the
findings here were first surfaced there and are repeated in this document
for proximity to the code, not because they're independently tracked in
two places.