# Hexagonal architecture for Oikos — design and phased refactor plan

**Date:** 2026-08-15
**Status:** In Progress — Phases 0–7 shipped (Phase 7 landed 2026-08-16: SeedService/SecretsService + ProvisioningService/Provisioner, CLI rewired). Phase 8 (nomos internal cleanup) not started. Phase 9 partially done (docs + version bump landed with the earlier commits; the ExecutionService/PolicyService ≥90% coverage gates and gating-matrix test, and the governance/execution slice they depend on, are still open — the shipped "Phase 4/5/6" commits converged entities, relationships, observation, signals, knowledge, and learning, not the §5 Phase-4 governance slice).
**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, 2–9).

---

## 2. Current state (grounding)

What the code looks like today, with references:

| Finding | Evidence |
|---|---|
| Pure domain package already exists | `internal/domain` — `Entity`, `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):

```mermaid
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:

```mermaid
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.

```mermaid
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.

```mermaid
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 export` → `SeedService.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/domain` → `internal/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/db` → `adapters/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 2–3 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.
