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.
163 lines
4.6 KiB
Go
163 lines
4.6 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
|
"github.com/dtoro/oikos/internal/core/domain"
|
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
|
"github.com/dtoro/oikos/internal/observability"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// ─── Approval Rules (Policy) ───────────────────────────────────────────
|
|
|
|
func (s *Server) ListApprovalRules(ctx context.Context, req gen.ListApprovalRulesRequestObject) (gen.ListApprovalRulesResponseObject, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id, entity_type, action, risk_class, autonomy_level,
|
|
COALESCE((SELECT slug FROM entities WHERE id = scope_entity), ''),
|
|
version, updated_at
|
|
FROM approval_rules ORDER BY entity_type, action`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := []gen.ApprovalRule{}
|
|
for rows.Next() {
|
|
var rule gen.ApprovalRule
|
|
var scopeSlug string
|
|
if err := rows.Scan(&rule.Id, &rule.EntityType, &rule.Action,
|
|
&rule.RiskClass, &rule.AutonomyLevel, &scopeSlug,
|
|
&rule.Version); err != nil {
|
|
return nil, err
|
|
}
|
|
if scopeSlug != "" {
|
|
rule.ScopeEntity = &scopeSlug
|
|
}
|
|
items = append(items, rule)
|
|
}
|
|
if rows.Err() != nil {
|
|
return nil, rows.Err()
|
|
}
|
|
if items == nil {
|
|
items = []gen.ApprovalRule{}
|
|
}
|
|
return gen.ListApprovalRules200JSONResponse{Items: items}, nil
|
|
}
|
|
|
|
func (s *Server) CreateApprovalRule(ctx context.Context, req gen.CreateApprovalRuleRequestObject) (gen.CreateApprovalRuleResponseObject, error) {
|
|
if req.Body == nil {
|
|
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
|
}
|
|
|
|
id, err := uuid.NewV7()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var scopeEntity *uuid.UUID
|
|
if req.Body.ScopeEntity != nil && *req.Body.ScopeEntity != "" {
|
|
se, rerr := s.resolveEntityID(ctx, *req.Body.ScopeEntity)
|
|
if rerr != nil {
|
|
return nil, rerr
|
|
}
|
|
scopeEntity = &se
|
|
}
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO approval_rules (id, entity_type, action, risk_class, autonomy_level, scope_entity)
|
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
id, req.Body.EntityType, req.Body.Action, req.Body.RiskClass,
|
|
string(req.Body.AutonomyLevel), scopeEntity)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
|
|
return nil, fmt.Errorf("%w: rule for %s/%s already exists", domain.ErrAlreadyExists,
|
|
coalesceStr(req.Body.EntityType, "*"), req.Body.Action)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
actorType, actor := actorInfo(ctx)
|
|
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
|
|
&id, "POST", "/api/v1/policy/approval-rules", "",
|
|
nil,
|
|
map[string]any{"action": req.Body.Action, "risk_class": req.Body.RiskClass}); auditErr != nil {
|
|
return nil, auditErr
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Return 202 pending approval (dual-control).
|
|
return gen.CreateApprovalRule202JSONResponse{}, nil
|
|
}
|
|
|
|
func (s *Server) PatchApprovalRule(ctx context.Context, req gen.PatchApprovalRuleRequestObject) (gen.PatchApprovalRuleResponseObject, error) {
|
|
if req.Body == nil {
|
|
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
|
|
}
|
|
|
|
id, err := s.resolveEntityID(ctx, req.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var scopeEntity *uuid.UUID
|
|
if req.Body.ScopeEntity != nil && *req.Body.ScopeEntity != "" {
|
|
se, rerr := s.resolveEntityID(ctx, *req.Body.ScopeEntity)
|
|
if rerr != nil {
|
|
return nil, rerr
|
|
}
|
|
scopeEntity = &se
|
|
}
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
result, err := tx.Exec(ctx, `
|
|
UPDATE approval_rules
|
|
SET entity_type = COALESCE($2, entity_type),
|
|
action = COALESCE($3, action),
|
|
risk_class = COALESCE($4, risk_class),
|
|
autonomy_level = COALESCE($5, autonomy_level),
|
|
scope_entity = COALESCE($6, scope_entity),
|
|
version = version + 1,
|
|
updated_at = now()
|
|
WHERE id = $1`,
|
|
id, req.Body.EntityType, req.Body.Action, req.Body.RiskClass,
|
|
string(req.Body.AutonomyLevel), scopeEntity)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if result.RowsAffected() == 0 {
|
|
return nil, fmt.Errorf("%w: approval rule %s", domain.ErrNotFound, req.Id)
|
|
}
|
|
|
|
actorType, actor := actorInfo(ctx)
|
|
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "patch",
|
|
&id, "PATCH", "/api/v1/policy/approval-rules/"+req.Id, "",
|
|
nil,
|
|
map[string]any{"action": req.Body.Action}); auditErr != nil {
|
|
return nil, auditErr
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return gen.PatchApprovalRule202JSONResponse{}, nil
|
|
}
|