Files
oikos/plans/done/2026-08-15-hexagonal-architecture.md
dtoro 104593de0c plans: review and archive all plans to done/
Architecture has changed drastically (hexagonal refactor, web client
extraction). Every active plan has been reviewed, annotated with
'Completed' or 'Won't do' status, and moved to plans/done/.

Completed (7): gaps-and-improvements, liveness-drift, gated-execution,
  nomos-code-review, codebase-cleanup, mascot-physics, backend-eval
Won't do (7): prometheus-lxc, control-room-webui, activity-gaps,
  activity-timeline, frontend-os-apps, haos-capability-gaps, arr-audit
2026-08-16 11:18:47 +02:00

38 KiB
Raw Blame History

Hexagonal architecture for Oikos — design and phased refactor plan

Date: 2026-08-15 Status: Complete — all 10 phases shipped (2026-08-16) Scope: All Go code (cmd/oikos, cmd/nomos, cmd/webhook) and the UI split. One hexagon covers the oikos backend; nomos is an external agent client that gets an internal cleanup (Phase 8) but stays outside the core. Phase 1 extracts the client — web SPA, desktop wrapper, web image — into its own repository, making oikos backend-only before the hexagon refactor churns the tree.


1. Summary

Adopt a hexagonal (ports & adapters) architecture across the Oikos backend. The domain core (entities, signals, executions, policy, knowledge) becomes a pure package with no infrastructure imports. All I/O moves behind named ports implemented by adapters (Postgres, SSH, MCP, HTTP, probes, Infisical/SOPS). REST and MCP — currently two parallel silos each embedding raw SQL — become thin driving adapters over one shared application-service layer.

No API, MCP-tool, DB-schema, or wire-format changes. The client (web SPA + desktop wrapper) moves to its own repository in Phase 1; the backend restructuring is delivered in nine further shippable phases (0, 29).


2. Current state (grounding)

What the code looks like today, with references:

