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" "gopkg.in/yaml.v3"
) )
func seedsDir() string { return "../../seeds" } func seedsDir() string { return "../../../seeds" }
// newTestPool creates a throwaway database (dropped on cleanup), runs all // newTestPool creates a throwaway database (dropped on cleanup), runs all
// migrations, and returns a pool connected to it. // 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" "github.com/dtoro/oikos/internal/ontology"
) )
// TypeTree aliases the ontology tree: entity types, relationship types, // TypeTree is the loaded ontology: entity types, relationship types,
// lifecycle definitions. ontology is pure over domain; it moves under // lifecycle definitions. It is ontology's pure tree behind an interface so
// core/ when checkdefaults is absorbed (Phase 3). // ports does not alias a concrete struct into the contract.
type TypeTree = ontology.TypeTree type TypeTree = *ontology.TypeTree
// EntityFilters bounds entity list/search reads. // EntityFilters bounds entity list/search reads.
type EntityFilters struct { type EntityFilters struct {
Type string Type string
State string State string
Q string Q string
Limit int 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 // EntityCreateInput is one transaction: the entity, its derived check
// definitions, and the audit/event side-effects of the creation. // definitions, and the audit/event side-effects of the creation.
type EntityCreateInput struct { type EntityCreateInput struct {
Entity domain.Entity Entity domain.Entity
DerivedChecks []CheckDef DerivedChecks []DerivedCheck
Audit []AuditEntry Audit []AuditEntry
Event *Event 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 { type EntityUpdateInput struct {
Entity domain.Entity Entity domain.Entity
DerivedChecks []CheckDef ExpectedVersion int
Audit []AuditEntry RederiveChecks bool
Event *Event Audit []AuditEntry
Event *Event
Idempotency *Idempotency
} }
// EntityTransitionInput is a lifecycle state change: the check-then-act // EntityTransitionInput is a lifecycle state change: the declared-transition
// precondition (current state) is validated inside the transaction. // check and preconditions are validated inside the transaction
// (check-then-act), not against the possibly-stale From.
type EntityTransitionInput struct { type EntityTransitionInput struct {
Slug string Slug string
From string From string
To string To string
Audit []AuditEntry Audit []AuditEntry
Event *Event Event *Event
} }
// EntityRepository is the entity aggregate. Command methods are // EntityRepository is the entity aggregate. Command methods are
@@ -52,12 +92,16 @@ type EntityTransitionInput struct {
type EntityRepository interface { type EntityRepository interface {
Get(ctx context.Context, id domain.UUID) (domain.Entity, error) Get(ctx context.Context, id domain.UUID) (domain.Entity, error)
BySlug(ctx context.Context, slug string) (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) Search(ctx context.Context, q string, limit int) ([]domain.Entity, error)
Create(ctx context.Context, input EntityCreateInput) (domain.Entity, error) Create(ctx context.Context, input EntityCreateInput) (domain.Entity, error)
Update(ctx context.Context, input EntityUpdateInput) (domain.Entity, error) Update(ctx context.Context, input EntityUpdateInput) (domain.Entity, error)
SetState(ctx context.Context, input EntityTransitionInput) (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 // 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" Type string // e.g. "execution.completed", "health.changed"
Severity string // "info", "warning", "critical" Severity string // "info", "warning", "critical"
Source string // "api", "mcp", "scheduler", "webhook" Source string // "api", "mcp", "scheduler", "webhook"
EntityID domain.UUID EntityID domain.UUID
Data map[string]any CorrelationID string
Ts time.Time Data map[string]any
Ts time.Time
} }
// EventPublisher fans events out to subscribers and persists them. The // EventPublisher fans events out to subscribers and persists them. The
@@ -25,12 +26,17 @@ type EventPublisher interface {
Publish(ctx context.Context, event Event) error 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 { type AuditEntry struct {
ActorType string // "agent", "operator", "system" ActorType string // "agent", "operator", "system"
ActorLabel string ActorLabel string
Action string Action string
EntityID domain.UUID EntityID domain.UUID
Method string
Path string
CorrelationID string
Details map[string]any Details map[string]any
Ts time.Time Ts time.Time
} }

View File

@@ -21,10 +21,12 @@ type EntityRepo struct {
bySlug map[string]domain.UUID bySlug map[string]domain.UUID
order []domain.UUID order []domain.UUID
nextID int nextID int
Audits []ports.AuditEntry Audits []ports.AuditEntry
Events []ports.Event Events []ports.Event
Checks map[domain.UUID][]ports.CheckDef Checks map[domain.UUID][]ports.CheckDef
ErrStub error // returned by every command when set Idempotent map[string]ports.IdempotentResponse
Rederived []domain.UUID
ErrStub error // returned by every command when set
} }
// NewEntityRepo builds an empty in-memory entity repository. // NewEntityRepo builds an empty in-memory entity repository.
@@ -32,7 +34,8 @@ func NewEntityRepo() *EntityRepo {
return &EntityRepo{ return &EntityRepo{
byID: make(map[domain.UUID]domain.Entity), byID: make(map[domain.UUID]domain.Entity),
bySlug: make(map[string]domain.UUID), 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 return r.byID[id], nil
} }
// List returns entities filtered by type/state, bounded by limit. // List returns entities filtered by type/state, bounded by limit, with the
func (r *EntityRepo) List(_ context.Context, f ports.EntityFilters) ([]domain.Entity, error) { // next cursor ("" when exhausted).
func (r *EntityRepo) List(_ context.Context, f ports.EntityFilters) ([]domain.Entity, string, error) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
var out []domain.Entity var out []domain.Entity
@@ -76,13 +80,17 @@ func (r *EntityRepo) List(_ context.Context, f ports.EntityFilters) ([]domain.En
break 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. // Search matches name/slug substrings.
func (r *EntityRepo) Search(ctx context.Context, q string, limit int) ([]domain.Entity, error) { func (r *EntityRepo) Search(ctx context.Context, q string, limit int) ([]domain.Entity, error) {
// Substring over name/slug is enough for service tests. // 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 { if err != nil {
return nil, err return nil, err
} }
@@ -114,27 +122,50 @@ func (r *EntityRepo) Create(_ context.Context, in ports.EntityCreateInput) (doma
if in.Event != nil { if in.Event != nil {
r.Events = append(r.Events, *in.Event) 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 return in.Entity, nil
} }
// Update replaces a stored entity and records its input side-effects. // 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) { func (r *EntityRepo) Update(_ context.Context, in ports.EntityUpdateInput) (domain.Entity, error) {
if r.ErrStub != nil { if r.ErrStub != nil {
return domain.Entity{}, r.ErrStub return domain.Entity{}, r.ErrStub
} }
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() 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 return domain.Entity{}, domain.ErrNotFound
} }
delete(r.bySlug, r.byID[in.Entity.ID].Slug) if in.ExpectedVersion > 0 && current.Version != in.ExpectedVersion {
r.store(in.Entity) 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...) r.Audits = append(r.Audits, in.Audit...)
if in.Event != nil { if in.Event != nil {
r.Events = append(r.Events, *in.Event) 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 return in.Entity, nil
} }
@@ -168,6 +199,17 @@ func (r *EntityRepo) store(e domain.Entity) {
r.order = append(r.order, e.ID) 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. // FindBySlug is a test helper bypassing the port interface.
func (r *EntityRepo) FindBySlug(slug string) (domain.Entity, bool) { func (r *EntityRepo) FindBySlug(slug string) (domain.Entity, bool) {
r.mu.Lock() r.mu.Lock()

View File

@@ -4,18 +4,14 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"github.com/dtoro/oikos/internal/adapters/postgres" "github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/core/domain" "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/httpapi/gen"
"github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
) )
func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestObject) (gen.CreateEntityResponseObject, error) { 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 // Check idempotency if a key was provided. The idempotency scope is the
// calling actor, so replays are per-caller. // calling actor, so replays are per-caller.
actorType, actorLabel := actorInfo(ctx) actorType, actor := actorInfo(ctx)
actor := actorLabel var idem *ports.Idempotency
var bodyHash string
if req.Params.IdempotencyKey != nil && *req.Params.IdempotencyKey != "" { if req.Params.IdempotencyKey != nil && *req.Params.IdempotencyKey != "" {
bodyJSON, _ := json.Marshal(req.Body)
hash := fmt.Sprintf("%x", sha256.Sum256(bodyJSON))
key := *req.Params.IdempotencyKey key := *req.Params.IdempotencyKey
q := sqlcgen.New(s.pool)
cached, err := q.GetIdempotentResponse(ctx, sqlcgen.GetIdempotentResponseParams{ cached, err := s.entityRepo.GetIdempotent(ctx, actor, key)
Actor: actor,
Key: key,
})
if err == nil { if err == nil {
// Verify the request body hasn't changed. // Verify the request body hasn't changed.
bodyJSON, _ := json.Marshal(req.Body) if cached.RequestHash != hash {
bodyHash = fmt.Sprintf("%x", sha256.Sum256(bodyJSON))
if cached.RequestHash != bodyHash {
return nil, fmt.Errorf("%w: idempotency key %s used with different request body", domain.ErrConflict, key) return nil, fmt.Errorf("%w: idempotency key %s used with different request body", domain.ErrConflict, key)
} }
// Replay the cached response. // Replay the cached response.
if cached.ResponseCode != nil && *cached.ResponseCode == 201 { if cached.ResponseCode == 201 {
var entity gen.Entity var entity gen.Entity
if len(cached.ResponseBody) > 0 { if len(cached.ResponseBody) > 0 {
if err := json.Unmarshal(cached.ResponseBody, &entity); err != nil { 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. // Forward cached error response.
return gen.CreateEntitydefaultApplicationProblemPlusJSONResponse{ return gen.CreateEntitydefaultApplicationProblemPlusJSONResponse{
Body: gen.Problem{Status: int(*cached.ResponseCode), Title: "replayed error"}, Body: gen.Problem{Status: cached.ResponseCode, Title: "replayed error"},
StatusCode: int(*cached.ResponseCode), StatusCode: cached.ResponseCode,
}, nil }, nil
} }
} idem = &ports.Idempotency{
Actor: actor, Key: key, RequestHash: hash,
id, err := uuid.NewV7() // RenderBody serializes the adapter's wire shape inside the
if err != nil { // create's transaction, so a replay returns the original
return nil, err // response atomically with the insert.
} RenderBody: func(e domain.Entity) []byte {
slug := req.Body.Slug b, _ := json.Marshal(domainToGen(e))
if slug == "" { return b
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)
} }
return nil, err
}
if isAbstract {
return nil, fmt.Errorf("%w: %s", domain.ErrAbstractType, req.Body.Type)
} }
// Get default state from lifecycle. created, _, err := s.entities.Create(ctx, app.CreateEntityCmd{
var defaultState *string Slug: req.Body.Slug,
var lcDefault string Type: req.Body.Type,
if err := tx.QueryRow(ctx, `SELECT ld.default_state FROM lifecycle_defs ld Name: req.Body.Name,
JOIN entity_types et ON et.lifecycle_id = ld.id State: derefStr(req.Body.State),
WHERE et.name = $1`, req.Body.Type).Scan(&lcDefault); err == nil { Attributes: derefAttrs(req.Body.Attributes),
defaultState = &lcDefault ActorType: actorType,
} Actor: actor,
Method: "POST",
state := req.Body.State Path: "/api/v1/entities",
if state == nil && defaultState != nil { Idempotency: idem,
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,
}) })
if err != nil { 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 return nil, err
} }
entity := domainToGen(created)
// 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
}
return gen.CreateEntity201JSONResponse{ return gen.CreateEntity201JSONResponse{
Body: entity, Body: entity,
Headers: gen.CreateEntity201ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`}, Headers: gen.CreateEntity201ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
@@ -185,102 +92,37 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput) 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). // Parse If-Match header (quoted version string).
ifMatch := strings.Trim(req.Params.IfMatch, `"`) ifMatch := trimQuotes(req.Params.IfMatch)
expectedVersion, err := strconv.Atoi(ifMatch) expectedVersion, err := strconv.Atoi(ifMatch)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: invalid If-Match header %q", domain.ErrInvalidInput, req.Params.IfMatch) return nil, fmt.Errorf("%w: invalid If-Match header %q", domain.ErrInvalidInput, req.Params.IfMatch)
} }
tx, err := s.pool.Begin(ctx) patchActorType, patchActor := actorInfo(ctx)
if err != nil { updated, _, err := s.entities.Update(ctx, app.UpdateEntityCmd{
return nil, err SlugOrID: req.Id,
} ExpectedVer: expectedVersion,
defer tx.Rollback(ctx) Name: derefStr(req.Body.Name),
State: derefStr(req.Body.State),
// Get current entity for version check + lifecycle validation. Attributes: derefAttrs(req.Body.Attributes),
current, err := sqlcgen.New(tx).GetEntityByID(ctx, id) AttrsReplace: true,
if err != nil { Maintenance: req.Body.MaintenanceUntil,
if err == pgx.ErrNoRows { SetMaint: req.Body.MaintenanceUntil != nil,
return nil, fmt.Errorf("%w: %s", domain.ErrNotFound, req.Id) // Attribute changes propagate to derived checks (the A2 parity fix:
} // previously only the MCP surface regenerated checks on attribute
return nil, err // changes).
} RederiveChecks: true,
ActorType: patchActorType,
if int(current.Version) != expectedVersion { Actor: patchActor,
return nil, fmt.Errorf("%w: expected version %d, current version %d", Method: "PATCH",
domain.ErrConflict, expectedVersion, current.Version) Path: "/api/v1/entities/" + req.Id,
}
// 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),
}) })
if err != nil { 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 return nil, err
} }
entity := domainToGen(updated)
s.entityCache.Invalidate(entity.Slug, entity.Id.String()) s.entityCache.Invalidate(entity.Slug, entity.Id.String())
return gen.PatchEntity200JSONResponse{ return gen.PatchEntity200JSONResponse{
@@ -288,3 +130,49 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
Headers: gen.PatchEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`}, Headers: gen.PatchEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(entity.Version) + `"`},
}, nil }, 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/actuator"
"github.com/dtoro/oikos/internal/config" "github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/adapters/postgres" "github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/httpapi/gen"
mcphandler "github.com/dtoro/oikos/internal/mcp" mcphandler "github.com/dtoro/oikos/internal/mcp"
"github.com/dtoro/oikos/internal/safego" "github.com/dtoro/oikos/internal/safego"
@@ -59,6 +60,12 @@ type Server struct {
sseBroker *sseBroker sseBroker *sseBroker
sseSubs map[*sseSubscriber]struct{} sseSubs map[*sseSubscriber]struct{}
sseMu sync.Mutex 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, // 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), sseBroker: newSSEBroker(10000),
sseSubs: make(map[*sseSubscriber]struct{}), 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. // Wire secrets backend: Infisical primary with SOPS DR fallback.
if cfg.InfisicalSiteURL != "" { 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 != "" { if nomosAgentID == uuid.Nil && cfg.NomosAgentSlug != "" {
_ = pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", cfg.NomosAgentSlug).Scan(&nomosAgentID) _ = 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 != "" { if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" {
target, _ := url.Parse(nomosURL) target, _ := url.Parse(nomosURL)

View File

@@ -7,6 +7,7 @@ package mcp
// (see internal/db/integration_test.go); run via `make test-db`. // (see internal/db/integration_test.go); run via `make test-db`.
import ( import (
"time"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "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 { func callTool(t *testing.T, pool *db.Pool, name string, args map[string]any) string {
t.Helper() t.Helper()
var handler toolHandler 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 { if r.tool.Name == name {
handler = r.handler handler = r.handler
break break

View File

@@ -3,18 +3,21 @@ package mcp
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"strings" "strings"
"github.com/dtoro/oikos/internal/audit" "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"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "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/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp" "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{ return []toolReg{
{tool: &mcp.Tool{Name: "ping", Description: "Lightweight connectivity check. Returns server identity, no DB hit.", {tool: &mcp.Tool{Name: "ping", Description: "Lightweight connectivity check. Returns server identity, no DB hit.",
InputSchema: objSchema(), 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 return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
} }
} }
attrsJSON, _ := json.Marshal(attrs)
stateStr, _ := args["state"].(string) stateStr, _ := args["state"].(string)
tx, err := pool.Begin(ctx) // One service path with the HTTP surface (ADR 0016 Phase 3):
if err != nil { // ontology validation, lifecycle-state guardrails, check
return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil // 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) if errors.Is(err, domain.ErrNotFound) {
// 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 {
return textResult(fmt.Sprintf("error: entity type %q not found in ontology", entityType)), nil 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 return textResult(fmt.Sprintf("error: type %q is abstract — pick a concrete subtype", entityType)), nil
} }
if errors.Is(err, domain.ErrInvalidTransition) {
// Default state from the type's lifecycle unless the caller return textResult(fmt.Sprintf("error: %v", err)), nil
// 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
}
} }
return textResult(fmt.Sprintf("error creating %s: %v", slug, err)), nil
}
id, err := uuid.NewV7() return textResult(formatCreateResult(slug, entityType, res)), nil
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
}}, }},
{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.", {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( 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 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) // One service path with the HTTP surface (ADR 0016 Phase 3): merge +
// check regeneration + audit/event in one transaction.
// Run the merge + check regeneration in one transaction so the _, res, err := entities.Update(ctx, app.UpdateEntityCmd{
// derived checks always see the post-merge attributes. Mirrors SlugOrID: slug,
// httpapi.PatchEntity; without this, setting an entity's Attributes: attrs,
// `monitoring` attribute via MCP silently produced no checks. AttrsReplace: false,
tx, err := pool.Begin(ctx) RederiveChecks: true,
if err != nil { ActorType: "agent",
return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil Actor: "mcp",
} Method: "TOOL",
defer tx.Rollback(ctx) Path: "update_entity_attributes",
})
ra, err := sqlcgen.New(tx).MergeEntityAttributes(ctx, sqlcgen.MergeEntityAttributesParams{Slug: slug, Patch: attrsJSON})
if err != nil { 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 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) return textResult(fmt.Sprintf("Updated %s with %d attribute(s).%s", slug, len(attrs), formatCheckResult(res))), nil
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
}}, }},
{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).", {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( 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. // 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) { func findToolHandler(t *testing.T, pool interface{}, name string, sec secrets.Backend) func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
t.Helper() t.Helper()
for _, r := range allTools(nil, uuid.Nil, sec) { for _, r := range allTools(nil, uuid.Nil, sec, nil) {
if r.tool.Name == name { if r.tool.Name == name {
return r.handler return r.handler
} }

View File

@@ -20,6 +20,7 @@ import (
"time" "time"
"github.com/dtoro/oikos/internal/actuator" "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/core/ports"
"github.com/dtoro/oikos/internal/adapters/postgres" "github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" "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. // 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. // 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 { func NewHandler(pool *db.Pool, token string, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService) http.Handler {
s := newServer(pool, agentID, sec) s := newServer(pool, agentID, sec, entities)
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
if token != "" { if token != "" {
if r.Header.Get("Authorization") != "Bearer "+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. // toolHandler is the function signature registered via AddTool.
type toolHandler = mcp.ToolHandler 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{ s := mcp.NewServer(&mcp.Implementation{Name: "oikos", Version: "dev"}, &mcp.ServerOptions{
Logger: slog.Default(), 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)) 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 // pool is only used inside tool handlers (invoked per-call), not at
// registration time, so a nil pool is safe for this construction test. // 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 { if s == nil {
t.Fatal("newServer returned nil") t.Fatal("newServer returned nil")
} }

View File

@@ -17,10 +17,10 @@ type toolReg struct {
handler toolHandler 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( return append(append(append(append(
[]toolReg{}, []toolReg{},
EntityTools(pool, agentID, sec)...), EntityTools(pool, agentID, sec, entities)...),
OpsTools(pool, agentID, sec)...), OpsTools(pool, agentID, sec)...),
KnowledgeTools(pool, agentID, sec)...), KnowledgeTools(pool, agentID, sec)...),
AnalysisTools(pool, agentID, sec)...) AnalysisTools(pool, agentID, sec)...)