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

38 KiB
Raw Permalink Blame History

Oikos — Component Views

Companion to the Model and the Framework. 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 §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. If you're asking what does the system do and why, read 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 internal/httpapi, internal/mcp, internal/policy live — the decision/execution gate
2. oikos scheduler internal/scheduler, internal/checkdefaults live — the observe loop
3. oikos notifier internal/notifier live — approval delivery
4. nomos cmd/nomos live — the agent, unauthenticated gateway
5. web control room web/src live — standalone SPA
6. PostgreSQL/TimescaleDB migrations/, seeds/ live — the System's own source of truth
7. Dormant components internal/actuator, internal/learning 🔴 compiled, never started
8. Auxiliary components cmd/webhook, cmd/desktop live — deploy + packaging, not decision logic
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 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)
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)

oikos api — internal call structure: the run gate, by file

README.md's §3.3 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.

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 (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 and 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.
  • 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

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. 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 and §6.5. 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. 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.
  • 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). 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 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; 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 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.sql020_session_reliability.up.sql, ADR-0008). 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 §7: dual entitiescheck_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 — 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 — 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, 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, 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) 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 ("revive auto-act," the one item the general-gated-execution plan's own header still marks open) and §9.4 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 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

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.tsMascotLayerMascot.svelteicons.tsapps.ts).

Two app kinds, picked by one flag:

Kind Window Titlebar Taskbar Opened by
Windowed (default) wmkit floating window yes yes openAppWindowwm.open
Docked (docked: true) none — renders on the Docked Layer no no openAppWindowtoggleDocked

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) 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)
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, 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.