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
686 lines
38 KiB
Markdown
686 lines
38 KiB
Markdown
# 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.
|