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

@@ -17,7 +17,7 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService) []toolReg {
func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService, relService *app.RelationshipService) []toolReg {
return []toolReg{
{tool: &mcp.Tool{Name: "ping", Description: "Lightweight connectivity check. Returns server identity, no DB hit.",
InputSchema: objSchema(),
@@ -261,21 +261,40 @@ func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *
if source == "" || target == "" || relType == "" {
return textResult("error: source, target, and type are required"), nil
}
srcEnt, err := sqlcgen.New(pool).GetEntityBySlug(ctx, source)
if err != nil {
return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil
}
tgtEnt, err := sqlcgen.New(pool).GetEntityBySlug(ctx, target)
if err != nil {
return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil
}
_, err = sqlcgen.New(pool).InsertRelationshipIfAbsent(ctx, sqlcgen.InsertRelationshipIfAbsentParams{
SourceID: srcEnt.ID, TargetID: tgtEnt.ID, Type: relType, Attributes: []byte(`{"by":"nomos"}`),
})
if err != nil {
return textResult(fmt.Sprintf("error creating relationship: %v (is %q a valid relationship type?)", err, relType)), nil
}
return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil
// Resolve source and target entities.
srcRow := pool.QueryRow(ctx, `SELECT id,type FROM entities WHERE slug = $1`, source)
var srcID string
var srcType string
if err := srcRow.Scan(&srcID, &srcType); err != nil {
return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil
}
tgtRow := pool.QueryRow(ctx, `SELECT id,type FROM entities WHERE slug = $1`, target)
var tgtID string
var tgtType string
if err := tgtRow.Scan(&tgtID, &tgtType); err != nil {
return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil
}
_, err := relService.Create(ctx, app.CreateRelationshipCmd{
SourceID: domain.UUID(srcID),
TargetID: domain.UUID(tgtID),
SourceType: srcType,
TargetType: tgtType,
Type: relType,
Attributes: map[string]any{"by": "nomos"},
ActorType: "agent",
Actor: "mcp",
Method: "TOOL",
Path: "create_relationship",
})
if err != nil {
if errors.Is(err, domain.ErrAlreadyExists) {
return textResult(fmt.Sprintf("%s —%s→ %s already exists.", source, relType, target)), nil
}
return textResult(fmt.Sprintf("error creating relationship: %v", err)), nil
}
return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil
}},
{tool: &mcp.Tool{Name: "end_relationship", Description: "End an existing relationship (soft-delete by setting valid_to) — the graph-structure \"delete\" surface. Use it when you discover an edge is no longer true (a service moved hosts, a route was removed, a dependency dissolved). The edge is kept for history; only the currently-active edge is ended. Idempotent — ending an already-ended or non-existent edge is a no-op. Does NOT require approval.",
InputSchema: objSchema(