diff --git a/.golangci.yml b/.golangci.yml index f074b7d4..02dfa34f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,41 +1,89 @@ -# golangci-lint configuration for Oikos +# golangci-lint v2 configuration for Oikos # Docs: https://golangci-lint.run/usage/configuration/ +# Default-enabled linters (errcheck, govet, ineffassign, staticcheck, unused) +# are not listed below. gosimple/typecheck were absorbed into staticcheck in v2. +version: "2" run: - timeout: 5m tests: true - linters: enable: - - govet # go vet - - staticcheck # advanced static analysis - - ineffassign # detect ineffectual assignments - - unused # find unused identifiers - - errcheck # check for unchecked errors - - gosimple # simplifications - - typecheck # standard type checking - - misspell # find commonly misspelled English words in comments - - revive # fast, configurable linter (replaces golint) - -linters-settings: - errcheck: - # Allow unchecked errors on common Close/Flush patterns (deferred cleanup) - exclude-functions: - - (io.Closer).Close - - (*os.File).Close - + - depguard + - misspell + - revive + settings: + depguard: + # ADR 0016 dependency rules. Rules only constrain files that exist: + # internal/core is live since Phase 0 (domain moved); internal/nomos + # and its bans activate in Phase 8; full audit at Phase 9. + rules: + core-no-agent-tech: + files: + - "**/internal/core/**" + deny: + - pkg: github.com/dtoro/oikos/internal/nomos + desc: core never links agent-client packages (ADR 0016 §3.1 rule 3) + - pkg: github.com/dtoro/oikos/internal/nomos/** + desc: core never links agent-client packages (ADR 0016 §3.1 rule 3) + - pkg: github.com/openai/openai-go + desc: core never links the LLM SDK — nomos is an external client + - pkg: github.com/openai/openai-go/** + desc: core never links the LLM SDK — nomos is an external client + - pkg: github.com/modelcontextprotocol/go-sdk + desc: core never links MCP packages — mcpserver is a driving adapter + - pkg: github.com/modelcontextprotocol/go-sdk/** + desc: core never links MCP packages — mcpserver is a driving adapter + core-purity: + files: + - "**/internal/core/**" + deny: + - pkg: github.com/dtoro/oikos/internal/adapters + desc: core must not import adapters — depend on core/ports instead + - pkg: github.com/dtoro/oikos/internal/adapters/** + desc: core must not import adapters — depend on core/ports instead + - pkg: github.com/dtoro/oikos/cmd + desc: core must not import composition roots + - pkg: github.com/dtoro/oikos/cmd/** + desc: core must not import composition roots + nomos-isolation: + files: + - "**/internal/nomos/**" + deny: + - pkg: github.com/dtoro/oikos/internal/core + desc: nomos must not import core — consume oikos via MCP/REST + - pkg: github.com/dtoro/oikos/internal/core/** + desc: nomos must not import core — consume oikos via MCP/REST + - pkg: github.com/dtoro/oikos/internal/adapters + desc: nomos must not import adapters — consume oikos via MCP/REST + - pkg: github.com/dtoro/oikos/internal/adapters/** + desc: nomos must not import adapters — consume oikos via MCP/REST + errcheck: + # Allow unchecked errors on common Close/Flush patterns (deferred cleanup) + exclude-functions: + - (io.Closer).Close + - (*os.File).Close + exclusions: + generated: lax + rules: + - linters: + - errcheck + path: _test\.go + - linters: + - all + path: internal/httpapi/gen/ + - linters: + - all + path: internal/db/sqlcgen/ + paths: + - third_party$ + - builtin$ + - examples$ issues: - # Exclude generated code - exclude-rules: - - path: _test\.go - linters: - - errcheck - - path: internal/httpapi/gen/ - linters: - - all - - path: internal/db/sqlcgen/ - linters: - - all - # Don't auto-exclude common patterns - exclude-use-default: false max-issues-per-linter: 0 max-same-issues: 0 +formatters: + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9fd26e3d..4a0c5020 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,6 +68,15 @@ cmd/oikos/ Single-binary entry point cmd/nomos/ Nomos MCP client gateway cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini) internal/ All Go packages + core/ Hexagon core ([ADR 0016](docs/adr/0016-hexagonal-ports-adapters.md)): + domain/ (pure model, stdlib only), ports/ (driven-port + interfaces), app/ (use-case services) — populated by the + phased refactor (plans/2026-08-15-hexagonal-architecture.md); + core may not import adapters, enforced by depguard + adapters/ Ports' implementations (postgres, ssh, probes, remote, + secrets, events) + driving adapters (httpapi, mcpserver, + scheduler, execworker, cli) — scaffolded; packages move + here phase by phase httpapi/ REST + MCP server (OpenAPI-generated) mcp/ MCP tool implementations db/ Connection pool, migrations, seeds, sqlc queries @@ -76,7 +85,6 @@ internal/ All Go packages learning/ Pattern recognition, anomaly detection policy/ Risk classifier secrets/ Infisical + SOPS backend - domain/ Core types: entities, approvals, signals, patterns ontology/ Type hierarchy, relationship validation knowledge/ Knowledge YAML seed ingestion web/ Control-room SPA (Svelte 5) — standalone, not embedded diff --git a/VERSION b/VERSION index 9eb2aa3f..fd9620c0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.32.0 +0.32.1 diff --git a/docs/adr/0016-hexagonal-ports-adapters.md b/docs/adr/0016-hexagonal-ports-adapters.md new file mode 100644 index 00000000..c1962131 --- /dev/null +++ b/docs/adr/0016-hexagonal-ports-adapters.md @@ -0,0 +1,69 @@ +# ADR 0016 — Hexagonal (ports & adapters) architecture for the oikos backend + +Status: accepted (2026-08-15) · Plan: plans/2026-08-15-hexagonal-architecture.md + +## Context +The oikos backend grew as delivery-layer packages with business logic inside +them. `internal/httpapi` (~3500 LOC) and `internal/mcp` (~2100 LOC, 67 tools) +each embed raw SQL next to validation, policy, and audit writes — two parallel +silos re-implementing the same use-cases. The scheduler is a ~1100-LOC +monolith mixing probe dispatch, health aggregation, signal transitions, and +metric writes. There is one accidental port/adapter pair +(`secrets.Backend`) and one inverted dependency (`internal/db` imports +`checkdefaults`). Use-cases have no home: every new consumer (REST handler, +MCP tool, scheduler pass) copy-pastes query + policy + audit logic, and +behavior drifts between paths that must agree — the run/approve/execute +lifecycle exists in three variants. + +A staff-level review of the refactor plan settled the open questions: one +hexagon (not per-binary), nomos stays an external agent client over the wire, +aggregate-scoped repository methods instead of a UnitOfWork port, reads bypass +services via a shared `ReadModels` port, and the client (web SPA + desktop) +moves to its own repository before the backend churn starts. + +## Decision +Adopt ports & adapters across the backend, delivered in ten shippable phases: + +- `internal/core/domain` (pure model, stdlib only), `internal/core/app` + (application services = use-cases), `internal/core/ports` (driven-port + interfaces). Core imports nothing from adapters; adapters and `cmd/*` + import core. +- All I/O behind named ports implemented by adapters under + `internal/adapters/`: postgres (repositories), ssh (CommandExecutor), + probes/* (one Checker per check kind), remote (TargetResolver), secrets, + events (EventPublisher/SSE), plus the driving adapters httpapi, mcpserver, + scheduler, execworker, cli. +- One repository method = one transaction = one aggregate's atomic boundary + (`§3.6` of the plan). Inputs carry audit/event entries and derived checks; + no `pgx.Tx` crosses into core; no UnitOfWork port. +- Commands flow through `core/app` services; invariant-free reads flow + adapter → `ReadModels` → presenter with no service hop (CQRS-lite). +- REST and MCP keep their existing wire formats and become thin driving + adapters over the same services; presenters stay per-adapter. +- nomos never links core: it consumes oikos exclusively through MCP/REST. + Its Phase 8 cleanup defines nomos-local ports (`LLMClient`, `HomelabClient`, + `SessionStore`) outside `core/ports`. +- The Gitea `webhook` receiver and the desktop shell are leaf utilities — + documented, not restructured. +- Dependency rules are enforced with `depguard` from Phase 0 (rule 3, the + `internal/nomos` bans, activates when that package exists in Phase 8). + +## Consequences +- No API, MCP-tool, DB-schema, or wire-format changes ride along; behavior + parity is guarded by the existing handler contract tests plus a repository + conformance suite asserting atomicity and check-then-act per command method. +- `internal/db` shrinks to connection/migrations/sqlcgen inside the postgres + adapter; the `db → checkdefaults` inversion disappears with `SeedService`. +- Each phase merges green (`make lint test generate-check`, `make test-db` + where repositories change) with a patch version bump; per-phase abort + criteria revert a merge that breaks transaction semantics. Minor bump at + Phase 9 completion. +- Cost accepted: ~10 phases of import churn, sqlc path updates under + `make generate-check`, and a temporary period where old and new package + locations coexist (depguard denies core→adapter imports from Phase 0 so + the new tree can never grow the old inversions). +- Risk trade-off recorded in the plan: a repository method whose tx span is + too narrow is a bug class this design makes possible; the conformance + suite is the mitigation, and the check-then-act sites enumerated in the + plan (approval decide, entity transition, check derivation, execution + claim) are its first assertions. diff --git a/internal/adapters/doc.go b/internal/adapters/doc.go new file mode 100644 index 00000000..39ce71da --- /dev/null +++ b/internal/adapters/doc.go @@ -0,0 +1,5 @@ +// Package adapters hosts the ports' implementations (postgres, ssh, probes, +// remote, secrets, events) and the driving adapters (httpapi, mcpserver, +// scheduler, execworker, cli). Packages move here phase by phase per +// plans/2026-08-15-hexagonal-architecture.md. +package adapters diff --git a/internal/core/app/doc.go b/internal/core/app/doc.go new file mode 100644 index 00000000..7174fc31 --- /dev/null +++ b/internal/core/app/doc.go @@ -0,0 +1,5 @@ +// Package app hosts the application services (use-cases) of the oikos +// core. Services take ports as constructor arguments, return domain types +// and sentinel errors, and own the command side of the system. See +// docs/adr/0016-hexagonal-ports-adapters.md. +package app diff --git a/internal/domain/approval.go b/internal/core/domain/approval.go similarity index 100% rename from internal/domain/approval.go rename to internal/core/domain/approval.go diff --git a/internal/core/domain/doc.go b/internal/core/domain/doc.go new file mode 100644 index 00000000..3b6a9616 --- /dev/null +++ b/internal/core/domain/doc.go @@ -0,0 +1,4 @@ +// Package domain holds the pure model of the oikos core: entities, signals, +// executions, approvals, patterns, and their sentinel errors. It imports +// only the standard library. +package domain diff --git a/internal/domain/domain_test.go b/internal/core/domain/domain_test.go similarity index 100% rename from internal/domain/domain_test.go rename to internal/core/domain/domain_test.go diff --git a/internal/domain/entity.go b/internal/core/domain/entity.go similarity index 100% rename from internal/domain/entity.go rename to internal/core/domain/entity.go diff --git a/internal/domain/errors.go b/internal/core/domain/errors.go similarity index 100% rename from internal/domain/errors.go rename to internal/core/domain/errors.go diff --git a/internal/domain/execution.go b/internal/core/domain/execution.go similarity index 100% rename from internal/domain/execution.go rename to internal/core/domain/execution.go diff --git a/internal/domain/pattern.go b/internal/core/domain/pattern.go similarity index 100% rename from internal/domain/pattern.go rename to internal/core/domain/pattern.go diff --git a/internal/domain/signal.go b/internal/core/domain/signal.go similarity index 100% rename from internal/domain/signal.go rename to internal/core/domain/signal.go diff --git a/internal/core/ports/doc.go b/internal/core/ports/doc.go new file mode 100644 index 00000000..6d9ea583 --- /dev/null +++ b/internal/core/ports/doc.go @@ -0,0 +1,5 @@ +// Package ports declares the driven-port interfaces of the oikos core: +// repositories, executors, resolvers, probes, secrets, and events. +// Core packages define these interfaces; adapters under internal/adapters +// implement them. See docs/adr/0016-hexagonal-ports-adapters.md. +package ports diff --git a/internal/db/integration_test.go b/internal/db/integration_test.go index e6066962..07353700 100644 --- a/internal/db/integration_test.go +++ b/internal/db/integration_test.go @@ -18,7 +18,7 @@ import ( "strings" "testing" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" "github.com/jackc/pgx/v5" "gopkg.in/yaml.v3" ) diff --git a/internal/httpapi/approval_rules.go b/internal/httpapi/approval_rules.go index 8e2d3028..8fd15c1c 100644 --- a/internal/httpapi/approval_rules.go +++ b/internal/httpapi/approval_rules.go @@ -6,7 +6,7 @@ import ( "strings" "github.com/dtoro/oikos/internal/db/sqlcgen" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" "github.com/google/uuid" diff --git a/internal/httpapi/approvals.go b/internal/httpapi/approvals.go index 5a1a72cb..101bee8a 100644 --- a/internal/httpapi/approvals.go +++ b/internal/httpapi/approvals.go @@ -8,7 +8,7 @@ import ( "time" "github.com/dtoro/oikos/internal/db/sqlcgen" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" "github.com/dtoro/oikos/internal/safego" diff --git a/internal/httpapi/autonomy.go b/internal/httpapi/autonomy.go index 31144557..a579c78f 100644 --- a/internal/httpapi/autonomy.go +++ b/internal/httpapi/autonomy.go @@ -5,7 +5,7 @@ import ( "fmt" "github.com/dtoro/oikos/internal/db/sqlcgen" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" ) diff --git a/internal/httpapi/checks.go b/internal/httpapi/checks.go index 6e918a59..60b122ea 100644 --- a/internal/httpapi/checks.go +++ b/internal/httpapi/checks.go @@ -7,7 +7,7 @@ import ( "strings" "github.com/dtoro/oikos/internal/db/sqlcgen" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" "github.com/google/uuid" diff --git a/internal/httpapi/client_lifecycle.go b/internal/httpapi/client_lifecycle.go index 532f8944..a5361edb 100644 --- a/internal/httpapi/client_lifecycle.go +++ b/internal/httpapi/client_lifecycle.go @@ -10,7 +10,7 @@ import ( "time" "github.com/dtoro/oikos/internal/db/sqlcgen" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" "github.com/google/uuid" diff --git a/internal/httpapi/entity_mutations.go b/internal/httpapi/entity_mutations.go index 8eabf301..3fe0a027 100644 --- a/internal/httpapi/entity_mutations.go +++ b/internal/httpapi/entity_mutations.go @@ -11,7 +11,7 @@ import ( "github.com/dtoro/oikos/internal/db" "github.com/dtoro/oikos/internal/db/sqlcgen" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" "github.com/google/uuid" diff --git a/internal/httpapi/entity_types.go b/internal/httpapi/entity_types.go index 0656f3df..e256e4d9 100644 --- a/internal/httpapi/entity_types.go +++ b/internal/httpapi/entity_types.go @@ -7,7 +7,7 @@ import ( "strings" "github.com/dtoro/oikos/internal/db/sqlcgen" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" ) diff --git a/internal/httpapi/executions.go b/internal/httpapi/executions.go index 4578127d..db67113b 100644 --- a/internal/httpapi/executions.go +++ b/internal/httpapi/executions.go @@ -8,7 +8,7 @@ import ( "time" "github.com/dtoro/oikos/internal/db/sqlcgen" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" "github.com/google/uuid" diff --git a/internal/httpapi/impl.go b/internal/httpapi/impl.go index 08d03f0d..16ea1f85 100644 --- a/internal/httpapi/impl.go +++ b/internal/httpapi/impl.go @@ -7,7 +7,7 @@ import ( "time" "github.com/dtoro/oikos/internal/db/sqlcgen" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/google/uuid" "github.com/jackc/pgx/v5" diff --git a/internal/httpapi/metrics.go b/internal/httpapi/metrics.go index 347cf53c..01eee7bd 100644 --- a/internal/httpapi/metrics.go +++ b/internal/httpapi/metrics.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/jackc/pgx/v5/pgtype" ) diff --git a/internal/httpapi/patterns.go b/internal/httpapi/patterns.go index 3f30acc4..4f75f2a0 100644 --- a/internal/httpapi/patterns.go +++ b/internal/httpapi/patterns.go @@ -5,7 +5,7 @@ import ( "fmt" "github.com/dtoro/oikos/internal/db/sqlcgen" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" "github.com/jackc/pgx/v5" diff --git a/internal/httpapi/problem.go b/internal/httpapi/problem.go index 5ac14a4b..8633bbfa 100644 --- a/internal/httpapi/problem.go +++ b/internal/httpapi/problem.go @@ -6,7 +6,7 @@ import ( "log/slog" "net/http" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" ) diff --git a/internal/httpapi/relationships.go b/internal/httpapi/relationships.go index 2d0f42e7..475cc9a6 100644 --- a/internal/httpapi/relationships.go +++ b/internal/httpapi/relationships.go @@ -8,7 +8,7 @@ import ( "time" "github.com/dtoro/oikos/internal/db/sqlcgen" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" ) diff --git a/internal/httpapi/signals.go b/internal/httpapi/signals.go index 8e202bb7..d95f5683 100644 --- a/internal/httpapi/signals.go +++ b/internal/httpapi/signals.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/jackc/pgx/v5" ) diff --git a/internal/httpapi/skills.go b/internal/httpapi/skills.go index 1c63291e..f147bc88 100644 --- a/internal/httpapi/skills.go +++ b/internal/httpapi/skills.go @@ -7,7 +7,7 @@ import ( "log/slog" "github.com/dtoro/oikos/internal/db/sqlcgen" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" "github.com/google/uuid" diff --git a/internal/ontology/preconditions_test.go b/internal/ontology/preconditions_test.go index 3bd92bbf..9ec91ed7 100644 --- a/internal/ontology/preconditions_test.go +++ b/internal/ontology/preconditions_test.go @@ -5,7 +5,7 @@ import ( "errors" "testing" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) diff --git a/internal/ontology/validate.go b/internal/ontology/validate.go index 963641a1..3b218bd2 100644 --- a/internal/ontology/validate.go +++ b/internal/ontology/validate.go @@ -10,7 +10,7 @@ import ( "context" "fmt" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" "github.com/jackc/pgx/v5/pgxpool" "github.com/google/uuid" ) diff --git a/internal/ontology/validate_test.go b/internal/ontology/validate_test.go index b8af0351..327fc912 100644 --- a/internal/ontology/validate_test.go +++ b/internal/ontology/validate_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" ) func fixtureTree() *TypeTree { diff --git a/internal/policy/classify.go b/internal/policy/classify.go index 3d57f3cd..caccd52b 100644 --- a/internal/policy/classify.go +++ b/internal/policy/classify.go @@ -7,7 +7,7 @@ import ( "encoding/json" "fmt" - "github.com/dtoro/oikos/internal/domain" + "github.com/dtoro/oikos/internal/core/domain" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) diff --git a/plans/2026-08-15-hexagonal-architecture.md b/plans/2026-08-15-hexagonal-architecture.md new file mode 100644 index 00000000..4e9cf542 --- /dev/null +++ b/plans/2026-08-15-hexagonal-architecture.md @@ -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. diff --git a/plans/index.md b/plans/index.md index d12259e5..e1c3af39 100644 --- a/plans/index.md +++ b/plans/index.md @@ -22,6 +22,7 @@ went sideways, open an investigation. | 2026-08-04 | [Hermes MCP client integration](done/2026-08-04-hermes-mcp-client-integration.md) | Done — deployed | | 2026-08-05 | [Agent execution safety: QEMU guest agent gate + host-mutation guard](done/2026-08-05-agent-execution-safety-qemu-guest-agent-gate.md) | Done — implemented (1b9c761) | | 2026-08-05 | [Backend evaluation: architecture, security, and reliability improvements](2026-08-05-backend-evaluation-improvements.md) | Done — all three phases (B, D, E) implemented as code (0.28.0–0.29.0), deployed, and hardened via review. Remaining: C (security) and F (performance) backlog. | +| 2026-08-15 | [Hexagonal architecture — design and phased refactor](2026-08-15-hexagonal-architecture.md) | In Progress — Phase 0 done (ADR 0016, core scaffold, domain moved, depguard) | ## Done