Files
breakpilot-compliance/ai-compliance-sdk/internal/iace/compliance_crossover.go
T
Benjamin Admin 16fd406c1a feat(iace): secondary-harm chain model + AllPatterns drift fix
Task #17 — Folgegefahren-Modell as Vorbereitungs-Commit (no DB schema
change yet; persistence via separate [migration-approved] commit).

New:
- secondary_harms.go: SecondaryHarm struct + six canonical categories
  (consumer_safety, product_liability, food_safety, environmental,
  reputation, financial) with DE labels.
- hazard_pattern_types.go: HazardPattern extended with optional
  SecondaryHarms field — pattern library can now attach consequential-
  damage chains.
- hazard_patterns_secondary_demo.go: two worked examples
  - HP2000 Glasbruch carbonated bottling (the "Cola splitter" scenario
    from the IACE strategy discussion) with consumer_safety + food_safety
    + reputation chains
  - HP2001 Pharma fill-finish cross-contamination with consumer_safety
    + product_liability under AMG §84

Bonus fix:
- compliance_crossover.go AllPatterns() was a duplicate enumeration that
  silently drifted from collectAllPatterns() in pattern_registry.go.
  Pre-fix: 1058 patterns visible. Post-fix: 1213 patterns. The 155 invisible
  patterns included CRA, ISO12100 gaps, robot-cell, CNC extended, VDMA,
  textile-agri, GT-bremse — anything added after the original AllPatterns
  was authored. Audit-Suite (cmd/iace-audit) now sees the full set.

Next steps for full secondary-harm rollout:
- DB migration: hazards table + secondary_harms array column
- API: surface secondary_harms in /projects/:id/hazards response
- Frontend: collapsible Folgegefahren-Panel in HazardTable
2026-05-21 23:36:26 +02:00

230 lines
6.4 KiB
Go

