phase 2 review: fix SSE deadlock, MCP panic, lifecycle 500, NOT NULL bug

Reviewed the phase-2 implementation (parts 2–5) end to end. The suite hung
for 600s and several handlers were never exercised because there were no
tests for the new mutation/event/MCP surface. Fixes:

- CRITICAL: sseListener ran on context.Background() and held a pooled
  connection forever, so pool.Close() deadlocked (600s test timeout).
  NewHandler now takes a ctx that governs the listener; ListenAndServe and
  the test helper cancel it before closing the pool.
- CRITICAL: MCP AddTool panicked ("missing input schema") at construction
  under go-sdk v1.6.1 — so NewHandler (and every API handler) panicked.
  Added object input schemas to all 8 tools via an objSchema helper.
- HIGH: PatchEntity parsed lifecycle transitions as map[string][]string but
  the shape is {from:{to:{requires:[]}}}, so every state-change PATCH 500'd.
  Parse the nested shape; allow same-state no-ops.
- HIGH: CreateEntity bound SQL NULL for attributes when omitted, violating
  the NOT NULL column (the default only applies when omitted). Default to
  '{}'.
- MED: serveSSEWriter ignored the request ctx (per-client goroutine leak on
  disconnect) and set an invalid Content-Length: -1. Thread ctx through;
  omit the header. writeSSE now nil-checks the flusher (io.Pipe path passed
  nil → would have panicked on first event).
- MED: SSE `data:` leaked raw sqlcgen.Event (PascalCase, base64 JSONB).
  Emit canonical gen.Event so SSE matches GET /events. Verified live.
- LOW: CreateEntity uses uuid.NewV7 (ADR-0005) + real actor from context in
  audit; removed dead bearerAuth; fixed vet unkeyed-field warnings.

Tests (would have caught all of the above): entity create/patch with
If-Match 409/400, valid+invalid lifecycle transitions, idempotency replay,
duplicate-slug 409, abstract-type 422, event+audit side effects, MCP tool
registration. Live smoke test confirmed NOTIFY→listener→SSE delivery.

Also adds the missing Phase 2 deliverable: Gitea Actions CI (vet,
golangci-lint, govulncheck, generated-code drift guard, race tests against
TimescaleDB, docker build) and wires sqlc into `make generate`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 13:26:43 +02:00
parent 6b61495e3b
commit f1b0b65149
10 changed files with 457 additions and 202 deletions

View File

@@ -24,6 +24,23 @@ const (
graphNodeCap = 500
)
// actorInfo returns the caller's (type, label) from the request context,
// falling back to operator/unknown when unset.
func actorInfo(ctx context.Context) (string, string) {
if a := GetActor(ctx); a != nil {
typ := a.Type
if typ == "" {
typ = "operator"
}
label := a.Label
if label == "" {
label = a.ID
}
return typ, label
}
return "operator", "unknown"
}
func clampLimit(l *int) int {
if l == nil {
return defaultLimit
@@ -556,7 +573,7 @@ func (s *Server) AckSignal(ctx context.Context, req gen.AckSignalRequestObject)
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.AckSignal200JSONResponse{gen.SignalUpdatedJSONResponse(sig)}, nil
return gen.AckSignal200JSONResponse{SignalUpdatedJSONResponse: gen.SignalUpdatedJSONResponse(sig)}, nil
}
func (s *Server) ResolveSignal(ctx context.Context, req gen.ResolveSignalRequestObject) (gen.ResolveSignalResponseObject, error) {
@@ -593,7 +610,7 @@ func (s *Server) ResolveSignal(ctx context.Context, req gen.ResolveSignalRequest
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.ResolveSignal200JSONResponse{gen.SignalUpdatedJSONResponse(sig)}, nil
return gen.ResolveSignal200JSONResponse{SignalUpdatedJSONResponse: gen.SignalUpdatedJSONResponse(sig)}, nil
}
func (s *Server) MuteSignal(ctx context.Context, req gen.MuteSignalRequestObject) (gen.MuteSignalResponseObject, error) {
@@ -630,7 +647,7 @@ func (s *Server) MuteSignal(ctx context.Context, req gen.MuteSignalRequestObject
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return gen.MuteSignal200JSONResponse{gen.SignalUpdatedJSONResponse(sig)}, nil
return gen.MuteSignal200JSONResponse{SignalUpdatedJSONResponse: gen.SignalUpdatedJSONResponse(sig)}, nil
}
// ─── Observability reads ─────────────────────────────────────────────
@@ -758,8 +775,10 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb
return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput)
}
// Check idempotency if a key was provided.
actor := "operator"
// Check idempotency if a key was provided. The idempotency scope is the
// calling actor, so replays are per-caller.
actorType, actorLabel := actorInfo(ctx)
actor := actorLabel
var bodyHash string
if req.Params.IdempotencyKey != nil && *req.Params.IdempotencyKey != "" {
key := *req.Params.IdempotencyKey
@@ -796,7 +815,10 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb
}
}
id := uuid.New()
id, err := uuid.NewV7()
if err != nil {
return nil, err
}
slug := req.Body.Slug
if slug == "" {
slug = req.Body.Type + ":" + req.Body.Name
@@ -835,7 +857,9 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb
state = defaultState
}
var attrsJSON []byte
// attributes is NOT NULL; the column default only applies when omitted,
// not when an explicit NULL is bound — so default to an empty object.
attrsJSON := []byte("{}")
if req.Body.Attributes != nil {
attrsJSON, _ = json.Marshal(req.Body.Attributes)
}
@@ -881,7 +905,7 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb
// Audit.
entityID := inserted.ID
if auditErr := observability.Audit(ctx, q, "operator", actor, "create",
if auditErr := observability.Audit(ctx, q, actorType, actor, "create",
&entityID, "POST", "/api/v1/entities", "",
map[string]any{"type": req.Body.Type, "slug": slug}); auditErr != nil {
return nil, auditErr
@@ -952,8 +976,10 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
return nil, err
}
} else {
// Check the transition is valid.
var transitions map[string][]string
// Transitions are stored as {from: {to: {requires: [...]}}}
// (see seeds/ontology.yaml). Parse the nested shape and check
// that an edge from→to exists.
var transitions map[string]map[string]json.RawMessage
if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
return nil, fmt.Errorf("parse lifecycle transitions: %w", err)
}
@@ -964,20 +990,16 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
}
toState := *req.Body.State
if allowed, ok := transitions[fromState]; ok {
found := false
for _, s := range allowed {
if s == toState {
found = true
break
}
// A no-op (same state) is always allowed — the caller may be
// updating attributes and echoing the current state.
if toState != fromState {
tos, ok := transitions[fromState]
if !ok {
return nil, fmt.Errorf("%w: no transitions from %q", domain.ErrInvalidTransition, fromState)
}
if !found {
if _, ok := tos[toState]; !ok {
return nil, fmt.Errorf("%w: %s → %s", domain.ErrInvalidTransition, fromState, toState)
}
} else if fromState != "" {
// No transitions defined from current state.
return nil, fmt.Errorf("%w: %s → %s", domain.ErrInvalidTransition, fromState, toState)
}
}
}
@@ -1014,7 +1036,8 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
entity := sqlcEntityToGen(updated)
// Audit.
if auditErr := observability.Audit(ctx, q, "operator", "operator", "patch",
patchActorType, patchActor := actorInfo(ctx)
if auditErr := observability.Audit(ctx, q, patchActorType, patchActor, "patch",
&id, "PATCH", "/api/v1/entities/"+req.Id, "",
map[string]any{"version": expectedVersion}); auditErr != nil {
return nil, auditErr