Files
oikos/internal/httpapi/entity_mutations.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

179 lines
5.1 KiB
Go

package httpapi
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"strconv"
"github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/google/uuid"
)
func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestObject) (gen.CreateEntityResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
// Check idempotency if a key was provided. The idempotency scope is the
// calling actor, so replays are per-caller.
actorType, actor := actorInfo(ctx)
var idem *ports.Idempotency
if req.Params.IdempotencyKey != nil && *req.Params.IdempotencyKey != "" {
bodyJSON, _ := json.Marshal(req.Body)
hash := fmt.Sprintf("%x", sha256.Sum256(bodyJSON))
key := *req.Params.IdempotencyKey
cached, err := s.entityRepo.GetIdempotent(ctx, actor, key)
if err == nil {
// Verify the request body hasn't changed.
if cached.RequestHash != hash {
return nil, fmt.Errorf("%w: idempotency key %s used with different request body", domain.ErrConflict, key)
}
// Replay the cached response.
if cached.ResponseCode == 201 {
var entity gen.Entity
if len(cached.ResponseBody) > 0 {
if err := json.Unmarshal(cached.ResponseBody, &entity); err != nil {
return nil, fmt.Errorf("unmarshal cached response: %w", err)
}
}
return gen.CreateEntity201JSONResponse{
Body: entity,
Headers: gen.CreateEntity201ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
}, nil
}
// Forward cached error response.
return gen.CreateEntitydefaultApplicationProblemPlusJSONResponse{
Body: gen.Problem{Status: cached.ResponseCode, Title: "replayed error"},
StatusCode: cached.ResponseCode,
}, nil
}
idem = &ports.Idempotency{
Actor: actor, Key: key, RequestHash: hash,
// RenderBody serializes the adapter's wire shape inside the
// create's transaction, so a replay returns the original
// response atomically with the insert.
RenderBody: func(e domain.Entity) []byte {
b, _ := json.Marshal(domainToGen(e))
return b
},
}
}
created, _, err := s.entities.Create(ctx, app.CreateEntityCmd{
Slug: req.Body.Slug,
Type: req.Body.Type,
Name: req.Body.Name,
State: derefStr(req.Body.State),
Attributes: derefAttrs(req.Body.Attributes),
ActorType: actorType,
Actor: actor,
Method: "POST",
Path: "/api/v1/entities",
Idempotency: idem,
})
if err != nil {
return nil, err
}
entity := domainToGen(created)
return gen.CreateEntity201JSONResponse{
Body: entity,
Headers: gen.CreateEntity201ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
}, nil
}
func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObject) (gen.PatchEntityResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
// Parse If-Match header (quoted version string).
ifMatch := trimQuotes(req.Params.IfMatch)
expectedVersion, err := strconv.Atoi(ifMatch)
if err != nil {
return nil, fmt.Errorf("%w: invalid If-Match header %q", domain.ErrInvalidInput, req.Params.IfMatch)
}
patchActorType, patchActor := actorInfo(ctx)
updated, _, err := s.entities.Update(ctx, app.UpdateEntityCmd{
SlugOrID: req.Id,
ExpectedVer: expectedVersion,
Name: derefStr(req.Body.Name),
State: derefStr(req.Body.State),
Attributes: derefAttrs(req.Body.Attributes),
AttrsReplace: true,
Maintenance: req.Body.MaintenanceUntil,
SetMaint: req.Body.MaintenanceUntil != nil,
// Attribute changes propagate to derived checks (the A2 parity fix:
// previously only the MCP surface regenerated checks on attribute
// changes).
RederiveChecks: true,
ActorType: patchActorType,
Actor: patchActor,
Method: "PATCH",
Path: "/api/v1/entities/" + req.Id,
})
if err != nil {
return nil, err
}
entity := domainToGen(updated)
s.entityCache.Invalidate(entity.Slug, entity.Id.String())
return gen.PatchEntity200JSONResponse{
Body: entity,
Headers: gen.PatchEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
}, nil
}
func derefStr(p *string) string {
if p == nil {
return ""
}
return *p
}
func derefAttrs(p *map[string]any) map[string]any {
if p == nil {
return nil
}
return *p
}
func trimQuotes(s string) string {
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
return s[1 : len(s)-1]
}
return s
}
// domainToGen converts a domain entity to the wire shape.
func domainToGen(e domain.Entity) gen.Entity {
id, _ := uuid.Parse(string(e.ID))
out := gen.Entity{
Id: id,
Slug: e.Slug,
Type: e.Type,
Name: e.Name,
Version: e.Version,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
if e.State != "" {
out.State = &e.State
}
if e.MaintenanceUntil != nil {
out.MaintenanceUntil = e.MaintenanceUntil
}
if len(e.Attributes) > 0 {
attrs := e.Attributes
out.Attributes = &attrs
}
return out
}