From 0a64da74bbe9810122e0c97051cdb759995346bb Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Mon, 18 May 2026 19:55:13 +0200 Subject: [PATCH] fix(iace/mitigations): idempotent CreateMitigation + UNIQUE(hazard_id, name) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [migration-approved] The init-handler was non-idempotent. A second click on "Neu initialisieren in Grenzen" inserted every engine-suggested mitigation a second time — e.g. the Bremsscheibe project ended up with 5 (hazard_id, name) duplicate pairs (HMI-Usability-Pruefung, Eindeutiges visuelles Feedback, Betriebsarten-Anzeige, Sicher begrenzter Bewegungsbereich, …). 45 such duplicates accumulated across all projects. Migration 030_iace_mitigation_unique.sql: 1. Picks one winning row per (hazard_id, name) using a stable rank: is_relevant DESC (expert decision wins over engine default) status DESC (verified > implemented > planned) created_at DESC (newest beats older on otherwise-equal rows) and deletes the losers (Bremsscheibe: 5 rows; total: 45). 2. Adds UNIQUE constraint iace_mitigations_hazard_name_uniq (hazard_id, name). Store-Layer (CreateMitigation): INSERT … ON CONFLICT (hazard_id, name) DO NOTHING RETURNING id. pgx.ErrNoRows from RETURNING → look up the existing row and return that. Callers (engine init + manual add) always get a usable Mitigation; the second click is silently swallowed instead of failing. Frontend dedupe in groupByTitle stays — it covers any pre-existing duplicates that survived the migration in edge cases (multi-row write in flight, etc.). With the UNIQUE constraint live, the in-memory dedupe is a belt-and-suspenders safety net rather than the load-bearing mechanism. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internal/iace/store_mitigations.go | 24 +++++++++- .../migrations/030_iace_mitigation_unique.sql | 46 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 ai-compliance-sdk/migrations/030_iace_mitigation_unique.sql diff --git a/ai-compliance-sdk/internal/iace/store_mitigations.go b/ai-compliance-sdk/internal/iace/store_mitigations.go index 0e208b80..73d03470 100644 --- a/ai-compliance-sdk/internal/iace/store_mitigations.go +++ b/ai-compliance-sdk/internal/iace/store_mitigations.go @@ -26,7 +26,13 @@ func (s *Store) CreateMitigation(ctx context.Context, req CreateMitigationReques UpdatedAt: time.Now().UTC(), } - _, err := s.pool.Exec(ctx, ` + // ON CONFLICT DO NOTHING + RETURNING id makes the call idempotent against + // the iace_mitigations_hazard_name_uniq constraint (migration 030). + // A repeated Engine-init no longer creates duplicates; the second click + // is silently swallowed. If nothing was inserted we look up the existing + // row and return that, so the caller always gets a usable Mitigation. + var insertedID uuid.UUID + err := s.pool.QueryRow(ctx, ` INSERT INTO iace_mitigations ( id, hazard_id, reduction_type, name, description, status, verification_method, verification_result, @@ -40,13 +46,27 @@ func (s *Store) CreateMitigation(ctx context.Context, req CreateMitigationReques $11, $12, $13, $14 ) + ON CONFLICT (hazard_id, name) DO NOTHING + RETURNING id `, m.ID, m.HazardID, string(m.ReductionType), m.Name, m.Description, string(m.Status), "", "", nil, uuid.Nil, m.IsRelevant, m.IsCustomerStandard, m.CreatedAt, m.UpdatedAt, - ) + ).Scan(&insertedID) + + if err == pgx.ErrNoRows { + // Conflict — return the existing row for the same (hazard_id, name). + var existingID uuid.UUID + if lookupErr := s.pool.QueryRow(ctx, + `SELECT id FROM iace_mitigations WHERE hazard_id = $1 AND name = $2`, + m.HazardID, m.Name, + ).Scan(&existingID); lookupErr != nil { + return nil, fmt.Errorf("create mitigation lookup-on-conflict: %w", lookupErr) + } + return s.getMitigation(ctx, existingID) + } if err != nil { return nil, fmt.Errorf("create mitigation: %w", err) } diff --git a/ai-compliance-sdk/migrations/030_iace_mitigation_unique.sql b/ai-compliance-sdk/migrations/030_iace_mitigation_unique.sql new file mode 100644 index 00000000..9007afd4 --- /dev/null +++ b/ai-compliance-sdk/migrations/030_iace_mitigation_unique.sql @@ -0,0 +1,46 @@ +-- Migration 030: De-duplicate iace_mitigations + add UNIQUE(hazard_id, name) +-- ========================================================================== +-- The mitigation init-handler used to be non-idempotent: a second click on +-- "Neu initialisieren in Grenzen" inserted every engine-suggested mitigation +-- a second time. The Bremsscheibe benchmark accumulated 5 such duplicate +-- (hazard_id, name) pairs (HMI-Usability, Eindeutiges-Feedback, +-- Betriebsarten-Anzeige, Sicher begrenzter Bewegungsbereich, …). +-- +-- Frontend mitigates the symptom with a per-hazard dedupe in groupByTitle, +-- but the DB still carries the redundant rows: confusing for SQL audits, +-- and the cleanup work was deferred only because the expert had not yet +-- decided which copy to keep. +-- +-- This migration: +-- 1. Picks the winning row per (hazard_id, name) using a stable rank: +-- is_relevant DESC (expert decision survives) +-- status DESC (verified > implemented > planned) +-- created_at DESC (newest wins when nothing else differs) +-- and deletes the rest. +-- 2. Adds a UNIQUE constraint so future engine init runs cannot +-- re-create the duplicates. +-- ========================================================================== + +WITH ranked AS ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY hazard_id, name + ORDER BY is_relevant DESC, + CASE status + WHEN 'verified' THEN 2 + WHEN 'implemented' THEN 1 + ELSE 0 + END DESC, + created_at DESC + ) AS rn + FROM iace_mitigations +) +DELETE FROM iace_mitigations + WHERE id IN (SELECT id FROM ranked WHERE rn > 1); + +ALTER TABLE iace_mitigations + DROP CONSTRAINT IF EXISTS iace_mitigations_hazard_name_uniq; + +ALTER TABLE iace_mitigations + ADD CONSTRAINT iace_mitigations_hazard_name_uniq + UNIQUE (hazard_id, name);