Problem: the hexagonal-architecture plan (plans/2026-08-15-hexagonal-
architecture.md) needs its foundation — an accepted ADR, the target
directory tree, and machine-checked dependency rules — before any
service extraction starts. Also folds the four outstanding review
findings (F3.1/F5/F6/F7) into the plan: ObservationService owns the
bounded probe-concurrency contract (scheduler.go:133), Phase 9 gates
ExecutionService+PolicyService ≥ 90% with a gating-matrix test,
per-phase abort criteria, and the §3.2 internal/config note.
Change:
- docs/adr/0016-hexagonal-ports-adapters.md records context, decision,
and consequences of the ports & adapters migration.
- internal/domain → internal/core/domain (mechanical import rewrite,
20 files), new internal/core/{ports,app}, internal/adapters trees
with package docs.
- .golangci.yml: depguard rules for §3.1 (core purity, no agent-client
tech in core, nomos isolation — the nomos rules self-activate when
internal/nomos exists in Phase 8). Config migrated to golangci-lint
v2 format so it loads at all (the v1 config errored under v2, masked
by CI's advisory continue-on-error). Verified depguard fires on a
planted openai-go import in internal/core/app.
- CONTRIBUTING.md layout section now shows the core/adapters tree.
Risk: import path churn is mechanical and tests pass unchanged; the
lint config migration surfaces the pre-existing 400-issue baseline
(advisory in CI, unchanged policy) — new/moved packages lint clean.
Verification: go vet ./..., make test (race, core/domain at 100%
coverage), make generate-check, golangci-lint on internal/core/... and
internal/adapters/... — 0 issues; depguard violation probe confirmed.
38 KiB
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):
flowchart TD
subgraph delivery["Delivery — contains the use-cases today"]
httpapi["httpapi (~3500 LOC, 30 files)"]
mcp["mcp (~2100 LOC, 67 tools, classifyAndGate)"]
end
subgraph execution["Execution"]
scheduler["scheduler (~1100 LOC, one file)"]
execworker["execworker"]
actuator["actuator (SSH + breaker)"]
end
subgraph domainsvc["Domain services"]
checkdefaults["checkdefaults"]
learning["learning"]
knowledge["knowledge"]
execlog["execlog"]
observability["observability"]
end
subgraph dbinfra["DB infra"]
db["db + sqlcgen"]
remote["remote"]
audit["audit"]
end
subgraph nearpure["Near-pure"]
ontology["ontology"]
policy["policy"]
secrets["secrets"]
end
subgraph pure["Pure (stdlib only)"]
domain["domain"]
config["config"]
safego["safego"]
health["health"]
end
httpapi --> db
httpapi --> actuator
httpapi --> mcp
httpapi --> secrets
mcp --> db
mcp --> actuator
mcp --> execlog
mcp --> policy
mcp --> remote
mcp --> checkdefaults
mcp --> audit
scheduler --> db
scheduler --> actuator
scheduler --> remote
execworker --> db
execworker --> actuator
execworker --> remote
actuator --> secrets
checkdefaults --> ontology
ontology --> domain
policy --> domain
db --> checkdefaults
remote --> db
audit --> db
learning --> db
knowledge --> db
execlog --> observability
observability --> db
The problem: use-cases have no home. Every new consumer (REST tool, MCP tool, scheduler) re-implements or copy-pastes query + policy + audit logic.
3. Target architecture
3.1 System context
One hexagon. nomos, the Gitea webhook receiver, and the desktop shell stay
outside — nomos and the desktop shell are external clients of the core (over
MCP/REST), the webhook is a leaf deploy utility:
flowchart LR
subgraph ext["External clients"]
spa["web SPA"]
desktop["desktop shell"]
curl["curl / scripts"]
nomos["nomos agent"]
timers["timers"]
end
subgraph oikos["OIKOS CORE HEXAGON"]
subgraph driving["Driving adapters (left side of the hexagon)"]
httpapi["httpapi — REST"]
mcpserver["mcpserver — MCP tools"]
schedad["scheduler — timer"]
execworker["execworker — queue poller"]
cli["cli — seed/export/secret"]
end
subgraph core["Core"]
app["app services (use-cases)"]
dom["core/domain (pure model)"]
end
subgraph driven["Driven adapters (right side of the hexagon)"]
postgres["postgres — repositories"]
ssh["ssh — CommandExecutor"]
probes["probes/* — Checker per kind"]
remote["remote — TargetResolver"]
secretsad["secrets — Infisical/SOPS"]
events["events — EventPublisher/SSE"]
end
end
subgraph leaves["Leaf utilities — unchanged"]
webhook["webhook — Gitea push → deploy"]
end
spa -- REST --> httpapi
desktop -- REST --> httpapi
curl -- REST --> httpapi
nomos -- MCP --> mcpserver
timers -- tick --> schedad
driving --> app
app --> dom
app -- "via ports" --> driven
webhook -. "docker compose on mac-mini" .-> oikos
Dependency rules (enforced with depguard from Phase 0):
- Core imports nothing from adapters; adapters and
cmd/*import core. Adapter→adapter imports are allowed only via ports (e.g. the target-resolver adapter consumesEntityRepository, not the postgres package). - The core never links agent-client tech:
internal/coremust not importinternal/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. - nomos is external:
internal/nomosmust not importinternal/coreorinternal/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):
- Load env config (
internal/config— pure, importable by adapters). - Build secrets manager and overlay config — before the DB pool. Note the
chicken-egg:
INFISICAL_ENCRYPTION_KEYbootstraps Infisical itself and cannot live in Infisical; it stays in env/.env. - Connect pool, run repositories, resolver, executor, events.
- 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 adestructivecommand - 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:
- Core does the pure work first: validate against the cached OntologyStore,
derive desired checks from the TypeTree (
checkdefaultslogic, pure), classify risk, build audit/event entries. - Core passes one complete input struct — e.g.
EntityRepository.Create(ctx, CreateInput{Entity, DerivedChecks, Audit, Event}). - The postgres adapter runs
BEGIN → writes → COMMITinternally and returns the result; partial failures roll back exactly as today. - Cross-aggregate operations that transact together today stay one method
(
ApprovalRepository.Decidespans approvals + executions + audit).
What is explicitly rejected:
- UnitOfWork/Tx-manager port: every port doubles into tx/non-tx variants,
fakes multiply,
pgx.Txleaks into the core this refactor exists to protect. - Fine-grained autocommit ports: silently deletes the check-then-act guarantees (token double-spend, transition races).
Failure-mode cost: a tx span that should span two repo calls but doesn't is a bug — mitigated by the conformance suite (§5 Phase 2) asserting atomicity and check-then-act behavior per command method.
4. How the components work together (interaction docs)
A. Agent run tool (the core OODA act path).
nomos → MCP client → mcpserver run tool → parses args, maps to
ExecutionService.Submit(ctx, target, command, purpose, declaredRisk). Submit
loads the entity (EntityRepository), resolves the SSH target
(TargetResolver), calls PolicyService.Classify (policy rules + DB-backed
signal context; classifier can only lower autonomy). Read-only /
reversible-low → executes now via CommandExecutor, streaming output through
ExecutionRepository.AppendLog and EventPublisher (SPA live output);
records audit + event; returns exit code. Config-mutation / destructive →
persists execution as queued + creates approval, publishes event; returns
"awaiting approval". Later the operator clicks Approve in the SPA → REST →
ApprovalService.Decide → marks approved → execworker poller claims the
execution (advisory lock) → ExecutionService.Dispatch → SSH → results +
audit. Both entry paths converge on Submit/Dispatch — one policy, one audit
trail.
sequenceDiagram
autonumber
participant N as nomos (MCP client)
participant M as mcpserver run tool
participant ES as ExecutionService
participant PS as PolicyService
participant CE as CommandExecutor (ssh)
participant ER as ExecutionRepository
participant AP as ApprovalService
participant OP as Operator (SPA)
participant EW as execworker
N->>M: run(target, command, purpose, declaredRisk)
M->>ES: Submit(target, command, purpose, risk)
ES->>PS: Classify(command, declaredRisk)
alt read-only / reversible-low
ES->>CE: execute now
CE-->>ES: exit code + output (streamed)
ES->>ER: AppendLog + status + audit
ES-->>M: result
M-->>N: exit code + output
else config-mutation / destructive
ES->>ER: create execution (queued)
ES->>AP: create approval + event
ES-->>M: awaiting approval
M-->>N: queued, needs approval
OP->>AP: Decide(approval_id, approved)
AP->>ER: mark execution approved
EW->>ER: Claim next queued (advisory lock)
EW->>ES: Dispatch(execution)
ES->>CE: execute
CE-->>ES: exit code + output
ES->>ER: status + logs + audit
end
B. Scheduler observe pass.
Timer adapter ticks → ObservationService.RunPass → CheckRepository
.ListEnabled → group by entity → TargetResolver per check → dispatch to the
Checker adapter selected by check kind (each probe is its own adapter; adding
a check kind = new adapter + seed row, no core change) → aggregate per-entity
health (worst-of + maintenance windows) → SignalRepository.Upsert
(open/resolve transitions with signal triggers) → MetricsRepository
.InsertSamples → EventPublisher. The SPA receives health changes over SSE.
The scheduler package shrinks to: ticker, advisory lock (memory: startup
pg_advisory_lock(0x01c05e6) on held connection), and pass-loop error
handling.
sequenceDiagram
autonumber
participant T as scheduler (timer adapter)
participant OS as ObservationService
participant CR as CheckRepository
participant TR as TargetResolver
participant CK as Checker (probes/*)
participant SR as SignalRepository
participant MR as MetricsRepository
participant EP as EventPublisher
participant SPA as web SPA (SSE)
T->>OS: RunPass
OS->>CR: ListEnabled
CR-->>OS: enabled check defs
loop per check
OS->>TR: ResolveForCheck(check)
TR-->>OS: SSH target + wrapper
OS->>CK: Check(def, target) — adapter chosen by kind
CK-->>OS: result (value, state, msg)
end
OS->>OS: aggregate health (worst-of + maintenance windows)
OS->>SR: Upsert signals (open/resolve + triggers)
OS->>MR: InsertSamples
OS->>EP: Publish health events
EP-->>SPA: SSE fan-out
C. Operator creates an entity via the SPA.
REST adapter validates the request shape → EntityService.Create →
OntologyStore validates type + required attributes → EntityRepository insert →
check derivation (moved checkdefaults logic, pure over the TypeTree) →
CheckRepository ensure defs → AuditRepository + EventPublisher → response
mapped by the REST presenter. The MCP create_entity tool calls the same
service with its own presenter — one validation path.
D. Agent upserts knowledge.
MCP knowledge tool → KnowledgeService.Upsert → KnowledgeRepository (row +
revision), links about edges via RelationshipRepository, dedupes via
trigram check → audit + event. REST knowledge endpoints share the service.
E. Seed and export (bootstrap/DR).
oikos seed CLI adapter → SeedService.Ingest reads YAML → repositories
upsert; oikos 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
- Write
docs/adr/0016-hexagonal-ports-adapters.md(context, decision, consequences; references this plan). - Create
internal/core/{domain,ports,app}andinternal/adapters/trees. - Move
internal/domain→internal/core/domain(mechanical import rewrite). - Add
depguardto.golangci.ymlcovering all three §3.1 rules:internal/core/**may not importinternal/adapters/**,cmd/**,internal/nomos, openai-go, or MCP-client packages. (Rule 3 —internal/nomosimport bans — activates in Phase 8 when the package exists.) - 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.
- 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/(asdesktop/),compose/web/(Dockerfile + Caddyfile, adjusted build context), and theui/desktop/desktop-package/installMakefile targets. - New repo gets its own
VERSIONfile with the same bump-on-main rule. The web Dockerfile'sCOPY 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. - New repo CI mirrors the current
.giteawebjob (npm lint / typecheck / test / build) plus a desktop build job. - New repo deploy: Gitea webhook → deploy script mirroring
deploy.shessentials (CI-green gate, version-taggedoikos-web:v$VERSION, prune to 3 newest tags, builds the working tree — same constraint as oikos deploys). Own minimaldocker-compose.ymlpublishing8091:80with the same mem/cpu limits and restart policy. Second webhook receiver + launchd unit on the mac-mini mirroringcmd/webhook, or one more route on the existing receiver — implementer's choice. - Cutover on mac-mini, in order: stop and remove the old
webservice from the oikos stack (frees host port 8091) → bring up the new compose project → verify the outer Caddy (LXC 121) still servesoikos.hubris.network: SPA fallback, Authentik flow,/api+/mcp+/agentsplit. No Caddy changes expected — routing targets the published port, not a Docker network. - Strip from oikos:
web/,cmd/desktop/,compose/web/, the ui/desktop/deploy-ui Makefile targets, the CIwebjob, thewebservice indocker-compose.yml, andoikos-webfromdeploy.sh's build and prune lists. Update README / CONTRIBUTING / AGENTS.md layout sections to point at the new repo. Historicalplans/anddocs/adr/references stay as-is (append-only convention). - Desktop auto-update:
updateURLin the desktop main changes todtoro/oikos-webreleases. 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. - 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
webservice 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
- Define driven-port interfaces in
internal/core/ports(§3.3) againstcore/domaintypes only. - Move
secrets.Backendinterface →ports.Secrets; adapters keep impls; delete mcp's localsecretBackend. - Postgres adapter: repository structs wrapping existing pool + sqlcgen
(move
internal/db→adapters/postgres; queries dir moves with it, sqlc.yaml path updated,make generateverified). - ssh adapter wraps actuator functions behind
CommandExecutor; remote adapter implementsTargetResolveronEntityRepository. - Add
internal/core/ports/ports_testfakes (in-memory repos, recording executor, fake checker, spy publisher) for service tests.
Phase 3 — first vertical slice: entities + graph + composition root
- Implement
EntityService,GraphService,MonitoringService(absorb checkdefaults into core). - Rewire
httpapientities/relationships/ontology/graph/checks handlers andmcpserverentity/graph tools to the services; delete their inline SQL. cmd/oikos/main.gobecomes the composition root (§3.5); main owns the chi router and mounts REST +/mcp.- Port
mutations_test.go/api_test.goentity cases to service-level tests with fakes; keep handler contract tests.
Phase 4 — governance + execution slice (highest value)
- Implement
PolicyService,ExecutionService,ApprovalService,AuditService,SecretsService. - Dismantle
classifyAndGate: tool handler → arg mapping → Submit; policy rules → PolicyService; approval creation → ApprovalService; SSH dispatch → ExecutionService.Dispatch via CommandExecutor. execworkerbecomes a poller adapter calling ExecutionService;execlogfolds into the execution-log repository + EventPublisher adapter (identical throttling/SSE behavior).- Rewire
httpapiexecutions/approvals/classifications/risk-classes/ autonomy/audit/events/activity handlers and mcp ops tools. - Preserve
idempotency_keyssemantics across the two converged paths.
Phase 5 — observation slice
- Split
scheduler.go:ObservationService+SignalServicein core; one probe adapter per check kind underadapters/probes/;Checkerregistry keyed by check kind. The probe concurrency cap moves with it —RunPasskeeps the bounded worker pool of 10 (scheduler.go:133). MetricsRepository+ health aggregation move behind ports; staleness sweep in service; timer + advisory lock stay in the scheduler adapter.- Rewire
httpapisignals/fleet-health/dashboard handlers.
Phase 6 — knowledge + learning slice
KnowledgeService(search, upsert, revisions, tags, merge, drift) shared by REST + MCP knowledge tools.LearningServicewith Pattern/Feedback repositories; keep ≥80% coverage gate on the moved logic.
Phase 7 — seeds, provisioning, inversions
SeedServiceabsorbsdb/seed.go+db/export.go;dbpackage reduces to connection/migrations/sqlcgen inside the postgres adapter — thedb → checkdefaultsedge is gone.ProvisioningService+Provisionerport for pct/qm flows.- CLI subcommands become adapters over SeedService/SecretsService.
Phase 8 — nomos internal cleanup (no hexagon) + leaves
- Extract
cmd/nomoslogic intointernal/nomosplain packages:session,turngate,retrycap,messagequeue,assent(keep existing unit tests moving with them). - Define nomos-local ports in
internal/nomos:LLMClient,HomelabClient(MCP client),SessionStore.cmd/nomos/main.gobecomes the composition root wiring openai-go, the MCP client, and the pgx store into those ports. - depguard rules 2–3 from §3.1 go live:
internal/corebansinternal/nomos,openai-go, MCP-client packages;internal/nomosbansinternal/coreandinternal/adapters. - Document
webhookanddesktopas leaf utilities in ADR-0016 (no restructuring).
Phase 9 — cleanup + docs + gates
- Delete dead code paths and the old package locations; full depguard audit (zero core→adapter imports).
- Coverage gates:
ExecutionServiceandPolicyService≥ 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%). - 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-dbfor 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.networkwith Authentik flow and SSE intact; desktop app builds from the new repo; oikos CI green without the web job. - Phase 4 acceptance: MCP
runread-only executes, config_mutation queues an approval, approval via REST resumes execution — all observable in SPA. - Phase 9 acceptance:
rg "internal/adapters" internal/corereturns nothing; ADR + docs updated; deployed viadeploy.shfrom 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 todtoro/oikos-webwith 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/adapterstree). Revisit only if nomos grows a second consumer of its session logic.