Files
oikos/internal/httpapi/approval_rules.go
dtoro e074f04bdf 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.
2026-08-15 22:09:19 +02:00

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
}