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

@@ -0,0 +1,78 @@
package app
import (
"context"
"fmt"
"time"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports"
)
// RelationshipService owns the relationship aggregate's use-cases: create
// with ontology edge validation, soft-delete, and list reads. The adapter
// resolves slug→entity and extracts types before calling the service; the
// service validates endpoint types against the (cached) ontology.
type RelationshipService struct {
rels ports.RelationshipRepository
onto ports.OntologyStore
}
// NewRelationshipService wires the service.
func NewRelationshipService(rels ports.RelationshipRepository, onto ports.OntologyStore) *RelationshipService {
return &RelationshipService{rels: rels, onto: onto}
}
// CreateRelationshipCmd is one edge creation. SourceType and TargetType are
// the resolved entity types (used for ontology edge validation).
type CreateRelationshipCmd struct {
SourceID domain.UUID
TargetID domain.UUID
SourceType string
TargetType string
Type string
Attributes map[string]any
ActorType string
Actor string
Method string
Path string
}
// Create validates the edge against the ontology and creates it in one
// transaction.
func (s *RelationshipService) Create(ctx context.Context, cmd CreateRelationshipCmd) (domain.Relationship, error) {
tree, err := s.onto.LoadTypeTree(ctx)
if err != nil {
return domain.Relationship{}, err
}
if err := tree.ValidateEdge(cmd.Type, cmd.SourceType, cmd.TargetType); err != nil {
return domain.Relationship{}, fmt.Errorf("invalid edge: %w", err)
}
rel := domain.Relationship{
SourceID: cmd.SourceID,
TargetID: cmd.TargetID,
Type: cmd.Type,
Attributes: cmd.Attributes,
ValidFrom: time.Now(),
}
created, err := s.rels.Create(ctx, ports.RelationshipCreateInput{
Relationship: rel,
Audit: []ports.AuditEntry{{
ActorType: cmd.ActorType, ActorLabel: cmd.Actor, Action: "create",
EntityID: cmd.SourceID, Method: cmd.Method, Path: cmd.Path,
Details: map[string]any{"source": cmd.SourceID, "target": cmd.TargetID, "type": cmd.Type},
}},
})
if err != nil {
return domain.Relationship{}, err
}
return created, nil
}
// End terminates an active relationship (soft-delete). Audit is kept at the
// adapter level since End is a simple state toggle with no ontology check.
func (s *RelationshipService) End(ctx context.Context, source, target domain.UUID, relType string) error {
return s.rels.End(ctx, source, target, relType)
}