Files
oikos/internal/core/app/entities.go
dtoro 9e3783734e
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
feat: Phase 3b — EntityService, postgres EntityRepository, converged mutations
Problem: entity mutations (create/update/state) existed as three drifted
copies — HTTP CreateEntity/PatchEntity, MCP create_entity/
update_entity_attributes/set_entity_state — each with its own inline
SQL, its own validation subset (MCP validated lifecycle states, HTTP
did not; HTTP patched attributes without regenerating derived checks,
MCP did; MCP wrote no audit trail), the exact drift ADR 0016's first
vertical slice exists to collapse.

Change:
- internal/core/ports: DerivedCheck, Idempotency (adapter-owned request
  hash + cached-body renderer so the replay record commits in the
  create's transaction), IdempotentResponse + GetIdempotent read,
  AuditEntry gains Method/Path/CorrelationID, Event gains
  CorrelationID; EntityUpdateInput carries ExpectedVersion +
  RederiveChecks (derivation for updates runs repo-side: the graph
  host fallback reads relationships through the open tx).
- internal/adapters/postgres/repositories.go: EntityRepo (Create/
  Update/SetState/reads/idempotency) preserving the load-bearing
  check-then-act invariants in-tx: version WHERE-clause, declared
  transitions + preconditions (ValidateTransition), duplicate-slug
  mapping, audit/event/writeCheck all inside one BEGIN…COMMIT.
  OntologyRepo: TTL-cached OntologyStore.
- internal/core/app/entities.go: EntityService — ontology validation
  (type exists, concrete, state declared — the stricter MCP rule now
  governs both surfaces), default-state resolution, id generation,
  derivation for creates, audit/event construction, idempotency
  pass-through.
- httpapi CreateEntity/PatchEntity rewired to the service; PATCH now
  regenerates derived checks (the A2 parity gap). MCP create/update/
  set-state tools call the same service — and now write audit + event
  rows like the HTTP surface always did.
- Integration-test seed paths fixed for the adapters/postgres package
  depth (../../../seeds).

Pre-existing failures documented: TestAPIEndToEnd (entity_types 60 vs
59; 501-endpoint now 200), TestClientLifecycleEndToEnd, TestPhase3*
rows — verified failing identically at ec11956 (scratch approval-
notifier commit test drift), unrelated to this change. All mutation
integration tests (create/patch/idempotency/audit/regeneration) pass.

Verification: make test-db (postgres + mcp green, httpapi green except
the pre-existing set), full non-DB suite (19 pkgs), golangci on new
packages — 0 issues.
2026-08-15 23:38:21 +02:00

263 lines
8.4 KiB
Go

package app
import (
"context"
"encoding/json"
"time"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/google/uuid"
)
// EntityService owns the entity aggregate's use-cases: create with derived
// checks, update with optimistic versioning and check regeneration,
// lifecycle transitions. Validation against the (cached) ontology happens
// here, in the pure half; transactional check-then-act invariants
// (version, declared transitions, preconditions) run in the repository
// (plan §3.6).
type EntityService struct {
entities ports.EntityRepository
onto ports.OntologyStore
}
// NewEntityService wires the service.
func NewEntityService(entities ports.EntityRepository, onto ports.OntologyStore) *EntityService {
return &EntityService{entities: entities, onto: onto}
}
// CreateEntityCmd is one entity creation. Actor identifies the calling
// surface for the audit trail ("operator:<label>", "agent:mcp").
type CreateEntityCmd struct {
Slug string
Type string
Name string
State string // "" → lifecycle default
Attributes map[string]any
ActorType string
Actor string
Method string // audit context, e.g. "POST" / "TOOL"
Path string // audit context, e.g. "/api/v1/entities" / "create_entity"
// Idempotency, when set, replays-protects the create. The adapter owns
// the request hash and the cached-body renderer (its wire shape); the
// repository stores the record in the create's transaction.
Idempotency *ports.Idempotency
}
// Create validates the type (exists, concrete, state declared), derives
// default checks, and commits entity + checks + audit + event in one
// transaction.
func (s *EntityService) Create(ctx context.Context, cmd CreateEntityCmd) (domain.Entity, DeriveResult, error) {
var res DeriveResult
tree, err := s.onto.LoadTypeTree(ctx)
if err != nil {
return domain.Entity{}, res, err
}
slug := cmd.Slug
if slug == "" {
slug = cmd.Type + ":" + cmd.Name
}
// Caller-supplied states are validated against the lifecycle's declared
// states — a create bypass of lifecycle guardrails would let an agent
// create in a terminal state without satisfying the preconditions that
// SetState enforces for the same transition. Both surfaces (REST, MCP)
// now share this rule.
state := cmd.State
if state == "" {
state = tree.DefaultState(cmd.Type)
}
if err := tree.ValidateEntity(cmd.Type, state); err != nil {
return domain.Entity{}, res, err
}
id, err := uuid.NewV7()
if err != nil {
return domain.Entity{}, res, err
}
e := domain.Entity{
ID: domain.UUID(id.String()),
Slug: slug,
Type: cmd.Type,
Name: cmd.Name,
State: state,
Attributes: cmd.Attributes,
}
// Derivation for a create has no graph fallback available — a new entity
// has no edges yet. A type whose address comes from its host (a service)
// produces no checks on this pass; the gap is deliberate and visible
// (coverage sweep), and the next mutation or ingest fills it once the
// hosting edge exists.
attrsJSON, _ := json.Marshal(cmd.Attributes)
defs, dres := Derive(tree, CheckTarget{
ID: string(e.ID), Slug: slug, Type: cmd.Type, Name: cmd.Name, Attrs: attrsJSON,
}, nil)
// Derive is pure and cannot count writes; every derived def is ensured
// by the repository in the create's transaction (a write failure aborts
// the whole create), so the ensured count is the derived count.
dres.Created = len(defs)
res = dres
derived := make([]ports.DerivedCheck, len(defs))
for i, d := range defs {
derived[i] = ports.DerivedCheck{Kind: d.Kind, Config: d.Config, IntervalS: d.IntervalS}
}
input := ports.EntityCreateInput{
Entity: e,
DerivedChecks: derived,
Audit: []ports.AuditEntry{{
ActorType: cmd.ActorType, ActorLabel: cmd.Actor, Action: "create",
EntityID: e.ID, Method: cmd.Method, Path: cmd.Path,
Details: map[string]any{"type": cmd.Type, "slug": slug},
}},
Event: &ports.Event{
Type: "entity.created", Severity: "info", Source: "oikos-api", EntityID: e.ID,
Data: map[string]any{"slug": slug, "type": cmd.Type},
},
}
input.Idempotency = cmd.Idempotency
created, err := s.entities.Create(ctx, input)
if err != nil {
return domain.Entity{}, res, err
}
return created, res, nil
}
// UpdateEntityCmd mutates an entity. Attributes are REPLACED when
// AttrsReplace is true and shallow-merged otherwise. SetState semantics
// ride along: a State change is lifecycle-validated.
type UpdateEntityCmd struct {
SlugOrID string
ExpectedVer int
Name string
State string
Attributes map[string]any
AttrsReplace bool
Maintenance *time.Time
SetMaint bool
// RederiveChecks regenerates default checks from the post-update
// attributes (a `monitoring` attribute change re-wires probes).
RederiveChecks bool
ActorType string
Actor string
Method string
Path string
}
// Update loads the current entity, validates the target state against the
// ontology, and commits the mutation atomically. The optimistic-version and
// transition-precondition checks run inside the repository's transaction.
// When RederiveChecks is set the returned DeriveResult summarizes the
// derivation the transaction applied (for surface messages).
func (s *EntityService) Update(ctx context.Context, cmd UpdateEntityCmd) (domain.Entity, DeriveResult, error) {
var dres DeriveResult
current, err := s.resolve(ctx, cmd.SlugOrID)
if err != nil {
return domain.Entity{}, dres, err
}
tree, err := s.onto.LoadTypeTree(ctx)
if err != nil {
return domain.Entity{}, dres, err
}
if cmd.State != "" && cmd.State != current.State {
if err := tree.ValidateEntity(current.Type, cmd.State); err != nil {
return domain.Entity{}, dres, err
}
}
e := domain.Entity{
ID: current.ID,
Slug: current.Slug,
Type: current.Type,
Name: cmd.Name,
State: cmd.State,
Version: current.Version,
}
switch {
case cmd.Attributes == nil:
e.Attributes = nil // keep current
case cmd.AttrsReplace:
e.Attributes = cmd.Attributes
default:
merged := map[string]any{}
for k, v := range current.Attributes {
merged[k] = v
}
for k, v := range cmd.Attributes {
merged[k] = v
}
e.Attributes = merged
}
if cmd.SetMaint {
e.MaintenanceUntil = cmd.Maintenance
}
updated, err := s.entities.Update(ctx, ports.EntityUpdateInput{
Entity: e,
ExpectedVersion: cmd.ExpectedVer,
RederiveChecks: cmd.RederiveChecks,
Audit: []ports.AuditEntry{{
ActorType: cmd.ActorType, ActorLabel: cmd.Actor, Action: "patch",
EntityID: current.ID, Method: cmd.Method, Path: cmd.Path,
Details: map[string]any{"version": current.Version},
}},
Event: &ports.Event{
Type: "entity.updated", Severity: "info", Source: "oikos-api", EntityID: current.ID,
Data: map[string]any{"slug": current.Slug, "type": current.Type},
},
})
if err != nil {
return domain.Entity{}, dres, err
}
if cmd.RederiveChecks {
attrsJSON, _ := json.Marshal(updated.Attributes)
defs, d := Derive(tree, CheckTarget{
ID: string(updated.ID), Slug: updated.Slug, Type: updated.Type,
Name: updated.Name, Attrs: attrsJSON,
}, nil)
d.Created = len(defs)
dres = d
}
return updated, dres, nil
}
// SetState transitions an entity's lifecycle. Declared-transition and
// precondition enforcement happens in the repository's transaction; a
// stale FromState is refused there (check-then-act).
func (s *EntityService) SetState(ctx context.Context, slug, fromState, toState, actorType, actor, method, path string) (domain.Entity, error) {
current, err := s.entities.BySlug(ctx, slug)
if err != nil {
return domain.Entity{}, err
}
after, err := s.entities.SetState(ctx, ports.EntityTransitionInput{
Slug: slug,
From: fromState,
To: toState,
Audit: []ports.AuditEntry{{
ActorType: actorType, ActorLabel: actor, Action: "state",
EntityID: current.ID, Method: method, Path: path,
Details: map[string]any{"from": fromState, "to": toState},
}},
Event: &ports.Event{
Type: "entity.state.changed", Severity: "info", Source: "oikos-api", EntityID: current.ID,
Data: map[string]any{"slug": slug, "from": fromState, "to": toState},
},
})
if err != nil {
return domain.Entity{}, err
}
return after, nil
}
func (s *EntityService) resolve(ctx context.Context, slugOrID string) (domain.Entity, error) {
if _, err := uuid.Parse(slugOrID); err == nil {
return s.entities.Get(ctx, domain.UUID(slugOrID))
}
return s.entities.BySlug(ctx, slugOrID)
}