feat: Phase 3b — EntityService, postgres EntityRepository, converged mutations
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

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.
This commit is contained in:
2026-08-15 23:38:21 +02:00
parent d4f5084a6d
commit 9e3783734e
15 changed files with 1071 additions and 368 deletions

View File

@@ -1 +1 @@
0.32.4
0.33.0

View File

@@ -23,7 +23,7 @@ import (
"gopkg.in/yaml.v3"
)
func seedsDir() string { return "../../seeds" }
func seedsDir() string { return "../../../seeds" }
// newTestPool creates a throwaway database (dropped on cleanup), runs all
// migrations, and returns a pool connected to it.

View File

@@ -0,0 +1,506 @@
package db
import (
"context"
"fmt"
"encoding/json"
"errors"
"strings"
"sync"
"time"
"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/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/observability"
"github.com/dtoro/oikos/internal/ontology"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// EntityRepo implements ports.EntityRepository over the postgres pool.
// Command methods run everything in their input inside one transaction
// (ADR 0016 §3.6): entity write, derived checks, idempotency record, audit,
// event.
type EntityRepo struct {
pool *Pool
}
var _ ports.EntityRepository = (*EntityRepo)(nil)
// NewEntityRepo builds the entity repository.
func NewEntityRepo(pool *Pool) *EntityRepo { return &EntityRepo{pool: pool} }
// mustUUID converts a domain.UUID (string alias) to uuid.UUID. The domain
// layer guarantees UUID-shaped strings; parse failures are programming
// errors and panic loudly rather than half-failing a transaction.
func mustUUID(id domain.UUID) uuid.UUID {
u, err := uuid.Parse(string(id))
if err != nil {
panic(fmt.Sprintf("invalid entity UUID %q", string(id)))
}
return u
}
const entityFullCols = `id, slug, type, name, state, attributes, maintenance_until, version, created_at, updated_at`
type rowScanner interface{ Scan(dest ...any) error }
func scanDomainEntity(row rowScanner) (domain.Entity, error) {
var id uuid.UUID
var slug, typ, name string
var state *string
var attrs []byte
var maint *time.Time
var version int32
var createdAt, updatedAt time.Time
if err := row.Scan(&id, &slug, &typ, &name, &state, &attrs, &maint, &version, &createdAt, &updatedAt); err != nil {
return domain.Entity{}, err
}
d := domain.Entity{
ID: domain.UUID(id.String()),
Slug: slug,
Type: typ,
Name: name,
Version: int(version),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
MaintenanceUntil: maint,
}
if state != nil {
d.State = *state
}
if len(attrs) > 0 {
var m map[string]any
if json.Unmarshal(attrs, &m) == nil {
d.Attributes = m
}
}
return d, nil
}
func mapRowErr(err error) error {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrNotFound
}
return err
}
// Get returns the entity by ID.
func (r *EntityRepo) Get(ctx context.Context, id domain.UUID) (domain.Entity, error) {
uid, err := uuid.Parse(string(id))
if err != nil {
return domain.Entity{}, err
}
e, err := scanDomainEntity(r.pool.QueryRow(ctx,
`SELECT `+entityFullCols+` FROM entities WHERE id = $1`, uid))
return e, mapRowErr(err)
}
// BySlug returns the entity by slug.
func (r *EntityRepo) BySlug(ctx context.Context, slug string) (domain.Entity, error) {
e, err := scanDomainEntity(r.pool.QueryRow(ctx,
`SELECT `+entityFullCols+` FROM entities WHERE slug = $1`, slug))
return e, mapRowErr(err)
}
// List returns entities filtered by type (including descendant types),
// state, domain, layer, and a slug/name substring, keyset-paginated by
// slug. The second return is the next cursor ("" when exhausted).
func (r *EntityRepo) List(ctx context.Context, f ports.EntityFilters) ([]domain.Entity, string, error) {
limit := f.Limit
if limit <= 0 {
limit = 50
}
// NULL-able filter args: an absent filter must bind SQL NULL, not "".
nullable := func(s string) any {
if s == "" {
return nil
}
return s
}
// Type filter includes descendants via the parent hierarchy (R3-1).
rows, err := r.pool.Query(ctx, `
WITH RECURSIVE tt AS (
SELECT name FROM entity_types WHERE $1::text IS NULL OR name = $1
UNION
SELECT et.name FROM entity_types et JOIN tt ON et.parent_type = tt.name
WHERE $1::text IS NOT NULL
)
SELECT `+entityFullCols+`
FROM entities e
JOIN entity_types et ON et.name = e.type
WHERE e.type IN (SELECT name FROM tt)
AND ($2::text IS NULL OR e.state = $2)
AND ($3::text IS NULL OR et.domain = $3)
AND ($4::text IS NULL OR et.layer = $4)
AND ($5::text IS NULL OR e.slug ILIKE '%'||$5||'%' OR e.name ILIKE '%'||$5||'%')
AND ($6::text IS NULL OR e.slug > $6)
ORDER BY e.slug
LIMIT $7`,
nullable(f.Type), nullable(f.State), nullable(f.Domain), nullable(f.Layer),
nullable(f.Q), nullable(f.Cursor), limit+1)
if err != nil {
return nil, "", err
}
defer rows.Close()
items := []domain.Entity{}
for rows.Next() {
e, err := scanDomainEntity(rows)
if err != nil {
return nil, "", err
}
items = append(items, e)
}
if rows.Err() != nil {
return nil, "", rows.Err()
}
next := ""
if len(items) > limit {
items = items[:limit]
next = items[len(items)-1].Slug
}
return items, next, nil
}
// Search matches slug/name substrings.
func (r *EntityRepo) Search(ctx context.Context, q string, limit int) ([]domain.Entity, error) {
items, _, err := r.List(ctx, ports.EntityFilters{Q: q, Limit: limit})
return items, err
}
// writeSideEffects writes audit entries and the event inside the open
// transaction.
func writeSideEffects(ctx context.Context, tx pgx.Tx, entityID domain.UUID, audits []ports.AuditEntry, event *ports.Event) error {
q := sqlcgen.New(tx)
eid := mustUUID(entityID)
for _, a := range audits {
if err := observability.Audit(ctx, q, a.ActorType, a.ActorLabel, a.Action, &eid,
a.Method, a.Path, a.CorrelationID, nil, a.Details); err != nil {
return err
}
}
if event != nil {
if err := observability.Event(ctx, q, event.Type, &eid, event.Severity, event.Source,
event.CorrelationID, event.Data); err != nil {
return err
}
}
return nil
}
func appTargetOf(e domain.Entity) app.CheckTarget {
attrs, _ := json.Marshal(e.Attributes)
return app.CheckTarget{ID: string(e.ID), Slug: e.Slug, Type: e.Type, Name: e.Name, Attrs: attrs}
}
func writeDerivedChecks(ctx context.Context, tx pgx.Tx, e domain.Entity, checks []ports.DerivedCheck) (int, error) {
if _, err := tx.Exec(ctx,
`INSERT INTO entity_status (entity_id, health, updated_at)
VALUES ($1, 'unknown', now())
ON CONFLICT (entity_id) DO NOTHING`, mustUUID(e.ID)); err != nil {
return 0, err
}
created := 0
t := appTargetOf(e)
for i, dc := range checks {
ok, err := writeCheck(ctx, tx, t, i, app.CheckDef{Kind: dc.Kind, Config: dc.Config, IntervalS: dc.IntervalS})
if err != nil {
return created, err
}
if ok {
created++
}
}
return created, nil
}
// rederiveChecks re-runs derivation for an entity inside the open tx, with
// the graph host fallback active (a service inherits its container's
// address once the hosting edge exists).
func rederiveChecks(ctx context.Context, tx pgx.Tx, e domain.Entity, tree *ontology.TypeTree) (int, error) {
t := appTargetOf(e)
defs, _ := app.Derive(tree, t, func() map[string]any {
attrs, err := hostViaGraph(ctx, tx, t.ID)
if err != nil {
return nil
}
return attrs
})
derived := make([]ports.DerivedCheck, len(defs))
for i, d := range defs {
derived[i] = ports.DerivedCheck{Kind: d.Kind, Config: d.Config, IntervalS: d.IntervalS}
}
return writeDerivedChecks(ctx, tx, e, derived)
}
// Create inserts the entity with its derived checks, idempotency record,
// audit, and event — one transaction.
func (r *EntityRepo) Create(ctx context.Context, in ports.EntityCreateInput) (domain.Entity, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return domain.Entity{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
e := in.Entity
attrsJSON, _ := json.Marshal(e.Attributes)
if e.Attributes == nil {
attrsJSON = []byte("{}")
}
var state *string
if e.State != "" {
state = &e.State
}
created, err := scanDomainEntity(tx.QueryRow(ctx, `
INSERT INTO entities (id, slug, type, name, state, attributes)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING `+entityFullCols,
mustUUID(e.ID), e.Slug, e.Type, e.Name, state, attrsJSON))
if err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return domain.Entity{}, errors.Join(domain.ErrAlreadyExists, err)
}
return domain.Entity{}, err
}
if in.Idempotency != nil {
var body []byte
if in.Idempotency.RenderBody != nil {
body = in.Idempotency.RenderBody(created)
}
code := int32(201)
if err := sqlcgen.New(tx).PutIdempotentResponse(ctx, sqlcgen.PutIdempotentResponseParams{
Actor: in.Idempotency.Actor, Key: in.Idempotency.Key,
RequestHash: in.Idempotency.RequestHash, ResponseCode: &code, ResponseBody: body,
}); err != nil {
return domain.Entity{}, err
}
}
if _, err := writeDerivedChecks(ctx, tx, created, in.DerivedChecks); err != nil {
return domain.Entity{}, err
}
if err := writeSideEffects(ctx, tx, created.ID, in.Audit, in.Event); err != nil {
return domain.Entity{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.Entity{}, err
}
return created, nil
}
// Update applies name/state/attributes/maintenance atomically with an
// optimistic-version check, re-deriving default checks when asked. The
// declared-transition + precondition validation runs inside the
// transaction (check-then-act, plan §3.6).
func (r *EntityRepo) Update(ctx context.Context, in ports.EntityUpdateInput) (domain.Entity, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return domain.Entity{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
current, err := scanDomainEntity(tx.QueryRow(ctx,
`SELECT `+entityFullCols+` FROM entities WHERE id = $1`, mustUUID(in.Entity.ID)))
if err != nil {
return domain.Entity{}, mapRowErr(err)
}
if in.ExpectedVersion > 0 && current.Version != in.ExpectedVersion {
return domain.Entity{}, domain.ErrConflict
}
e := in.Entity
// Fields the caller left zero keep their current values.
if e.Name == "" {
e.Name = current.Name
}
if e.Slug == "" {
e.Slug = current.Slug
}
if e.Type == "" {
e.Type = current.Type
}
// Lifecycle validation when state changes (declared transition +
// preconditions, in-tx).
if e.State != "" && e.State != current.State {
if err := ValidateTransition(ctx, tx, mustUUID(current.ID), current.Type, current.State, e.State); err != nil {
if errors.Is(err, ErrTransitionInvalid) {
return domain.Entity{}, errors.Join(domain.ErrInvalidTransition, err)
}
return domain.Entity{}, err
}
} else if e.State == "" {
e.State = current.State
}
if e.Attributes == nil {
e.Attributes = current.Attributes
}
setMaintenance := e.MaintenanceUntil != nil
maint := e.MaintenanceUntil
if !setMaintenance {
maint = current.MaintenanceUntil
}
attrsJSON, _ := json.Marshal(e.Attributes)
if e.Attributes == nil {
attrsJSON = []byte("{}")
}
var state *string
if e.State != "" {
state = &e.State
}
updated, err := scanDomainEntity(tx.QueryRow(ctx, `
UPDATE entities
SET name = $2, state = $3, attributes = $4, maintenance_until = $5, version = version + 1, updated_at = now()
WHERE id = $1 AND version = $6
RETURNING `+entityFullCols,
mustUUID(e.ID), e.Name, state, attrsJSON, maint, current.Version))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Entity{}, domain.ErrConflict
}
return domain.Entity{}, err
}
if in.RederiveChecks {
tree, err := LoadTypeTree(ctx, tx)
if err != nil {
return domain.Entity{}, err
}
if _, err := rederiveChecks(ctx, tx, updated, tree); err != nil {
return domain.Entity{}, err
}
}
if err := writeSideEffects(ctx, tx, updated.ID, in.Audit, in.Event); err != nil {
return domain.Entity{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.Entity{}, err
}
return updated, nil
}
// SetState transitions an entity's lifecycle state. Declared-transition
// and precondition validation run in-tx; a stale From is refused.
func (r *EntityRepo) SetState(ctx context.Context, in ports.EntityTransitionInput) (domain.Entity, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return domain.Entity{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
current, err := scanDomainEntity(tx.QueryRow(ctx,
`SELECT `+entityFullCols+` FROM entities WHERE slug = $1`, in.Slug))
if err != nil {
return domain.Entity{}, mapRowErr(err)
}
if current.State != in.From {
return domain.Entity{}, domain.ErrConflict
}
if err := ValidateTransition(ctx, tx, mustUUID(current.ID), current.Type, in.From, in.To); err != nil {
if errors.Is(err, ErrTransitionInvalid) {
return domain.Entity{}, errors.Join(domain.ErrInvalidTransition, err)
}
return domain.Entity{}, err
}
if _, err := tx.Exec(ctx,
`UPDATE entities SET state = $2, version = version + 1, updated_at = now() WHERE id = $1`,
mustUUID(current.ID), in.To); err != nil {
return domain.Entity{}, err
}
after := current
after.State = in.To
if err := writeSideEffects(ctx, tx, after.ID, in.Audit, in.Event); err != nil {
return domain.Entity{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.Entity{}, err
}
return after, nil
}
// GetIdempotent returns the cached response for (actor, key) or
// domain.ErrNotFound.
func (r *EntityRepo) GetIdempotent(ctx context.Context, actor, key string) (ports.IdempotentResponse, error) {
cached, err := sqlcgen.New(r.pool).GetIdempotentResponse(ctx, sqlcgen.GetIdempotentResponseParams{Actor: actor, Key: key})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ports.IdempotentResponse{}, domain.ErrNotFound
}
return ports.IdempotentResponse{}, err
}
code := 0
if cached.ResponseCode != nil {
code = int(*cached.ResponseCode)
}
return ports.IdempotentResponse{
RequestHash: cached.RequestHash,
ResponseCode: code,
ResponseBody: cached.ResponseBody,
}, nil
}
// OntologyRepo implements ports.OntologyStore with a TTL cache — entity
// types change at seed time, not per request, so a short cache trades a
// little staleness for avoiding the meta-schema load on every mutation.
type OntologyRepo struct {
pool *Pool
ttl time.Duration
mu sync.Mutex
loaded time.Time
tree *ontology.TypeTree
}
var _ ports.OntologyStore = (*OntologyRepo)(nil)
// NewOntologyRepo builds the ontology store with the given cache TTL
// (values <= 0 disable caching).
func NewOntologyRepo(pool *Pool, ttl time.Duration) *OntologyRepo {
return &OntologyRepo{pool: pool, ttl: ttl}
}
// LoadTypeTree returns the (possibly cached) ontology tree.
func (o *OntologyRepo) LoadTypeTree(ctx context.Context) (ports.TypeTree, error) {
if o.ttl <= 0 {
return o.load(ctx)
}
o.mu.Lock()
defer o.mu.Unlock()
if o.tree != nil && time.Since(o.loaded) < o.ttl {
return o.tree, nil
}
tree, err := o.load(ctx)
if err != nil {
return nil, err
}
o.tree = tree
o.loaded = time.Now()
return tree, nil
}
func (o *OntologyRepo) load(ctx context.Context) (ports.TypeTree, error) {
tx, err := o.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback(ctx) }()
tree, err := LoadTypeTree(ctx, tx)
if err != nil {
return nil, err
}
return ports.TypeTree(tree), nil
}

