Problem: the hexagon's Phase 2 (plans/2026-08-15-hexagonal-architecture.md) must give the use-cases-to-be their contract surface: driven-port interfaces, test fakes, the secrets interface moved into core, and the postgres package inside the adapters tree — before the first vertical slice (Phase 3) can wire a composition root. Change: - internal/core/ports: full driven-port catalog per plan §3.3 — repositories as transaction-scoped aggregates whose inputs carry derived checks, audit, and events (§3.6), plus CommandExecutor, TargetResolver, Checker, Secrets, EventPublisher, Provisioner. Port-local payload types (Event, AuditEntry, CheckDef, KnowledgeEntry, ExecResult) keep signatures off infrastructure; TypeTree aliases internal/ontology (pure over domain) until checkdefaults is absorbed. ReadModels intentionally not declared yet — it materializes with the Phase 3 slice and grows as report handlers rewire. - secrets.Backend is now an alias of ports.Secrets; implementations (Infisical, SOPS, Manager) unchanged. mcp's local secretBackend subset is deleted; tool constructors take ports.Secrets. - internal/db → internal/adapters/postgres (mechanical import rewrite; package identifier stays db until the Phase 3 repository split). sqlc.yaml, Makefile, golangci exclusions, and docs follow the move; make generate-check verified. - internal/adapters/ssh: Executor implements ports.CommandExecutor over the actuator dial pool + RunStreaming (10-min default timeout carried over from the httpapi path). - internal/adapters/remote: Resolver implements ports.TargetResolver delegating to internal/remote (still pool-based; drops onto ports.EntityRepository when repositories land in Phase 3 — documented transitional import). - internal/core/ports/portstest: importable fakes — in-memory EntityRepo (with check-then-act SetState, side-effect recording), RecordingExecutor, FakeChecker, SpyPublisher; port-satisfaction guards; tests. Risk: ports are declared ahead of implementations — signatures firm up per phase as slices land (documented in the package doc); the remote→postgres transitional import is explicit and dissolves in Phase 3. Verification: go vet, make test (race, 19 packages), generate-check, golangci on core+adapters — 0 issues; full-repo baseline down 365→344.
703 lines
38 KiB
Markdown
703 lines
38 KiB
Markdown
# Hexagonal architecture for Oikos — design and phased refactor plan
|
||
|
||
**Date:** 2026-08-15
|
||
**Status:** In progress — Phases 0–2 shipped; Phases 3–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.
|