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

@@ -453,6 +453,113 @@ func (r *EntityRepo) GetIdempotent(ctx context.Context, actor, key string) (port
}, nil
}
// RelRepo implements ports.RelationshipRepository over the postgres pool.
type RelRepo struct {
pool *Pool
}
var _ ports.RelationshipRepository = (*RelRepo)(nil)
func NewRelRepo(pool *Pool) *RelRepo { return &RelRepo{pool: pool} }
func (r *RelRepo) Create(ctx context.Context, input ports.RelationshipCreateInput) (domain.Relationship, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return domain.Relationship{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
attrsJSON := []byte("{}")
if len(input.Relationship.Attributes) > 0 {
attrsJSON, _ = json.Marshal(input.Relationship.Attributes)
}
_, err = tx.Exec(ctx, `
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
VALUES ($1, $2, $3, $4, now())`,
mustUUID(input.Relationship.SourceID), mustUUID(input.Relationship.TargetID),
input.Relationship.Type, attrsJSON)
if err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return domain.Relationship{}, errors.Join(domain.ErrAlreadyExists, err)
}
return domain.Relationship{}, err
}
if err := writeSideEffects(ctx, tx, input.Relationship.SourceID, input.Audit, input.Event); err != nil {
return domain.Relationship{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.Relationship{}, err
}
input.Relationship.ValidFrom = time.Now()
return input.Relationship, nil
}
func (r *RelRepo) End(ctx context.Context, source, target domain.UUID, relType string) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
result, err := sqlcgen.New(tx).EndCurrentRelationship(ctx, sqlcgen.EndCurrentRelationshipParams{
SourceID: mustUUID(source), TargetID: mustUUID(target), Type: relType,
})
if err != nil {
return err
}
if result == 0 {
return domain.ErrNotFound
}
return tx.Commit(ctx)
}
func (r *RelRepo) ListFor(ctx context.Context, entityID domain.UUID, direction string) ([]domain.Relationship, error) {
eid := mustUUID(entityID)
switch direction {
case "outbound":
return queryRelsBySource(r.pool, eid)
case "inbound":
return queryRelsByTarget(r.pool, eid)
default:
return queryRelsBoth(r.pool, eid)
}
}
func queryRelsBySource(pool *Pool, eid uuid.UUID) ([]domain.Relationship, error) {
rows, err := pool.Query(context.Background(),
`SELECT source_id, target_id, type, attributes, valid_from, valid_to
FROM relationships WHERE valid_to IS NULL AND source_id = $1 ORDER BY type`, eid)
if err != nil {
return nil, err
}
defer rows.Close()
return scanEdges(rows)
}
func queryRelsByTarget(pool *Pool, eid uuid.UUID) ([]domain.Relationship, error) {
rows, err := pool.Query(context.Background(),
`SELECT source_id, target_id, type, attributes, valid_from, valid_to
FROM relationships WHERE valid_to IS NULL AND target_id = $1 ORDER BY type`, eid)
if err != nil {
return nil, err
}
defer rows.Close()
return scanEdges(rows)
}
func queryRelsBoth(pool *Pool, eid uuid.UUID) ([]domain.Relationship, error) {
rows, err := pool.Query(context.Background(),
`SELECT source_id, target_id, type, attributes, valid_from, valid_to
FROM relationships WHERE valid_to IS NULL AND (source_id = $1 OR target_id = $1) ORDER BY type`, eid)
if err != nil {
return nil, err
}
defer rows.Close()
return scanEdges(rows)
}
// 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.