feat: Phase 0 of hexagonal refactor — ADR 0016, core scaffold, depguard rules
Problem: the hexagonal-architecture plan (plans/2026-08-15-hexagonal-
architecture.md) needs its foundation — an accepted ADR, the target
directory tree, and machine-checked dependency rules — before any
service extraction starts. Also folds the four outstanding review
findings (F3.1/F5/F6/F7) into the plan: ObservationService owns the
bounded probe-concurrency contract (scheduler.go:133), Phase 9 gates
ExecutionService+PolicyService ≥ 90% with a gating-matrix test,
per-phase abort criteria, and the §3.2 internal/config note.
Change:
- docs/adr/0016-hexagonal-ports-adapters.md records context, decision,
and consequences of the ports & adapters migration.
- internal/domain → internal/core/domain (mechanical import rewrite,
20 files), new internal/core/{ports,app}, internal/adapters trees
with package docs.
- .golangci.yml: depguard rules for §3.1 (core purity, no agent-client
tech in core, nomos isolation — the nomos rules self-activate when
internal/nomos exists in Phase 8). Config migrated to golangci-lint
v2 format so it loads at all (the v1 config errored under v2, masked
by CI's advisory continue-on-error). Verified depguard fires on a
planted openai-go import in internal/core/app.
- CONTRIBUTING.md layout section now shows the core/adapters tree.
Risk: import path churn is mechanical and tests pass unchanged; the
lint config migration surfaces the pre-existing 400-issue baseline
(advisory in CI, unchanged policy) — new/moved packages lint clean.
Verification: go vet ./..., make test (race, core/domain at 100%
coverage), make generate-check, golangci-lint on internal/core/... and
internal/adapters/... — 0 issues; depguard violation probe confirmed.
This commit is contained in:
100
.golangci.yml
100
.golangci.yml
@@ -1,41 +1,89 @@
|
|||||||
# golangci-lint configuration for Oikos
|
# golangci-lint v2 configuration for Oikos
|
||||||
# Docs: https://golangci-lint.run/usage/configuration/
|
# 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:
|
run:
|
||||||
timeout: 5m
|
|
||||||
tests: true
|
tests: true
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
- govet # go vet
|
- depguard
|
||||||
- staticcheck # advanced static analysis
|
- misspell
|
||||||
- ineffassign # detect ineffectual assignments
|
- revive
|
||||||
- unused # find unused identifiers
|
settings:
|
||||||
- errcheck # check for unchecked errors
|
depguard:
|
||||||
- gosimple # simplifications
|
# ADR 0016 dependency rules. Rules only constrain files that exist:
|
||||||
- typecheck # standard type checking
|
# internal/core is live since Phase 0 (domain moved); internal/nomos
|
||||||
- misspell # find commonly misspelled English words in comments
|
# and its bans activate in Phase 8; full audit at Phase 9.
|
||||||
- revive # fast, configurable linter (replaces golint)
|
rules:
|
||||||
|
core-no-agent-tech:
|
||||||
linters-settings:
|
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:
|
errcheck:
|
||||||
# Allow unchecked errors on common Close/Flush patterns (deferred cleanup)
|
# Allow unchecked errors on common Close/Flush patterns (deferred cleanup)
|
||||||
exclude-functions:
|
exclude-functions:
|
||||||
- (io.Closer).Close
|
- (io.Closer).Close
|
||||||
- (*os.File).Close
|
- (*os.File).Close
|
||||||
|
exclusions:
|
||||||
issues:
|
generated: lax
|
||||||
# Exclude generated code
|
rules:
|
||||||
exclude-rules:
|
- linters:
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- errcheck
|
- errcheck
|
||||||
- path: internal/httpapi/gen/
|
path: _test\.go
|
||||||
linters:
|
- linters:
|
||||||
- all
|
- all
|
||||||
- path: internal/db/sqlcgen/
|
path: internal/httpapi/gen/
|
||||||
linters:
|
- linters:
|
||||||
- all
|
- all
|
||||||
# Don't auto-exclude common patterns
|
path: internal/db/sqlcgen/
|
||||||
exclude-use-default: false
|
paths:
|
||||||
|
- third_party$
|
||||||
|
- builtin$
|
||||||
|
- examples$
|
||||||
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
|
formatters:
|
||||||
|
exclusions:
|
||||||
|
generated: lax
|
||||||
|
paths:
|
||||||
|
- third_party$
|
||||||
|
- builtin$
|
||||||
|
- examples$
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ cmd/oikos/ Single-binary entry point
|
|||||||
cmd/nomos/ Nomos MCP client gateway
|
cmd/nomos/ Nomos MCP client gateway
|
||||||
cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
|
cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
|
||||||
internal/ All Go packages
|
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)
|
httpapi/ REST + MCP server (OpenAPI-generated)
|
||||||
mcp/ MCP tool implementations
|
mcp/ MCP tool implementations
|
||||||
db/ Connection pool, migrations, seeds, sqlc queries
|
db/ Connection pool, migrations, seeds, sqlc queries
|
||||||
@@ -76,7 +85,6 @@ internal/ All Go packages
|
|||||||
learning/ Pattern recognition, anomaly detection
|
learning/ Pattern recognition, anomaly detection
|
||||||
policy/ Risk classifier
|
policy/ Risk classifier
|
||||||
secrets/ Infisical + SOPS backend
|
secrets/ Infisical + SOPS backend
|
||||||
domain/ Core types: entities, approvals, signals, patterns
|
|
||||||
ontology/ Type hierarchy, relationship validation
|
ontology/ Type hierarchy, relationship validation
|
||||||
knowledge/ Knowledge YAML seed ingestion
|
knowledge/ Knowledge YAML seed ingestion
|
||||||
web/ Control-room SPA (Svelte 5) — standalone, not embedded
|
web/ Control-room SPA (Svelte 5) — standalone, not embedded
|
||||||
|
|||||||
69
docs/adr/0016-hexagonal-ports-adapters.md
Normal file
69
docs/adr/0016-hexagonal-ports-adapters.md
Normal file
@@ -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.
|
||||||
5
internal/adapters/doc.go
Normal file
5
internal/adapters/doc.go
Normal file
@@ -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
|
||||||
5
internal/core/app/doc.go
Normal file
5
internal/core/app/doc.go
Normal file
@@ -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
|
||||||
4
internal/core/domain/doc.go
Normal file
4
internal/core/domain/doc.go
Normal file
@@ -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
|
||||||
5
internal/core/ports/doc.go
Normal file
5
internal/core/ports/doc.go
Normal file
@@ -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
|
||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/domain"
|
"github.com/dtoro/oikos/internal/core/domain"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"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/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"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/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/dtoro/oikos/internal/safego"
|
"github.com/dtoro/oikos/internal/safego"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"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/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"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/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"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/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/db"
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"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/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"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/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"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/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"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/httpapi/gen"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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/httpapi/gen"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"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/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"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/httpapi/gen"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"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/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"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/httpapi/gen"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"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/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/domain"
|
"github.com/dtoro/oikos/internal/core/domain"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/domain"
|
"github.com/dtoro/oikos/internal/core/domain"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/domain"
|
"github.com/dtoro/oikos/internal/core/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func fixtureTree() *TypeTree {
|
func fixtureTree() *TypeTree {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/domain"
|
"github.com/dtoro/oikos/internal/core/domain"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|||||||
702
plans/2026-08-15-hexagonal-architecture.md
Normal file
702
plans/2026-08-15-hexagonal-architecture.md
Normal file
@@ -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.
|
||||||
@@ -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-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 | [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-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
|
## Done
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user