feat(gap): Regulatory Gap Analysis Engine — Phase A Backend

Product Profile → Regulatory Classification → MC Gap Assessment → Priority List.

- 12 regulations supported (CRA, AI Act, NIS2, DSGVO, Data Act, MiCA, PSD2, AML, MDR, Machinery, TDDDG, LkSG)
- Scope signal extraction from product profile
- Priority scoring: Severity × Deadline × Dependency
- 5 industry templates (IoT, Exchange, Cobot, SaaS, Medical)
- 8 API endpoints under /sdk/v1/gap/
- DB migration for gap_projects table
- Full build passes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-10 23:11:30 +02:00
parent 58f370f4ff
commit dabc2358ab
9 changed files with 1396 additions and 1 deletions
@@ -0,0 +1,168 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/breakpilot/ai-compliance-sdk/internal/gap"
)
// GapHandler handles regulatory gap analysis endpoints.
type GapHandler struct {
engine *gap.Engine
store *gap.Store
}
// NewGapHandler creates a new GapHandler.
func NewGapHandler(pool *pgxpool.Pool) *GapHandler {
store := gap.NewStore(pool)
return &GapHandler{
engine: gap.NewEngine(store),
store: store,
}
}
// CreateProject creates a new gap analysis project.
// POST /sdk/v1/gap/projects
func (h *GapHandler) CreateProject(c *gin.Context) {
var profile gap.ProductProfile
if err := c.ShouldBindJSON(&profile); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := c.GetHeader("X-Tenant-ID")
if tenantID != "" {
profile.TenantID, _ = uuid.Parse(tenantID)
}
if err := h.store.CreateProfile(&profile); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create project"})
return
}
c.JSON(http.StatusCreated, gin.H{"project": profile})
}
// GetProject returns a gap project by ID.
// GET /sdk/v1/gap/projects/:id
func (h *GapHandler) GetProject(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
profile, err := h.store.GetProfile(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
c.JSON(http.StatusOK, gin.H{"project": profile})
}
// ListProjects lists gap projects for a tenant.
// GET /sdk/v1/gap/projects
func (h *GapHandler) ListProjects(c *gin.Context) {
tenantID, err := uuid.Parse(c.GetHeader("X-Tenant-ID"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "X-Tenant-ID required"})
return
}
profiles, err := h.store.ListProfiles(tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list projects"})
return
}
c.JSON(http.StatusOK, gin.H{"projects": profiles, "total": len(profiles)})
}
// AnalyzeProject runs the full gap analysis.
// POST /sdk/v1/gap/projects/:id/analyze
func (h *GapHandler) AnalyzeProject(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
profile, err := h.store.GetProfile(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
report, err := h.engine.Analyze(profile)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, report)
}
// QuickAnalyze runs gap analysis without saving a project.
// POST /sdk/v1/gap/analyze
func (h *GapHandler) QuickAnalyze(c *gin.Context) {
var profile gap.ProductProfile
if err := c.ShouldBindJSON(&profile); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
profile.ID = uuid.New()
report, err := h.engine.Analyze(&profile)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, report)
}
// GetTemplates returns available industry templates.
// GET /sdk/v1/gap/templates
func (h *GapHandler) GetTemplates(c *gin.Context) {
templates := make([]gin.H, 0, len(gap.IndustryTemplates))
for key, tmpl := range gap.IndustryTemplates {
templates = append(templates, gin.H{
"key": key,
"name": tmpl.Name,
"description": tmpl.Description,
"product_type": tmpl.ProductType,
})
}
c.JSON(http.StatusOK, gin.H{"templates": templates})
}
// GetTemplate returns a specific template by key.
// GET /sdk/v1/gap/templates/:key
func (h *GapHandler) GetTemplate(c *gin.Context) {
key := c.Param("key")
tmpl, ok := gap.IndustryTemplates[key]
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "template not found"})
return
}
c.JSON(http.StatusOK, gin.H{"template": tmpl})
}
// GetRegulations returns all supported regulations with deadlines.
// GET /sdk/v1/gap/regulations
func (h *GapHandler) GetRegulations(c *gin.Context) {
regs := make([]gin.H, 0, len(gap.RegulationNames))
for id, name := range gap.RegulationNames {
entry := gin.H{"id": id, "name": name}
if dl, ok := gap.RegulationDeadlines[id]; ok {
entry["deadline"] = dl
}
regs = append(regs, entry)
}
c.JSON(http.StatusOK, gin.H{"regulations": regs})
}
+4 -1
View File
@@ -152,6 +152,9 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine {
maximizerSvc := maximizer.NewService(maximizerStore, uccaStore, maximizerRules)
maximizerHandlers := handlers.NewMaximizerHandlers(maximizerSvc)
// Gap Analysis
gapHandler := handlers.NewGapHandler(pool)
rbacMiddleware := rbac.NewMiddleware(rbacService, policyEngine)
// Router
@@ -176,7 +179,7 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine {
uccaHandlers, escalationHandlers, obligationsHandlers, ragHandlers,
roadmapHandlers, workshopHandlers, portfolioHandlers,
academyHandlers, trainingHandlers, whistleblowerHandlers, iaceHandler,
maximizerHandlers, regulatoryNewsHandlers)
gapHandler, maximizerHandlers, regulatoryNewsHandlers)
return router
}
+17
View File
@@ -26,6 +26,7 @@ func registerRoutes(
trainingHandlers *handlers.TrainingHandlers,
whistleblowerHandlers *handlers.WhistleblowerHandlers,
iaceHandler *handlers.IACEHandler,
gapHandler *handlers.GapHandler,
maximizerHandlers *handlers.MaximizerHandlers,
regulatoryNewsHandlers *handlers.RegulatoryNewsHandlers,
) {
@@ -48,6 +49,7 @@ func registerRoutes(
registerTrainingRoutes(v1, trainingHandlers)
registerWhistleblowerRoutes(v1, whistleblowerHandlers)
registerIACERoutes(v1, iaceHandler)
registerGapRoutes(v1, gapHandler)
registerMaximizerRoutes(v1, maximizerHandlers)
v1.GET("/regulatory-news", regulatoryNewsHandlers.GetNews)
}
@@ -362,6 +364,7 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
iaceRoutes.GET("/evidence-types", h.ListEvidenceTypes)
iaceRoutes.GET("/protective-measures-library", h.ListProtectiveMeasures)
iaceRoutes.GET("/failure-modes", h.ListFailureModes)
iaceRoutes.GET("/operational-states", h.ListOperationalStates)
iaceRoutes.GET("/component-library", h.ListComponentLibrary)
iaceRoutes.GET("/energy-sources", h.ListEnergySources)
iaceRoutes.GET("/tags", h.ListTags)
@@ -457,3 +460,17 @@ func registerMaximizerRoutes(v1 *gin.RouterGroup, h *handlers.MaximizerHandlers)
m.GET("/dimensions", h.GetDimensionSchema)
}
}
func registerGapRoutes(v1 *gin.RouterGroup, h *handlers.GapHandler) {
g := v1.Group("/gap")
{
g.POST("/projects", h.CreateProject)
g.GET("/projects", h.ListProjects)
g.GET("/projects/:id", h.GetProject)
g.POST("/projects/:id/analyze", h.AnalyzeProject)
g.POST("/analyze", h.QuickAnalyze)
g.GET("/templates", h.GetTemplates)
g.GET("/templates/:key", h.GetTemplate)
g.GET("/regulations", h.GetRegulations)
}
}
@@ -0,0 +1,442 @@
package gap
import (
"strings"
"time"
)
// RegulationDeadlines maps regulation IDs to their enforcement deadlines.
var RegulationDeadlines = map[RegulationID]time.Time{
RegCRA: time.Date(2027, 6, 1, 0, 0, 0, 0, time.UTC),
RegAIAct: time.Date(2025, 8, 1, 0, 0, 0, 0, time.UTC),
RegDataAct: time.Date(2025, 9, 12, 0, 0, 0, 0, time.UTC),
RegNIS2: time.Date(2025, 10, 18, 0, 0, 0, 0, time.UTC),
RegDSGVO: time.Date(2018, 5, 25, 0, 0, 0, 0, time.UTC),
RegMiCA: time.Date(2024, 12, 30, 0, 0, 0, 0, time.UTC),
RegPSD2: time.Date(2018, 1, 13, 0, 0, 0, 0, time.UTC),
RegMDR: time.Date(2021, 5, 26, 0, 0, 0, 0, time.UTC),
RegMachinery: time.Date(2027, 1, 20, 0, 0, 0, 0, time.UTC),
RegEAA: time.Date(2025, 6, 28, 0, 0, 0, 0, time.UTC),
RegTDDDG: time.Date(2021, 12, 1, 0, 0, 0, 0, time.UTC),
RegLkSG: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
}
// RegulationNames maps IDs to human-readable names.
var RegulationNames = map[RegulationID]string{
RegCRA: "Cyber Resilience Act (CRA)",
RegAIAct: "KI-Verordnung (AI Act)",
RegNIS2: "NIS2-Richtlinie",
RegDSGVO: "DSGVO",
RegDataAct: "EU Data Act",
RegMiCA: "Markets in Crypto-Assets (MiCA)",
RegPSD2: "Zahlungsdiensterichtlinie (PSD2)",
RegAML: "Geldwäschegesetz (GwG/AML)",
RegMDR: "Medizinprodukteverordnung (MDR)",
RegMachinery: "Maschinenverordnung (EU) 2023/1230",
RegEAA: "European Accessibility Act",
RegTDDDG: "TDDDG (Telemedien-Datenschutz)",
RegLkSG: "Lieferkettensorgfaltspflichtengesetz",
}
// Classifier determines which regulations apply to a product profile.
type Classifier struct{}
// NewClassifier creates a Classifier.
func NewClassifier() *Classifier { return &Classifier{} }
// ClassifyAll evaluates all regulations against the product profile.
func (c *Classifier) ClassifyAll(p *ProductProfile) []ApplicableRegulation {
results := []ApplicableRegulation{
c.classifyCRA(p),
c.classifyAIAct(p),
c.classifyNIS2(p),
c.classifyDSGVO(p),
c.classifyDataAct(p),
c.classifyMiCA(p),
c.classifyPSD2(p),
c.classifyAML(p),
c.classifyMDR(p),
c.classifyMachinery(p),
c.classifyTDDDG(p),
c.classifyLkSG(p),
}
// Only return applicable ones
applicable := make([]ApplicableRegulation, 0, len(results))
for _, r := range results {
if r.Applicable {
applicable = append(applicable, r)
}
}
return applicable
}
// ExtractScopeSignals derives scope signals from the product profile.
func (c *Classifier) ExtractScopeSignals(p *ProductProfile) []string {
signals := []string{}
if p.ConnectedToInternet {
signals = append(signals, "networked_product")
}
if p.HasSoftwareUpdates {
signals = append(signals, "has_software_updates")
}
if p.UsesAI {
signals = append(signals, "uses_ai")
}
if p.ProcessesPersonalData {
signals = append(signals, "processes_personal_data")
}
if p.IsCriticalInfraSupplier {
signals = append(signals, "is_kritis_operator")
}
for _, tech := range p.Technologies {
switch strings.ToLower(tech) {
case "blockchain", "smart_contract":
signals = append(signals, "uses_blockchain")
case "encryption":
signals = append(signals, "uses_encryption")
case "api":
signals = append(signals, "has_public_api")
case "cloud":
signals = append(signals, "uses_cloud")
case "ota_updates":
signals = append(signals, "has_software_updates")
}
}
for _, dp := range p.DataProcessing {
switch strings.ToLower(dp) {
case "health_data":
signals = append(signals, "processes_health_data")
case "financial_data":
signals = append(signals, "processes_financial_data")
case "personal_data":
signals = append(signals, "processes_personal_data")
}
}
if p.ProductType == ProductTypeExchange {
signals = append(signals, "operates_payment_service",
"holds_client_funds", "uses_blockchain")
}
return dedupStrings(signals)
}
// ── Private classification methods ──────────────────────────────────
func (c *Classifier) classifyCRA(p *ProductProfile) ApplicableRegulation {
r := c.newResult(RegCRA)
hasDigitalElements := p.ConnectedToInternet || p.HasSoftwareUpdates ||
containsAny(p.Technologies, "api", "cloud", "ota_updates", "network")
if !hasDigitalElements {
r.Reasoning = "Produkt hat keine digitalen Elemente"
return r
}
r.Applicable = true
r.Confidence = 0.9
if containsAny(p.Technologies, "ota_updates") && p.ConnectedToInternet {
r.RiskLevel = "high"
r.Reasoning = "Vernetztes Produkt mit Software-Updates → CRA Class I/II"
r.Requirements = []string{
"Schwachstellenmanagement (Art. 10)",
"SBOM erstellen (Anhang I, Teil II)",
"Security Updates bereitstellen (Art. 13)",
"Meldepflicht bei Schwachstellen (Art. 11)",
"Konformitätsbewertung (Art. 24-28)",
}
} else {
r.RiskLevel = "medium"
r.Reasoning = "Produkt mit digitalen Elementen → CRA Default-Kategorie"
r.Requirements = []string{
"Cybersicherheitsanforderungen (Anhang I)",
"Technische Dokumentation (Anhang V)",
}
}
return r
}
func (c *Classifier) classifyAIAct(p *ProductProfile) ApplicableRegulation {
r := c.newResult(RegAIAct)
if !p.UsesAI && !containsAny(p.Technologies, "ai", "machine_learning", "neural_network") {
r.Reasoning = "Produkt verwendet keine KI"
return r
}
r.Applicable = true
isSafetyRelevant := p.ProductType == ProductTypeMachinery ||
p.ProductType == ProductTypeMedicalDevice ||
containsAny(p.DataProcessing, "health_data")
if isSafetyRelevant {
r.RiskLevel = "high"
r.Confidence = 0.9
r.Reasoning = "KI in sicherheitsrelevantem Produkt → Hochrisiko-KI"
r.Requirements = []string{
"Risikomanagement (Art. 9)",
"Datenqualität (Art. 10)",
"Technische Dokumentation (Art. 11)",
"Aufzeichnungspflichten (Art. 12)",
"Transparenz (Art. 13)",
"Menschliche Aufsicht (Art. 14)",
"Genauigkeit und Robustheit (Art. 15)",
"FRIA — Grundrechte-Folgenabschätzung",
}
} else {
r.RiskLevel = "medium"
r.Confidence = 0.8
r.Reasoning = "KI-System → Transparenzpflichten (Limited Risk)"
r.Requirements = []string{
"Transparenzpflicht (Art. 52)",
"KI-Kennzeichnung",
}
}
return r
}
func (c *Classifier) classifyNIS2(p *ProductProfile) ApplicableRegulation {
r := c.newResult(RegNIS2)
isDirectlyAffected := p.IsCriticalInfraSupplier ||
p.ProductType == ProductTypeExchange ||
containsAny(p.DataProcessing, "financial_data")
isSupplyChain := p.ConnectedToInternet && (p.ProductType == ProductTypeSaaS ||
p.ProductType == ProductTypeIoT)
if !isDirectlyAffected && !isSupplyChain {
r.Reasoning = "Kein KRITIS-Betreiber oder -Zulieferer"
return r
}
r.Applicable = true
if isDirectlyAffected {
r.RiskLevel = "high"
r.Confidence = 0.85
r.Reasoning = "Direkt betroffen als KRITIS-Betreiber/Finanzdienstleister"
} else {
r.RiskLevel = "medium"
r.Confidence = 0.7
r.Reasoning = "Indirekt betroffen als Zulieferer vernetzter Dienste"
}
r.Requirements = []string{
"Cybersicherheits-Risikomanagement (Art. 21)",
"Meldepflichten (Art. 23)",
"Supply-Chain-Sicherheit",
"Incident Response",
}
return r
}
func (c *Classifier) classifyDSGVO(p *ProductProfile) ApplicableRegulation {
r := c.newResult(RegDSGVO)
if !p.ProcessesPersonalData && !containsAny(p.DataProcessing, "personal_data", "health_data") {
r.Reasoning = "Keine Verarbeitung personenbezogener Daten"
return r
}
r.Applicable = true
r.RiskLevel = "high"
r.Confidence = 0.95
r.Reasoning = "Verarbeitung personenbezogener Daten → DSGVO anwendbar"
r.Requirements = []string{
"Rechtsgrundlage (Art. 6)",
"Informationspflichten (Art. 13/14)",
"Betroffenenrechte (Art. 15-22)",
"Verarbeitungsverzeichnis (Art. 30)",
"TOM (Art. 32)",
}
if containsAny(p.DataProcessing, "health_data") {
r.Requirements = append(r.Requirements, "DSFA (Art. 35)", "Besondere Kategorien (Art. 9)")
}
return r
}
func (c *Classifier) classifyDataAct(p *ProductProfile) ApplicableRegulation {
r := c.newResult(RegDataAct)
isConnectedProduct := p.ConnectedToInternet && (p.ProductType == ProductTypeIoT ||
p.ProductType == ProductTypeHardware || p.ProductType == ProductTypeMachinery)
if !isConnectedProduct {
r.Reasoning = "Kein vernetztes Produkt mit Datengenerierung"
return r
}
r.Applicable = true
r.RiskLevel = "medium"
r.Confidence = 0.8
r.Reasoning = "Vernetztes Produkt generiert Nutzungsdaten → Data Act anwendbar"
r.Requirements = []string{
"Nutzerdatenzugang (Art. 3-5)",
"Datenweitergabe an Dritte (Art. 5)",
"Beschränkung der Datennutzung (Art. 6)",
}
return r
}
func (c *Classifier) classifyMiCA(p *ProductProfile) ApplicableRegulation {
r := c.newResult(RegMiCA)
if p.ProductType != ProductTypeExchange &&
!containsAny(p.Technologies, "blockchain", "smart_contract", "crypto") {
r.Reasoning = "Kein Kryptowerte-Bezug"
return r
}
r.Applicable = true
r.RiskLevel = "high"
r.Confidence = 0.9
r.Reasoning = "Kryptowerte-Dienstleistung → MiCA anwendbar"
r.Requirements = []string{
"CASP-Zulassung (Art. 59-63)",
"Eigenmittelanforderungen (Art. 67)",
"Organisatorische Anforderungen (Art. 68)",
"Custody-Trennung (Art. 70)",
"Marktmissbrauchsvorschriften (Art. 86-92)",
}
return r
}
func (c *Classifier) classifyPSD2(p *ProductProfile) ApplicableRegulation {
r := c.newResult(RegPSD2)
if p.ProductType != ProductTypeExchange &&
!containsAny(p.Technologies, "payment", "fiat_gateway") &&
!containsAny(p.DataProcessing, "financial_data") {
r.Reasoning = "Kein Zahlungsdienstbezug"
return r
}
r.Applicable = true
r.RiskLevel = "high"
r.Confidence = 0.85
r.Reasoning = "Zahlungsdienste oder Fiat-Gateway → PSD2 anwendbar"
r.Requirements = []string{
"Starke Kundenauthentifizierung (Art. 97)",
"Sicherheit der Kommunikation (Art. 98)",
"Open Banking API (Art. 36)",
}
return r
}
func (c *Classifier) classifyAML(p *ProductProfile) ApplicableRegulation {
r := c.newResult(RegAML)
if p.ProductType != ProductTypeExchange &&
!containsAny(p.DataProcessing, "financial_data") {
r.Reasoning = "Kein Verpflichteter nach GwG"
return r
}
r.Applicable = true
r.RiskLevel = "high"
r.Confidence = 0.9
r.Reasoning = "Finanzdienstleistung → AML/GwG anwendbar"
r.Requirements = []string{
"KYC-Verfahren (§10 GwG)",
"Transaktionsmonitoring",
"Verdachtsmeldung (§43 GwG)",
"PEP-Prüfung",
}
return r
}
func (c *Classifier) classifyMDR(p *ProductProfile) ApplicableRegulation {
r := c.newResult(RegMDR)
if p.ProductType != ProductTypeMedicalDevice {
r.Reasoning = "Kein Medizinprodukt"
return r
}
r.Applicable = true
r.RiskLevel = "high"
r.Confidence = 0.9
r.Reasoning = "Medizinprodukt → MDR anwendbar"
r.Requirements = []string{
"Konformitätsbewertung",
"Klinische Bewertung (Art. 61)",
"Post-Market Surveillance (Art. 83-86)",
"UDI (Art. 27)",
}
return r
}
func (c *Classifier) classifyMachinery(p *ProductProfile) ApplicableRegulation {
r := c.newResult(RegMachinery)
if p.ProductType != ProductTypeMachinery && p.ProductType != ProductTypeHardware {
r.Reasoning = "Kein Maschinenprodukt"
return r
}
r.Applicable = true
r.RiskLevel = "high"
r.Confidence = 0.85
r.Reasoning = "Maschinenprodukt → Maschinenverordnung anwendbar"
r.Requirements = []string{
"CE-Konformitätsbewertung",
"Risikobeurteilung (Anhang III)",
"Betriebsanleitung",
"Technische Dokumentation",
}
return r
}
func (c *Classifier) classifyTDDDG(p *ProductProfile) ApplicableRegulation {
r := c.newResult(RegTDDDG)
hasTelemedien := p.ProductType == ProductTypeSaaS ||
(p.ConnectedToInternet && containsAny(p.Technologies, "api", "cloud"))
if !hasTelemedien {
r.Reasoning = "Kein Telemediendienst"
return r
}
r.Applicable = true
r.RiskLevel = "medium"
r.Confidence = 0.8
r.Reasoning = "Online-Dienst → TDDDG anwendbar"
r.Requirements = []string{
"Cookie-Einwilligung (§25 TDDDG)",
"Impressumspflicht",
}
return r
}
func (c *Classifier) classifyLkSG(p *ProductProfile) ApplicableRegulation {
r := c.newResult(RegLkSG)
// LkSG applies to companies with >1000 employees — we can't determine this
// from product profile alone. Flag as "unclear" for larger companies.
r.Reasoning = "LkSG-Anwendbarkeit hängt von Unternehmensgröße ab (>1000 MA)"
return r
}
// ── Helpers ─────────────────────────────────────────────────────────
func (c *Classifier) newResult(id RegulationID) ApplicableRegulation {
r := ApplicableRegulation{
ID: id,
Name: RegulationNames[id],
Applicable: false,
Confidence: 0,
}
if dl, ok := RegulationDeadlines[id]; ok {
r.Deadline = &dl
}
return r
}
func containsAny(slice []string, values ...string) bool {
for _, s := range slice {
for _, v := range values {
if strings.EqualFold(s, v) {
return true
}
}
}
return false
}
func dedupStrings(in []string) []string {
seen := make(map[string]bool, len(in))
out := make([]string, 0, len(in))
for _, s := range in {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
return out
}
@@ -0,0 +1,290 @@
package gap
import (
"fmt"
"math"
"sort"
"time"
)
// Engine orchestrates the gap analysis pipeline.
type Engine struct {
classifier *Classifier
store *Store
}
// NewEngine creates a new gap analysis engine.
func NewEngine(store *Store) *Engine {
return &Engine{
classifier: NewClassifier(),
store: store,
}
}
// Analyze runs the full gap analysis for a product profile.
func (e *Engine) Analyze(profile *ProductProfile) (*GapReport, error) {
// Step 1: Extract scope signals
signals := e.classifier.ExtractScopeSignals(profile)
// Step 2: Classify regulations
regulations := e.classifier.ClassifyAll(profile)
// Step 3: Fetch applicable MCs from DB
mcGroups, err := e.store.FetchApplicableMCs(signals, regulations)
if err != nil {
return nil, fmt.Errorf("fetch MCs: %w", err)
}
// Step 4: Assess gaps
gaps := make([]GapItem, 0, len(mcGroups))
for _, mc := range mcGroups {
status := e.assessGapStatus(mc, profile.ExistingCertifications)
item := GapItem{
MCID: mc.MasterControlID,
MCName: mc.CanonicalName,
Regulation: mc.Regulation,
Status: status,
Title: mc.Title,
Description: mc.Description,
Severity: mc.Severity,
ControlCount: mc.ControlCount,
Recommendation: e.generateRecommendation(mc, status),
}
item.Priority = e.calculatePriority(item, regulations)
gaps = append(gaps, item)
}
// Step 5: Sort by priority (highest first)
sort.Slice(gaps, func(i, j int) bool {
return gaps[i].Priority.Score > gaps[j].Priority.Score
})
// Assign ranks
for i := range gaps {
gaps[i].Priority.Rank = i + 1
}
// Step 6: Build report
report := &GapReport{
ProfileID: profile.ID,
ProfileName: profile.Name,
Regulations: regulations,
Summary: e.buildSummary(gaps, regulations),
Gaps: gaps,
CreatedAt: time.Now(),
}
return report, nil
}
// assessGapStatus determines if a MC is fulfilled based on existing certs.
func (e *Engine) assessGapStatus(mc MCGroup, certs []string) GapStatus {
// If customer has ISO 27001, many security controls are likely fulfilled
for _, cert := range certs {
switch cert {
case "ISO27001":
if isSecurityTopic(mc.CanonicalName) {
return GapPartial // Likely partially covered
}
case "CE":
if isMachineryTopic(mc.CanonicalName) {
return GapFulfilled
}
case "SOC2":
if isSecurityTopic(mc.CanonicalName) {
return GapPartial
}
}
}
// Default: missing (customer must verify)
return GapMissing
}
// calculatePriority computes the priority score for a gap.
func (e *Engine) calculatePriority(item GapItem, regs []ApplicableRegulation) Priority {
p := Priority{
SeverityFactor: severityToFactor(item.Severity),
DeadlineFactor: 1.0,
DependencyFactor: 1.0,
}
// Find deadline for this regulation
for _, reg := range regs {
if reg.ID == item.Regulation && reg.Deadline != nil {
monthsUntil := time.Until(*reg.Deadline).Hours() / (24 * 30)
if monthsUntil < 6 {
p.DeadlineFactor = 3.0
} else if monthsUntil < 12 {
p.DeadlineFactor = 2.0
}
}
}
// Dependency: foundational controls get higher priority
if isFoundational(item.MCName) {
p.DependencyFactor = 2.0
}
p.Score = p.SeverityFactor * p.DeadlineFactor * p.DependencyFactor
// Only gaps count — fulfilled items get score 0
if item.Status == GapFulfilled {
p.Score = 0
} else if item.Status == GapPartial {
p.Score *= 0.5
}
return p
}
// generateRecommendation creates an actionable recommendation for a gap.
func (e *Engine) generateRecommendation(mc MCGroup, status GapStatus) string {
if status == GapFulfilled {
return "Bereits erfüllt — keine Maßnahme erforderlich."
}
// Build recommendation from MC name + regulation
name := mc.CanonicalName
switch {
case contains(name, "encryption"):
return "Verschlüsselungsimplementierung prüfen und dokumentieren."
case contains(name, "access_control"):
return "Zugriffskontrollkonzept erstellen und Berechtigungen überprüfen."
case contains(name, "incident"):
return "Incident-Response-Plan erstellen und Meldeprozesse etablieren."
case contains(name, "vulnerability"):
return "Schwachstellenmanagement einführen (Scanning, CVE-Tracking, Patching)."
case contains(name, "audit_logging"):
return "Protokollierung implementieren und Audit-Trail sicherstellen."
case contains(name, "data_retention"):
return "Löschkonzept erstellen mit konkreten Fristen pro Datenkategorie."
case contains(name, "consent"):
return "Einwilligungsmanagement implementieren (Opt-In, Widerruf, Dokumentation)."
case contains(name, "dpia"):
return "Datenschutz-Folgenabschätzung durchführen und dokumentieren."
case contains(name, "training"):
return "Schulungsprogramm für Mitarbeiter etablieren."
case contains(name, "risk_management"):
return "Risikobewertung durchführen und Maßnahmenplan erstellen."
default:
return fmt.Sprintf("Anforderung '%s' prüfen und Umsetzung planen.", mc.Title)
}
}
// buildSummary aggregates gap statistics.
func (e *Engine) buildSummary(gaps []GapItem, regs []ApplicableRegulation) GapSummary {
s := GapSummary{
TotalApplicableRegulations: len(regs),
TotalGaps: len(gaps),
GapsByStatus: map[string]int{},
GapsBySeverity: map[string]int{},
GapsByRegulation: map[string]int{},
}
fulfilled := 0
for _, g := range gaps {
s.GapsByStatus[string(g.Status)]++
s.GapsBySeverity[g.Severity]++
s.GapsByRegulation[string(g.Regulation)]++
if g.Status == GapFulfilled {
fulfilled++
}
// Rough effort estimate per gap
switch g.Severity {
case "CRITICAL":
s.EstimatedEffortWeeks += 2
case "HIGH":
s.EstimatedEffortWeeks += 1
case "MEDIUM":
s.EstimatedEffortWeeks += 0.5
case "LOW":
s.EstimatedEffortWeeks += 0.25
}
}
if len(gaps) > 0 {
s.OverallCompliancePercent = math.Round(float64(fulfilled)/float64(len(gaps))*1000) / 10
}
// Only count effort for non-fulfilled gaps
s.EstimatedEffortWeeks = math.Round(s.EstimatedEffortWeeks*10) / 10
return s
}
// ── Helpers ──────────────────────────────────────────────────────────
func severityToFactor(sev string) float64 {
switch sev {
case "CRITICAL":
return 4.0
case "HIGH":
return 3.0
case "MEDIUM":
return 2.0
case "LOW":
return 1.0
default:
return 2.0
}
}
func isFoundational(name string) bool {
foundational := []string{
"risk_management", "policy", "asset_management",
"access_control_rbac", "encryption_key",
}
for _, f := range foundational {
if contains(name, f) {
return true
}
}
return false
}
func isSecurityTopic(name string) bool {
topics := []string{
"encryption", "access_control", "vulnerability", "patch_management",
"audit_logging", "monitoring", "firewall", "network_security",
"session_management", "multi_factor_auth", "key_management",
"backup", "disaster_recovery", "incident",
}
for _, t := range topics {
if contains(name, t) {
return true
}
}
return false
}
func isMachineryTopic(name string) bool {
return contains(name, "product_safety") || contains(name, "certification")
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&
(s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
findSubstring(s, substr)))
}
func findSubstring(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
// MCGroup represents a Master Control with aggregated info for gap analysis.
type MCGroup struct {
MasterControlID string
CanonicalName string
Title string
Description string
Regulation RegulationID
Severity string
ControlCount int
}
+149
View File
@@ -0,0 +1,149 @@
// Package gap implements the Regulatory Gap Analysis Engine.
//
// Given a product profile, the engine determines which regulations apply,
// identifies gaps against Master Controls, and produces a prioritized
// action list.
package gap
import (
"time"
"github.com/google/uuid"
)
// ── Product Profile ─────────────────────────────────────────────────
// ProductType classifies the product category.
type ProductType string
const (
ProductTypeSoftware ProductType = "software"
ProductTypeHardware ProductType = "hardware"
ProductTypeIoT ProductType = "iot"
ProductTypeSaaS ProductType = "saas"
ProductTypeExchange ProductType = "exchange"
ProductTypeMedicalDevice ProductType = "medical_device"
ProductTypeMachinery ProductType = "machinery"
ProductTypeOther ProductType = "other"
)
// ProductProfile describes a customer's product for gap analysis.
type ProductProfile struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
ProductType ProductType `json:"product_type" db:"product_type"`
// Technology stack
Technologies []string `json:"technologies" db:"-"` // encryption, api, blockchain, ai, ota_updates, cloud
// Data processing categories
DataProcessing []string `json:"data_processing" db:"-"` // personal_data, health_data, financial_data, telemetry
// Target markets
Markets []string `json:"markets" db:"-"` // EU, DE, AT, CH, US
// Boolean flags (derived from technologies or set explicitly)
ConnectedToInternet bool `json:"connected_to_internet" db:"connected_to_internet"`
HasSoftwareUpdates bool `json:"has_software_updates" db:"has_software_updates"`
UsesAI bool `json:"uses_ai" db:"uses_ai"`
ProcessesPersonalData bool `json:"processes_personal_data" db:"processes_personal_data"`
IsCriticalInfraSupplier bool `json:"is_critical_infra_supplier" db:"is_critical_infra_supplier"`
// Existing certifications (reduces gap count)
ExistingCertifications []string `json:"existing_certifications" db:"-"` // ISO27001, CE, SOC2
// Metadata
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// ── Regulation Classification ───────────────────────────────────────
// RegulationID identifies a regulation.
type RegulationID string
const (
RegCRA RegulationID = "cra"
RegAIAct RegulationID = "ai_act"
RegNIS2 RegulationID = "nis2"
RegDSGVO RegulationID = "dsgvo"
RegDataAct RegulationID = "data_act"
RegMiCA RegulationID = "mica"
RegPSD2 RegulationID = "psd2"
RegAML RegulationID = "aml"
RegMDR RegulationID = "mdr"
RegMachinery RegulationID = "machinery_regulation"
RegEAA RegulationID = "eaa"
RegTDDDG RegulationID = "tdddg"
RegLkSG RegulationID = "lksg"
)
// ApplicableRegulation describes a regulation that applies to a product.
type ApplicableRegulation struct {
ID RegulationID `json:"id"`
Name string `json:"name"`
Applicable bool `json:"applicable"`
Confidence float64 `json:"confidence"`
Reasoning string `json:"reasoning"`
Deadline *time.Time `json:"deadline,omitempty"`
RiskLevel string `json:"risk_level"` // high, medium, low
Requirements []string `json:"requirements,omitempty"`
}
// ── Gap Analysis ────────────────────────────────────────────────────
// GapStatus indicates how well a control is fulfilled.
type GapStatus string
const (
GapFulfilled GapStatus = "fulfilled"
GapPartial GapStatus = "partial"
GapMissing GapStatus = "missing"
GapUnclear GapStatus = "unclear"
)
// GapItem represents a single gap finding.
type GapItem struct {
MCID string `json:"mc_id"`
MCName string `json:"mc_name"`
Regulation RegulationID `json:"regulation"`
Status GapStatus `json:"status"`
Title string `json:"title"`
Description string `json:"description"`
Severity string `json:"severity"` // CRITICAL, HIGH, MEDIUM, LOW
Priority Priority `json:"priority"`
Recommendation string `json:"recommendation"`
ControlCount int `json:"control_count"`
}
// Priority determines the order of action.
type Priority struct {
Score float64 `json:"score"`
SeverityFactor float64 `json:"severity_factor"`
DeadlineFactor float64 `json:"deadline_factor"`
DependencyFactor float64 `json:"dependency_factor"`
Rank int `json:"rank"`
}
// ── Gap Report ──────────────────────────────────────────────────────
// GapReport is the full analysis result.
type GapReport struct {
ProfileID uuid.UUID `json:"profile_id"`
ProfileName string `json:"profile_name"`
Regulations []ApplicableRegulation `json:"regulations"`
Summary GapSummary `json:"summary"`
Gaps []GapItem `json:"gaps"`
CreatedAt time.Time `json:"created_at"`
}
// GapSummary provides aggregate statistics.
type GapSummary struct {
TotalApplicableRegulations int `json:"total_applicable_regulations"`
TotalGaps int `json:"total_gaps"`
GapsByStatus map[string]int `json:"gaps_by_status"`
GapsBySeverity map[string]int `json:"gaps_by_severity"`
GapsByRegulation map[string]int `json:"gaps_by_regulation"`
OverallCompliancePercent float64 `json:"overall_compliance_percent"`
EstimatedEffortWeeks float64 `json:"estimated_effort_weeks"`
}
+245
View File
@@ -0,0 +1,245 @@
package gap
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store handles database operations for gap analysis.
type Store struct {
pool *pgxpool.Pool
}
// NewStore creates a new Store.
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// ── Product Profile CRUD ────────────────────────────────────────────
// CreateProfile saves a product profile.
func (s *Store) CreateProfile(p *ProductProfile) error {
ctx := context.Background()
p.ID = uuid.New()
p.CreatedAt = time.Now()
p.UpdatedAt = time.Now()
techJSON, _ := json.Marshal(p.Technologies)
dataJSON, _ := json.Marshal(p.DataProcessing)
marketsJSON, _ := json.Marshal(p.Markets)
certsJSON, _ := json.Marshal(p.ExistingCertifications)
_, err := s.pool.Exec(ctx, `
INSERT INTO compliance.gap_projects
(id, tenant_id, name, description, product_type,
technologies, data_processing, markets,
connected_to_internet, has_software_updates, uses_ai,
processes_personal_data, is_critical_infra_supplier,
existing_certifications, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`,
p.ID, p.TenantID, p.Name, p.Description, p.ProductType,
techJSON, dataJSON, marketsJSON,
p.ConnectedToInternet, p.HasSoftwareUpdates, p.UsesAI,
p.ProcessesPersonalData, p.IsCriticalInfraSupplier,
certsJSON, p.CreatedAt, p.UpdatedAt,
)
return err
}
// GetProfile loads a product profile by ID.
func (s *Store) GetProfile(id uuid.UUID) (*ProductProfile, error) {
ctx := context.Background()
p := &ProductProfile{}
var techJSON, dataJSON, marketsJSON, certsJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT id, tenant_id, name, description, product_type,
technologies, data_processing, markets,
connected_to_internet, has_software_updates, uses_ai,
processes_personal_data, is_critical_infra_supplier,
existing_certifications, created_at, updated_at
FROM compliance.gap_projects WHERE id = $1`, id,
).Scan(
&p.ID, &p.TenantID, &p.Name, &p.Description, &p.ProductType,
&techJSON, &dataJSON, &marketsJSON,
&p.ConnectedToInternet, &p.HasSoftwareUpdates, &p.UsesAI,
&p.ProcessesPersonalData, &p.IsCriticalInfraSupplier,
&certsJSON, &p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, err
}
json.Unmarshal(techJSON, &p.Technologies)
json.Unmarshal(dataJSON, &p.DataProcessing)
json.Unmarshal(marketsJSON, &p.Markets)
json.Unmarshal(certsJSON, &p.ExistingCertifications)
return p, nil
}
// ListProfiles lists profiles for a tenant.
func (s *Store) ListProfiles(tenantID uuid.UUID) ([]ProductProfile, error) {
ctx := context.Background()
rows, err := s.pool.Query(ctx, `
SELECT id, name, description, product_type, created_at
FROM compliance.gap_projects
WHERE tenant_id = $1
ORDER BY created_at DESC`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var profiles []ProductProfile
for rows.Next() {
var p ProductProfile
if err := rows.Scan(&p.ID, &p.Name, &p.Description,
&p.ProductType, &p.CreatedAt); err != nil {
return nil, err
}
profiles = append(profiles, p)
}
return profiles, nil
}
// ── Master Control Queries ──────────────────────────────────────────
// FetchApplicableMCs queries Master Controls relevant for the given
// scope signals and regulations.
func (s *Store) FetchApplicableMCs(signals []string, regs []ApplicableRegulation) ([]MCGroup, error) {
if len(regs) == 0 {
return nil, nil
}
ctx := context.Background()
sourceNames := regulationToSourceNames(regs)
if len(sourceNames) == 0 {
return nil, nil
}
// Build parameterized query
placeholders := make([]string, len(sourceNames))
args := make([]interface{}, len(sourceNames))
for i, name := range sourceNames {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args[i] = name
}
query := fmt.Sprintf(`
SELECT DISTINCT mc.master_control_id, mc.canonical_name, mc.total_controls,
pc.source_citation->>'source' as regulation_source
FROM compliance.master_controls mc
JOIN compliance.master_control_members mcm ON mcm.master_control_uuid = mc.id
JOIN compliance.canonical_controls cc ON cc.id = mcm.control_uuid
LEFT JOIN compliance.canonical_controls pc ON pc.id = cc.parent_control_uuid
WHERE pc.source_citation->>'source' IN (%s)
GROUP BY mc.master_control_id, mc.canonical_name, mc.total_controls,
pc.source_citation->>'source'
ORDER BY mc.total_controls DESC
LIMIT 500`,
strings.Join(placeholders, ","))
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query MCs: %w", err)
}
defer rows.Close()
var groups []MCGroup
for rows.Next() {
var g MCGroup
var regSource *string
if err := rows.Scan(&g.MasterControlID, &g.CanonicalName,
&g.ControlCount, &regSource); err != nil {
return nil, err
}
g.Title = formatTitle(g.CanonicalName)
g.Severity = inferSeverity(g.CanonicalName)
if regSource != nil {
g.Regulation = sourceToRegID(*regSource)
}
groups = append(groups, g)
}
return groups, nil
}
// ── Helpers ─────────────────────────────────────────────────────────
func regulationToSourceNames(regs []ApplicableRegulation) []string {
mapping := map[RegulationID][]string{
RegCRA: {"Cyber Resilience Act (CRA)"},
RegAIAct: {"KI-Verordnung (EU) 2024/1689"},
RegNIS2: {"NIS2-Richtlinie (EU) 2022/2555"},
RegDSGVO: {"DSGVO (EU) 2016/679"},
RegDataAct: {"Data Act"},
RegMiCA: {"Markets in Crypto-Assets (MiCA)"},
RegPSD2: {"Zahlungsdiensterichtlinie 2"},
RegAML: {"Geldwaeschegesetz (GwG)", "AML-Verordnung"},
RegMDR: {"Medizinprodukteverordnung (EU) 2017/745 (MDR)"},
RegMachinery: {"Maschinenverordnung (EU) 2023/1230"},
RegTDDDG: {"TDDDG"},
RegLkSG: {"Lieferkettensorgfaltspflichtengesetz (LkSG)"},
}
var names []string
for _, reg := range regs {
if sources, ok := mapping[reg.ID]; ok {
names = append(names, sources...)
}
}
return names
}
func sourceToRegID(source string) RegulationID {
switch {
case strings.Contains(source, "CRA") || strings.Contains(source, "Cyber Resilience"):
return RegCRA
case strings.Contains(source, "KI-Verordnung"):
return RegAIAct
case strings.Contains(source, "NIS2"):
return RegNIS2
case strings.Contains(source, "DSGVO"):
return RegDSGVO
case strings.Contains(source, "Data Act"):
return RegDataAct
case strings.Contains(source, "MiCA") || strings.Contains(source, "Crypto"):
return RegMiCA
case strings.Contains(source, "Zahlungsdienst"):
return RegPSD2
case strings.Contains(source, "Geldwäsche") || strings.Contains(source, "AML"):
return RegAML
case strings.Contains(source, "Medizinprodukt"):
return RegMDR
case strings.Contains(source, "Maschinenverordnung"):
return RegMachinery
case strings.Contains(source, "TDDDG"):
return RegTDDDG
default:
return RegDSGVO
}
}
func formatTitle(name string) string {
return strings.ReplaceAll(
strings.ReplaceAll(name, "_", " "),
" ", " ")
}
func inferSeverity(name string) string {
high := []string{"encryption", "access_control", "incident", "vulnerability",
"authentication", "key_management", "data_breach"}
for _, h := range high {
if strings.Contains(name, h) {
return "HIGH"
}
}
return "MEDIUM"
}
@@ -0,0 +1,57 @@
package gap
// IndustryTemplates provides pre-configured product profiles for common verticals.
var IndustryTemplates = map[string]ProductProfile{
"iot_gateway": {
Name: "IoT Gateway",
Description: "Vernetztes IoT-Gateway mit OTA-Updates und Cloud-Backend",
ProductType: ProductTypeIoT,
Technologies: []string{"ota_updates", "cloud", "encryption", "api", "network"},
DataProcessing: []string{"telemetry"},
Markets: []string{"EU"},
ConnectedToInternet: true,
HasSoftwareUpdates: true,
},
"crypto_exchange": {
Name: "Krypto-Exchange",
Description: "Kryptowerte-Handelsplattform mit Fiat On/Off-Ramp und Custody",
ProductType: ProductTypeExchange,
Technologies: []string{"blockchain", "api", "encryption", "database", "payment", "fiat_gateway"},
DataProcessing: []string{"personal_data", "financial_data"},
Markets: []string{"EU"},
ConnectedToInternet: true,
ProcessesPersonalData: true,
},
"industrial_cobot": {
Name: "Industrieller Cobot",
Description: "Kollaborierender Roboter mit KI-Steuerung und Kamera-Tracking",
ProductType: ProductTypeMachinery,
Technologies: []string{"ai", "sensor", "actuator", "network", "camera"},
Markets: []string{"EU"},
ConnectedToInternet: true,
UsesAI: true,
HasSoftwareUpdates: true,
},
"saas_platform": {
Name: "SaaS-Plattform",
Description: "Cloud-basierte B2B-Software mit Nutzerdaten",
ProductType: ProductTypeSaaS,
Technologies: []string{"cloud", "api", "database", "encryption"},
DataProcessing: []string{"personal_data"},
Markets: []string{"EU"},
ConnectedToInternet: true,
HasSoftwareUpdates: true,
ProcessesPersonalData: true,
},
"medical_software": {
Name: "Medizin-Software",
Description: "KI-basierte Diagnose-Software als Medizinprodukt",
ProductType: ProductTypeMedicalDevice,
Technologies: []string{"ai", "database", "cloud"},
DataProcessing: []string{"health_data", "personal_data"},
Markets: []string{"EU"},
UsesAI: true,
ProcessesPersonalData: true,
ConnectedToInternet: true,
},
}
@@ -0,0 +1,24 @@
-- Migration 025: Gap Analysis Projects
-- Product profiles for regulatory gap analysis.
CREATE TABLE IF NOT EXISTS compliance.gap_projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(200) NOT NULL,
description TEXT DEFAULT '',
product_type VARCHAR(50) NOT NULL DEFAULT 'software',
technologies JSONB DEFAULT '[]',
data_processing JSONB DEFAULT '[]',
markets JSONB DEFAULT '["EU"]',
connected_to_internet BOOLEAN DEFAULT false,
has_software_updates BOOLEAN DEFAULT false,
uses_ai BOOLEAN DEFAULT false,
processes_personal_data BOOLEAN DEFAULT false,
is_critical_infra_supplier BOOLEAN DEFAULT false,
existing_certifications JSONB DEFAULT '[]',
last_analysis_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_gap_projects_tenant ON compliance.gap_projects(tenant_id);