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:
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
|
||||
110
internal/core/domain/approval.go
Normal file
110
internal/core/domain/approval.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// Approval is a short-TTL signed grant for a gated action.
|
||||
type Approval struct {
|
||||
EntityID UUID
|
||||
SubjectEntityID UUID
|
||||
Action string
|
||||
RiskClass string
|
||||
Kind string
|
||||
Payload map[string]any
|
||||
Status string
|
||||
TokenHash string
|
||||
ExpiresAt time.Time
|
||||
DecidedAt *time.Time
|
||||
DecidedBy UUID
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Approval statuses.
|
||||
const (
|
||||
ApprovalPending = "pending"
|
||||
ApprovalApproved = "approved"
|
||||
ApprovalDenied = "denied"
|
||||
ApprovalExpired = "expired"
|
||||
ApprovalRevoked = "revoked"
|
||||
)
|
||||
|
||||
// Approval kinds.
|
||||
const (
|
||||
ApprovalKindExecution = "execution"
|
||||
ApprovalKindPolicyChange = "policy-change"
|
||||
ApprovalKindPatternActivation = "pattern-activation"
|
||||
)
|
||||
|
||||
// CheckDef defines a probe (R3-7: probes as data, not code).
|
||||
type CheckDef struct {
|
||||
EntityID UUID
|
||||
TargetID UUID
|
||||
TargetType string
|
||||
Kind string
|
||||
Config map[string]any
|
||||
IntervalS int
|
||||
TimeoutS int
|
||||
Zone string
|
||||
Enabled bool
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Check kinds.
|
||||
const (
|
||||
CheckHTTP = "http"
|
||||
CheckTCP = "tcp"
|
||||
CheckDisk = "disk"
|
||||
CheckCertExpiry = "cert-expiry"
|
||||
CheckDrift = "drift"
|
||||
CheckSSHScript = "ssh-script"
|
||||
)
|
||||
|
||||
// EntityStatus is the current health of an entity (R3-6: replaces state_snapshots).
|
||||
type EntityStatus struct {
|
||||
EntityID UUID
|
||||
Health string
|
||||
LastCheckAt *time.Time
|
||||
Details map[string]any
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Health values.
|
||||
const (
|
||||
HealthHealthy = "healthy"
|
||||
HealthDegraded = "degraded"
|
||||
HealthDown = "down"
|
||||
HealthUnknown = "unknown"
|
||||
)
|
||||
|
||||
// RiskClass is the four-level safety model.
|
||||
type RiskClass struct {
|
||||
Name string
|
||||
Description string
|
||||
ApprovalRequired string
|
||||
AutonomyAllowed bool
|
||||
}
|
||||
|
||||
// Risk class names.
|
||||
const (
|
||||
RiskReadOnly = "read_only"
|
||||
RiskReversibleLow = "reversible_low"
|
||||
RiskConfigMutation = "config_mutation"
|
||||
RiskDestructive = "destructive"
|
||||
)
|
||||
|
||||
// ApprovalRule maps (entity_type, action) → risk_class + autonomy.
|
||||
type ApprovalRule struct {
|
||||
ID UUID
|
||||
EntityType string
|
||||
Action string
|
||||
RiskClass string
|
||||
AutonomyLevel string
|
||||
ScopeEntity UUID
|
||||
Version int
|
||||
}
|
||||
|
||||
// Autonomy levels.
|
||||
const (
|
||||
AutonomyAuto = "auto"
|
||||
AutonomyEscalate = "escalate"
|
||||
AutonomyNever = "never"
|
||||
)
|
||||
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
|
||||
139
internal/core/domain/domain_test.go
Normal file
139
internal/core/domain/domain_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsNil(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
u UUID
|
||||
want bool
|
||||
}{
|
||||
{"empty string", UUID(""), true},
|
||||
{"single char", UUID("x"), false},
|
||||
{"uuid string", UUID("550e8400-e29b-41d4-a716-446655440000"), false},
|
||||
{"nil literal", UUID(""), true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := c.u.IsNil()
|
||||
if got != c.want {
|
||||
t.Errorf("UUID(%q).IsNil() = %v, want %v", c.u, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanTransition(t *testing.T) {
|
||||
type tc struct {
|
||||
name string
|
||||
from string
|
||||
to string
|
||||
want bool
|
||||
}
|
||||
var cases []tc
|
||||
|
||||
for from, targets := range ValidSignalTransitions {
|
||||
for _, to := range targets {
|
||||
cases = append(cases, tc{from + "->" + to, from, to, true})
|
||||
}
|
||||
}
|
||||
|
||||
disallowed := []tc{
|
||||
{"raised->raised", SignalRaised, SignalRaised, false},
|
||||
{"resolved->raised", SignalResolved, SignalRaised, false},
|
||||
{"failed->raised", SignalFailed, SignalRaised, false},
|
||||
{"acknowledged->raised", SignalAcknowledged, SignalRaised, false},
|
||||
{"muted->resolved", SignalMuted, SignalResolved, false},
|
||||
{"acting->acknowledged", SignalActing, SignalAcknowledged, false},
|
||||
}
|
||||
cases = append(cases, disallowed...)
|
||||
|
||||
cases = append(cases,
|
||||
tc{"unknown source", "nonexistent", SignalRaised, false},
|
||||
tc{"unknown target", SignalRaised, "nonexistent", false},
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
s := &Signal{State: c.from}
|
||||
got := s.CanTransition(c.to)
|
||||
if got != c.want {
|
||||
t.Errorf("CanTransition(%q -> %q) = %v, want %v", c.from, c.to, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSentinelErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
msg string
|
||||
}{
|
||||
{"ErrNotFound", ErrNotFound, "entity not found"},
|
||||
{"ErrInvalidTransition", ErrInvalidTransition, "invalid lifecycle transition"},
|
||||
{"ErrApprovalRequired", ErrApprovalRequired, "operator approval required"},
|
||||
{"ErrAutonomyBlocked", ErrAutonomyBlocked, "autonomy policy blocks this action"},
|
||||
{"ErrConflict", ErrConflict, "concurrent modification conflict"},
|
||||
{"ErrCircuitOpen", ErrCircuitOpen, "circuit breaker open for target"},
|
||||
{"ErrAbstractType", ErrAbstractType, "cannot instantiate abstract entity type"},
|
||||
{"ErrInvalidEdge", ErrInvalidEdge, "relationship endpoint type mismatch"},
|
||||
{"ErrCardinality", ErrCardinality, "relationship cardinality violation"},
|
||||
{"ErrSeedHashMismatch", ErrSeedHashMismatch, "seed content hash mismatch"},
|
||||
{"ErrAlreadyExists", ErrAlreadyExists, "entity already exists"},
|
||||
{"ErrQuarantined", ErrQuarantined, "pattern is quarantined"},
|
||||
{"ErrSkillDeprecated", ErrSkillDeprecated, "skill is deprecated"},
|
||||
{"ErrInvalidInput", ErrInvalidInput, "invalid input"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if c.err == nil {
|
||||
t.Fatal("sentinel error is nil")
|
||||
}
|
||||
if !errors.Is(c.err, c.err) {
|
||||
t.Errorf("errors.Is failed for %s", c.name)
|
||||
}
|
||||
if c.err.Error() != c.msg {
|
||||
t.Errorf("Error() = %q, want %q", c.err.Error(), c.msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalTransitionsComplete(t *testing.T) {
|
||||
// Non-terminal states must be keys in ValidSignalTransitions.
|
||||
// SignalResolved is a terminal state (no outgoing transitions) and is
|
||||
// intentionally absent from the map.
|
||||
nonTerminal := []string{
|
||||
SignalRaised,
|
||||
SignalAcknowledged,
|
||||
SignalActing,
|
||||
SignalMuted,
|
||||
SignalFailed,
|
||||
}
|
||||
for _, state := range nonTerminal {
|
||||
targets, ok := ValidSignalTransitions[state]
|
||||
if !ok {
|
||||
t.Errorf("non-terminal state %q missing from ValidSignalTransitions", state)
|
||||
continue
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
t.Errorf("state %q maps to empty transition list", state)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolved is terminal: it should not appear as a source key.
|
||||
if _, ok := ValidSignalTransitions[SignalResolved]; ok {
|
||||
t.Errorf("terminal state %q should not have outgoing transitions", SignalResolved)
|
||||
}
|
||||
|
||||
// No state anywhere in the map may map to nil/empty.
|
||||
for state, targets := range ValidSignalTransitions {
|
||||
if len(targets) == 0 {
|
||||
t.Errorf("state %q maps to empty/nil transition list", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
76
internal/core/domain/entity.go
Normal file
76
internal/core/domain/entity.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Entity is the core graph node — every object in the OS is an entity.
|
||||
// Typed tables (signals, executions, etc.) reference entities(id) for
|
||||
// indexed querying; graph edges live in the relationships table.
|
||||
type Entity struct {
|
||||
ID UUID
|
||||
Slug string
|
||||
Type string
|
||||
Name string
|
||||
State string
|
||||
Attributes map[string]any
|
||||
MaintenanceUntil *time.Time
|
||||
Version int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// EntityType is the meta-schema entry defining what entities can exist.
|
||||
type EntityType struct {
|
||||
Name string
|
||||
ParentType string
|
||||
IsAbstract bool
|
||||
Domain string
|
||||
Layer string
|
||||
Description string
|
||||
LifecycleID string
|
||||
AttributeSchema map[string]any
|
||||
SchemaVersion int
|
||||
Status string
|
||||
}
|
||||
|
||||
// RelationshipType defines a typed edge between entity types.
|
||||
type RelationshipType struct {
|
||||
Name string
|
||||
Inverse string
|
||||
SourceType string
|
||||
TargetType string
|
||||
Cardinality string
|
||||
Description string
|
||||
}
|
||||
|
||||
// LifecycleDef is the state machine for an entity type.
|
||||
type LifecycleDef struct {
|
||||
ID string
|
||||
States []string
|
||||
DefaultState string
|
||||
TerminalStates []string
|
||||
Transitions map[string]map[string]TransitionReq
|
||||
}
|
||||
|
||||
// TransitionReq holds the named preconditions for a lifecycle transition.
|
||||
type TransitionReq struct {
|
||||
Requires []string `json:"requires"`
|
||||
}
|
||||
|
||||
// Relationship is a typed edge between two entities.
|
||||
type Relationship struct {
|
||||
SourceID UUID
|
||||
TargetID UUID
|
||||
Type string
|
||||
Attributes map[string]any
|
||||
ValidFrom time.Time
|
||||
ValidTo *time.Time
|
||||
}
|
||||
|
||||
// UUID is a type alias for UUID values. Using string for simplicity;
|
||||
// the DB layer uses pgx's UUID type. Conversion happens at the boundary.
|
||||
type UUID string
|
||||
|
||||
// IsNil returns true if the UUID is empty.
|
||||
func (u UUID) IsNil() bool { return u == "" }
|
||||
22
internal/core/domain/errors.go
Normal file
22
internal/core/domain/errors.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors. Used throughout the codebase for typed error handling.
|
||||
// The API middleware maps these to HTTP status codes (SG11).
|
||||
var (
|
||||
ErrNotFound = errors.New("entity not found")
|
||||
ErrInvalidTransition = errors.New("invalid lifecycle transition")
|
||||
ErrApprovalRequired = errors.New("operator approval required")
|
||||
ErrAutonomyBlocked = errors.New("autonomy policy blocks this action")
|
||||
ErrConflict = errors.New("concurrent modification conflict")
|
||||
ErrCircuitOpen = errors.New("circuit breaker open for target")
|
||||
ErrAbstractType = errors.New("cannot instantiate abstract entity type")
|
||||
ErrInvalidEdge = errors.New("relationship endpoint type mismatch")
|
||||
ErrCardinality = errors.New("relationship cardinality violation")
|
||||
ErrSeedHashMismatch = errors.New("seed content hash mismatch")
|
||||
ErrAlreadyExists = errors.New("entity already exists")
|
||||
ErrQuarantined = errors.New("pattern is quarantined")
|
||||
ErrSkillDeprecated = errors.New("skill is deprecated")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
)
|
||||
64
internal/core/domain/execution.go
Normal file
64
internal/core/domain/execution.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// Classification persists every autonomous decision the classifier makes (SA5).
|
||||
// This is the audit trail for "why did the OS auto-act / escalate?"
|
||||
type Classification struct {
|
||||
EntityID UUID
|
||||
SignalEntityID UUID
|
||||
TargetEntityID UUID
|
||||
Action string
|
||||
RecommendedAction map[string]any
|
||||
RiskClass string
|
||||
Route string
|
||||
BlastRadius []UUID
|
||||
PatternConfidence float64
|
||||
SkillID UUID
|
||||
AutonomyCheck string
|
||||
Reasoning map[string]any
|
||||
CorrelationID string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Classification routes.
|
||||
const (
|
||||
RouteAutoAct = "auto-act"
|
||||
RouteEscalate = "escalate"
|
||||
RouteHold = "hold"
|
||||
)
|
||||
|
||||
// Execution is a detailed record of one action the OS performed.
|
||||
type Execution struct {
|
||||
EntityID UUID
|
||||
ClassificationID UUID
|
||||
SignalEntityID UUID
|
||||
TargetEntityID UUID
|
||||
Action string
|
||||
RiskClass string
|
||||
ApprovalID UUID
|
||||
AgentID UUID
|
||||
SkillID UUID
|
||||
SkillVersion int
|
||||
Status string
|
||||
Result map[string]any
|
||||
DurationMs int
|
||||
Verified bool
|
||||
CorrelationID string
|
||||
StartedAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Execution lifecycle states.
|
||||
const (
|
||||
ExecProposed = "proposed"
|
||||
ExecApproved = "approved"
|
||||
ExecExecuting = "executing"
|
||||
ExecVerified = "verified"
|
||||
ExecFailed = "failed"
|
||||
ExecTimedOut = "timed-out"
|
||||
ExecRolledBack = "rolled-back"
|
||||
ExecCancelled = "cancelled"
|
||||
ExecExpired = "expired"
|
||||
)
|
||||
76
internal/core/domain/pattern.go
Normal file
76
internal/core/domain/pattern.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// Feedback records what was learned from an execution.
|
||||
type Feedback struct {
|
||||
EntityID UUID
|
||||
ExecutionID UUID
|
||||
Outcome string
|
||||
Observation string
|
||||
Lesson string
|
||||
UnexpectedSideEffects []string
|
||||
Tags []string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Feedback outcomes.
|
||||
const (
|
||||
OutcomeSuccess = "success"
|
||||
OutcomeFailure = "failure"
|
||||
OutcomePartial = "partial"
|
||||
OutcomeUnexpected = "unexpected"
|
||||
)
|
||||
|
||||
// Pattern is a generalized rule extracted from accumulated feedback.
|
||||
type Pattern struct {
|
||||
EntityID UUID
|
||||
AppliesType string
|
||||
Action string
|
||||
Pattern string
|
||||
Confidence float64
|
||||
EvidenceCount int
|
||||
SuccessCount int
|
||||
FailureCount int
|
||||
Status string
|
||||
Quarantined bool
|
||||
Version int
|
||||
LastValidatedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Pattern lifecycle states.
|
||||
const (
|
||||
PatternHypothesized = "hypothesized"
|
||||
PatternValidated = "validated"
|
||||
PatternActive = "active"
|
||||
PatternDeprecated = "deprecated"
|
||||
PatternInvalidated = "invalidated"
|
||||
)
|
||||
|
||||
// Skill is a codified procedure refined through validated patterns.
|
||||
type Skill struct {
|
||||
EntityID UUID
|
||||
Version int
|
||||
Name string
|
||||
Procedure map[string]any
|
||||
AppliesType string
|
||||
Action string
|
||||
PatternIDs []UUID
|
||||
Status string
|
||||
SuccessRate float64
|
||||
ChangedBy UUID
|
||||
ChangeReason string
|
||||
LastUsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Skill lifecycle states.
|
||||
const (
|
||||
SkillDrafted = "drafted"
|
||||
SkillTested = "tested"
|
||||
SkillActive = "active"
|
||||
SkillRefined = "refined"
|
||||
SkillDeprecated = "deprecated"
|
||||
SkillFailed = "failed"
|
||||
)
|
||||
65
internal/core/domain/signal.go
Normal file
65
internal/core/domain/signal.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// Signal is an attention record — something the lab noticed that needs
|
||||
// attention and possibly action. Dual entity: has an entities row + a
|
||||
// signals table row for indexed querying.
|
||||
type Signal struct {
|
||||
EntityID UUID
|
||||
Kind string
|
||||
Severity string
|
||||
TargetEntityID UUID
|
||||
CheckID UUID
|
||||
Evidence string
|
||||
LikelyCause string
|
||||
State string
|
||||
OccurrenceCount int
|
||||
FirstSeenAt time.Time
|
||||
LastSeenAt time.Time
|
||||
FlapCount int
|
||||
HoldDownUntil *time.Time
|
||||
MuteUntil *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Signal lifecycle states (see lifecycle_defs in seeds/ontology.yaml).
|
||||
const (
|
||||
SignalRaised = "raised"
|
||||
SignalAcknowledged = "acknowledged"
|
||||
SignalActing = "acting"
|
||||
SignalMuted = "muted"
|
||||
SignalResolved = "resolved"
|
||||
SignalFailed = "failed"
|
||||
)
|
||||
|
||||
// Signal severities.
|
||||
const (
|
||||
SeverityInfo = "info"
|
||||
SeverityWarning = "warning"
|
||||
SeverityCritical = "critical"
|
||||
)
|
||||
|
||||
// ValidSignalTransitions defines legal state transitions.
|
||||
var ValidSignalTransitions = map[string][]string{
|
||||
SignalRaised: {SignalAcknowledged, SignalMuted, SignalResolved},
|
||||
SignalAcknowledged: {SignalActing, SignalResolved, SignalMuted},
|
||||
SignalActing: {SignalResolved, SignalRaised, SignalFailed},
|
||||
SignalFailed: {SignalAcknowledged},
|
||||
SignalMuted: {SignalRaised},
|
||||
}
|
||||
|
||||
// CanTransition returns true if from→to is a legal signal state transition.
|
||||
func (s *Signal) CanTransition(to string) bool {
|
||||
allowed, ok := ValidSignalTransitions[s.State]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, a := range allowed {
|
||||
if a == to {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
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
|
||||
Reference in New Issue
Block a user