# Hexagonal architecture for Oikos — design and phased refactor plan **Date:** 2026-08-15 **Status:** In Progress — Phases 0–7 shipped plus the Phase 4 governance/execution slice (2026-08-16: PolicyService + ExecutionService extracted from classifyAndGate with the gating-matrix test, ≥90% coverage gate met at 95.6%; also fixed the inverted flag-space regex that refused valid `tail -n` commands). Phase 8 (nomos internal cleanup) not started. Phase 9 remainder: ApprovalService.Decide convergence (httpapi approvals handler still holds the HMAC/window logic), execlog fold into the events adapter, execworker poller over ExecutionService, final depguard audit, archive. **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.