package handlers import ( "net/http" "strings" "github.com/gin-gonic/gin" "github.com/breakpilot/ai-compliance-sdk/internal/ucca" ) // ComplianceGraphHandlers serves the read-only Compliance Execution Graph // (Regulation -> Obligation -> Control -> Evidence) over the file-backed bridge artifacts. // It is intentionally SEPARATE from the DB-backed ObligationsHandlers: this is the curated // cross-session graph (Registry join keys + accepted control mappings + evidence requirements), // loaded once at startup. Fail-closed: if the graph could not load, every request answers 503. type ComplianceGraphHandlers struct { joins *ucca.ObligationJoinKeys mappings *ucca.ControlMappingSet evidence *ucca.EvidenceRequirementSet loadErr error } // NewComplianceGraphHandlers loads the graph once. Construction never fails; a load error is // retained and surfaced as 503 per request (matches the codebase's load-warn-continue startup). func NewComplianceGraphHandlers() *ComplianceGraphHandlers { joins, mappings, evidence, err := ucca.LoadComplianceGraph() return &ComplianceGraphHandlers{joins: joins, mappings: mappings, evidence: evidence, loadErr: err} } // LoadError exposes a startup load failure so the wiring can log a warning. func (h *ComplianceGraphHandlers) LoadError() error { return h.loadErr } // RegisterRoutes mounts the compliance-graph routes under /compliance. func (h *ComplianceGraphHandlers) RegisterRoutes(r *gin.RouterGroup) { g := r.Group("/compliance") g.GET("/obligation-status", h.ObligationStatus) } type cgControlDTO struct { Framework string `json:"framework"` Control string `json:"control"` MappingType string `json:"mapping_type"` EvidenceRequired []string `json:"evidence_required"` EvidenceStatus string `json:"evidence_status"` // missing | partial | present | none_required } type cgStatusResponse struct { ObligationID string `json:"obligation_id"` OverallStatus string `json:"overall_status"` // unknown_obligation | unmapped | not_assessed | open | met LegalBasis []string `json:"legal_basis,omitempty"` CitationSpans string `json:"citation_spans"` // "pending" until the Legal-KG attaches spans Controls []cgControlDTO `json:"controls"` Note string `json:"note,omitempty"` } // ObligationStatus answers GET /sdk/v1/compliance/obligation-status?obligation_id=... // // It NEVER asserts fulfillment automatically. With no evidence collection wired (MVP), a mapped // obligation is "not_assessed" and every required evidence is "missing" — the honest picture is // "required vs present evidence", not "a document exists". Fail-closed otherwise: // - no obligation_id -> 400 // - graph not loaded -> 503 // - id not in the Registry -> 200 overall_status=unknown_obligation // - mapped but no control yet -> 200 overall_status=unmapped func (h *ComplianceGraphHandlers) ObligationStatus(c *gin.Context) { if h.loadErr != nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "compliance graph unavailable", "detail": h.loadErr.Error()}) return } obID := strings.TrimSpace(c.Query("obligation_id")) if obID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "obligation_id query parameter required"}) return } resp := cgStatusResponse{ObligationID: obID, CitationSpans: "pending", Controls: []cgControlDTO{}} if h.joins.FindObligation(obID) == nil { resp.OverallStatus = "unknown_obligation" resp.Note = "obligation_id not in the Registry join-key contract" c.JSON(http.StatusOK, resp) return } // MVP: hasEvidence=nil -> no collection wired -> all required evidence counts as missing. st := ucca.AssessObligationStatus(h.joins, h.mappings, h.evidence, obID, nil) resp.LegalBasis = st.LegalBasis if len(st.Controls) == 0 { resp.OverallStatus = "unmapped" resp.Note = "no accepted control maps to this obligation yet" c.JSON(http.StatusOK, resp) return } for _, cs := range st.Controls { types := make([]string, 0, len(cs.RequiredEvidence)) for _, e := range cs.RequiredEvidence { types = append(types, e.EvidenceType) } resp.Controls = append(resp.Controls, cgControlDTO{ Framework: cs.Framework, Control: cs.Control, MappingType: cs.MappingType, EvidenceRequired: types, EvidenceStatus: cgEvidenceStatus(len(cs.RequiredEvidence), len(cs.MissingEvidence)), }) } // No fulfillment claim without real evidence collection. resp.OverallStatus = "not_assessed" resp.Note = "evidence collection not wired (MVP) — fulfillment not asserted" c.JSON(http.StatusOK, resp) } func cgEvidenceStatus(required, missing int) string { switch { case required == 0: return "none_required" case missing == 0: return "present" case missing == required: return "missing" default: return "partial" } }