package iace
import "sort"
// GetProjectComplianceTriggers analyses a project's hazards and the full
// pattern library to determine which DSGVO/AI Act/CRA/NIS2/Data Act
// obligations are triggered. It returns a deduplicated, severity-sorted
// summary with boolean flags for each regulation family.
func GetProjectComplianceTriggers(hazards []Hazard, patterns []HazardPattern) *ComplianceTriggerSummary {
triggerMap := GetComplianceTriggerMap()
// Build set of pattern IDs present in the pattern library for quick lookup
patternByID := make(map[string]HazardPattern, len(patterns))
for _, p := range patterns {
patternByID[p.ID] = p
}
// Collect all fired pattern IDs from the project's hazards.
// Hazards created from pattern matching store the source pattern ID
// in their Description ("Pattern HPXXX") or Name field.
firedPatterns := make(map[string]bool)
for _, h := range hazards {
extractPatternIDs(h.Description, firedPatterns)
extractPatternIDs(h.Name, firedPatterns)
extractPatternIDs(h.Scenario, firedPatterns)
}
// Also check each pattern against the hazard categories present
// in the project — if a pattern generates a category that exists
// among the hazards, consider the pattern relevant.
hazardCats := make(map[string]bool)
for _, h := range hazards {
if h.Category != "" {
hazardCats[h.Category] = true
}
}
for _, p := range patterns {
for _, cat := range p.GeneratedHazardCats {
if hazardCats[cat] {
firedPatterns[p.ID] = true
break
}
}
}
// Collect tag-level information from hazard metadata
tags := collectHazardTags(hazards)
tagTriggers := GetTagBasedTriggers(tags)
// Gather all triggers from fired patterns
var results []TriggerResult
seenRegulation := make(map[string]bool)
for pid := range firedPatterns {
triggers, ok := triggerMap[pid]
if !ok {
continue
}
for _, t := range triggers {
key := t.Regulation + "|" + t.Module
if seenRegulation[key] {
continue
}
seenRegulation[key] = true
// Try to find a hazard name for context
hName := findHazardNameForPattern(pid, hazards)
results = append(results, TriggerResult{
HazardID: "",
HazardName: hName,
PatternID: pid,
Trigger: t,
})
}
}
// Append tag-based triggers (deduplicated against pattern triggers)
for _, t := range tagTriggers {
key := t.Regulation + "|" + t.Module
if seenRegulation[key] {
continue
}
seenRegulation[key] = true
results = append(results, TriggerResult{
HazardID: "",
HazardName: "Tag-basiert",
PatternID: "",
Trigger: t,
})
}
// Sort by severity: high > medium > low
sort.Slice(results, func(i, j int) bool {
return severityRank(results[i].Trigger.Severity) > severityRank(results[j].Trigger.Severity)
})
// Build boolean summary flags
summary := buildSummaryFlags(results)
return &ComplianceTriggerSummary{
Triggers: results,
Total: len(results),
Summary: summary,
}
}
// AllPatterns returns every registered hazard pattern. Delegates to
// collectAllPatterns() in pattern_registry.go so new pattern sources only
// need to be added in one place. Pre-2026-05-21 this function maintained
// a duplicate enumeration which silently drifted from the registry —
// CRA, ISO12100-gap, robot-cell, CNC, VDMA, textile-agri, GT-bremse and
// secondary-harm patterns were invisible to AllPatterns callers.
func AllPatterns() []HazardPattern {
return collectAllPatterns()
}
// extractPatternIDs scans a text for "HP" followed by digits and adds
// any found pattern IDs to the set.
func extractPatternIDs(text string, set map[string]bool) {
for i := 0; i < len(text)-2; i++ {
if text[i] == 'H' && text[i+1] == 'P' && i+2 < len(text) && text[i+2] >= '0' && text[i+2] <= '9' {
end := i + 2
for end < len(text) && text[end] >= '0' && text[end] <= '9' {
end++
}
set[text[i:end]] = true
}
}
}
// findHazardNameForPattern returns the name of the first hazard whose
// description/name/scenario mentions the given pattern ID.
func findHazardNameForPattern(pid string, hazards []Hazard) string {
for _, h := range hazards {
if containsPatternID(h.Description, pid) || containsPatternID(h.Name, pid) || containsPatternID(h.Scenario, pid) {
return h.Name
}
}
if len(hazards) > 0 {
return hazards[0].Name
}
return ""
}
// containsPatternID checks whether text contains the exact pattern ID token.
func containsPatternID(text, pid string) bool {
idx := 0
for idx <= len(text)-len(pid) {
if text[idx:idx+len(pid)] == pid {
// Ensure it is not a substring of a longer ID
after := idx + len(pid)
if after >= len(text) || text[after] < '0' || text[after] > '9' {
return true
}
}
idx++
}
return false
}
// collectHazardTags extracts tag-like signals from hazard fields.
func collectHazardTags(hazards []Hazard) []string {
tagSet := make(map[string]bool)
for _, h := range hazards {
// Infer tags from hazard category names
switch h.Category {
case "software", "steuerung", "steuerungsfehler":
tagSet["has_software"] = true
tagSet["programmable"] = true
case "cyber", "cybersicherheit", "netzwerk":
tagSet["is_networked"] = true
tagSet["has_software"] = true
case "ki", "kuenstliche_intelligenz", "ai_ml":
tagSet["has_ai"] = true
tagSet["has_software"] = true
case "sensorik", "sensor":
tagSet["sensor_part"] = true
}
}
tags := make([]string, 0, len(tagSet))
for t := range tagSet {
tags = append(tags, t)
}
return tags
}
// severityRank maps severity strings to sort-order integers.
func severityRank(s string) int {
switch s {
case "high":
return 3
case "medium":
return 2
case "low":
return 1
default:
return 0
}
}
// buildSummaryFlags derives boolean flags from the collected trigger results.
func buildSummaryFlags(results []TriggerResult) map[string]bool {
summary := map[string]bool{
"dsfa_required": false,
"ai_act_relevant": false,
"cra_relevant": false,
"nis2_relevant": false,
"data_act_relevant": false,
}
for _, r := range results {
reg := r.Trigger.Regulation
if len(reg) >= 4 && reg[:4] == "DSGV" {
summary["dsfa_required"] = true
}
if len(reg) >= 6 && reg[:6] == "AI Act" {
summary["ai_act_relevant"] = true
}
if len(reg) >= 3 && reg[:3] == "CRA" {
summary["cra_relevant"] = true
}
if len(reg) >= 4 && reg[:4] == "NIS2" {
summary["nis2_relevant"] = true
}
if len(reg) >= 11 && reg[:11] == "EU Data Act" {
summary["data_act_relevant"] = true
}
}
return summary
}