Finding Evidence
Pure domain package already exists internal/domainEntity, Signal, Execution, Approval, sentinel errors; imports only stdlib
One working port/adapter pair internal/secrets/backend.go:15 Backend interface; InfisicalBackend, SOPSBackend, caching Manager
Business logic lives in HTTP handlers internal/httpapi/entities.go embeds raw SQL in ListEntities/GetGraph; Server struct (server.go:54) holds *db.Pool, cache, SSE broker
Business logic lives in MCP handlers internal/mcp/entity_tools.go:16 — every *Tools(pool *db.Pool, ...) builds tools over direct SQL; server.go:754 classifyAndGate (~300 lines) mixes classification, approval creation, audit, SSH dispatch
Scheduler is a monolith internal/scheduler/scheduler.go (~1100 LOC) — probe switch at line 331 (http, tcp, disk, cert-expiry, vm-status, ping, ssh-script, backup-freshness, dns), health aggregation, signal upserts, metric writes inline
Inverted dependency internal/db imports checkdefaults (seed ingest knows about check derivation); actuator imports db
Duplicated port mcp/server.go defines a local secretBackend subset of secrets.Backend
No composition root wiring is scattered: httpapi.NewHandler builds secrets + SSE; cmd/oikos/main.go builds the rest
nomos is a flat main package cmd/nomos/*.go — agent, store (direct pgx), MCP client, HTTP server, turn gating all in one package

Layered as-is (arrows = imports):

flowchart TD
    subgraph delivery["Delivery — contains the use-cases today"]
        httpapi["httpapi (~3500 LOC, 30 files)"]
        mcp["mcp (~2100 LOC, 67 tools, classifyAndGate)"]
    end
    subgraph execution["Execution"]
        scheduler["scheduler (~1100 LOC, one file)"]
        execworker["execworker"]
        actuator["actuator (SSH + breaker)"]
    end
    subgraph domainsvc["Domain services"]
        checkdefaults["checkdefaults"]
        learning["learning"]
        knowledge["knowledge"]
        execlog["execlog"]
        observability["observability"]
    end
    subgraph dbinfra["DB infra"]
        db["db + sqlcgen"]
        remote["remote"]
        audit["audit"]
    end
    subgraph nearpure["Near-pure"]
        ontology["ontology"]
        policy["policy"]
        secrets["secrets"]
    end
    subgraph pure["Pure (stdlib only)"]
        domain["domain"]
        config["config"]
        safego["safego"]
        health["health"]
    end

    httpapi --> db
    httpapi --> actuator
    httpapi --> mcp
    httpapi --> secrets
    mcp --> db
    mcp --> actuator
    mcp --> execlog
    mcp --> policy
    mcp --> remote
    mcp --> checkdefaults
    mcp --> audit
    scheduler --> db
    scheduler --> actuator
    scheduler --> remote
    execworker --> db
    execworker --> actuator
    execworker --> remote
    actuator --> secrets
    checkdefaults --> ontology
    ontology --> domain
    policy --> domain
    db --> checkdefaults
    remote --> db
    audit --> db
    learning --> db
    knowledge --> db
    execlog --> observability
    observability --> db

The problem: use-cases have no home. Every new consumer (REST tool, MCP tool, scheduler) re-implements or copy-pastes query + policy + audit logic.


3. Target architecture

3.1 System context

One hexagon. nomos, the Gitea webhook receiver, and the desktop shell stay outside — nomos and the desktop shell are external clients of the core (over MCP/REST), the webhook is a leaf deploy utility:

flowchart LR
    subgraph ext["External clients"]
        spa["web SPA"]
        desktop["desktop shell"]
        curl["curl / scripts"]
        nomos["nomos agent"]
        timers["timers"]
    end

    subgraph oikos["OIKOS CORE HEXAGON"]
        subgraph driving["Driving adapters (left side of the hexagon)"]
            httpapi["httpapi — REST"]
            mcpserver["mcpserver — MCP tools"]
            schedad["scheduler — timer"]
            execworker["execworker — queue poller"]
            cli["cli — seed/export/secret"]
        end
        subgraph core["Core"]
            app["app services (use-cases)"]
            dom["core/domain (pure model)"]
        end
        subgraph driven["Driven adapters (right side of the hexagon)"]
            postgres["postgres — repositories"]
            ssh["ssh — CommandExecutor"]
            probes["probes/* — Checker per kind"]
            remote["remote — TargetResolver"]
            secretsad["secrets — Infisical/SOPS"]
            events["events — EventPublisher/SSE"]
        end
    end

    subgraph leaves["Leaf utilities — unchanged"]
        webhook["webhook — Gitea push → deploy"]
    end

    spa -- REST --> httpapi
    desktop -- REST --> httpapi
    curl -- REST --> httpapi
    nomos -- MCP --> mcpserver
    timers -- tick --> schedad

    driving --> app
    app --> dom
    app -- "via ports" --> driven

    webhook -. "docker compose on mac-mini" .-> oikos

Dependency rules (enforced with depguard from Phase 0):

  1. Core imports nothing from adapters; adapters and cmd/* import core. Adapter→adapter imports are allowed only via ports (e.g. the target-resolver adapter consumes EntityRepository, not the postgres package).
  2. The core never links agent-client tech: internal/core must not import internal/nomos, the OpenAI SDK, or MCP client packages. nomos consumes oikos exclusively through its public MCP/REST surface — over the wire, not through shared packages.
  3. nomos is external: internal/nomos must not import internal/core or internal/adapters. Its tables (agent_sessions, agent_messages, session_plan_steps, nomos_plan_executions) stay in the shared migrations tree — owned operationally by the oikos deploy, accessed by nomos only through its own store code.

3.2 Target directory tree

internal/
  core/
    domain/            moved from internal/domain (pure, unchanged)
    app/               application services (use-cases) — see §3.4
    ports/             driven-port interfaces — see §3.3
  adapters/
    postgres/          pool, migrations, sqlcgen, repository impls
    ssh/               from internal/actuator: dial pool, breaker, streaming,
                       provisioning (pct/qm)
    remote/            target resolver impl (from internal/remote), built on
                       ports.EntityRepository — no direct postgres import
    probes/            one file per check kind (http, tcp, dns, cert, ping,
                       sshscript, vmstatus, backup)
    secrets/           from internal/secrets (Backend interface moves to
                       core/ports; Infisical/SOPS/Manager stay here)
    events/            EventPublisher adapter: SSE broker + events table
    httpapi/           REST driving adapter (handlers, auth, SSE endpoint)
    mcpserver/         from internal/mcp — tool schema + arg mapping only
    scheduler/         thin timer adapter — ticks ObservationService
    execworker/        thin poller adapter — claims queued executions
    cli/               oikos seed/export/secret subcommands
  config/              unchanged (pure stdlib — legitimately importable by
                       adapters, not only by cmd/* composition roots)
internal/nomos/        Phase 8: nomos internals — plain packages, no hexagon
  session/             chat sessions, plan execution logic
  turngate/ retrycap/ messagequeue/ assent/
  ports.go             local ports: LLMClient, HomelabClient (MCP),
                      SessionStore — defined here, not in core/ports
cmd/
  oikos/               composition root: build adapters → services → adapters
  nomos/               composition root: wires openai-go, MCP client, pgx store
                      into internal/nomos ports
  webhook/             unchanged leaf
  desktop/             unchanged leaf (UI client)

3.3 Ports catalog (driven — core declares, adapters implement)

Command-side repository methods are transaction-scoped aggregates: one method = one BEGIN…COMMIT = everything that must succeed or fail together (see §3.6). Read methods are plain queries.

Port Key methods Consumed by Adapter(s) Origin in current code
EntityRepository Get/bySlug, List(filters), Search (reads); Create(CreateInput), Update(UpdateInput), SetState(TransitionInput) — each input carries derived checks + audit + event, committed atomically EntityService, GraphService, resolver, seeds postgres raw SQL in httpapi/entities.go, mcp/entity_tools.go; db/sqlcgen
RelationshipRepository Create, End, ListFor(entity, dir); endpoint validation via OntologyStore happens in core before Create GraphService postgres httpapi/relationships.go, mcp relation tools
OntologyStore LoadTypeTree (types, rel types, lifecycles), cached EntityService, GraphService, PolicyService postgres + in-memory cache ontology.TypeTree built from db
CheckRepository ListEnabled, ListFor(entity) (reads); EnsureFor(entity, desiredDefs) — read-diff-write in one tx; SetEnabled MonitoringService, ObservationService postgres scheduler.go, httpapi/checks.go, db/checks.go
SignalRepository Open, History (reads); UpsertWithTriggers(upsertInput) — check-then-act on signal state in one tx; Transition(ack/resolve/mute) SignalService, ObservationService postgres scheduler.go signal upserts, httpapi/signals.go
ExecutionRepository List(cursor), ReadLog (reads); SubmitQueued(SubmitInput) — execution + approval + audit + event in one tx; Claim(next) — advisory-lock claim; AppendLog; Complete(CompleteInput) ExecutionService postgres execworker, httpapi/executions.go, execlog, mcp/server.go
ApprovalRepository ListPending (read); Decide(DecideInput) — token verification (check-then-act) + approval status + gated-execution status + audit in one tx ApprovalService postgres httpapi/approvals.go:94-145, mcp/server.go approval insert
MetricsRepository InsertSamples (write path) ObservationService postgres (Timescale) scheduler.go; bucketed/trend queries live in ReadModels
AuditRepository AppendAudit, AppendEvent (command side-effects, usually passed into other inputs) AuditService, all services postgres observability, audit
KnowledgeRepository Search, GetContent, Revisions, Tags, Orphans, Duplicates (reads); Upsert(UpsertInput) — row + revision + about-edges in one tx; Merge, SoftDelete/Restore KnowledgeService postgres httpapi/knowledge*.go, mcp/knowledge_tools.go
LearningRepository ListFeedback, ListPatterns, ListSkills (reads); UpsertPattern, Validate/Quarantine LearningService postgres learning, httpapi/patterns.go
ReadModels Query-shaped reads for report endpoints: graph, fleet health, dashboard, metric buckets, trends, audit trail, event timeline, agent activity, drift reports, learning views httpapi + mcpserver adapters directly — no service hop (see §3.4) postgres report SQL currently inline in httpapi/* handlers
CommandExecutor Run(target, cmd, opts) streaming/combined; returns exit code ExecutionService, probe adapter (ssh-script), ProvisioningService ssh (dial pool + circuit breaker) actuator.RunStreaming/RunCombinedOutput
TargetResolver ResolveExecTarget(slug), ResolveForCheck, ResolveHost, IsGuest ExecutionService, probes, ssh adapter remote (built on EntityRepository) remote/remote.go
Secrets Get, List, Set, Name config overlay, ExecutionService, ssh (signers, host keys) infisical, sops, manager secrets.Backend (interface moves to ports; delete mcp's local copy)
EventPublisher Publish(ctx, Event) all services events (SSE broker + events table, LISTEN/NOTIFY) httpapi/sse.go broker + observability.Event
Checker Check(ctx, CheckDef, resolved target) → result{value, state, msg} ObservationService probes/http, probes/tcp, probes/dns, probes/cert, probes/ping, probes/sshscript, probes/vmstatus, probes/backup, probes/disk scheduler.go:331 kind switch
Provisioner CreateLXC, CreateVM (pct/qm flows) ProvisioningService ssh (proxmox commands) actuator provisioning files

Driving adapters (outside → core): httpapi (REST, SPA/desktop/curl), mcpserver (67 tools, nomos and any MCP agent), scheduler (timer), execworker (queue poller), cli (seed/export/secret), sse endpoint (read side). nomos, the Gitea webhook, and the desktop shell stay outside the hexagon — external clients and a leaf utility respectively.

3.4 Application services (core/app)

Service Use-cases Absorbs logic from
EntityService create/update/merge entities, lifecycle transitions (ontology-validated), check derivation on attribute change, enrollment httpapi/entity_mutations.go, mcp create/update tools, checkdefaults
GraphService relations CRUD, graph read model, blast radius, infra drift discovery, knowledge-graph audit httpapi/entities.go graph SQL, mcp/discover.go, audit
MonitoringService check-def CRUD, enable/disable, defaults httpapi/checks.go, default_checks.go
ObservationService one observe pass: load enabled checks, resolve targets, run probes (Checker port) under bounded worker-pool concurrency (10 — scheduler.go:133 SetLimit; the cap is a service contract, not a timer detail), aggregate health, transition signals, record metrics, sweep staleness scheduler/scheduler.go
SignalService ack/resolve/mute, history, triggers httpapi/signals.go
PolicyService classify command/signal, preflight, autonomy rules (classifier can only lower autonomy) policy, half of classifyAndGate
ExecutionService submit (classify → gate → auto-run or queue), status, streaming logs, cancel other half of classifyAndGate, httpapi/executions.go, execworker dispatch
ApprovalService list pending, decide → resume queued execution httpapi/approvals.go
KnowledgeService search, upsert with revisions + entity links, tags, merge, drift httpapi/knowledge*.go, mcp/knowledge_tools.go
LearningService pattern extraction (Wilson confidence), feedback, skills learning
AuditService audit trail, event timeline, agent activity, drift report httpapi/audit.go, events.go, activity.go, audit
SecretsService get/list; set routes through approval flow mcp/secrets tools
ProvisioningService LXC/VM create with provisioning_steps tracking actuator pct/qm paths, httpapi/pct_create_test.go flow
SeedService seed ingest (ontology, inventory, policy, knowledge) + export to YAML db/seed.go, db/export.go — fixes the db → checkdefaults inversion

Services take ports as constructor arguments; they return domain types and sentinel errors. Presenters (JSON shapes, MCP tool results) stay in the adapters. REST and MCP keep their existing wire formats — mapping code just moves to the adapters.

Reads bypass services. Roughly half of httpapi and several MCP tools are invariant-free reports (metrics, audit trail, event timeline, dashboard, fleet health, learning views). Those adapters call the ReadModels port directly — no SQL in handlers, no service hop, no ceremony. Services exist only where invariants, policy, or multi-step coordination apply (the command side). This is the CQRS-lite line: commands flow through core/app, reads flow adapter → ReadModels → presenter.

3.5 Composition

cmd/oikos/main.go becomes the single composition root per role:

pool      := postgres.Connect(...)
repos     := postgres.NewRepositories(pool)
resolver  := remote.New(repos.Entities)
sshExec   := ssh.NewExecutor(secretsMgr, resolver)
events    := events.NewPublisher(pool, broker)
execSvc   := app.NewExecutionService(repos.Executions, repos.Approvals,
             policySvc, sshExec, events, repos.Audit)
httpH     := httpapi.New(cfg, entitySvc, graphSvc, execSvc, ..., readModels)
mcpH      := mcpserver.New(cfg, entitySvc, graphSvc, execSvc, ..., readModels)

httpapi stops mounting business deps; it mounts mcpserver's handler at /mcp as pure routing (or main mounts both on one chi router — Phase 3 detail, recommend main owns the router).

Startup order (composition root contract):

  1. Load env config (internal/config — pure, importable by adapters).
  2. Build secrets manager and overlay config — before the DB pool. Note the chicken-egg: INFISICAL_ENCRYPTION_KEY bootstraps Infisical itself and cannot live in Infisical; it stays in env/.env.
  3. Connect pool, run repositories, resolver, executor, events.
  4. Build services, then driving adapters; start background loops last.

Shutdown order: cancel root ctx (stops the events adapter's dedicated LISTEN/NOTIFY connection and SSE broker) → then close the pool — reversing this deadlocks pool.Close() on the held connection (constraint documented at httpapi/server.go:66-70; ownership moves to the events adapter).

3.6 Transaction and consistency strategy

Decision (review F1): aggregate-scoped repository methods — no UnitOfWork port, no pgx.Tx in core. One repository method = one transaction = everything that must succeed or fail together.

Current code protects invariants with multi-statement tx blocks in 20+ places (pool.Begin sites). The load-bearing ones:

  • approval decide: HMAC token check-then-act + approval status + gated execution status + audit (httpapi/approvals.go:94-145) — double-approve must not double-execute a destructive command
  • entity state transition: lifecycle precondition check-then-act + update + audit + event (httpapi/entity_mutations.go:200-274)
  • check derivation read-diff-write (db/checks.go:21)
  • execution claim (advisory lock), signal upsert transitions, seed ingest

Mechanics:

  1. Core does the pure work first: validate against the cached OntologyStore, derive desired checks from the TypeTree (checkdefaults logic, pure), classify risk, build audit/event entries.
  2. Core passes one complete input struct — e.g. EntityRepository.Create(ctx, CreateInput{Entity, DerivedChecks, Audit, Event}).
  3. The postgres adapter runs BEGIN → writes → COMMIT internally and returns the result; partial failures roll back exactly as today.
  4. Cross-aggregate operations that transact together today stay one method (ApprovalRepository.Decide spans approvals + executions + audit).

What is explicitly rejected:

  • UnitOfWork/Tx-manager port: every port doubles into tx/non-tx variants, fakes multiply, pgx.Tx leaks into the core this refactor exists to protect.
  • Fine-grained autocommit ports: silently deletes the check-then-act guarantees (token double-spend, transition races).

Failure-mode cost: a tx span that should span two repo calls but doesn't is a bug — mitigated by the conformance suite (§5 Phase 2) asserting atomicity and check-then-act behavior per command method.


4. How the components work together (interaction docs)

A. Agent run tool (the core OODA act path). nomos → MCP client → mcpserver run tool → parses args, maps to ExecutionService.Submit(ctx, target, command, purpose, declaredRisk). Submit loads the entity (EntityRepository), resolves the SSH target (TargetResolver), calls PolicyService.Classify (policy rules + DB-backed signal context; classifier can only lower autonomy). Read-only / reversible-low → executes now via CommandExecutor, streaming output through ExecutionRepository.AppendLog and EventPublisher (SPA live output); records audit + event; returns exit code. Config-mutation / destructive → persists execution as queued + creates approval, publishes event; returns "awaiting approval". Later the operator clicks Approve in the SPA → REST → ApprovalService.Decide → marks approved → execworker poller claims the execution (advisory lock) → ExecutionService.Dispatch → SSH → results + audit. Both entry paths converge on Submit/Dispatch — one policy, one audit trail.

sequenceDiagram
    autonumber
    participant N as nomos (MCP client)
    participant M as mcpserver run tool
    participant ES as ExecutionService
    participant PS as PolicyService
    participant CE as CommandExecutor (ssh)
    participant ER as ExecutionRepository
    participant AP as ApprovalService
    participant OP as Operator (SPA)
    participant EW as execworker

    N->>M: run(target, command, purpose, declaredRisk)
    M->>ES: Submit(target, command, purpose, risk)
    ES->>PS: Classify(command, declaredRisk)
    alt read-only / reversible-low
        ES->>CE: execute now
        CE-->>ES: exit code + output (streamed)
        ES->>ER: AppendLog + status + audit
        ES-->>M: result
        M-->>N: exit code + output
    else config-mutation / destructive
        ES->>ER: create execution (queued)
        ES->>AP: create approval + event
        ES-->>M: awaiting approval
        M-->>N: queued, needs approval
        OP->>AP: Decide(approval_id, approved)
        AP->>ER: mark execution approved
        EW->>ER: Claim next queued (advisory lock)
        EW->>ES: Dispatch(execution)
        ES->>CE: execute
        CE-->>ES: exit code + output
        ES->>ER: status + logs + audit
    end

B. Scheduler observe pass. Timer adapter ticks → ObservationService.RunPass → CheckRepository .ListEnabled → group by entity → TargetResolver per check → dispatch to the Checker adapter selected by check kind (each probe is its own adapter; adding a check kind = new adapter + seed row, no core change) → aggregate per-entity health (worst-of + maintenance windows) → SignalRepository.Upsert (open/resolve transitions with signal triggers) → MetricsRepository .InsertSamples → EventPublisher. The SPA receives health changes over SSE. The scheduler package shrinks to: ticker, advisory lock (memory: startup pg_advisory_lock(0x01c05e6) on held connection), and pass-loop error handling.

sequenceDiagram
    autonumber
    participant T as scheduler (timer adapter)
    participant OS as ObservationService
    participant CR as CheckRepository
    participant TR as TargetResolver
    participant CK as Checker (probes/*)
    participant SR as SignalRepository
    participant MR as MetricsRepository
    participant EP as EventPublisher
    participant SPA as web SPA (SSE)

    T->>OS: RunPass
    OS->>CR: ListEnabled
    CR-->>OS: enabled check defs
    loop per check
        OS->>TR: ResolveForCheck(check)
        TR-->>OS: SSH target + wrapper
        OS->>CK: Check(def, target) — adapter chosen by kind
        CK-->>OS: result (value, state, msg)
    end
    OS->>OS: aggregate health (worst-of + maintenance windows)
    OS->>SR: Upsert signals (open/resolve + triggers)
    OS->>MR: InsertSamples
    OS->>EP: Publish health events
    EP-->>SPA: SSE fan-out

C. Operator creates an entity via the SPA. REST adapter validates the request shape → EntityService.Create → OntologyStore validates type + required attributes → EntityRepository insert → check derivation (moved checkdefaults logic, pure over the TypeTree) → CheckRepository ensure defs → AuditRepository + EventPublisher → response mapped by the REST presenter. The MCP create_entity tool calls the same service with its own presenter — one validation path.

D. Agent upserts knowledge. MCP knowledge tool → KnowledgeService.Upsert → KnowledgeRepository (row + revision), links about edges via RelationshipRepository, dedupes via trigram check → audit + event. REST knowledge endpoints share the service.

E. Seed and export (bootstrap/DR). oikos seed CLI adapter → SeedService.Ingest reads YAML → repositories upsert; oikos exportSeedService.Export walks repositories → regenerates seeds/*.yaml. The db package keeps only connection, migrations, sqlcgen.

F. Nomos chat turn (external agent). nomos is outside the hexagon; its internals use local ports, not core/ports. HTTP /query or chat bridge → nomos session logic → loads session (SessionStore, local port → pgx) → builds context → LLMClient (local port → openai-go) streams → tool calls loop through HomelabClient (local port → MCP client → oikos MCP endpoint, flow A) → assistant output persisted. Turn gating, retry caps, message queue are nomos-internal logic; transports and models sit behind nomos's own ports. nomos touches oikos state only via the MCP/REST surface.


5. Phased roadmap

Each phase ships green: make lint test generate-check, make test-db where repos change, manual smoke on the dev compose profile. Version bump per repo rules on each merged phase (patch per phase; minor at Phase 9 completion). Deploy from the primary checkout only, tree clean (deploy builds the working tree).

Abort criteria (every phase): if handler contract tests fail without a wire-format explanation, make test-db shows changed transaction semantics (atomicity, check-then-act, double-approve protection), or the dev-profile smoke shows behavior drift — revert the phase merge. Do not patch forward through a broken invariant.

Phase 0 — ADR + scaffold + dependency rule

  1. Write docs/adr/0016-hexagonal-ports-adapters.md (context, decision, consequences; references this plan).
  2. Create internal/core/{domain,ports,app} and internal/adapters/ trees.
  3. Move internal/domaininternal/core/domain (mechanical import rewrite).
  4. Add depguard to .golangci.yml covering all three §3.1 rules: internal/core/** may not import internal/adapters/**, cmd/**, internal/nomos, openai-go, or MCP-client packages. (Rule 3 — internal/nomos import bans — activates in Phase 8 when the package exists.)
  5. Update CONTRIBUTING layout section.

Phase 1 — extract the client (web SPA + desktop) into a new repo

Grounding: web/ is a self-contained npm package (oikos-web, hand-written API client, vite dev-proxy to :8090/:8092). The build-coupled neighbors move with it: compose/web/ (Dockerfile + Caddyfile → oikos-web image), and cmd/desktop/ (Wails wrapper — make desktop copies web/dist into the binary and its auto-update reads dtoro/oikos releases, cmd/desktop/main.go:41).

Decisions (settled in review): full UI delivery stack moves; new repo gets its own webhook-triggered deploy pipeline and its own compose project on the mac-mini; this runs before the hexagon phases so the backend refactor and its Phase 9 doc rewrite land once on a backend-only tree.

  1. Create git.hubris.network/dtoro/oikos-web (matches the npm package name). Fresh git history; the oikos repo retains the old history. Copy: web/, cmd/desktop/ (as desktop/), compose/web/ (Dockerfile + Caddyfile, adjusted build context), and the ui / desktop / desktop-package / install Makefile targets.
  2. New repo gets its own VERSION file with the same bump-on-main rule. The web Dockerfile's COPY VERSION ./ and vite's VERSION read now resolve inside the new repo: the SPA sidebar shows the UI repo's version; the backend version remains available via the API/MCP ping.
  3. New repo CI mirrors the current .gitea web job (npm lint / typecheck / test / build) plus a desktop build job.
  4. New repo deploy: Gitea webhook → deploy script mirroring deploy.sh essentials (CI-green gate, version-tagged oikos-web:v$VERSION, prune to 3 newest tags, builds the working tree — same constraint as oikos deploys). Own minimal docker-compose.yml publishing 8091:80 with the same mem/cpu limits and restart policy. Second webhook receiver + launchd unit on the mac-mini mirroring cmd/webhook, or one more route on the existing receiver — implementer's choice.
  5. Cutover on mac-mini, in order: stop and remove the old web service from the oikos stack (frees host port 8091) → bring up the new compose project → verify the outer Caddy (LXC 121) still serves oikos.hubris.network: SPA fallback, Authentik flow, /api+/mcp+/agent split. No Caddy changes expected — routing targets the published port, not a Docker network.
  6. Strip from oikos: web/, cmd/desktop/, compose/web/, the ui/desktop/deploy-ui Makefile targets, the CI web job, the web service in docker-compose.yml, and oikos-web from deploy.sh's build and prune lists. Update README / CONTRIBUTING / AGENTS.md layout sections to point at the new repo. Historical plans/ and docs/adr/ references stay as-is (append-only convention).
  7. Desktop auto-update: updateURL in the desktop main changes to dtoro/oikos-web releases. Tag the first new-repo release ≥ the last oikos desktop version so the updater sees an upgrade. Existing installed desktop builds keep checking the old repo and will stop finding updates — one manual reinstall for the single operator; note it in the desktop release notes.
  8. Rollback: both sides keep versioned images. If the new pipeline fails, stop the new project, and a pre-split oikos checkout can re-up its web service and reclaim 8091.

Phase 1 acceptance: SPA served end-to-end from the new pipeline with auth and SSE intact; desktop app builds and updates from the new repo; oikos CI green with no web job; an UI-only commit deploys without touching the backend stack; oikos-web images absent from the oikos deploy prune list.

Phase 2 — ports package + conformance wrappers

  1. Define driven-port interfaces in internal/core/ports (§3.3) against core/domain types only.
  2. Move secrets.Backend interface → ports.Secrets; adapters keep impls; delete mcp's local secretBackend.
  3. Postgres adapter: repository structs wrapping existing pool + sqlcgen (move internal/dbadapters/postgres; queries dir moves with it, sqlc.yaml path updated, make generate verified).
  4. ssh adapter wraps actuator functions behind CommandExecutor; remote adapter implements TargetResolver on EntityRepository.
  5. Add internal/core/ports/ports_test fakes (in-memory repos, recording executor, fake checker, spy publisher) for service tests.

Phase 3 — first vertical slice: entities + graph + composition root

  1. Implement EntityService, GraphService, MonitoringService (absorb checkdefaults into core).
  2. Rewire httpapi entities/relationships/ontology/graph/checks handlers and mcpserver entity/graph tools to the services; delete their inline SQL.
  3. cmd/oikos/main.go becomes the composition root (§3.5); main owns the chi router and mounts REST + /mcp.
  4. Port mutations_test.go / api_test.go entity cases to service-level tests with fakes; keep handler contract tests.

Phase 4 — governance + execution slice (highest value)

  1. Implement PolicyService, ExecutionService, ApprovalService, AuditService, SecretsService.
  2. Dismantle classifyAndGate: tool handler → arg mapping → Submit; policy rules → PolicyService; approval creation → ApprovalService; SSH dispatch → ExecutionService.Dispatch via CommandExecutor.
  3. execworker becomes a poller adapter calling ExecutionService; execlog folds into the execution-log repository + EventPublisher adapter (identical throttling/SSE behavior).
  4. Rewire httpapi executions/approvals/classifications/risk-classes/ autonomy/audit/events/activity handlers and mcp ops tools.
  5. Preserve idempotency_keys semantics across the two converged paths.

Phase 5 — observation slice

  1. Split scheduler.go: ObservationService + SignalService in core; one probe adapter per check kind under adapters/probes/; Checker registry keyed by check kind. The probe concurrency cap moves with it — RunPass keeps the bounded worker pool of 10 (scheduler.go:133).
  2. MetricsRepository + health aggregation move behind ports; staleness sweep in service; timer + advisory lock stay in the scheduler adapter.
  3. Rewire httpapi signals/fleet-health/dashboard handlers.

Phase 6 — knowledge + learning slice

  1. KnowledgeService (search, upsert, revisions, tags, merge, drift) shared by REST + MCP knowledge tools.
  2. LearningService with Pattern/Feedback repositories; keep ≥80% coverage gate on the moved logic.

Phase 7 — seeds, provisioning, inversions

  1. SeedService absorbs db/seed.go + db/export.go; db package reduces to connection/migrations/sqlcgen inside the postgres adapter — the db → checkdefaults edge is gone.
  2. ProvisioningService + Provisioner port for pct/qm flows.
  3. CLI subcommands become adapters over SeedService/SecretsService.

Phase 8 — nomos internal cleanup (no hexagon) + leaves

  1. Extract cmd/nomos logic into internal/nomos plain packages: session, turngate, retrycap, messagequeue, assent (keep existing unit tests moving with them).
  2. Define nomos-local ports in internal/nomos: LLMClient, HomelabClient (MCP client), SessionStore. cmd/nomos/main.go becomes the composition root wiring openai-go, the MCP client, and the pgx store into those ports.
  3. depguard rules 23 from §3.1 go live: internal/core bans internal/nomos, openai-go, MCP-client packages; internal/nomos bans internal/core and internal/adapters.
  4. Document webhook and desktop as leaf utilities in ADR-0016 (no restructuring).

Phase 9 — cleanup + docs + gates

  1. Delete dead code paths and the old package locations; full depguard audit (zero core→adapter imports).
  2. Coverage gates: ExecutionService and PolicyService ≥ 90% each (the safety-critical pair), plus a gating-matrix test — risk class × autonomy mode × declared risk → outcome (auto-run / queue / deny) asserted as a table, since line coverage alone cannot prove the classifier. Keep existing gates (policy + learning ≥ 80%, others ≥ 60%).
  3. Update README, CONTRIBUTING, AGENTS.md layout sections; bump minor version; deploy.

6. Risks and mitigations

Risk Mitigation
Behavior drift while extracting services No wire-format changes; existing handler tests (api_test.go, phase3_test.go, mutations_test.go, pct_create_test.go) keep running against adapters; new service tests with fakes cover logic before rewiring
Import churn breaks CI for days One phase per merge, mechanical moves, make generate-check after sqlc path moves
Dual execution paths (immediate vs queued) diverge Both converge on ExecutionService.Submit/Dispatch in Phase 4; idempotency_keys behavior asserted by test
SSE/execution-log streaming regressions EventPublisher adapter keeps broker + throttling behavior; sse_test.go unchanged
depguard false positives during migration Rule tightened per phase (initially warn-only on already-moved packages, deny at Phase 9)
Coverage gates dip mid-refactor Adjust per-phase in CI config, restore at Phase 9
UI split breaks serving or auth Port-8091 cutover is ordered (old service down before new up); outer Caddy untouched — it targets the published port; versioned-image rollback on both sides re-ups the old web service

7. Validation

  • Per phase: make lint test generate-check; make test-db for repository changes; docker compose --profile dev up + smoke: entity CRUD via REST and MCP, one observe pass, one gated execution end-to-end.
  • Phase 1 acceptance: SPA served from the new repo's pipeline at oikos.hubris.network with Authentik flow and SSE intact; desktop app builds from the new repo; oikos CI green without the web job.
  • Phase 4 acceptance: MCP run read-only executes, config_mutation queues an approval, approval via REST resumes execution — all observable in SPA.
  • Phase 9 acceptance: rg "internal/adapters" internal/core returns nothing; ADR + docs updated; deployed via deploy.sh from clean tree.

8. Out of scope / open decisions

  • No DB schema changes, no API/MCP contract changes, no SPA behavior changes, no binary-merging (nomos stays a separate deployable).
  • Decided (review): UI extraction = Phase 1 — web/, cmd/desktop/, compose/web/ move to dtoro/oikos-web with their own pipeline, compose project, and VERSION.
  • Presenter strategy: REST and MCP keep separate thin presenters (recommended, default) rather than shared DTOs.
  • Decided: one hexagon. nomos is an external agent client with a Phase 8 internal cleanup (local ports, no core/adapters tree). Revisit only if nomos grows a second consumer of its session logic.