Files
breakpilot-compliance/ai-compliance-sdk/internal/iace/store_mitigations.go
T
Benjamin Admin 0a64da74bb
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 56s
CI / iace-gt-coverage (push) Successful in 27s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / detect-changes (push) Successful in 11s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 17s
CI / loc-budget (push) Failing after 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
fix(iace/mitigations): idempotent CreateMitigation + UNIQUE(hazard_id, name)
[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) <noreply@anthropic.com>
2026-05-18 19:55:13 +02:00

295 lines
8.6 KiB
Go

package iace
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ============================================================================
// Mitigation CRUD Operations
// ============================================================================
// CreateMitigation creates a new mitigation measure for a hazard
func (s *Store) CreateMitigation(ctx context.Context, req CreateMitigationRequest) (*Mitigation, error) {
m := &Mitigation{
ID: uuid.New(),
HazardID: req.HazardID,
ReductionType: req.ReductionType,
Name: req.Name,
Description: req.Description,
Status: MitigationStatusPlanned,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
// 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,
verified_at, verified_by,
is_relevant, is_customer_standard,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8,
$9, $10,
$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)
}
return m, nil
}
// UpdateMitigation updates a mitigation with a dynamic set of fields
func (s *Store) UpdateMitigation(ctx context.Context, id uuid.UUID, updates map[string]interface{}) (*Mitigation, error) {
if len(updates) == 0 {
return s.getMitigation(ctx, id)
}
query := "UPDATE iace_mitigations SET updated_at = NOW()"
args := []interface{}{id}
argIdx := 2
for key, val := range updates {
switch key {
case "name", "description", "verification_result":
query += fmt.Sprintf(", %s = $%d", key, argIdx)
args = append(args, val)
argIdx++
case "status":
query += fmt.Sprintf(", status = $%d", argIdx)
args = append(args, val)
argIdx++
case "reduction_type":
query += fmt.Sprintf(", reduction_type = $%d", argIdx)
args = append(args, val)
argIdx++
case "verification_method":
query += fmt.Sprintf(", verification_method = $%d", argIdx)
args = append(args, val)
argIdx++
case "is_relevant":
query += fmt.Sprintf(", is_relevant = $%d", argIdx)
args = append(args, val)
argIdx++
case "is_customer_standard":
// CHECK constraint requires is_relevant=true when this is true,
// so we flip is_relevant on as well when the caller sets the
// customer-standard flag.
b, _ := val.(bool)
query += fmt.Sprintf(", is_customer_standard = $%d", argIdx)
args = append(args, b)
argIdx++
if b {
query += fmt.Sprintf(", is_relevant = $%d", argIdx)
args = append(args, true)
argIdx++
}
}
}
query += " WHERE id = $1"
_, err := s.pool.Exec(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("update mitigation: %w", err)
}
return s.getMitigation(ctx, id)
}
// VerifyMitigation marks a mitigation as verified
func (s *Store) VerifyMitigation(ctx context.Context, id uuid.UUID, verificationResult string, verifiedBy string) error {
now := time.Now().UTC()
verifiedByUUID, err := uuid.Parse(verifiedBy)
if err != nil {
return fmt.Errorf("invalid verified_by UUID: %w", err)
}
_, err = s.pool.Exec(ctx, `
UPDATE iace_mitigations SET
status = $2,
verification_result = $3,
verified_at = $4,
verified_by = $5,
updated_at = $4
WHERE id = $1
`, id, string(MitigationStatusVerified), verificationResult, now, verifiedByUUID)
if err != nil {
return fmt.Errorf("verify mitigation: %w", err)
}
return nil
}
// ListMitigations lists all mitigations for a hazard
func (s *Store) ListMitigations(ctx context.Context, hazardID uuid.UUID) ([]Mitigation, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id, hazard_id, reduction_type, name, description,
status, verification_method, verification_result,
verified_at, verified_by,
is_relevant, is_customer_standard,
created_at, updated_at
FROM iace_mitigations WHERE hazard_id = $1
ORDER BY created_at ASC
`, hazardID)
if err != nil {
return nil, fmt.Errorf("list mitigations: %w", err)
}
defer rows.Close()
var mitigations []Mitigation
for rows.Next() {
var m Mitigation
var reductionType, status, verificationMethod string
err := rows.Scan(
&m.ID, &m.HazardID, &reductionType, &m.Name, &m.Description,
&status, &verificationMethod, &m.VerificationResult,
&m.VerifiedAt, &m.VerifiedBy,
&m.IsRelevant, &m.IsCustomerStandard,
&m.CreatedAt, &m.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("list mitigations scan: %w", err)
}
m.ReductionType = ReductionType(reductionType)
m.Status = MitigationStatus(status)
m.VerificationMethod = VerificationMethod(verificationMethod)
mitigations = append(mitigations, m)
}
return mitigations, nil
}
// ListMitigationsByProject lists all mitigations for all hazards in a project.
func (s *Store) ListMitigationsByProject(ctx context.Context, projectID uuid.UUID) ([]Mitigation, error) {
rows, err := s.pool.Query(ctx, `
SELECT
m.id, m.hazard_id, m.reduction_type, m.name, m.description,
m.status, m.verification_method, m.verification_result,
m.verified_at, m.verified_by,
m.is_relevant, m.is_customer_standard,
m.created_at, m.updated_at
FROM iace_mitigations m
JOIN iace_hazards h ON h.id = m.hazard_id
WHERE h.project_id = $1
ORDER BY m.created_at ASC
`, projectID)
if err != nil {
return nil, fmt.Errorf("list mitigations by project: %w", err)
}
defer rows.Close()
var mitigations []Mitigation
for rows.Next() {
var m Mitigation
var reductionType, status, verificationMethod string
err := rows.Scan(
&m.ID, &m.HazardID, &reductionType, &m.Name, &m.Description,
&status, &verificationMethod, &m.VerificationResult,
&m.VerifiedAt, &m.VerifiedBy,
&m.IsRelevant, &m.IsCustomerStandard,
&m.CreatedAt, &m.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("list mitigations by project scan: %w", err)
}
m.ReductionType = ReductionType(reductionType)
m.Status = MitigationStatus(status)
m.VerificationMethod = VerificationMethod(verificationMethod)
mitigations = append(mitigations, m)
}
return mitigations, nil
}
// DeleteMitigation deletes a mitigation by ID.
func (s *Store) DeleteMitigation(ctx context.Context, id uuid.UUID) error {
_, err := s.pool.Exec(ctx, `DELETE FROM iace_mitigations WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("delete mitigation: %w", err)
}
return nil
}
// GetMitigation fetches a single mitigation by ID.
func (s *Store) GetMitigation(ctx context.Context, id uuid.UUID) (*Mitigation, error) {
return s.getMitigation(ctx, id)
}
// getMitigation is a helper to fetch a single mitigation by ID
func (s *Store) getMitigation(ctx context.Context, id uuid.UUID) (*Mitigation, error) {
var m Mitigation
var reductionType, status, verificationMethod string
err := s.pool.QueryRow(ctx, `
SELECT
id, hazard_id, reduction_type, name, description,
status, verification_method, verification_result,
verified_at, verified_by,
is_relevant, is_customer_standard,
created_at, updated_at
FROM iace_mitigations WHERE id = $1
`, id).Scan(
&m.ID, &m.HazardID, &reductionType, &m.Name, &m.Description,
&status, &verificationMethod, &m.VerificationResult,
&m.VerifiedAt, &m.VerifiedBy,
&m.IsRelevant, &m.IsCustomerStandard,
&m.CreatedAt, &m.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get mitigation: %w", err)
}
m.ReductionType = ReductionType(reductionType)
m.Status = MitigationStatus(status)
m.VerificationMethod = VerificationMethod(verificationMethod)
return &m, nil
}