# 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.