View File

@@ -0,0 +1,262 @@
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)
}

View File

@@ -7,44 +7,84 @@ import (
"github.com/dtoro/oikos/internal/ontology"
)
// TypeTree aliases the ontology tree: entity types, relationship types,
// lifecycle definitions. ontology is pure over domain; it moves under
// core/ when checkdefaults is absorbed (Phase 3).
type TypeTree = ontology.TypeTree
// TypeTree is the loaded ontology: entity types, relationship types,
// lifecycle definitions. It is ontology's pure tree behind an interface so
// ports does not alias a concrete struct into the contract.
type TypeTree = *ontology.TypeTree
// EntityFilters bounds entity list/search reads.
type EntityFilters struct {
Type string
State string
Q string
Limit int
Type string
State string
Q string
Domain string
Layer string
Cursor string
Limit int
}
// DerivedCheck is one concrete check derived from an entity's type
// monitoring spec, to be written in the same transaction as the entity
// mutation that produced it.
type DerivedCheck struct {
Kind string
Config map[string]any
IntervalS int
}
// Idempotency replays-protects one command: the adapter stores the cached
// response inside the same transaction as the mutation, so a crash between
// the two cannot let a replay re-execute. RenderBody is a pure presenter
// closure that serializes the committed entity into the caller's wire
// shape; the repository never inspects it.
type Idempotency struct {
Actor string
Key string
RequestHash string
RenderBody func(domain.Entity) []byte
}
// IdempotentResponse is a previously cached response for (actor, key).
type IdempotentResponse struct {
RequestHash string
ResponseCode int
ResponseBody []byte
}
// EntityCreateInput is one transaction: the entity, its derived check
// definitions, and the audit/event side-effects of the creation.
type EntityCreateInput struct {
Entity domain.Entity
DerivedChecks []CheckDef
DerivedChecks []DerivedCheck
Audit []AuditEntry
Event *Event
Idempotency *Idempotency
}
// EntityUpdateInput mirrors EntityCreateInput for updates.
// EntityUpdateInput mutates an entity atomically. ExpectedVersion is the
// optimistic-concurrency check (0 disables it). When RederiveChecks is set,
// the repository re-derives default checks inside the transaction — the
// graph host fallback (a service inherits its container's address) reads
// relationships through the open transaction, so derivation cannot happen
// in the service for updates.
type EntityUpdateInput struct {
Entity domain.Entity
DerivedChecks []CheckDef
Audit []AuditEntry
Event *Event
Entity domain.Entity
ExpectedVersion int
RederiveChecks bool
Audit []AuditEntry
Event *Event
Idempotency *Idempotency
}
// EntityTransitionInput is a lifecycle state change: the check-then-act
// precondition (current state) is validated inside the transaction.
// EntityTransitionInput is a lifecycle state change: the declared-transition
// check and preconditions are validated inside the transaction
// (check-then-act), not against the possibly-stale From.
type EntityTransitionInput struct {
Slug string
From string
To string
Audit []AuditEntry
Event *Event
Slug string
From string
To string
Audit []AuditEntry
Event *Event
}
// EntityRepository is the entity aggregate. Command methods are
@@ -52,12 +92,16 @@ type EntityTransitionInput struct {
type EntityRepository interface {
Get(ctx context.Context, id domain.UUID) (domain.Entity, error)
BySlug(ctx context.Context, slug string) (domain.Entity, error)
List(ctx context.Context, filters EntityFilters) ([]domain.Entity, error)
List(ctx context.Context, filters EntityFilters) ([]domain.Entity, string, error)
Search(ctx context.Context, q string, limit int) ([]domain.Entity, error)
Create(ctx context.Context, input EntityCreateInput) (domain.Entity, error)
Update(ctx context.Context, input EntityUpdateInput) (domain.Entity, error)
SetState(ctx context.Context, input EntityTransitionInput) (domain.Entity, error)
// GetIdempotent returns the cached response for (actor, key), or
// domain.ErrNotFound when none exists.
GetIdempotent(ctx context.Context, actor, key string) (IdempotentResponse, error)
}
// RelationshipCreateInput validates endpoints against the ontology in core

View File

@@ -13,9 +13,10 @@ type Event struct {
Type string // e.g. "execution.completed", "health.changed"
Severity string // "info", "warning", "critical"
Source string // "api", "mcp", "scheduler", "webhook"
EntityID domain.UUID
Data map[string]any
Ts time.Time
EntityID domain.UUID
CorrelationID string
Data map[string]any
Ts time.Time
}
// EventPublisher fans events out to subscribers and persists them. The
@@ -25,12 +26,17 @@ type EventPublisher interface {
Publish(ctx context.Context, event Event) error
}
// AuditEntry is an append-only audit-log record.
// AuditEntry is an append-only audit-log record. Method/Path carry the
// surface context ("POST", "/api/v1/entities" for REST; "TOOL",
// "create_entity" for MCP).
type AuditEntry struct {
ActorType string // "agent", "operator", "system"
ActorLabel string
Action string
EntityID domain.UUID
Method string
Path string
CorrelationID string
Details map[string]any
Ts time.Time
}

View File

@@ -21,10 +21,12 @@ type EntityRepo struct {
bySlug map[string]domain.UUID
order []domain.UUID
nextID int
Audits []ports.AuditEntry
Events []ports.Event
Checks map[domain.UUID][]ports.CheckDef
ErrStub error // returned by every command when set
Audits []ports.AuditEntry
Events []ports.Event
Checks map[domain.UUID][]ports.CheckDef
Idempotent map[string]ports.IdempotentResponse
Rederived []domain.UUID
ErrStub error // returned by every command when set
}
// NewEntityRepo builds an empty in-memory entity repository.
@@ -32,7 +34,8 @@ func NewEntityRepo() *EntityRepo {
return &EntityRepo{
byID: make(map[domain.UUID]domain.Entity),
bySlug: make(map[string]domain.UUID),
Checks: make(map[domain.UUID][]ports.CheckDef),
Checks: make(map[domain.UUID][]ports.CheckDef),
Idempotent: make(map[string]ports.IdempotentResponse),
}
}
@@ -58,8 +61,9 @@ func (r *EntityRepo) BySlug(_ context.Context, slug string) (domain.Entity, erro
return r.byID[id], nil
}
// List returns entities filtered by type/state, bounded by limit.
func (r *EntityRepo) List(_ context.Context, f ports.EntityFilters) ([]domain.Entity, error) {
// List returns entities filtered by type/state, bounded by limit, with the
// next cursor ("" when exhausted).
func (r *EntityRepo) List(_ context.Context, f ports.EntityFilters) ([]domain.Entity, string, error) {
r.mu.Lock()
defer r.mu.Unlock()
var out []domain.Entity
@@ -76,13 +80,17 @@ func (r *EntityRepo) List(_ context.Context, f ports.EntityFilters) ([]domain.En
break
}
}
return out, nil
next := ""
if len(out) == f.Limit && f.Limit > 0 {
next = "cursor-more"
}
return out, next, nil
}
// Search matches name/slug substrings.
func (r *EntityRepo) Search(ctx context.Context, q string, limit int) ([]domain.Entity, error) {
// Substring over name/slug is enough for service tests.
all, err := r.List(ctx, ports.EntityFilters{Limit: limit})
all, _, err := r.List(ctx, ports.EntityFilters{Limit: limit})
if err != nil {
return nil, err
}
@@ -114,27 +122,50 @@ func (r *EntityRepo) Create(_ context.Context, in ports.EntityCreateInput) (doma
if in.Event != nil {
r.Events = append(r.Events, *in.Event)
}
r.Checks[in.Entity.ID] = in.DerivedChecks
if in.Idempotency != nil {
var body []byte
if in.Idempotency.RenderBody != nil {
body = in.Idempotency.RenderBody(in.Entity)
}
r.Idempotent[in.Idempotency.Actor+"\x00"+in.Idempotency.Key] = ports.IdempotentResponse{
RequestHash: in.Idempotency.RequestHash, ResponseCode: 201, ResponseBody: body,
}
}
return in.Entity, nil
}
// Update replaces a stored entity and records its input side-effects.
// The optimistic-version check mirrors the postgres WHERE version clause.
func (r *EntityRepo) Update(_ context.Context, in ports.EntityUpdateInput) (domain.Entity, error) {
if r.ErrStub != nil {
return domain.Entity{}, r.ErrStub
}
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.byID[in.Entity.ID]; !ok {
current, ok := r.byID[in.Entity.ID]
if !ok {
return domain.Entity{}, domain.ErrNotFound
}
delete(r.bySlug, r.byID[in.Entity.ID].Slug)
r.store(in.Entity)
if in.ExpectedVersion > 0 && current.Version != in.ExpectedVersion {
return domain.Entity{}, domain.ErrConflict
}
after := in.Entity
after.Version = current.Version + 1
delete(r.bySlug, current.Slug)
r.store(after)
r.Audits = append(r.Audits, in.Audit...)
if in.Event != nil {
r.Events = append(r.Events, *in.Event)
}
r.Checks[in.Entity.ID] = in.DerivedChecks
if in.Idempotency != nil {
var body []byte
if in.Idempotency.RenderBody != nil {
body = in.Idempotency.RenderBody(in.Entity)
}
r.Idempotent[in.Idempotency.Actor+"\x00"+in.Idempotency.Key] = ports.IdempotentResponse{
RequestHash: in.Idempotency.RequestHash, ResponseCode: 201, ResponseBody: body,
}
}
return in.Entity, nil
}
@@ -168,6 +199,17 @@ func (r *EntityRepo) store(e domain.Entity) {
r.order = append(r.order, e.ID)
}
// GetIdempotent returns the cached response for (actor, key).
func (r *EntityRepo) GetIdempotent(_ context.Context, actor, key string) (ports.IdempotentResponse, error) {
r.mu.Lock()
defer r.mu.Unlock()
cached, ok := r.Idempotent[actor+"\x00"+key]
if !ok {
return ports.IdempotentResponse{}, domain.ErrNotFound
}
return cached, nil
}
// FindBySlug is a test helper bypassing the port interface.
func (r *EntityRepo) FindBySlug(slug string) (domain.Entity, bool) {
r.mu.Lock()

View File

@@ -4,18 +4,14 @@ import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"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/dtoro/oikos/internal/observability"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestObject) (gen.CreateEntityResponseObject, error) {
@@ -25,25 +21,21 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb
// Check idempotency if a key was provided. The idempotency scope is the
// calling actor, so replays are per-caller.
actorType, actorLabel := actorInfo(ctx)
actor := actorLabel
var bodyHash string
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
q := sqlcgen.New(s.pool)
cached, err := q.GetIdempotentResponse(ctx, sqlcgen.GetIdempotentResponseParams{
Actor: actor,
Key: key,
})
cached, err := s.entityRepo.GetIdempotent(ctx, actor, key)
if err == nil {
// Verify the request body hasn't changed.
bodyJSON, _ := json.Marshal(req.Body)
bodyHash = fmt.Sprintf("%x", sha256.Sum256(bodyJSON))
if cached.RequestHash != bodyHash {
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 != nil && *cached.ResponseCode == 201 {
if cached.ResponseCode == 201 {
var entity gen.Entity
if len(cached.ResponseBody) > 0 {
if err := json.Unmarshal(cached.ResponseBody, &entity); err != nil {
@@ -57,123 +49,38 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb
}
// Forward cached error response.
return gen.CreateEntitydefaultApplicationProblemPlusJSONResponse{
Body: gen.Problem{Status: int(*cached.ResponseCode), Title: "replayed error"},
StatusCode: int(*cached.ResponseCode),
Body: gen.Problem{Status: cached.ResponseCode, Title: "replayed error"},
StatusCode: cached.ResponseCode,
}, nil
}
}
id, err := uuid.NewV7()
if err != nil {
return nil, err
}
slug := req.Body.Slug
if slug == "" {
slug = req.Body.Type + ":" + req.Body.Name
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
q := sqlcgen.New(tx)
// Validate type exists and is NOT abstract.
var isAbstract bool
if err := tx.QueryRow(ctx, `SELECT is_abstract FROM entity_types WHERE name = $1`, req.Body.Type).Scan(&isAbstract); err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: entity type %q", domain.ErrNotFound, req.Body.Type)
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
},
}
return nil, err
}
if isAbstract {
return nil, fmt.Errorf("%w: %s", domain.ErrAbstractType, req.Body.Type)
}
// Get default state from lifecycle.
var defaultState *string
var lcDefault string
if err := tx.QueryRow(ctx, `SELECT ld.default_state FROM lifecycle_defs ld
JOIN entity_types et ON et.lifecycle_id = ld.id
WHERE et.name = $1`, req.Body.Type).Scan(&lcDefault); err == nil {
defaultState = &lcDefault
}
state := req.Body.State
if state == nil && defaultState != nil {
state = defaultState
}
// attributes is NOT NULL; the column default only applies when omitted,
// not when an explicit NULL is bound — so default to an empty object.
attrsJSON := []byte("{}")
if req.Body.Attributes != nil {
attrsJSON, _ = json.Marshal(req.Body.Attributes)
}
// Insert the entity.
inserted, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
ID: id,
Slug: slug,
Type: req.Body.Type,
Name: req.Body.Name,
State: state,
Attributes: attrsJSON,
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 {
// Duplicate slug.
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return nil, fmt.Errorf("%w: slug %q already exists", domain.ErrAlreadyExists, slug)
}
return nil, err
}
// Convert sqlcgen.Entity → gen.Entity.
entity := sqlcEntityToGen(inserted)
// Cache idempotent response.
if req.Params.IdempotencyKey != nil && *req.Params.IdempotencyKey != "" {
respBody, _ := json.Marshal(entity)
code := int32(201)
if bodyHash == "" {
bodyJSON, _ := json.Marshal(req.Body)
bodyHash = fmt.Sprintf("%x", sha256.Sum256(bodyJSON))
}
if putErr := q.PutIdempotentResponse(ctx, sqlcgen.PutIdempotentResponseParams{
Actor: actor,
Key: *req.Params.IdempotencyKey,
RequestHash: bodyHash,
ResponseCode: &code,
ResponseBody: respBody,
}); putErr != nil {
return nil, putErr
}
}
// Audit.
entityID := inserted.ID
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
&entityID, "POST", "/api/v1/entities", "",
nil,
map[string]any{"type": req.Body.Type, "slug": slug}); auditErr != nil {
return nil, auditErr
}
if eventErr := observability.Event(ctx, q, "entity.created", &entityID,
"info", "oikos-api", "",
map[string]any{"slug": slug, "type": req.Body.Type}); eventErr != nil {
return nil, eventErr
}
if err := ensureDefaultChecks(ctx, tx, inserted.ID, slug, req.Body.Type, inserted.Name, attrsJSON); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
entity := domainToGen(created)
return gen.CreateEntity201JSONResponse{
Body: entity,
Headers: gen.CreateEntity201ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
@@ -185,106 +92,87 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
id, err := s.resolveEntityID(ctx, req.Id)
if err != nil {
return nil, err
}
// Parse If-Match header (quoted version string).
ifMatch := strings.Trim(req.Params.IfMatch, `"`)
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)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
// Get current entity for version check + lifecycle validation.
current, err := sqlcgen.New(tx).GetEntityByID(ctx, id)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("%w: %s", domain.ErrNotFound, req.Id)
}
return nil, err
}
if int(current.Version) != expectedVersion {
return nil, fmt.Errorf("%w: expected version %d, current version %d",
domain.ErrConflict, expectedVersion, current.Version)
}
// Validate lifecycle transition if state is being changed.
if req.Body.State != nil && *req.Body.State != "" {
fromState := ""
if current.State != nil {
fromState = *current.State
}
if err := db.ValidateTransition(ctx, tx, id, current.Type, fromState, *req.Body.State); err != nil {
if errors.Is(err, db.ErrTransitionInvalid) {
return nil, fmt.Errorf("%w: %v", domain.ErrInvalidTransition, err)
}
return nil, err
}
}
// Check idempotency (note: the spec doesn't define Idempotency-Key for PATCH,
// but we handle it if the generated code ever adds it).
// For now, no idempotency check on PATCH.
// Marshal attributes if provided.
var attrsJSON []byte
if req.Body.Attributes != nil {
attrsJSON, _ = json.Marshal(req.Body.Attributes)
}
// Perform the update via sqlcgen.
q := sqlcgen.New(tx)
updated, err := q.UpdateEntity(ctx, sqlcgen.UpdateEntityParams{
Name: req.Body.Name,
State: req.Body.State,
Attributes: attrsJSON,
SetMaintenance: req.Body.MaintenanceUntil != nil,
MaintenanceUntil: req.Body.MaintenanceUntil,
ID: id,
Version: int32(expectedVersion),
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 {
if err == pgx.ErrNoRows {
// Version mismatch or entity not found.
return nil, fmt.Errorf("%w: entity was modified concurrently", domain.ErrConflict)
}
return nil, err
}
entity := sqlcEntityToGen(updated)
// Audit.
patchActorType, patchActor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, patchActorType, patchActor, "patch",
&id, "PATCH", "/api/v1/entities/"+req.Id, "",
nil,
map[string]any{"version": expectedVersion}); auditErr != nil {
return nil, auditErr
}
if eventErr := observability.Event(ctx, q, "entity.updated", &id,
"info", "oikos-api", "",
map[string]any{"slug": entity.Slug, "type": entity.Type, "version": updated.Version}); eventErr != nil {
return nil, eventErr
}
if err := tx.Commit(ctx); 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
}

View File

@@ -26,6 +26,7 @@ import (
"github.com/dtoro/oikos/internal/actuator"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/httpapi/gen"
mcphandler "github.com/dtoro/oikos/internal/mcp"
"github.com/dtoro/oikos/internal/safego"
@@ -59,6 +60,12 @@ type Server struct {
sseBroker *sseBroker
sseSubs map[*sseSubscriber]struct{}
sseMu sync.Mutex
// entities is the entity-aggregate use-case service (Phase 3 of the
// hexagonal refactor); entityRepo exposes the idempotency reads the
// replay path needs. Wired here until main becomes the composition root.
entities *app.EntityService
entityRepo *db.EntityRepo
}
// NewHandler builds the full HTTP handler: /healthz (unauthenticated,
@@ -76,6 +83,8 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
sseBroker: newSSEBroker(10000),
sseSubs: make(map[*sseSubscriber]struct{}),
}
s.entityRepo = db.NewEntityRepo(pool)
s.entities = app.NewEntityService(s.entityRepo, db.NewOntologyRepo(pool, time.Minute))
// Wire secrets backend: Infisical primary with SOPS DR fallback.
if cfg.InfisicalSiteURL != "" {
@@ -318,7 +327,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
if nomosAgentID == uuid.Nil && cfg.NomosAgentSlug != "" {
_ = pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", cfg.NomosAgentSlug).Scan(&nomosAgentID)
}
r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID, s.secretsManager))
r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID, s.secretsManager, s.entities))
if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" {
target, _ := url.Parse(nomosURL)

