diff --git a/admin-compliance/app/sdk/iace/[projectId]/interview/_components/LimitsFormSections.tsx b/admin-compliance/app/sdk/iace/[projectId]/interview/_components/LimitsFormSections.tsx index 121c0a9..151ddda 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/interview/_components/LimitsFormSections.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/interview/_components/LimitsFormSections.tsx @@ -7,6 +7,7 @@ import { AREA_OF_USE_OPTIONS, OPERATING_MODE_OPTIONS, PERSON_GROUP_OPTIONS, + INDUSTRY_SECTOR_OPTIONS, type LimitsFormData, } from '../_types' @@ -204,6 +205,22 @@ export function LimitsFormSections({ data, onChange, prefilled }: LimitsFormSect rows={4} /> + + {/* Section 7: Einsatzbereich / Branche */} + +
+

+ Die Branchenauswahl steuert welche branchenspezifischen Gefaehrdungsmuster (z.B. Medizintechnik, Lebensmittel, Aufzuege) bei der Risikoanalyse beruecksichtigt werden. Branchenfremde Muster werden automatisch ausgeblendet. +

+
+ onChange('industry_sectors', v)} + options={INDUSTRY_SECTOR_OPTIONS} + helpText="Waehlen Sie alle zutreffenden Branchen. Bei Mehrfachauswahl werden alle relevanten Gefaehrdungen beruecksichtigt." + /> +
) } diff --git a/admin-compliance/app/sdk/iace/[projectId]/interview/_types.ts b/admin-compliance/app/sdk/iace/[projectId]/interview/_types.ts index c158a15..4214098 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/interview/_types.ts +++ b/admin-compliance/app/sdk/iace/[projectId]/interview/_types.ts @@ -35,6 +35,9 @@ export interface LimitsFormData { // Section 6: Betroffene Personen person_groups: string[] qualification_requirements: string + + // Section 7: Einsatzbereich / Branche (fuer Pattern-Filterung) + industry_sectors: string[] } export const EMPTY_LIMITS_FORM: LimitsFormData = { @@ -59,6 +62,7 @@ export const EMPTY_LIMITS_FORM: LimitsFormData = { pneumatic_hydraulic_interfaces: '', person_groups: [], qualification_requirements: '', + industry_sectors: [], } export const AREA_OF_USE_OPTIONS = [ @@ -77,6 +81,43 @@ export const OPERATING_MODE_OPTIONS = [ 'Wartung', ] +export const INDUSTRY_SECTOR_OPTIONS = [ + 'Allgemeiner Maschinenbau', + 'Automobil / Zulieferer', + 'Robotik / Cobot', + 'Medizintechnik', + 'Lebensmittel / Getraenke', + 'Verpackung', + 'Pharma / Chemie', + 'Bau / Baumaschinen', + 'Forst / Holzbearbeitung', + 'Aufzuege / Foerdertechnik', + 'Textil', + 'Landmaschinen', + 'Druck / Papier', + 'Metall / CNC', + 'Schweissen / Oberflaechentechnik', +] + +/** Maps display labels to MachineTypes for pattern engine filtering */ +export const INDUSTRY_TO_MACHINE_TYPES: Record = { + 'Allgemeiner Maschinenbau': ['general_industry'], + 'Automobil / Zulieferer': ['automotive'], + 'Robotik / Cobot': ['robotics_cobot', 'cobot'], + 'Medizintechnik': ['medical_device', 'infusion_pump', 'ventilator', 'patient_monitor'], + 'Lebensmittel / Getraenke': ['food_processing'], + 'Verpackung': ['packaging'], + 'Pharma / Chemie': ['chemical', 'pharmaceutical'], + 'Bau / Baumaschinen': ['construction', 'crane', 'excavator'], + 'Forst / Holzbearbeitung': ['forestry', 'woodworking', 'circular_saw'], + 'Aufzuege / Foerdertechnik': ['elevator', 'lift', 'escalator', 'conveyor'], + 'Textil': ['textile', 'spinning', 'weaving', 'finishing'], + 'Landmaschinen': ['agricultural', 'tractor', 'harvester'], + 'Druck / Papier': ['printing'], + 'Metall / CNC': ['cnc', 'metalworking', 'lathe', 'milling'], + 'Schweissen / Oberflaechentechnik': ['welding', 'surface_treatment'], +} + export const PERSON_GROUP_OPTIONS = [ 'Bedienpersonal', 'Einrichter', @@ -93,7 +134,7 @@ export interface FormSection { number: number title: string description: string - icon: 'clipboard' | 'target' | 'alert' | 'box' | 'link' | 'users' + icon: 'clipboard' | 'target' | 'alert' | 'box' | 'link' | 'users' | 'briefcase' } export const FORM_SECTIONS: FormSection[] = [ @@ -139,4 +180,11 @@ export const FORM_SECTIONS: FormSection[] = [ description: 'Personengruppen und Qualifikationsanforderungen', icon: 'users', }, + { + id: 'industry_sector', + number: 7, + title: 'Einsatzbereich / Branche', + description: 'Branche bestimmt welche branchenspezifischen Gefaehrdungen beruecksichtigt werden', + icon: 'briefcase', + }, ] diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go index a41f204..67f3e45 100644 --- a/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_init.go @@ -1,7 +1,6 @@ package handlers import ( - "encoding/json" "fmt" "net/http" @@ -89,7 +88,6 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) { if len(existingComps) == 0 && len(parseResult.Components) > 0 { created := 0 for _, comp := range parseResult.Components { - // Derive component type from tags compType := deriveComponentType(comp.Tags) _, cerr := h.store.CreateComponent(ctx, iace.CreateComponentRequest{ ProjectID: projectID, @@ -117,9 +115,8 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) { energyIDs = append(energyIDs, e.SourceID) } - // Merge explicit operational_states from UI with parsed states from narrative operationalStates := mergeStringSlices(parseResult.OperationalStates, extractOperationalStatesFromMetadata(project.Metadata)) - stateTransitions := parseResult.StateTransitions + machineTypes := extractIndustrySectorsFromMetadata(project.Metadata) engine := iace.NewPatternEngine() matchOutput := engine.Match(iace.MatchInput{ @@ -128,8 +125,9 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) { LifecyclePhases: parseResult.LifecyclePhases, CustomTags: parseResult.CustomTags, OperationalStates: operationalStates, - StateTransitions: stateTransitions, + StateTransitions: parseResult.StateTransitions, HumanRoles: parseResult.Roles, + MachineTypes: machineTypes, }) steps = append(steps, InitStep{ Name: "Patterns abgeglichen", @@ -143,14 +141,12 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) { hazardIDsByCategory := make(map[string]uuid.UUID) if len(existingHazards) == 0 && len(matchOutput.MatchedPatterns) > 0 { - // Get first component for hazard assignment comps, _ := h.store.ListComponents(ctx, projectID) var defaultCompID uuid.UUID if len(comps) > 0 { defaultCompID = comps[0].ID } - // Deduplicate by category — one hazard per category created := 0 seenCat := make(map[string]bool) for _, mp := range matchOutput.MatchedPatterns { @@ -164,18 +160,13 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) { if name == "" { name = cat } - scenario := mp.ScenarioDE - hazardType := mp.GeneratedHazardType - if hazardType == "" { - hazardType = iace.DefaultHazardType - } hz, cerr := h.store.CreateHazard(ctx, iace.CreateHazardRequest{ ProjectID: projectID, ComponentID: defaultCompID, Name: name, - Description: scenario, + Description: mp.ScenarioDE, Category: cat, - Scenario: scenario, + Scenario: mp.ScenarioDE, Function: iace.EncodeOpStates(mp.OperationalStates), TriggerEvent: mp.TriggerDE, PossibleHarm: mp.HarmDE, @@ -198,7 +189,7 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) { } steps = append(steps, hazardStep) - // ── Step 6: Create mitigations (pattern-suggested + category fallback) ── + // ── Step 6: Create mitigations ── existingMits, _ := h.store.ListMitigationsByProject(ctx, projectID) mitStep := InitStep{Name: "Massnahmen erstellt", Status: "skipped"} @@ -214,7 +205,6 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) { created := 0 usedMeasureIDs := make(map[string]bool) - // A) Pattern-suggested measures (direct reference) for _, sm := range matchOutput.SuggestedMeasures { entry, ok := measureByID[sm.MeasureID] if !ok || usedMeasureIDs[sm.MeasureID] { @@ -229,10 +219,8 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) { rt = iace.ReductionTypeInformation } _, cerr := h.store.CreateMitigation(ctx, iace.CreateMitigationRequest{ - HazardID: hazardID, - ReductionType: rt, - Name: entry.Name, - Description: entry.Description, + HazardID: hazardID, ReductionType: rt, + Name: entry.Name, Description: entry.Description, }) if cerr == nil { created++ @@ -240,13 +228,10 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) { } } - // B) Category fallback — for each hazard category, add measures - // from the library that match (but weren't pattern-suggested) for hazCat, hazID := range hazardIDsByCategory { measCat := patternCatToMeasureCat(hazCat) - candidates := measuresByCat[measCat] added := 0 - for _, m := range candidates { + for _, m := range measuresByCat[measCat] { if usedMeasureIDs[m.ID] || added >= 8 { break } @@ -255,10 +240,8 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) { rt = iace.ReductionTypeInformation } _, cerr := h.store.CreateMitigation(ctx, iace.CreateMitigationRequest{ - HazardID: hazID, - ReductionType: rt, - Name: m.Name, - Description: m.Description, + HazardID: hazID, ReductionType: rt, + Name: m.Name, Description: m.Description, }) if cerr == nil { created++ @@ -267,7 +250,6 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) { } } } - mitStep = InitStep{Name: "Massnahmen erstellt", Status: "done", Count: created} } else if len(existingMits) > 0 { mitStep.Details = "Bereits vorhanden" @@ -285,11 +267,7 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) { if normResult != nil { normCount = len(normResult.ANorms) + len(normResult.B1Norms) + len(normResult.B2Norms) + len(normResult.CNorms) } - steps = append(steps, InitStep{ - Name: "Normen vorgeschlagen", - Status: "done", - Count: normCount, - }) + steps = append(steps, InitStep{Name: "Normen vorgeschlagen", Status: "done", Count: normCount}) // ── Audit trail ── h.store.AddAuditEntry(ctx, projectID, "project_initialization", projectID, @@ -301,172 +279,9 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) { "project_id": projectID.String(), "steps": steps, "summary": gin.H{ - "components": steps[1].Count, - "patterns": steps[2].Count, - "hazards": steps[3].Count, - "mitigations": steps[4].Count, - "norms": steps[5].Count, + "components": steps[1].Count, "patterns": steps[2].Count, + "hazards": steps[3].Count, "mitigations": steps[4].Count, + "norms": steps[5].Count, }, }) } - -// extractNarrativeFromMetadata builds a combined text from the limits_form. -func extractNarrativeFromMetadata(metadata json.RawMessage) string { - if metadata == nil { - return "" - } - var meta map[string]json.RawMessage - if err := json.Unmarshal(metadata, &meta); err != nil { - return "" - } - limitsRaw, ok := meta["limits_form"] - if !ok { - return "" - } - var limits map[string]interface{} - if err := json.Unmarshal(limitsRaw, &limits); err != nil { - return "" - } - - textFields := []string{ - "general_description", "intended_purpose", "foreseeable_misuse", - "space_limits", "time_limits", "environmental_conditions", - "energy_sources", "materials_processed", "operating_modes", - "maintenance_requirements", "personnel_requirements", - "interfaces_description", "control_system_description", - "safety_functions_description", - } - var result string - for _, field := range textFields { - if v, ok := limits[field]; ok { - if s, ok := v.(string); ok && s != "" { - result += s + "\n\n" - } - } - } - return result -} - -// patternCatToMeasureCat maps pattern hazard categories to measure categories. -// Patterns use "mechanical_hazard", measures use "mechanical". -func patternCatToMeasureCat(patternCat string) string { - m := map[string]string{ - "mechanical_hazard": "mechanical", - "electrical_hazard": "electrical", - "thermal_hazard": "thermal", - "noise_vibration": "noise_vibration", - "pneumatic_hydraulic": "pneumatic_hydraulic", - "material_environmental": "material_environmental", - "ergonomic": "ergonomic", - "ergonomic_hazard": "ergonomic", - "software_fault": "software_control", - "safety_function_failure": "safety_function", - "fire_explosion": "thermal", - "radiation_hazard": "material_environmental", - "unauthorized_access": "cyber_network", - "communication_failure": "cyber_network", - "firmware_corruption": "cyber_network", - "logging_audit_failure": "cyber_network", - "ai_misclassification": "ai_specific", - "false_classification": "ai_specific", - "model_drift": "ai_specific", - "data_poisoning": "ai_specific", - "sensor_spoofing": "ai_specific", - "unintended_bias": "ai_specific", - "sensor_fault": "software_control", - "configuration_error": "software_control", - "update_failure": "software_control", - "hmi_error": "software_control", - "emc_hazard": "electrical", - "maintenance_hazard": "mechanical", - "mode_confusion": "software_control", - } - if cat, ok := m[patternCat]; ok { - return cat - } - return "general" -} - -// deriveComponentType guesses the component type from its tags. -func deriveComponentType(tags []string) iace.ComponentType { - for _, t := range tags { - switch { - case t == "software" || t == "has_software": - return iace.ComponentTypeSoftware - case t == "firmware" || t == "has_firmware": - return iace.ComponentTypeFirmware - case t == "has_ai" || t == "ai_model": - return iace.ComponentTypeAIModel - case t == "hmi" || t == "display" || t == "touchscreen": - return iace.ComponentTypeHMI - case t == "sensor" || t == "camera": - return iace.ComponentTypeSensor - case t == "electric_motor" || t == "electric_drive": - return iace.ComponentTypeElectrical - case t == "networked" || t == "ethernet" || t == "wifi": - return iace.ComponentTypeNetwork - case t == "hydraulic" || t == "pneumatic": - return iace.ComponentTypeActuator - } - } - return iace.ComponentTypeMechanical -} - -// extractOperationalStatesFromMetadata reads the explicit operational_states -// selection that the user set via the Betriebszustand-UI. -func extractOperationalStatesFromMetadata(metadata json.RawMessage) []string { - if metadata == nil { - return nil - } - var meta map[string]json.RawMessage - if err := json.Unmarshal(metadata, &meta); err != nil { - return nil - } - raw, ok := meta["operational_states"] - if !ok { - return nil - } - var states []string - if err := json.Unmarshal(raw, &states); err != nil { - return nil - } - return states -} - -// mergeStringSlices merges two string slices, deduplicating entries. -func mergeStringSlices(a, b []string) []string { - seen := make(map[string]bool, len(a)+len(b)) - var result []string - for _, s := range a { - if !seen[s] { - seen[s] = true - result = append(result, s) - } - } - for _, s := range b { - if !seen[s] { - seen[s] = true - result = append(result, s) - } - } - return result -} - -// findHazardForMeasureByCategory finds a matching hazard for a measure. -func findHazardForMeasureByCategory(measureCat string, hazardsByCategory map[string]uuid.UUID) uuid.UUID { - // Direct match - if id, ok := hazardsByCategory[measureCat]; ok { - return id - } - // Fuzzy match — "mechanical" matches "mechanical_hazard" - for cat, id := range hazardsByCategory { - if len(measureCat) > 3 && len(cat) > 3 && cat[:4] == measureCat[:4] { - return id - } - } - // Fallback: first hazard - for _, id := range hazardsByCategory { - return id - } - return uuid.Nil -} diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_init_helpers.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_init_helpers.go new file mode 100644 index 0000000..6f5fb2e --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_init_helpers.go @@ -0,0 +1,207 @@ +package handlers + +import ( + "encoding/json" + + "github.com/breakpilot/ai-compliance-sdk/internal/iace" + "github.com/google/uuid" +) + +// extractNarrativeFromMetadata builds a combined text from the limits_form. +func extractNarrativeFromMetadata(metadata json.RawMessage) string { + if metadata == nil { + return "" + } + var meta map[string]json.RawMessage + if err := json.Unmarshal(metadata, &meta); err != nil { + return "" + } + limitsRaw, ok := meta["limits_form"] + if !ok { + return "" + } + var limits map[string]interface{} + if err := json.Unmarshal(limitsRaw, &limits); err != nil { + return "" + } + + textFields := []string{ + "general_description", "intended_purpose", "foreseeable_misuse", + "space_limits", "time_limits", "environmental_conditions", + "energy_sources", "materials_processed", "operating_modes", + "maintenance_requirements", "personnel_requirements", + "interfaces_description", "control_system_description", + "safety_functions_description", + } + var result string + for _, field := range textFields { + if v, ok := limits[field]; ok { + if s, ok := v.(string); ok && s != "" { + result += s + "\n\n" + } + } + } + return result +} + +// patternCatToMeasureCat maps pattern hazard categories to measure categories. +func patternCatToMeasureCat(patternCat string) string { + m := map[string]string{ + "mechanical_hazard": "mechanical", "electrical_hazard": "electrical", + "thermal_hazard": "thermal", "noise_vibration": "noise_vibration", + "pneumatic_hydraulic": "pneumatic_hydraulic", "material_environmental": "material_environmental", + "ergonomic": "ergonomic", "ergonomic_hazard": "ergonomic", + "software_fault": "software_control", "safety_function_failure": "safety_function", + "fire_explosion": "thermal", "radiation_hazard": "material_environmental", + "unauthorized_access": "cyber_network", "communication_failure": "cyber_network", + "firmware_corruption": "cyber_network", "logging_audit_failure": "cyber_network", + "ai_misclassification": "ai_specific", "false_classification": "ai_specific", + "model_drift": "ai_specific", "data_poisoning": "ai_specific", + "sensor_spoofing": "ai_specific", "unintended_bias": "ai_specific", + "sensor_fault": "software_control", "configuration_error": "software_control", + "update_failure": "software_control", "hmi_error": "software_control", + "emc_hazard": "electrical", "maintenance_hazard": "mechanical", + "mode_confusion": "software_control", "chemical_risk": "material_environmental", + } + if cat, ok := m[patternCat]; ok { + return cat + } + return "general" +} + +// deriveComponentType guesses the component type from its tags. +func deriveComponentType(tags []string) iace.ComponentType { + for _, t := range tags { + switch { + case t == "software" || t == "has_software": + return iace.ComponentTypeSoftware + case t == "firmware" || t == "has_firmware": + return iace.ComponentTypeFirmware + case t == "has_ai" || t == "ai_model": + return iace.ComponentTypeAIModel + case t == "hmi" || t == "display" || t == "touchscreen": + return iace.ComponentTypeHMI + case t == "sensor" || t == "camera": + return iace.ComponentTypeSensor + case t == "electric_motor" || t == "electric_drive": + return iace.ComponentTypeElectrical + case t == "networked" || t == "ethernet" || t == "wifi": + return iace.ComponentTypeNetwork + case t == "hydraulic" || t == "pneumatic": + return iace.ComponentTypeActuator + } + } + return iace.ComponentTypeMechanical +} + +// extractOperationalStatesFromMetadata reads the explicit operational_states +// selection that the user set via the Betriebszustand-UI. +func extractOperationalStatesFromMetadata(metadata json.RawMessage) []string { + if metadata == nil { + return nil + } + var meta map[string]json.RawMessage + if err := json.Unmarshal(metadata, &meta); err != nil { + return nil + } + raw, ok := meta["operational_states"] + if !ok { + return nil + } + var states []string + if err := json.Unmarshal(raw, &states); err != nil { + return nil + } + return states +} + +// mergeStringSlices merges two string slices, deduplicating entries. +func mergeStringSlices(a, b []string) []string { + seen := make(map[string]bool, len(a)+len(b)) + var result []string + for _, s := range a { + if !seen[s] { + seen[s] = true + result = append(result, s) + } + } + for _, s := range b { + if !seen[s] { + seen[s] = true + result = append(result, s) + } + } + return result +} + +// extractIndustrySectorsFromMetadata reads the industry_sectors selection +// from project metadata and maps them to MachineTypes for pattern filtering. +func extractIndustrySectorsFromMetadata(metadata json.RawMessage) []string { + if metadata == nil { + return nil + } + var meta map[string]json.RawMessage + if err := json.Unmarshal(metadata, &meta); err != nil { + return nil + } + limitsRaw, ok := meta["limits_form"] + if !ok { + return nil + } + var limits map[string]json.RawMessage + if err := json.Unmarshal(limitsRaw, &limits); err != nil { + return nil + } + sectorsRaw, ok := limits["industry_sectors"] + if !ok { + return nil + } + var sectors []string + if err := json.Unmarshal(sectorsRaw, §ors); err != nil { + return nil + } + labelMap := map[string][]string{ + "Allgemeiner Maschinenbau": {"general_industry"}, + "Automobil / Zulieferer": {"automotive"}, + "Robotik / Cobot": {"robotics_cobot", "cobot"}, + "Medizintechnik": {"medical_device", "infusion_pump", "ventilator", "patient_monitor"}, + "Lebensmittel / Getraenke": {"food_processing"}, + "Verpackung": {"packaging"}, + "Pharma / Chemie": {"chemical", "pharmaceutical"}, + "Bau / Baumaschinen": {"construction", "crane", "excavator"}, + "Forst / Holzbearbeitung": {"forestry", "woodworking", "circular_saw"}, + "Aufzuege / Foerdertechnik": {"elevator", "lift", "escalator", "conveyor"}, + "Textil": {"textile", "spinning", "weaving", "finishing"}, + "Landmaschinen": {"agricultural", "tractor", "harvester"}, + "Druck / Papier": {"printing"}, + "Metall / CNC": {"cnc", "metalworking", "lathe", "milling"}, + "Schweissen / Oberflaechentechnik": {"welding", "surface_treatment"}, + } + var result []string + seen := make(map[string]bool) + for _, sector := range sectors { + for _, mt := range labelMap[sector] { + if !seen[mt] { + seen[mt] = true + result = append(result, mt) + } + } + } + return result +} + +// findHazardForMeasureByCategory finds a matching hazard for a measure. +func findHazardForMeasureByCategory(measureCat string, hazardsByCategory map[string]uuid.UUID) uuid.UUID { + if id, ok := hazardsByCategory[measureCat]; ok { + return id + } + for cat, id := range hazardsByCategory { + if len(measureCat) > 3 && len(cat) > 3 && cat[:4] == measureCat[:4] { + return id + } + } + for _, id := range hazardsByCategory { + return id + } + return uuid.Nil +} diff --git a/ai-compliance-sdk/internal/iace/pattern_engine.go b/ai-compliance-sdk/internal/iace/pattern_engine.go index bb6c91c..2fbe310 100644 --- a/ai-compliance-sdk/internal/iace/pattern_engine.go +++ b/ai-compliance-sdk/internal/iace/pattern_engine.go @@ -20,6 +20,10 @@ type MatchInput struct { // FailureModes are the active failure mode IDs relevant for this project. // Used to filter patterns that require specific failure modes. FailureModes []string `json:"failure_modes,omitempty"` + // MachineTypes are the industry sectors / machine types for this project. + // Patterns with MachineTypes filter only fire if at least one matches. + // Empty = all patterns fire (backwards compatible). + MachineTypes []string `json:"machine_types,omitempty"` } // MatchOutput contains the results of pattern matching. @@ -317,6 +321,22 @@ func (e *PatternEngine) Match(input MatchInput) *MatchOutput { // patternMatches checks if a pattern fires given the resolved tag set, lifecycle phases, // operational states, and state transitions. func patternMatches(p HazardPattern, tagSet map[string]bool, input MatchInput) bool { + // If pattern requires specific machine types, project must match at least one. + // Patterns without MachineTypes fire for ALL projects (backwards compatible). + if len(p.MachineTypes) > 0 && len(input.MachineTypes) > 0 { + found := false + mtSet := toSet(input.MachineTypes) + for _, mt := range p.MachineTypes { + if mtSet[mt] { + found = true + break + } + } + if !found { + return false + } + } + // All required component tags must be present (AND) for _, t := range p.RequiredComponentTags { if !tagSet[t] {