feat: Phase 3b — EntityService, postgres EntityRepository, converged mutations
Problem: entity mutations (create/update/state) existed as three drifted
copies — HTTP CreateEntity/PatchEntity, MCP create_entity/
update_entity_attributes/set_entity_state — each with its own inline
SQL, its own validation subset (MCP validated lifecycle states, HTTP
did not; HTTP patched attributes without regenerating derived checks,
MCP did; MCP wrote no audit trail), the exact drift ADR 0016's first
vertical slice exists to collapse.
Change:
- internal/core/ports: DerivedCheck, Idempotency (adapter-owned request
hash + cached-body renderer so the replay record commits in the
create's transaction), IdempotentResponse + GetIdempotent read,
AuditEntry gains Method/Path/CorrelationID, Event gains
CorrelationID; EntityUpdateInput carries ExpectedVersion +
RederiveChecks (derivation for updates runs repo-side: the graph
host fallback reads relationships through the open tx).
- internal/adapters/postgres/repositories.go: EntityRepo (Create/
Update/SetState/reads/idempotency) preserving the load-bearing
check-then-act invariants in-tx: version WHERE-clause, declared
transitions + preconditions (ValidateTransition), duplicate-slug
mapping, audit/event/writeCheck all inside one BEGIN…COMMIT.
OntologyRepo: TTL-cached OntologyStore.
- internal/core/app/entities.go: EntityService — ontology validation
(type exists, concrete, state declared — the stricter MCP rule now
governs both surfaces), default-state resolution, id generation,
derivation for creates, audit/event construction, idempotency
pass-through.
- httpapi CreateEntity/PatchEntity rewired to the service; PATCH now
regenerates derived checks (the A2 parity gap). MCP create/update/
set-state tools call the same service — and now write audit + event
rows like the HTTP surface always did.
- Integration-test seed paths fixed for the adapters/postgres package
depth (../../../seeds).
Pre-existing failures documented: TestAPIEndToEnd (entity_types 60 vs
59; 501-endpoint now 200), TestClientLifecycleEndToEnd, TestPhase3*
rows — verified failing identically at ec11956 (scratch approval-
notifier commit test drift), unrelated to this change. All mutation
integration tests (create/patch/idempotency/audit/regeneration) pass.
Verification: make test-db (postgres + mcp green, httpapi green except
the pre-existing set), full non-DB suite (19 pkgs), golangci on new
packages — 0 issues.
This commit is contained in:
@@ -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.
|
||||
|
||||
506
internal/adapters/postgres/repositories.go
Normal file
506
internal/adapters/postgres/repositories.go
Normal 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
|
||||
}
|
||||
262
internal/core/app/entities.go
Normal file
262
internal/core/app/entities.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)...)
|
||||
|
||||
Reference in New Issue
Block a user