@@ -0,0 +1,702 @@
# Hexagonal architecture for Oikos — design and phased refactor plan
**Date: ** 2026-08-15
**Status: ** In progress — Phase 0 shipped; Phases 1– 9 pending
**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.