All oversized iace files now comply with the 500-line hard cap: - hazard_library_ai_sw.go split into ai_sw (false_classification..communication) and ai_fw (unauthorized_access..update_failure) - hazard_library_software_hmi.go split into software_hmi (software_fault+hmi) and config_integration (configuration_error+logging+integration) - hazard_library_machine_safety.go split to keep mechanical/electrical/thermal/emc, safety_functions extracted into hazard_library_safety_functions.go - store_hazards.go split: hazard library queries moved to store_hazard_library.go - store_projects.go split: component and classification ops to store_components.go - store_mitigations.go split: evidence/verification/ref-data to store_evidence.go - hazard_library.go GetBuiltinHazardLibrary() updated to call all sub-functions - All iace tests pass (go test ./internal/iace/...) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
394 lines
11 KiB
Go
394 lines
11 KiB
Go
package iace
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// ============================================================================
|
|
// Hazard CRUD Operations
|
|
// ============================================================================
|
|
|
|
// CreateHazard creates a new hazard within a project
|
|
func (s *Store) CreateHazard(ctx context.Context, req CreateHazardRequest) (*Hazard, error) {
|
|
h := &Hazard{
|
|
ID: uuid.New(),
|
|
ProjectID: req.ProjectID,
|
|
ComponentID: req.ComponentID,
|
|
LibraryHazardID: req.LibraryHazardID,
|
|
Name: req.Name,
|
|
Description: req.Description,
|
|
Scenario: req.Scenario,
|
|
Category: req.Category,
|
|
SubCategory: req.SubCategory,
|
|
Status: HazardStatusIdentified,
|
|
MachineModule: req.MachineModule,
|
|
Function: req.Function,
|
|
LifecyclePhase: req.LifecyclePhase,
|
|
HazardousZone: req.HazardousZone,
|
|
TriggerEvent: req.TriggerEvent,
|
|
AffectedPerson: req.AffectedPerson,
|
|
PossibleHarm: req.PossibleHarm,
|
|
ReviewStatus: ReviewStatusDraft,
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
}
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO iace_hazards (
|
|
id, project_id, component_id, library_hazard_id,
|
|
name, description, scenario, category, sub_category, status,
|
|
machine_module, function, lifecycle_phase, hazardous_zone,
|
|
trigger_event, affected_person, possible_harm, review_status,
|
|
created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4,
|
|
$5, $6, $7, $8, $9, $10,
|
|
$11, $12, $13, $14,
|
|
$15, $16, $17, $18,
|
|
$19, $20
|
|
)
|
|
`,
|
|
h.ID, h.ProjectID, h.ComponentID, h.LibraryHazardID,
|
|
h.Name, h.Description, h.Scenario, h.Category, h.SubCategory, string(h.Status),
|
|
h.MachineModule, h.Function, h.LifecyclePhase, h.HazardousZone,
|
|
h.TriggerEvent, h.AffectedPerson, h.PossibleHarm, string(h.ReviewStatus),
|
|
h.CreatedAt, h.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create hazard: %w", err)
|
|
}
|
|
|
|
return h, nil
|
|
}
|
|
|
|
// GetHazard retrieves a hazard by ID
|
|
func (s *Store) GetHazard(ctx context.Context, id uuid.UUID) (*Hazard, error) {
|
|
var h Hazard
|
|
var status, reviewStatus string
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
id, project_id, component_id, library_hazard_id,
|
|
name, description, scenario, category, sub_category, status,
|
|
machine_module, function, lifecycle_phase, hazardous_zone,
|
|
trigger_event, affected_person, possible_harm, review_status,
|
|
created_at, updated_at
|
|
FROM iace_hazards WHERE id = $1
|
|
`, id).Scan(
|
|
&h.ID, &h.ProjectID, &h.ComponentID, &h.LibraryHazardID,
|
|
&h.Name, &h.Description, &h.Scenario, &h.Category, &h.SubCategory, &status,
|
|
&h.MachineModule, &h.Function, &h.LifecyclePhase, &h.HazardousZone,
|
|
&h.TriggerEvent, &h.AffectedPerson, &h.PossibleHarm, &reviewStatus,
|
|
&h.CreatedAt, &h.UpdatedAt,
|
|
)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get hazard: %w", err)
|
|
}
|
|
|
|
h.Status = HazardStatus(status)
|
|
h.ReviewStatus = ReviewStatus(reviewStatus)
|
|
return &h, nil
|
|
}
|
|
|
|
// ListHazards lists all hazards for a project
|
|
func (s *Store) ListHazards(ctx context.Context, projectID uuid.UUID) ([]Hazard, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT
|
|
id, project_id, component_id, library_hazard_id,
|
|
name, description, scenario, category, sub_category, status,
|
|
machine_module, function, lifecycle_phase, hazardous_zone,
|
|
trigger_event, affected_person, possible_harm, review_status,
|
|
created_at, updated_at
|
|
FROM iace_hazards WHERE project_id = $1
|
|
ORDER BY created_at ASC
|
|
`, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list hazards: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var hazards []Hazard
|
|
for rows.Next() {
|
|
var h Hazard
|
|
var status, reviewStatus string
|
|
|
|
err := rows.Scan(
|
|
&h.ID, &h.ProjectID, &h.ComponentID, &h.LibraryHazardID,
|
|
&h.Name, &h.Description, &h.Scenario, &h.Category, &h.SubCategory, &status,
|
|
&h.MachineModule, &h.Function, &h.LifecyclePhase, &h.HazardousZone,
|
|
&h.TriggerEvent, &h.AffectedPerson, &h.PossibleHarm, &reviewStatus,
|
|
&h.CreatedAt, &h.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list hazards scan: %w", err)
|
|
}
|
|
|
|
h.Status = HazardStatus(status)
|
|
h.ReviewStatus = ReviewStatus(reviewStatus)
|
|
hazards = append(hazards, h)
|
|
}
|
|
|
|
return hazards, nil
|
|
}
|
|
|
|
// UpdateHazard updates a hazard with a dynamic set of fields
|
|
func (s *Store) UpdateHazard(ctx context.Context, id uuid.UUID, updates map[string]interface{}) (*Hazard, error) {
|
|
if len(updates) == 0 {
|
|
return s.GetHazard(ctx, id)
|
|
}
|
|
|
|
query := "UPDATE iace_hazards SET updated_at = NOW()"
|
|
args := []interface{}{id}
|
|
argIdx := 2
|
|
|
|
allowedFields := map[string]bool{
|
|
"name": true, "description": true, "scenario": true, "category": true,
|
|
"sub_category": true, "status": true, "component_id": true,
|
|
"machine_module": true, "function": true, "lifecycle_phase": true,
|
|
"hazardous_zone": true, "trigger_event": true, "affected_person": true,
|
|
"possible_harm": true, "review_status": true,
|
|
}
|
|
|
|
for key, val := range updates {
|
|
if allowedFields[key] {
|
|
query += fmt.Sprintf(", %s = $%d", key, argIdx)
|
|
args = append(args, val)
|
|
argIdx++
|
|
}
|
|
}
|
|
|
|
query += " WHERE id = $1"
|
|
|
|
_, err := s.pool.Exec(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update hazard: %w", err)
|
|
}
|
|
|
|
return s.GetHazard(ctx, id)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Risk Assessment Operations
|
|
// ============================================================================
|
|
|
|
// CreateRiskAssessment creates a new risk assessment for a hazard
|
|
func (s *Store) CreateRiskAssessment(ctx context.Context, assessment *RiskAssessment) error {
|
|
if assessment.ID == uuid.Nil {
|
|
assessment.ID = uuid.New()
|
|
}
|
|
if assessment.CreatedAt.IsZero() {
|
|
assessment.CreatedAt = time.Now().UTC()
|
|
}
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO iace_risk_assessments (
|
|
id, hazard_id, version, assessment_type,
|
|
severity, exposure, probability,
|
|
inherent_risk, control_maturity, control_coverage,
|
|
test_evidence_strength, c_eff, residual_risk,
|
|
risk_level, is_acceptable, acceptance_justification,
|
|
assessed_by, created_at
|
|
) VALUES (
|
|
$1, $2, $3, $4,
|
|
$5, $6, $7,
|
|
$8, $9, $10,
|
|
$11, $12, $13,
|
|
$14, $15, $16,
|
|
$17, $18
|
|
)
|
|
`,
|
|
assessment.ID, assessment.HazardID, assessment.Version, string(assessment.AssessmentType),
|
|
assessment.Severity, assessment.Exposure, assessment.Probability,
|
|
assessment.InherentRisk, assessment.ControlMaturity, assessment.ControlCoverage,
|
|
assessment.TestEvidenceStrength, assessment.CEff, assessment.ResidualRisk,
|
|
string(assessment.RiskLevel), assessment.IsAcceptable, assessment.AcceptanceJustification,
|
|
assessment.AssessedBy, assessment.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("create risk assessment: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetLatestAssessment retrieves the most recent risk assessment for a hazard
|
|
func (s *Store) GetLatestAssessment(ctx context.Context, hazardID uuid.UUID) (*RiskAssessment, error) {
|
|
var a RiskAssessment
|
|
var assessmentType, riskLevel string
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
id, hazard_id, version, assessment_type,
|
|
severity, exposure, probability,
|
|
inherent_risk, control_maturity, control_coverage,
|
|
test_evidence_strength, c_eff, residual_risk,
|
|
risk_level, is_acceptable, acceptance_justification,
|
|
assessed_by, created_at
|
|
FROM iace_risk_assessments
|
|
WHERE hazard_id = $1
|
|
ORDER BY version DESC, created_at DESC
|
|
LIMIT 1
|
|
`, hazardID).Scan(
|
|
&a.ID, &a.HazardID, &a.Version, &assessmentType,
|
|
&a.Severity, &a.Exposure, &a.Probability,
|
|
&a.InherentRisk, &a.ControlMaturity, &a.ControlCoverage,
|
|
&a.TestEvidenceStrength, &a.CEff, &a.ResidualRisk,
|
|
&riskLevel, &a.IsAcceptable, &a.AcceptanceJustification,
|
|
&a.AssessedBy, &a.CreatedAt,
|
|
)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get latest assessment: %w", err)
|
|
}
|
|
|
|
a.AssessmentType = AssessmentType(assessmentType)
|
|
a.RiskLevel = RiskLevel(riskLevel)
|
|
|
|
return &a, nil
|
|
}
|
|
|
|
// ListAssessments lists all risk assessments for a hazard, newest first
|
|
func (s *Store) ListAssessments(ctx context.Context, hazardID uuid.UUID) ([]RiskAssessment, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT
|
|
id, hazard_id, version, assessment_type,
|
|
severity, exposure, probability,
|
|
inherent_risk, control_maturity, control_coverage,
|
|
test_evidence_strength, c_eff, residual_risk,
|
|
risk_level, is_acceptable, acceptance_justification,
|
|
assessed_by, created_at
|
|
FROM iace_risk_assessments
|
|
WHERE hazard_id = $1
|
|
ORDER BY version DESC, created_at DESC
|
|
`, hazardID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list assessments: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var assessments []RiskAssessment
|
|
for rows.Next() {
|
|
var a RiskAssessment
|
|
var assessmentType, riskLevel string
|
|
|
|
err := rows.Scan(
|
|
&a.ID, &a.HazardID, &a.Version, &assessmentType,
|
|
&a.Severity, &a.Exposure, &a.Probability,
|
|
&a.InherentRisk, &a.ControlMaturity, &a.ControlCoverage,
|
|
&a.TestEvidenceStrength, &a.CEff, &a.ResidualRisk,
|
|
&riskLevel, &a.IsAcceptable, &a.AcceptanceJustification,
|
|
&a.AssessedBy, &a.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list assessments scan: %w", err)
|
|
}
|
|
|
|
a.AssessmentType = AssessmentType(assessmentType)
|
|
a.RiskLevel = RiskLevel(riskLevel)
|
|
|
|
assessments = append(assessments, a)
|
|
}
|
|
|
|
return assessments, nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Risk Summary (Aggregated View)
|
|
// ============================================================================
|
|
|
|
// GetRiskSummary computes an aggregated risk overview for a project
|
|
func (s *Store) GetRiskSummary(ctx context.Context, projectID uuid.UUID) (*RiskSummaryResponse, error) {
|
|
// Get all hazards for the project
|
|
hazards, err := s.ListHazards(ctx, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get risk summary - list hazards: %w", err)
|
|
}
|
|
|
|
summary := &RiskSummaryResponse{
|
|
TotalHazards: len(hazards),
|
|
AllAcceptable: true,
|
|
}
|
|
|
|
if len(hazards) == 0 {
|
|
summary.OverallRiskLevel = RiskLevelNegligible
|
|
return summary, nil
|
|
}
|
|
|
|
highestRisk := RiskLevelNegligible
|
|
|
|
for _, h := range hazards {
|
|
latest, err := s.GetLatestAssessment(ctx, h.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get risk summary - get assessment for hazard %s: %w", h.ID, err)
|
|
}
|
|
if latest == nil {
|
|
// Hazard without assessment counts as unassessed; consider it not acceptable
|
|
summary.AllAcceptable = false
|
|
continue
|
|
}
|
|
|
|
switch latest.RiskLevel {
|
|
case RiskLevelNotAcceptable:
|
|
summary.NotAcceptable++
|
|
case RiskLevelVeryHigh:
|
|
summary.VeryHigh++
|
|
case RiskLevelCritical:
|
|
summary.Critical++
|
|
case RiskLevelHigh:
|
|
summary.High++
|
|
case RiskLevelMedium:
|
|
summary.Medium++
|
|
case RiskLevelLow:
|
|
summary.Low++
|
|
case RiskLevelNegligible:
|
|
summary.Negligible++
|
|
}
|
|
|
|
if !latest.IsAcceptable {
|
|
summary.AllAcceptable = false
|
|
}
|
|
|
|
// Track highest risk level
|
|
if riskLevelSeverity(latest.RiskLevel) > riskLevelSeverity(highestRisk) {
|
|
highestRisk = latest.RiskLevel
|
|
}
|
|
}
|
|
|
|
summary.OverallRiskLevel = highestRisk
|
|
|
|
return summary, nil
|
|
}
|
|
|
|
// riskLevelSeverity returns a numeric severity for risk level comparison
|
|
func riskLevelSeverity(rl RiskLevel) int {
|
|
switch rl {
|
|
case RiskLevelNotAcceptable:
|
|
return 7
|
|
case RiskLevelVeryHigh:
|
|
return 6
|
|
case RiskLevelCritical:
|
|
return 5
|
|
case RiskLevelHigh:
|
|
return 4
|
|
case RiskLevelMedium:
|
|
return 3
|
|
case RiskLevelLow:
|
|
return 2
|
|
case RiskLevelNegligible:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|