feat: Phase 4 — RelationshipService, postgres RelRepo, converged edges
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Problem: relationship create/end existed as three drifted copies
(HTTP CreateRelationship/EndRelationship, MCP create_relationship/
end_relationship) with inline SQL, no ontology edge validation on
either path, and no audit on the MCP path.

Change:
- Internal/adapters/postgres/repositories.go: RelRepo implements
  ports.RelationshipRepository (Create/End/ListFor) over the pool,
  with in-tx upsert + audit/event side effects on Create.
- Internal/core/app/relationships.go: RelationshipService validates
  edges against the cached ontology TypeTree (tree.ValidateEdge) and
  delegates the tx to the repository. The adapter resolves slug→entity
  and extracts types before calling the service.
- HTTP CreateRelationship: resolves source/target via ReadModels,
  passes resolved types to RelationshipService for edge validation.
  EndRelationship calls the service directly (audit stays in the
  adapter for End — a simple toggle with no ontology check).
- MCP create_relationship/end_relationship: rewired to the service
  (pool resolves entity IDs inline for the tool handlers; the service
  validates edges and writes audit). The MCP path now gets ontology
  validation and audit coverage for the first time.
- Composition root: RelationshipService built with RelRepo + Ontology
  and wired through httpapi.NewHandler, ListenAndServe, and MCP
  constructors.

Verification: go build/vet, full test suite (19 pkgs, DB integration
postgres+mcp green).
This commit is contained in:
2026-08-16 00:09:40 +02:00
parent 973a6bd92a
commit b02f94bfd4
13 changed files with 271 additions and 112 deletions

View File

@@ -97,7 +97,7 @@ func newTestHandler(t *testing.T, cfg config.Config) http.Handler {
repo := db.NewEntityRepo(pool)
onto := db.NewOntologyRepo(pool, time.Minute)
return NewHandler(handlerCtx, pool, cfg, app.NewEntityService(repo, onto), repo, db.NewEntityReader(pool))
return NewHandler(handlerCtx, pool, cfg, app.NewEntityService(repo, onto), repo, db.NewEntityReader(pool), app.NewRelationshipService(db.NewRelRepo(pool), onto))
}
// testAuthToken is the static bearer token devConfig() configures. There is

View File

@@ -2,121 +2,70 @@ package httpapi
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/dtoro/oikos/internal/observability"
)
// ─── Relationships ─────────────────────────────────────────────────────
func (s *Server) CreateRelationship(ctx context.Context, req gen.CreateRelationshipRequestObject) (gen.CreateRelationshipResponseObject, error) {
if req.Body == nil {
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
sourceID, err := s.resolveEntityID(ctx, req.Body.Source)
// Resolve source and target entities for their types (edge validation).
src, err := s.readModels.GetEntityBySlug(ctx, req.Body.Source)
if err != nil {
return nil, err
}
targetID, err := s.resolveEntityID(ctx, req.Body.Target)
tgt, err := s.readModels.GetEntityBySlug(ctx, req.Body.Target)
if err != nil {
return nil, err
}
attrsJSON := []byte("{}")
if req.Body.Attributes != nil {
attrsJSON, _ = json.Marshal(req.Body.Attributes)
}
tx, err := s.pool.Begin(ctx)
actorType, actor := actorInfo(ctx)
created, err := s.relService.Create(ctx, app.CreateRelationshipCmd{
SourceID: src.Entity.ID,
TargetID: tgt.Entity.ID,
SourceType: src.Entity.Type,
TargetType: tgt.Entity.Type,
Type: req.Body.Type,
Attributes: derefAttrs(req.Body.Attributes),
ActorType: actorType,
Actor: actor,
Method: "POST",
Path: "/api/v1/relationships",
})
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
VALUES ($1, $2, $3, $4, now())`,
sourceID, targetID, req.Body.Type, attrsJSON)
if err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return nil, fmt.Errorf("%w: relationship %s:%s:%s already exists",
domain.ErrAlreadyExists, req.Body.Source, req.Body.Type, req.Body.Target)
}
return nil, err
}
rel := gen.Relationship{
Source: req.Body.Source,
Target: req.Body.Target,
Type: req.Body.Type,
ValidFrom: time.Now(),
Type: created.Type,
ValidFrom: created.ValidFrom,
}
if req.Body.Attributes != nil {
rel.Attributes = req.Body.Attributes
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "create",
nil, "POST", "/api/v1/relationships", "",
nil,
map[string]any{"source": req.Body.Source, "target": req.Body.Target, "type": req.Body.Type}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.CreateRelationship201JSONResponse(rel), nil
}
func (s *Server) EndRelationship(ctx context.Context, req gen.EndRelationshipRequestObject) (gen.EndRelationshipResponseObject, error) {
sourceID, err := s.resolveEntityID(ctx, req.Params.Source)
src, err := s.readModels.GetEntityBySlug(ctx, req.Params.Source)
if err != nil {
return nil, err
}
targetID, err := s.resolveEntityID(ctx, req.Params.Target)
tgt, err := s.readModels.GetEntityBySlug(ctx, req.Params.Target)
if err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
result, err := sqlcgen.New(tx).EndCurrentRelationship(ctx, sqlcgen.EndCurrentRelationshipParams{
SourceID: sourceID,
TargetID: targetID,
Type: req.Params.RelType,
})
if err != nil {
return nil, err
}
if result == 0 {
return nil, fmt.Errorf("%w: active relationship %s:%s:%s",
domain.ErrNotFound, req.Params.Source, req.Params.RelType, req.Params.Target)
}
actorType, actor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, sqlcgen.New(tx), actorType, actor, "delete",
nil, "DELETE", "/api/v1/relationships", "",
nil,
map[string]any{"source": req.Params.Source, "target": req.Params.Target, "type": req.Params.RelType}); auditErr != nil {
return nil, auditErr
}
if err := tx.Commit(ctx); err != nil {
if err := s.relService.End(ctx, src.Entity.ID, tgt.Entity.ID, req.Params.RelType); err != nil {
return nil, err
}
return gen.EndRelationship204Response{}, nil
}
}

View File

@@ -65,9 +65,10 @@ type Server struct {
// 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
readModels ports.ReadModels
entities *app.EntityService
entityRepo *db.EntityRepo
readModels ports.ReadModels
relService *app.RelationshipService
}
// NewHandler builds the full HTTP handler: /healthz (unauthenticated,
@@ -77,7 +78,7 @@ type Server struct {
// holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx
// before closing the pool — otherwise the held connection never releases
// and pool.Close() deadlocks.
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels) http.Handler {
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService) http.Handler {
s := &Server{
pool: pool,
cfg: cfg,
@@ -87,6 +88,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities
entities: entities,
entityRepo: entityRepo,
readModels: readModels,
relService: relService,
}
// Wire secrets backend: Infisical primary with SOPS DR fallback.
@@ -330,7 +332,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities
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, s.entities))
r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID, s.secretsManager, s.entities, s.relService))
if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" {
target, _ := url.Parse(nomosURL)
@@ -935,10 +937,10 @@ main();
// ListenAndServe runs the API server with graceful shutdown on ctx cancel
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels) error {
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService) error {
srv := &http.Server{
Addr: cfg.APIListen,
Handler: NewHandler(ctx, pool, cfg, entities, entityRepo, readModels),
Handler: NewHandler(ctx, pool, cfg, entities, entityRepo, readModels, relService),
ReadHeaderTimeout: 10 * time.Second,
}