From 86d1473a6a4b693486b66d97f803180346ceee14 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Thu, 25 Jun 2026 11:10:53 +0200 Subject: [PATCH] feat(ucca): obligation-join loader + citation_unit bridge + coverage report Consumes the cross-session contract obligations/obligation_join_keys.json (47 obligation_ids). Interim bridge = citation_unit (our source_norm <-> registry citation_units), to be hardened to the stable obligation_id (field now optional on ControlMapping). ComputeObligationCoverage joins the 47 registry obligations to our accepted control mappings: covered=2 (user_authentication_required, firmware_software_ authentication), mapped_rejected=3 ((2)(e) -> our OWASP mappings rejected, route via NIST/BSI), uncovered=42. This coverage signal is the feedback to the Obligation session for what to cut/refine next. Co-Authored-By: Claude Opus 4.7 --- .../internal/ucca/control_mapping.go | 15 +- .../internal/ucca/obligation_join.go | 148 ++++++++++++++++++ .../internal/ucca/obligation_join_test.go | 61 ++++++++ 3 files changed, 217 insertions(+), 7 deletions(-) create mode 100644 ai-compliance-sdk/internal/ucca/obligation_join.go create mode 100644 ai-compliance-sdk/internal/ucca/obligation_join_test.go diff --git a/ai-compliance-sdk/internal/ucca/control_mapping.go b/ai-compliance-sdk/internal/ucca/control_mapping.go index 6f1d53e4..5d148716 100644 --- a/ai-compliance-sdk/internal/ucca/control_mapping.go +++ b/ai-compliance-sdk/internal/ucca/control_mapping.go @@ -19,13 +19,14 @@ import ( // professional statement, not an AI guess. The retriever's score lives only in the rationale // of a candidate, never as structured truth. type ControlMapping struct { - SourceNorm string `json:"source_norm"` // e.g. "CRA Annex I Part I (2)(c)" - SourceRole string `json:"source_role"` // source_role of the norm (operational_requirement, ...) - TargetFramework string `json:"target_framework"` // e.g. "OWASP ASVS" - TargetControl string `json:"target_control"` // e.g. "V6.3.1" - MappingType string `json:"mapping_type"` // supports | partially_supports | implements | related | contradicts - MappingStatus string `json:"mapping_status"` // candidate | accepted | rejected | superseded - Provenance string `json:"provenance"` // retriever_candidate | human_curated | rule_based + SourceNorm string `json:"source_norm"` // e.g. "CRA Annex I Part I (2)(c)" + SourceRole string `json:"source_role"` // source_role of the norm (operational_requirement, ...) + TargetFramework string `json:"target_framework"` // e.g. "OWASP ASVS" + TargetControl string `json:"target_control"` // e.g. "V6.3.1" + MappingType string `json:"mapping_type"` // supports | partially_supports | implements | related | contradicts + MappingStatus string `json:"mapping_status"` // candidate | accepted | rejected | superseded + Provenance string `json:"provenance"` // retriever_candidate | human_curated | rule_based + ObligationID string `json:"obligation_id,omitempty"` // stable cross-session join key (Obligation Registry); empty until adopted, citation_unit is the interim bridge Rationale string `json:"rationale"` ReviewedBy string `json:"reviewed_by,omitempty"` // who decided (human or rule id) ReviewDate string `json:"review_date,omitempty"` // YYYY-MM-DD diff --git a/ai-compliance-sdk/internal/ucca/obligation_join.go b/ai-compliance-sdk/internal/ucca/obligation_join.go new file mode 100644 index 00000000..5e06386e --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/obligation_join.go @@ -0,0 +1,148 @@ +package ucca + +import ( + "encoding/json" + "os" + "regexp" + "strings" +) + +// ObligationKey is one entry of the Obligation Registry's cross-session contract +// (obligations/obligation_join_keys.json). obligation_id is the STABLE join key — assigned +// only by the Registry, never minted here. citation_units are the interim bridge until our +// ControlMapping adopts obligation_id directly. +type ObligationKey struct { + ObligationID string `json:"obligation_id"` + Regulation string `json:"regulation"` + Family string `json:"family"` + Tier string `json:"tier"` + CitationUnits []string `json:"citation_units"` + SourceRole string `json:"source_role"` +} + +// ObligationJoinKeys is the loaded contract + a citation-unit index for the interim join. +type ObligationJoinKeys struct { + SchemaVersion string `json:"schema_version"` + Count int `json:"count"` + ObligationIDs []ObligationKey `json:"obligation_ids"` + byCitationKey map[string][]string +} + +var citationRefRe = regexp.MustCompile(`\(([0-9a-zA-Z]+)\)`) + +// citationUnitKey normalizes a CRA Annex I reference for the INTERIM citation_unit join, so +// our "CRA Annex I Part I (2)(c)" and the Registry's "Annex I (2)(c)" collapse to the same +// key ("i:2.c"). Interim only — superseded by the stable obligation_id once adopted. +func citationUnitKey(cu string) string { + low := strings.ToLower(cu) + part := "" + switch { + case strings.Contains(low, "part ii"): + part = "ii" + case strings.Contains(low, "part i"), strings.Contains(low, "(2)"): + part = "i" // CRA Annex I Part I = the (2)(x) essential requirements + } + var refs []string + for _, m := range citationRefRe.FindAllStringSubmatch(cu, -1) { + refs = append(refs, strings.ToLower(m[1])) + } + return part + ":" + strings.Join(refs, ".") +} + +// LoadObligationJoinKeys reads the Registry contract and indexes it by citation-unit key. +func LoadObligationJoinKeys(path string) (*ObligationJoinKeys, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var o ObligationJoinKeys + if err := json.Unmarshal(raw, &o); err != nil { + return nil, err + } + o.byCitationKey = map[string][]string{} + for _, ob := range o.ObligationIDs { + for _, cu := range ob.CitationUnits { + k := citationUnitKey(cu) + o.byCitationKey[k] = append(o.byCitationKey[k], ob.ObligationID) + } + } + return &o, nil +} + +// ObligationsForCitation returns the obligation_ids that join (interim) to a citation +// reference such as a control_mapping.source_norm. +func (o *ObligationJoinKeys) ObligationsForCitation(citationRef string) []string { + return o.byCitationKey[citationUnitKey(citationRef)] +} + +// ObligationCoverage is one row of the cross-session coverage report: for a registry +// obligation, which of our accepted controls reach it (via the citation_unit join), how much +// evidence they require, and the resulting coverage status. +type ObligationCoverage struct { + ObligationID string `json:"obligation_id"` + Family string `json:"family"` + Status string `json:"status"` // covered | mapped_rejected | uncovered + AcceptedControls []string `json:"accepted_controls"` + EvidenceCount int `json:"evidence_count"` +} + +// ComputeObligationCoverage joins the Registry obligations to our accepted control mappings +// (via citation_unit) and reports per obligation: covered (>=1 accepted control reaches it), +// mapped_rejected (only rejected mappings reach it), or uncovered (no mapping reaches it). +// This is the signal back to the Obligation session for what to cut/refine next. +func ComputeObligationCoverage(joins *ObligationJoinKeys, mappings *ControlMappingSet, evidence *EvidenceRequirementSet) []ObligationCoverage { + type bucket struct { + acceptedCtrls map[string]bool + rejected bool + } + byKey := map[string]*bucket{} + for _, m := range mappings.All { + k := citationUnitKey(m.SourceNorm) + b := byKey[k] + if b == nil { + b = &bucket{acceptedCtrls: map[string]bool{}} + byKey[k] = b + } + switch { + case m.IsAccepted(): + b.acceptedCtrls[m.TargetFramework+":"+m.TargetControl] = true + case m.MappingStatus == "rejected": + b.rejected = true + } + } + + out := make([]ObligationCoverage, 0, len(joins.ObligationIDs)) + for _, ob := range joins.ObligationIDs { + cov := ObligationCoverage{ObligationID: ob.ObligationID, Family: ob.Family} + seen := map[string]bool{} + rejected := false + for _, cu := range ob.CitationUnits { + b := byKey[citationUnitKey(cu)] + if b == nil { + continue + } + rejected = rejected || b.rejected + for ck := range b.acceptedCtrls { + if seen[ck] { + continue + } + seen[ck] = true + cov.AcceptedControls = append(cov.AcceptedControls, ck) + fwCtrl := strings.SplitN(ck, ":", 2) + if len(fwCtrl) == 2 { + cov.EvidenceCount += len(evidence.RequiredFor(fwCtrl[0], fwCtrl[1])) + } + } + } + switch { + case len(cov.AcceptedControls) > 0: + cov.Status = "covered" + case rejected: + cov.Status = "mapped_rejected" + default: + cov.Status = "uncovered" + } + out = append(out, cov) + } + return out +} diff --git a/ai-compliance-sdk/internal/ucca/obligation_join_test.go b/ai-compliance-sdk/internal/ucca/obligation_join_test.go new file mode 100644 index 00000000..3beac57d --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/obligation_join_test.go @@ -0,0 +1,61 @@ +package ucca + +import "testing" + +func TestCitationUnitKey_Join(t *testing.T) { + // our source_norm and the registry citation_unit must collapse to the SAME key. + if citationUnitKey("CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff") != citationUnitKey("Annex I (2)(c)") { + t.Errorf("interim join broken: %q vs %q", + citationUnitKey("CRA Annex I Part I (2)(c)"), citationUnitKey("Annex I (2)(c)")) + } + // Part II must NOT collide with Part I. + if citationUnitKey("Annex I Part II (1)") == citationUnitKey("CRA Annex I Part I (2)(c)") { + t.Error("Part II must not join to Part I") + } +} + +func TestLoadObligationJoinKeys(t *testing.T) { + o, err := LoadObligationJoinKeys("../../../obligations/obligation_join_keys.json") + if err != nil { + t.Fatalf("load: %v", err) + } + if o.Count != len(o.ObligationIDs) { + t.Errorf("count %d != len %d", o.Count, len(o.ObligationIDs)) + } + if len(o.ObligationIDs) == 0 { + t.Fatal("empty contract") + } + if got := o.ObligationsForCitation("CRA Annex I Part I (2)(c)"); len(got) == 0 { + t.Error("expected an obligation joined to (2)(c)") + } +} + +func TestObligationCoverage_Report(t *testing.T) { + joins, err := LoadObligationJoinKeys("../../../obligations/obligation_join_keys.json") + if err != nil { + t.Fatalf("join keys: %v", err) + } + maps, err := LoadControlMappings("../../data/control_mappings") + if err != nil { + t.Fatalf("mappings: %v", err) + } + ev, err := LoadEvidenceRequirements("../../data/evidence_requirements") + if err != nil { + t.Fatalf("evidence: %v", err) + } + cov := ComputeObligationCoverage(joins, maps, ev) + if len(cov) == 0 { + t.Fatal("no coverage computed") + } + byStatus := map[string]int{} + for _, c := range cov { + byStatus[c.Status]++ + } + t.Logf("COVERAGE: %d Obligations | covered=%d mapped_rejected=%d uncovered=%d", + len(cov), byStatus["covered"], byStatus["mapped_rejected"], byStatus["uncovered"]) + for _, c := range cov { + if c.Status != "uncovered" { + t.Logf(" %-15s %-36s controls=%v evidence=%d", c.Status, c.ObligationID, c.AcceptedControls, c.EvidenceCount) + } + } +}