feat: Phase 4 — RelationshipService, postgres RelRepo, converged edges
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:
78
internal/core/app/relationships.go
Normal file
78
internal/core/app/relationships.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user