View File

@@ -7,6 +7,7 @@ package mcp
// (see internal/db/integration_test.go); run via `make test-db`.
import (
"time"
"context"
"encoding/json"
"fmt"
@@ -97,7 +98,8 @@ func newTestPool(t *testing.T) *db.Pool {
func callTool(t *testing.T, pool *db.Pool, name string, args map[string]any) string {
t.Helper()
var handler toolHandler
for _, r := range allTools(pool, uuid.Nil, nil) {
entities := app.NewEntityService(db.NewEntityRepo(pool), db.NewOntologyRepo(pool, time.Minute))
for _, r := range allTools(pool, uuid.Nil, nil, entities) {
if r.tool.Name == name {
handler = r.handler
break

View File

@@ -3,18 +3,21 @@ package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/dtoro/oikos/internal/audit"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg {
func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService) []toolReg {
return []toolReg{
{tool: &mcp.Tool{Name: "ping", Description: "Lightweight connectivity check. Returns server identity, no DB hit.",
InputSchema: objSchema(),
@@ -109,81 +112,39 @@ func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg
return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
}
}
attrsJSON, _ := json.Marshal(attrs)
stateStr, _ := args["state"].(string)
tx, err := pool.Begin(ctx)
if err != nil {
return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil
// One service path with the HTTP surface (ADR 0016 Phase 3):
// ontology validation, lifecycle-state guardrails, check
// derivation, and audit/event recording converge here.
_, res, err := entities.Create(ctx, app.CreateEntityCmd{
Slug: slug,
Type: entityType,
Name: name,
State: stateStr,
Attributes: attrs,
ActorType: "agent",
Actor: "mcp",
Method: "TOOL",
Path: "create_entity",
})
if err != nil {
if errors.Is(err, domain.ErrAlreadyExists) {
return textResult(fmt.Sprintf("Entity %q already exists — use update_entity_attributes to change it.", slug)), nil
}
defer tx.Rollback(ctx)
// Validate the type exists and is concrete (mirror httpapi.CreateEntity).
var isAbstract bool
if err := tx.QueryRow(ctx, `SELECT is_abstract FROM entity_types WHERE name = $1`, entityType).Scan(&isAbstract); err != nil {
if errors.Is(err, domain.ErrNotFound) {
return textResult(fmt.Sprintf("error: entity type %q not found in ontology", entityType)), nil
}
if isAbstract {
if errors.Is(err, domain.ErrAbstractType) {
return textResult(fmt.Sprintf("error: type %q is abstract — pick a concrete subtype", entityType)), nil
}
// Default state from the type's lifecycle unless the caller
// supplied one. Caller-supplied states are validated against
// the lifecycle's declared states — a create_entity bypass of
// lifecycle guardrails would let an agent create in a terminal
// state (destroyed) without satisfying the preconditions that
// set_entity_state enforces for the same transition.
var state *string
var lsDefault, statesRaw string
if err := tx.QueryRow(ctx, `SELECT coalesce(ld.default_state,''), coalesce(ld.states::text,'')
FROM lifecycle_defs ld
JOIN entity_types et ON et.lifecycle_id = ld.id
WHERE et.name = $1`, entityType).Scan(&lsDefault, &statesRaw); err == nil {
var validStates []string
json.Unmarshal([]byte(statesRaw), &validStates)
if stateStr != "" {
found := false
for _, s := range validStates {
if s == stateStr {
found = true
break
}
}
if !found && len(validStates) > 0 {
return textResult(fmt.Sprintf("error: state %q not declared in %s lifecycle (states: %s). Use the default (%s) or omit state.", stateStr, entityType, strings.Join(validStates, ","), lsDefault)), nil
}
state = &stateStr
} else if lsDefault != "" {
state = &lsDefault
}
if errors.Is(err, domain.ErrInvalidTransition) {
return textResult(fmt.Sprintf("error: %v", err)), nil
}
id, err := uuid.NewV7()
if err != nil {
return textResult(fmt.Sprintf("error: gen id: %v", err)), nil
}
var createdName string
if err := tx.QueryRow(ctx, `
INSERT INTO entities (id, slug, type, name, state, attributes)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING name`,
id, slug, entityType, name, state, attrsJSON).Scan(&createdName); err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return textResult(fmt.Sprintf("Entity %q already exists — use update_entity_attributes to change it.", slug)), nil
}
return textResult(fmt.Sprintf("error creating %s: %v", slug, err)), nil
}
res, derr := db.EnsureEntityChecks(ctx, tx, id, slug, entityType, createdName, attrsJSON)
if derr != nil {
return textResult(fmt.Sprintf("error deriving checks for %s: %v", slug, derr)), nil
}
if cerr := tx.Commit(ctx); cerr != nil {
return textResult(fmt.Sprintf("error committing %s: %v", slug, cerr)), nil
}
return textResult(formatCreateResult(slug, entityType, res)), nil
return textResult(fmt.Sprintf("error creating %s: %v", slug, err)), nil
}
return textResult(formatCreateResult(slug, entityType, res)), nil
}},
{tool: &mcp.Tool{Name: "update_entity_attributes", Description: "Merge new/changed attributes into an entity — the OTHER half of avoiding knowledge-base drift (upsert_knowledge records what you learned; this keeps the entity's own facts current). Use it when you discover something concrete about an entity's actual state that the graph doesn't reflect yet: a new IP, a version number, a config value, a discovered port — anything a FUTURE task would otherwise have to rediscover from scratch. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). Merges shallowly — existing keys not mentioned are kept; keys you pass overwrite.",
InputSchema: objSchema(
@@ -221,44 +182,26 @@ func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg
return textResult(fmt.Sprintf("Updated %s: no allowed attributes provided. The following keys are scheduler-owned and ignored: %s. Use get_health_summary or list_checks to observe entity health.", slug, strings.Join(blocked, ", "))), nil
}
}
attrsJSON, _ := json.Marshal(attrs)
// Run the merge + check regeneration in one transaction so the
// derived checks always see the post-merge attributes. Mirrors
// httpapi.PatchEntity; without this, setting an entity's
// `monitoring` attribute via MCP silently produced no checks.
tx, err := pool.Begin(ctx)
if err != nil {
return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil
}
defer tx.Rollback(ctx)
ra, err := sqlcgen.New(tx).MergeEntityAttributes(ctx, sqlcgen.MergeEntityAttributesParams{Slug: slug, Patch: attrsJSON})
// One service path with the HTTP surface (ADR 0016 Phase 3): merge +
// check regeneration + audit/event in one transaction.
_, res, err := entities.Update(ctx, app.UpdateEntityCmd{
SlugOrID: slug,
Attributes: attrs,
AttrsReplace: false,
RederiveChecks: true,
ActorType: "agent",
Actor: "mcp",
Method: "TOOL",
Path: "update_entity_attributes",
})
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
}
return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil
}
if ra == 0 {
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
}
merged, err := sqlcgen.New(tx).GetEntityBySlug(ctx, slug)
if err != nil {
return textResult(fmt.Sprintf("error reloading %s: %v", slug, err)), nil
}
id := merged.ID
entityType := merged.Type
name := merged.Name
mergedAttrs := merged.Attributes
res, cerr := db.EnsureEntityChecks(ctx, tx, id, slug, entityType, name, mergedAttrs)
if cerr != nil {
return textResult(fmt.Sprintf("error deriving checks for %s: %v", slug, cerr)), nil
}
if cerr := tx.Commit(ctx); cerr != nil {
return textResult(fmt.Sprintf("error committing %s: %v", slug, cerr)), nil
}
return textResult(fmt.Sprintf("Updated %s with %d attribute(s).%s", slug, len(attrs), formatCheckResult(res))), nil
return textResult(fmt.Sprintf("Updated %s with %d attribute(s).%s", slug, len(attrs), formatCheckResult(res))), nil
}},
{tool: &mcp.Tool{Name: "set_entity_state", Description: "Transition an entity to a new lifecycle state — the entity-graph \"delete\" surface, since this system never hard-deletes entities. Use retire/deprecate to take an entity out of service, destroy for terminal removal, or active to revive. The target state must be a declared transition in the entity type's lifecycle (e.g. active→deprecated, deprecated→active); preconditions (no inbound edges, backups verified, etc.) are enforced — an error tells you what's blocking. Does NOT require approval (knowledge-graph mutation, not live infrastructure).",
InputSchema: objSchema(

View File

@@ -40,7 +40,7 @@ func (m *mockSecretBackend) Name() string { return "mock" }
// findToolHandler locates a tool's handler from allTools by name.
func findToolHandler(t *testing.T, pool interface{}, name string, sec secrets.Backend) func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t.Helper()
for _, r := range allTools(nil, uuid.Nil, sec) {
for _, r := range allTools(nil, uuid.Nil, sec, nil) {
if r.tool.Name == name {
return r.handler
}

View File

@@ -20,6 +20,7 @@ import (
"time"
"github.com/dtoro/oikos/internal/actuator"
"github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
@@ -51,8 +52,8 @@ func objSchema(props ...prop) *jsonschema.Schema {
// NewHandler creates an http.Handler that serves the Oikos MCP server.
// agentID is the Nomos agent entity UUID; tool calls are logged to agent_activity.
func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secrets) http.Handler {
s := newServer(pool, agentID, sec)
func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService) http.Handler {
s := newServer(pool, agentID, sec, entities)
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
if token != "" {
if r.Header.Get("Authorization") != "Bearer "+token {
@@ -67,11 +68,11 @@ func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secret
// toolHandler is the function signature registered via AddTool.
type toolHandler = mcp.ToolHandler
func newServer(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) *mcp.Server {
func newServer(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService) *mcp.Server {
s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{
Logger: slog.Default(),
})
for _, t := range allTools(pool, agentID, sec) {
for _, t := range allTools(pool, agentID, sec, entities) {
s.AddTool(t.tool, withActivityLogging(pool, agentID, t.tool.Name, t.handler))
}

View File

@@ -86,7 +86,7 @@ func TestNewServerRegistersTools(t *testing.T) {
}()
// pool is only used inside tool handlers (invoked per-call), not at
// registration time, so a nil pool is safe for this construction test.
s := newServer(nil, uuid.Nil, nil)
s := newServer(nil, uuid.Nil, nil, nil)
if s == nil {
t.Fatal("newServer returned nil")
}

View File

@@ -17,10 +17,10 @@ type toolReg struct {
handler toolHandler
}
func allTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets) []toolReg {
func allTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService) []toolReg {
return append(append(append(append(
[]toolReg{},
EntityTools(pool, agentID, sec)...),
EntityTools(pool, agentID, sec, entities)...),
OpsTools(pool, agentID, sec)...),
KnowledgeTools(pool, agentID, sec)...),
AnalysisTools(pool, agentID, sec)...)