Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7eb7f61483 | |||
| 8c893ca783 | |||
| d1383227b2 | |||
| a5687bbc65 | |||
| da466b3821 | |||
| eca8ec43c5 | |||
| 37c9b8e773 | |||
| 50ae9e94d1 | |||
| 429ac957c1 | |||
| 9312ad18ef | |||
| 2063615d37 | |||
| 4d225f73a8 | |||
| c13aa9183a | |||
| 662aec209a | |||
| 8440ddfecb | |||
| 0ce4794767 | |||
| 8674b2cd9a | |||
| 80862e7073 | |||
| a8c61eb320 | |||
| 8f89fbf8a7 | |||
| 33790bb5e7 | |||
| 7287e989a6 | |||
| 63fe2d496e | |||
| 4e8eb2dc0e | |||
| 78aeedafae | |||
| 2e6eee6ba1 | |||
| f23ae32077 | |||
| 739a477d3f | |||
| 8609b696c9 | |||
| 207fc9cb56 | |||
| fdaf547b06 | |||
| fa536f9714 | |||
| cba066f49b | |||
| 75f7bd8de4 | |||
| f85fff4398 | |||
| 3bcffaf52c | |||
| 3a19affb67 | |||
| 2b985ad526 | |||
| 4e761c1363 | |||
| 6673c8052b | |||
| 5e5002c883 | |||
| 9aef5ecf6c | |||
| f6c5f4e0a9 | |||
| c72fd3eb5a | |||
| b0435f9885 | |||
| 2341bda621 | |||
| 4634cc09d0 | |||
| 1607c89459 | |||
| d4df1e01df | |||
| ed31fdc0df | |||
| 5412bf0ba3 | |||
| 8a9d5e7c4d | |||
| 01956ee690 | |||
| e46e74ddbb | |||
| 63d65af41b | |||
| 8937f105ea | |||
| 1584b8fb2f |
@@ -33,6 +33,14 @@ COPY migrations/ ./migrations/
|
||||
# Copy policy files (YAML rules)
|
||||
COPY policies/ ./policies/
|
||||
|
||||
# Copy Compliance Execution Graph data (file-backed: Registry join-key copy + accepted control
|
||||
# mappings + evidence requirements) consumed by GET /sdk/v1/compliance/obligation-status.
|
||||
# data/obligations/obligation_join_keys.json is a synced copy of the repo-root Registry contract
|
||||
# (the Obligation Registry owns the canonical file) — re-sync it when the Registry grows.
|
||||
COPY data/control_mappings/ ./data/control_mappings/
|
||||
COPY data/evidence_requirements/ ./data/evidence_requirements/
|
||||
COPY data/obligations/ ./data/obligations/
|
||||
|
||||
# Create non-root user
|
||||
RUN adduser -D -u 1000 appuser
|
||||
USER appuser
|
||||
|
||||
@@ -34,6 +34,8 @@ func main() {
|
||||
cmdEcho(os.Args[2:])
|
||||
case "hierarchy":
|
||||
cmdHierarchy(os.Args[2:])
|
||||
case "propose":
|
||||
cmdPropose(os.Args[2:])
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
@@ -41,7 +43,7 @@ func main() {
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "Usage: iace-audit <reachability|consistency|vocabulary|echo|hierarchy> [args]")
|
||||
fmt.Fprintln(os.Stderr, "Usage: iace-audit <reachability|consistency|vocabulary|echo|hierarchy|propose> [args]")
|
||||
}
|
||||
|
||||
func cmdReachability(_ []string) {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace/audit"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
||||
)
|
||||
|
||||
type narrativeInput struct {
|
||||
MachineType string `json:"machine_type"`
|
||||
Narrative string `json:"narrative"`
|
||||
MachineTypes []string `json:"machine_types,omitempty"`
|
||||
}
|
||||
|
||||
// cmdPropose — Method P: offline dedup-candidate proposer.
|
||||
//
|
||||
// iace-audit propose <narrative.json> [<ground-truth.json>]
|
||||
//
|
||||
// Detect near-duplicate patterns, screen survivors against a ground truth (if
|
||||
// given), judge them (heuristic by default, LLM when enabled), and write the
|
||||
// human-review queue to audit-reports/proposals.{md,json}. Propose-only — it
|
||||
// writes a report and never mutates the pattern library.
|
||||
//
|
||||
// Env:
|
||||
//
|
||||
// IACE_PROPOSE_THRESHOLD candidate score threshold (default 0.30)
|
||||
// IACE_PROPOSE_LLM=1 use the offline LLM judge instead of the heuristic
|
||||
// OLLAMA_URL ollama base URL (default http://localhost:11434)
|
||||
// SELF_HOSTED_LLM_MODEL model name (default qwen2.5:32b-instruct)
|
||||
func cmdPropose(args []string) {
|
||||
if len(args) < 1 {
|
||||
fmt.Fprintln(os.Stderr, "propose: usage: iace-audit propose <narrative.json> [<ground-truth.json>]")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
var in narrativeInput
|
||||
must(readJSONFile(args[0], &in))
|
||||
if in.Narrative == "" {
|
||||
fmt.Fprintln(os.Stderr, "propose: narrative is empty")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
var gt *iace.GroundTruth
|
||||
if len(args) >= 2 {
|
||||
var g iace.GroundTruth
|
||||
must(readJSONFile(args[1], &g))
|
||||
gt = &g
|
||||
}
|
||||
|
||||
threshold := envFloat("IACE_PROPOSE_THRESHOLD", 0.30)
|
||||
hazards, mits, fired := iace.BuildProposerInput(in.Narrative, in.MachineType, in.MachineTypes)
|
||||
candidates := iace.FindDedupCandidates(fired, threshold)
|
||||
|
||||
byID := make(map[string]iace.PatternMatch, len(fired))
|
||||
for _, pm := range fired {
|
||||
byID[pm.PatternID] = pm
|
||||
}
|
||||
|
||||
judge := selectJudge(in.MachineType)
|
||||
ctx := context.Background()
|
||||
|
||||
var proposals []iace.JudgedProposal
|
||||
blocked := 0
|
||||
for _, c := range candidates {
|
||||
var sr iace.ScreenResult
|
||||
if gt != nil {
|
||||
sr = iace.ScreenSupersession(gt, hazards, mits, c.KeepHazardName, c.DropName)
|
||||
if sr.RecallAfter < sr.RecallBefore || sr.DistinctGT {
|
||||
blocked++
|
||||
continue
|
||||
}
|
||||
}
|
||||
v, conf, rat := judge.Judge(ctx, c, byID[c.KeepPattern], byID[c.DropPattern])
|
||||
proposals = append(proposals, iace.JudgedProposal{
|
||||
Candidate: c, Screen: sr, Verdict: v, Confidence: conf, Rationale: rat, Judge: judge.Name(),
|
||||
})
|
||||
}
|
||||
|
||||
writeText("audit-reports/proposals.md", iace.RenderProposalQueue(in.MachineType, proposals))
|
||||
writeJSON("audit-reports/proposals.json", proposals)
|
||||
|
||||
// Type 2: foreign-framing candidates (zone terms with no narrative echo).
|
||||
framing := iace.FindFramingCandidates(fired, in.Narrative, envFloat("IACE_FRAMING_MIN_ORPHAN", 0.6))
|
||||
writeText("audit-reports/framing.md", iace.RenderFramingQueue(in.MachineType, framing))
|
||||
writeJSON("audit-reports/framing.json", framing)
|
||||
|
||||
// Type 3: vocab->tag proposals (unknown narrative tokens that pattern text
|
||||
// names as a whole word, with a dominant shared required tag).
|
||||
vocab := audit.RunVocabulary(map[string]any{"narrative": in.Narrative})
|
||||
var vgaps []audit.DictionarySuggestion
|
||||
for _, s := range vocab.SuggestedDictionaryEntries {
|
||||
if len(s.SuggestedTags) > 0 {
|
||||
vgaps = append(vgaps, s)
|
||||
}
|
||||
}
|
||||
writeText("audit-reports/vocab.md", renderVocabQueue(in.MachineType, vgaps))
|
||||
writeJSON("audit-reports/vocab.json", vgaps)
|
||||
|
||||
// Type 4: coverage blind-spots (empty ISO 12100 groups A-G) + LLM expansion.
|
||||
gaps := iace.FindCoverageGaps(hazards)
|
||||
var missing []iace.MissingHazard
|
||||
if lj, ok := judge.(iace.LLMJudge); ok {
|
||||
missing = iace.ProposeMissingHazards(ctx, lj.Completer, in.MachineType, in.Narrative, hazards, gaps)
|
||||
}
|
||||
writeText("audit-reports/coverage.md", iace.RenderCoverageQueue(in.MachineType, gaps, missing))
|
||||
writeJSON("audit-reports/coverage.json", gaps)
|
||||
|
||||
printSummary("Method P — Dedup Proposer ("+judge.Name()+")", map[string]int{
|
||||
"fired_patterns": len(fired),
|
||||
"candidates": len(candidates),
|
||||
"in_queue": len(proposals),
|
||||
"gt_blocked": blocked,
|
||||
"framing_flags": len(framing),
|
||||
"vocab_gaps": len(vgaps),
|
||||
"coverage_gaps": len(gaps),
|
||||
})
|
||||
if gt == nil {
|
||||
fmt.Fprintln(os.Stderr, "note: no ground truth provided — GT wall NOT applied (candidates not recall-screened)")
|
||||
}
|
||||
}
|
||||
|
||||
func selectJudge(machineClass string) iace.CandidateJudge {
|
||||
if os.Getenv("IACE_PROPOSE_LLM") != "1" {
|
||||
return iace.HeuristicJudge{}
|
||||
}
|
||||
base := envStr("OLLAMA_URL", "http://localhost:11434")
|
||||
model := envStr("SELF_HOSTED_LLM_MODEL", "qwen2.5:32b-instruct")
|
||||
reg := llm.NewProviderRegistry("ollama", "")
|
||||
reg.Register(llm.NewOllamaAdapter(base, model))
|
||||
fmt.Printf("using LLM judge (ollama %s, model %s)\n", base, model)
|
||||
return iace.LLMJudge{Completer: iace.NewRegistryCompleter(reg, model), MachineClass: machineClass}
|
||||
}
|
||||
|
||||
func readJSONFile(path string, v any) error {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, v)
|
||||
}
|
||||
|
||||
func writeText(path, content string) {
|
||||
_ = os.MkdirAll("audit-reports", 0o755)
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "warn: could not write", path, err)
|
||||
return
|
||||
}
|
||||
fmt.Println("→ wrote", path)
|
||||
}
|
||||
|
||||
func envStr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envFloat(key string, def float64) float64 {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func renderVocabQueue(machine string, entries []audit.DictionarySuggestion) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# Vocab→tag review queue — %s\n\n", machine)
|
||||
fmt.Fprintf(&b, "%d unknown token(s) appear in pattern text but map to no dictionary tag. Propose-only — a human (or the LLM) confirms the tag, then adds a keyword_dictionary entry and pins a GT case.\n\n", len(entries))
|
||||
for i, s := range entries {
|
||||
tag := "<tag>"
|
||||
if len(s.SuggestedTags) > 0 {
|
||||
tag = s.SuggestedTags[0]
|
||||
}
|
||||
fmt.Fprintf(&b, "## %d. \"%s\" → suggested tag(s): %s\n", i+1, s.Token, strings.Join(s.SuggestedTags, ", "))
|
||||
fmt.Fprintf(&b, "- named by %d pattern(s): %s\n", len(s.PatternIDs), strings.Join(s.PatternIDs, ", "))
|
||||
fmt.Fprintf(&b, "- suggested action: add keyword_dictionary entry {%q → %s} so narratives mentioning it trigger those patterns; human confirms\n\n", s.Token, tag)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Control-Mapping: CRA Annex I -> NIST SP 800-53 Rev. 5. Eine Zeile = ein Mapping (Schema: ControlMapping).
|
||||
// Reviewt 2026-06-25 (benjamin): 3 accepted, mapping_type=primary_implementation (kanonische Primaer-Control je Anforderung).
|
||||
// Heimat der OWASP-Rejects (2)(e)/(2)(l)/(2)(i): dort war OWASP nicht der Zielstandard ("Mapping ueber NIST/BSI erforderlich").
|
||||
// related-Controls (SC-3(3), RA-5, AC-6, SI-16, ...) folgen separat als mapping_type=supports — hier nur der kanonische Einstieg.
|
||||
// obligation_id (Registry-Handoff #4 adoptiert, #6 auf CORE re-pointet 2026-06-26): SI-7->software_integrity_protection (CORE (2)(f)), SI-2->provide_security_updates, CM-7->attack_surface_minimization (CORE (2)(j)). Join exakt. Die domaenen-scoped IDs (signed_update_integrity, remote_access_attack_surface_min) bleiben gueltige Obligations und zeigen per specializes->CORE auf diese Ziele.
|
||||
{"source_norm": "CRA Annex I Part I (2)(e) — Integritaet", "source_role": "operational_requirement", "target_framework": "NIST SP 800-53", "target_control": "SI-7", "mapping_type": "primary_implementation", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "NIST SI-7 = Software, Firmware, and Information Integrity — kanonische Integritaetskontrolle (Signaturpruefung, Manipulationserkennung).", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "Primaere Implementierung der CRA-Integritaetsanforderung; OWASP war hier kein passender Treffer. Related (spaeter, supports): SA-10, CM-14.", "version": "2026-06-25", "obligation_id": "software_integrity_protection"}
|
||||
{"source_norm": "CRA Annex I Part I (2)(l) — Sichere Updates", "source_role": "operational_requirement", "target_framework": "NIST SP 800-53", "target_control": "SI-2", "mapping_type": "primary_implementation", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "NIST SI-2 = Flaw Remediation — kanonische Update-/Patch-Kontrolle.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "Primaere Implementierung der CRA-Update-Anforderung. Related (spaeter, supports): RA-5, CM-3, SA-11.", "version": "2026-06-25", "obligation_id": "provide_security_updates"}
|
||||
{"source_norm": "CRA Annex I Part I (2)(i) — Angriffsflaeche minimieren", "source_role": "operational_requirement", "target_framework": "NIST SP 800-53", "target_control": "CM-7", "mapping_type": "primary_implementation", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "NIST CM-7 = Least Functionality — Deaktivierung nicht benoetigter Ports/Dienste/Funktionen.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "CM-7 als Primaer-Control fuer Angriffsflaeche (nicht SC-3(3)). Related (spaeter, supports): SC-3(3), AC-6, SI-16.", "version": "2026-06-25", "obligation_id": "attack_surface_minimization"}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Evidence-Requirements je NIST-SP-800-53-Control (Schema: EvidenceRequirement). Eine Zeile = eine geforderte Evidenz.
|
||||
// WICHTIG: evidence_type ist FRAMEWORK-AGNOSTISCH (geteilter Katalog config_export/test_report/repo_scan/sbom/...) —
|
||||
// dieselben Typen tragen CRA, NIST, ISO 27001, IEC 62443, BSI. (framework, control) ist nur der Verweis, nicht der Typ.
|
||||
// Stand 2026-06-25, Basis: die 3 accepted CRA->NIST primary_implementation-Mappings (SI-7 Integritaet, SI-2 Updates, CM-7 Angriffsflaeche).
|
||||
{"framework": "NIST SP 800-53", "control": "SI-7", "evidence_type": "sbom", "evidence_source": "ci", "freshness_requirement": "per_release", "required": true, "rationale": "SBOM weist die Integritaet/Herkunft der Software-Bestandteile nach (bekannte, unmanipulierte Komponenten).", "version": "2026-06-25"}
|
||||
{"framework": "NIST SP 800-53", "control": "SI-7", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Secure-Boot-/Code-Signing-Konfiguration als Nachweis der Integritaetspruefung.", "version": "2026-06-25"}
|
||||
{"framework": "NIST SP 800-53", "control": "SI-2", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Konfiguration des sicheren Update-/Patch-Mechanismus (signierte/automatische Updates) als technischer Nachweis.", "version": "2026-06-25"}
|
||||
{"framework": "NIST SP 800-53", "control": "SI-2", "evidence_type": "test_report", "evidence_source": "ci", "freshness_requirement": "per_release", "required": true, "rationale": "Update-/Patch-Verifikationstest (CI) belegt, dass Sicherheitsupdates greifen.", "version": "2026-06-25"}
|
||||
{"framework": "NIST SP 800-53", "control": "CM-7", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Konfiguration deaktivierter Ports/Dienste/Funktionen als Nachweis minimierter Angriffsflaeche.", "version": "2026-06-25"}
|
||||
{"framework": "NIST SP 800-53", "control": "CM-7", "evidence_type": "repo_scan", "evidence_source": "scanner", "freshness_requirement": "per_release", "required": true, "rationale": "Angriffsflaechen-Scan (offene Ports/Dienste) als Nachweis tatsaechlich minimierter Angriffsflaeche.", "version": "2026-06-25"}
|
||||
@@ -0,0 +1,846 @@
|
||||
{
|
||||
"schema_version": "obligation_join_keys_v1",
|
||||
"contract": "obligation_id ist der stabile Join-Key. Legal Knowledge Graph haengt citation_spans an obligation_id; Compliance Execution Graph mappt control_mapping.source_norm -> obligation_id. Interim-Bruecke = citation_units. obligation_id NIE neu vergeben (re-link).",
|
||||
"count": 95,
|
||||
"obligation_ids": [
|
||||
{
|
||||
"obligation_id": "sbom_creation",
|
||||
"regulation": "CRA",
|
||||
"family": "sbom",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I Part II (1)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "sbom_dependency_coverage",
|
||||
"regulation": "CRA",
|
||||
"family": "sbom",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Art. 3(36) i.V.m. Annex I Part II (1)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "sbom_format_standard",
|
||||
"regulation": "CRA",
|
||||
"family": "sbom",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I Part II (1)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "sbom_maintenance_update",
|
||||
"regulation": "CRA",
|
||||
"family": "sbom",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I Part II (1)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "sbom_completeness_verification",
|
||||
"regulation": "CRA",
|
||||
"family": "sbom",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "sbom_tooling_automation",
|
||||
"regulation": "CRA",
|
||||
"family": "sbom",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "IMPLEMENTATION"
|
||||
},
|
||||
{
|
||||
"obligation_id": "sbom_access_provision",
|
||||
"regulation": "CRA",
|
||||
"family": "sbom",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "sbom_authority_provision",
|
||||
"regulation": "CRA",
|
||||
"family": "sbom",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Art. 31 / Annex I Part II (1)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "sbom_confidentiality",
|
||||
"regulation": "CRA",
|
||||
"family": "sbom",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Art. 31(4)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "sbom_supply_chain_contracts",
|
||||
"regulation": "CRA",
|
||||
"family": "sbom",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "sbom_technical_documentation",
|
||||
"regulation": "CRA",
|
||||
"family": "sbom",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Art. 31 i.V.m. Annex VII"
|
||||
],
|
||||
"source_role": "EVIDENCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "vuln_identification_inventory",
|
||||
"regulation": "CRA",
|
||||
"family": "vuln",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I Part II (1)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "vuln_assessment_prioritization",
|
||||
"regulation": "CRA",
|
||||
"family": "vuln",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I Part II (1)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "vuln_remediation_patching",
|
||||
"regulation": "CRA",
|
||||
"family": "vuln",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I Part II (2) & (8)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "vuln_handling_process",
|
||||
"regulation": "CRA",
|
||||
"family": "vuln",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Article 13(8) & Annex VII"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "coordinated_vulnerability_disclosure",
|
||||
"regulation": "CRA",
|
||||
"family": "vuln",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I Part II (5)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "exploited_vuln_reporting_authorities",
|
||||
"regulation": "CRA",
|
||||
"family": "vuln",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Article 14 & Article 16"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "vuln_info_dissemination_users",
|
||||
"regulation": "CRA",
|
||||
"family": "vuln",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I Part II (4) & (6)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "attack_surface_minimization",
|
||||
"regulation": "CRA",
|
||||
"family": "core",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I Part I (2)(j)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "software_integrity_protection",
|
||||
"regulation": "CRA",
|
||||
"family": "core",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I Part I (2)(f)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "user_authentication_required",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I (2)(d)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "authentication_policy_documented",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "auth_exceptions_documented",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "mfa_required",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "step_up_authentication",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "privileged_op_reauth",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "strong_crypto_authentication",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I (2)(e)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "credential_lifecycle_management",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "credential_confidentiality_protection",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I (2)(e)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "password_policy",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "no_default_credentials",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I (2)(a)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "account_lockout_failed_attempts",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "server_side_validation",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "session_binding_management",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "reauth_after_inactivity",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "token_validation_lifecycle",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "mutual_authentication",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "revocation_check",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "encrypted_auth_channel",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I (2)(e)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "tls_certificate_auth",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "service_to_service_auth",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "auth_key_management",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "biometric_authentication",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "federated_auth_assertions",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "separate_authn_authz",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "remote_access_authentication",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "supplier_access_auth",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "personal_admin_accounts",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "firmware_software_authentication",
|
||||
"regulation": "CRA",
|
||||
"family": "authentication",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I (2)(c)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "event_logging_security_events",
|
||||
"regulation": "CRA",
|
||||
"family": "logging",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I Part I (2)(k)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "access_control_event_logging",
|
||||
"regulation": "CRA",
|
||||
"family": "logging",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I Part I (2)(k)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "audit_trail_admin_actions",
|
||||
"regulation": "CRA",
|
||||
"family": "logging",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I Part I (2)(k)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "log_integrity_immutability",
|
||||
"regulation": "CRA",
|
||||
"family": "logging",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I Part I (2)(k)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "log_access_control_protection",
|
||||
"regulation": "CRA",
|
||||
"family": "logging",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I Part I (2)(k)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "log_retention_archival",
|
||||
"regulation": "CRA",
|
||||
"family": "logging",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "centralized_log_management",
|
||||
"regulation": "CRA",
|
||||
"family": "logging",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "log_monitoring_alerting",
|
||||
"regulation": "CRA",
|
||||
"family": "logging",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I Part I (2)(k)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "log_data_minimization_privacy",
|
||||
"regulation": "CRA",
|
||||
"family": "logging",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "log_format_standardization",
|
||||
"regulation": "CRA",
|
||||
"family": "logging",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "log_timestamp_synchronization",
|
||||
"regulation": "CRA",
|
||||
"family": "logging",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "logging_availability_resilience",
|
||||
"regulation": "CRA",
|
||||
"family": "logging",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "logging_thread_safety_correctness",
|
||||
"regulation": "CRA",
|
||||
"family": "logging",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "IMPLEMENTATION"
|
||||
},
|
||||
{
|
||||
"obligation_id": "logging_library_supply_chain",
|
||||
"regulation": "CRA",
|
||||
"family": "logging",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "logging_config_management",
|
||||
"regulation": "CRA",
|
||||
"family": "logging",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "logging_governance_roles",
|
||||
"regulation": "CRA",
|
||||
"family": "logging",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "incident_response_logging",
|
||||
"regulation": "CRA",
|
||||
"family": "logging",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "log_transmission_security",
|
||||
"regulation": "CRA",
|
||||
"family": "logging",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "network_traffic_logging",
|
||||
"regulation": "CRA",
|
||||
"family": "logging",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "remote_access_control_least_privilege",
|
||||
"regulation": "CRA",
|
||||
"family": "remote_access",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I (1)(2)(d)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "remote_access_confidentiality_integrity",
|
||||
"regulation": "CRA",
|
||||
"family": "remote_access",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I (1)(2)(b)(c)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "remote_session_management",
|
||||
"regulation": "CRA",
|
||||
"family": "remote_access",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "remote_access_mfa",
|
||||
"regulation": "CRA",
|
||||
"family": "remote_access",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "remote_access_encryption",
|
||||
"regulation": "CRA",
|
||||
"family": "remote_access",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "reject_insecure_remote_protocols",
|
||||
"regulation": "CRA",
|
||||
"family": "remote_access",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "remote_access_logging_audit",
|
||||
"regulation": "CRA",
|
||||
"family": "remote_access",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I (1)(2)(g)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "remote_access_user_validation_ot",
|
||||
"regulation": "CRA",
|
||||
"family": "remote_access",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "remote_access_training",
|
||||
"regulation": "CRA",
|
||||
"family": "remote_access",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "remote_access_architecture_design",
|
||||
"regulation": "CRA",
|
||||
"family": "remote_access",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "remote_access_attack_surface_min",
|
||||
"regulation": "CRA",
|
||||
"family": "remote_access",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I (1)(2)(a)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "remote_access_vuln_patch_mgmt",
|
||||
"regulation": "CRA",
|
||||
"family": "remote_access",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I (2)(1)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "remote_access_threat_detection",
|
||||
"regulation": "CRA",
|
||||
"family": "remote_access",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "remote_maintenance_governance",
|
||||
"regulation": "CRA",
|
||||
"family": "remote_access",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "temporary_remote_access_mgmt",
|
||||
"regulation": "CRA",
|
||||
"family": "remote_access",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "remote_access_data_export_protection",
|
||||
"regulation": "CRA",
|
||||
"family": "remote_access",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "component_remote_interface_security",
|
||||
"regulation": "CRA",
|
||||
"family": "remote_access",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "remote_access_fallback_concept",
|
||||
"regulation": "CRA",
|
||||
"family": "remote_access",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "provide_security_updates",
|
||||
"regulation": "CRA",
|
||||
"family": "updates",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I (2)(c)",
|
||||
"Art. 13"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "support_period_maintenance",
|
||||
"regulation": "CRA",
|
||||
"family": "updates",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Art. 13(8)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "signed_update_integrity",
|
||||
"regulation": "CRA",
|
||||
"family": "updates",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I (1)(3)(f)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "trusted_update_source",
|
||||
"regulation": "CRA",
|
||||
"family": "updates",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I (1)(3)(d)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "update_testing_validation",
|
||||
"regulation": "CRA",
|
||||
"family": "updates",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "update_rollback",
|
||||
"regulation": "CRA",
|
||||
"family": "updates",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "GUIDANCE"
|
||||
},
|
||||
{
|
||||
"obligation_id": "automatic_updates_optout",
|
||||
"regulation": "CRA",
|
||||
"family": "updates",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I (2)(c)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "update_risk_assessment",
|
||||
"regulation": "CRA",
|
||||
"family": "updates",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"citation_units": [
|
||||
"Annex I (1)(2)"
|
||||
],
|
||||
"source_role": "LEGAL_BASIS"
|
||||
},
|
||||
{
|
||||
"obligation_id": "secure_modification_control",
|
||||
"regulation": "CRA",
|
||||
"family": "updates",
|
||||
"tier": "BEST_PRACTICE",
|
||||
"citation_units": [],
|
||||
"source_role": "IMPLEMENTATION"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func newComplianceGraphTestRouter(t *testing.T) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewComplianceGraphHandlers()
|
||||
if err := h.LoadError(); err != nil {
|
||||
t.Fatalf("compliance graph failed to load (candidate paths): %v", err)
|
||||
}
|
||||
r := gin.New()
|
||||
h.RegisterRoutes(r.Group("/sdk/v1"))
|
||||
return r
|
||||
}
|
||||
|
||||
func getObligationStatus(t *testing.T, r *gin.Engine, query string) (int, cgStatusResponse) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/sdk/v1/compliance/obligation-status"+query, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
var resp cgStatusResponse
|
||||
if w.Code == http.StatusOK {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
}
|
||||
return w.Code, resp
|
||||
}
|
||||
|
||||
func TestObligationStatus(t *testing.T) {
|
||||
r := newComplianceGraphTestRouter(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantHTTP int
|
||||
wantOverall string
|
||||
wantControls bool // expect >=1 control
|
||||
}{
|
||||
{"missing param -> 400", "", http.StatusBadRequest, "", false},
|
||||
{"unknown id -> unknown_obligation", "?obligation_id=does_not_exist", http.StatusOK, "unknown_obligation", false},
|
||||
{"mapped (OWASP V6) -> not_assessed", "?obligation_id=user_authentication_required", http.StatusOK, "not_assessed", true},
|
||||
{"NIST adopted (SI-2) -> not_assessed", "?obligation_id=provide_security_updates", http.StatusOK, "not_assessed", true},
|
||||
{"CORE attack_surface_minimization -> CM-7", "?obligation_id=attack_surface_minimization", http.StatusOK, "not_assessed", true},
|
||||
{"CORE software_integrity_protection -> SI-7", "?obligation_id=software_integrity_protection", http.StatusOK, "not_assessed", true},
|
||||
{"in registry, no control -> unmapped", "?obligation_id=sbom_creation", http.StatusOK, "unmapped", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
code, resp := getObligationStatus(t, r, tt.query)
|
||||
if code != tt.wantHTTP {
|
||||
t.Fatalf("http %d, want %d", code, tt.wantHTTP)
|
||||
}
|
||||
if tt.wantHTTP != http.StatusOK {
|
||||
return
|
||||
}
|
||||
if resp.OverallStatus != tt.wantOverall {
|
||||
t.Errorf("overall_status=%q, want %q", resp.OverallStatus, tt.wantOverall)
|
||||
}
|
||||
if tt.wantControls && len(resp.Controls) == 0 {
|
||||
t.Error("expected >=1 control")
|
||||
}
|
||||
if !tt.wantControls && len(resp.Controls) != 0 {
|
||||
t.Errorf("expected 0 controls, got %d", len(resp.Controls))
|
||||
}
|
||||
if resp.CitationSpans != "pending" {
|
||||
t.Errorf("citation_spans=%q, want pending", resp.CitationSpans)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The MVP must NEVER auto-assert fulfillment: with no evidence collection wired, every required
|
||||
// evidence is "missing" and the overall status stays "not_assessed".
|
||||
func TestObligationStatus_NoFulfillmentClaim(t *testing.T) {
|
||||
r := newComplianceGraphTestRouter(t)
|
||||
code, resp := getObligationStatus(t, r, "?obligation_id=user_authentication_required")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("http %d", code)
|
||||
}
|
||||
if resp.OverallStatus == "met" || resp.OverallStatus == "erfuellt" {
|
||||
t.Fatalf("MVP must not assert fulfillment, got overall_status=%q", resp.OverallStatus)
|
||||
}
|
||||
for _, ctl := range resp.Controls {
|
||||
if len(ctl.EvidenceRequired) > 0 && ctl.EvidenceStatus != "missing" {
|
||||
t.Errorf("control %s/%s evidence_status=%q, want missing (no collection wired)", ctl.Framework, ctl.Control, ctl.EvidenceStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pin the curated evidence_required set per NIST obligation. A required:false row silently
|
||||
// drops from evidence_required, which the table test above (control-count only) would miss.
|
||||
func TestObligationStatus_NISTEvidenceTypes(t *testing.T) {
|
||||
r := newComplianceGraphTestRouter(t)
|
||||
want := map[string][]string{
|
||||
"attack_surface_minimization": {"config_export", "repo_scan"},
|
||||
"software_integrity_protection": {"sbom", "config_export"},
|
||||
"provide_security_updates": {"config_export", "test_report"},
|
||||
}
|
||||
for ob, exp := range want {
|
||||
_, resp := getObligationStatus(t, r, "?obligation_id="+ob)
|
||||
if len(resp.Controls) != 1 {
|
||||
t.Fatalf("%s: want 1 control, got %d", ob, len(resp.Controls))
|
||||
}
|
||||
if got := resp.Controls[0].EvidenceRequired; !sameStringSet(got, exp) {
|
||||
t.Errorf("%s evidence_required = %v, want %v", ob, got, exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sameStringSet(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
m := make(map[string]bool, len(a))
|
||||
for _, x := range a {
|
||||
m[x] = true
|
||||
}
|
||||
for _, x := range b {
|
||||
if !m[x] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -153,6 +153,12 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine {
|
||||
ragHandlers := handlers.NewRAGHandlers(corpusVersionStore)
|
||||
obligationsHandlers := handlers.NewObligationsHandlersWithStore(obligationsStore)
|
||||
|
||||
// Compliance Execution Graph (file-backed: Registry join keys + accepted control mappings + evidence)
|
||||
complianceGraphHandlers := handlers.NewComplianceGraphHandlers()
|
||||
if err := complianceGraphHandlers.LoadError(); err != nil {
|
||||
log.Printf("WARNING: compliance graph not loaded (obligation-status -> 503): %v", err)
|
||||
}
|
||||
|
||||
// Regulatory News
|
||||
allV2Regs, err := ucca.LoadAllV2Regulations()
|
||||
if err != nil {
|
||||
@@ -201,7 +207,8 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine {
|
||||
uccaHandlers, escalationHandlers, obligationsHandlers, ragHandlers,
|
||||
roadmapHandlers, workshopHandlers, portfolioHandlers,
|
||||
academyHandlers, trainingHandlers, whistleblowerHandlers, iaceHandler,
|
||||
gapHandler, maximizerHandlers, regulatoryNewsHandlers, useCaseHandler)
|
||||
gapHandler, maximizerHandlers, regulatoryNewsHandlers, useCaseHandler,
|
||||
complianceGraphHandlers)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ func registerRoutes(
|
||||
maximizerHandlers *handlers.MaximizerHandlers,
|
||||
regulatoryNewsHandlers *handlers.RegulatoryNewsHandlers,
|
||||
useCaseHandler *handlers.UseCaseHandler,
|
||||
complianceGraphHandlers *handlers.ComplianceGraphHandlers,
|
||||
) {
|
||||
v1 := router.Group("/sdk/v1")
|
||||
{
|
||||
@@ -54,6 +55,7 @@ func registerRoutes(
|
||||
registerMaximizerRoutes(v1, maximizerHandlers)
|
||||
registerUseCaseRoutes(v1, useCaseHandler)
|
||||
v1.GET("/regulatory-news", regulatoryNewsHandlers.GetNews)
|
||||
complianceGraphHandlers.RegisterRoutes(v1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ type DictionarySuggestion struct {
|
||||
Token string `json:"token"`
|
||||
Field string `json:"field"`
|
||||
PatternIDs []string `json:"pattern_ids"`
|
||||
// SuggestedTags are the RequiredComponentTags shared by the naming patterns,
|
||||
// ranked by frequency — the candidate tags a keyword_dictionary entry for this
|
||||
// token would emit so narratives mentioning it can trigger those patterns.
|
||||
SuggestedTags []string `json:"suggested_tags,omitempty"`
|
||||
}
|
||||
|
||||
type VocabularyReport struct {
|
||||
|
||||
@@ -66,6 +66,10 @@ func runVocabulary(form map[string]any) VocabularyReport {
|
||||
|
||||
// For each unknown token check if any pattern names it
|
||||
patterns := iace.AllPatterns()
|
||||
byID := make(map[string]iace.HazardPattern, len(patterns))
|
||||
for _, p := range patterns {
|
||||
byID[p.ID] = p
|
||||
}
|
||||
for _, tok := range report.UnknownTokens {
|
||||
hits := patternsMentioning(tok, patterns)
|
||||
if len(hits) == 0 {
|
||||
@@ -74,6 +78,7 @@ func runVocabulary(form map[string]any) VocabularyReport {
|
||||
report.SuggestedDictionaryEntries = append(report.SuggestedDictionaryEntries, DictionarySuggestion{
|
||||
Token: tok,
|
||||
PatternIDs: hits,
|
||||
SuggestedTags: suggestTagsFor(hits, byID),
|
||||
})
|
||||
}
|
||||
sort.Slice(report.SuggestedDictionaryEntries, func(i, j int) bool {
|
||||
@@ -129,18 +134,24 @@ func dictTokenHit(tok string, dict map[string]bool) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// patternsMentioning returns up to 8 pattern IDs whose scenario/trigger/
|
||||
// harm/zone text contains the token (case-insensitive substring).
|
||||
// patternsMentioning returns up to 8 pattern IDs whose scenario/trigger/harm/
|
||||
// zone text names the token as a WHOLE WORD. Whole-word (not substring) matching
|
||||
// is essential: a substring match flags common fragments like "stehen" inside
|
||||
// "entstehen", producing spurious hits and nonsensical tag suggestions.
|
||||
func patternsMentioning(tok string, patterns []iace.HazardPattern) []string {
|
||||
tokLower := strings.ToLower(tok)
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, p := range patterns {
|
||||
hay := strings.ToLower(p.ScenarioDE + " " + p.TriggerDE + " " + p.HarmDE + " " + p.ZoneDE + " " + p.NameDE)
|
||||
if !strings.Contains(hay, tokLower) {
|
||||
continue
|
||||
matched := false
|
||||
for _, w := range tokenRE.FindAllString(hay, -1) {
|
||||
if w == tokLower {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
if seen[p.ID] {
|
||||
}
|
||||
if !matched || seen[p.ID] {
|
||||
continue
|
||||
}
|
||||
seen[p.ID] = true
|
||||
@@ -151,3 +162,57 @@ func patternsMentioning(tok string, patterns []iace.HazardPattern) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// suggestTagsFor returns the RequiredComponentTags shared across the naming
|
||||
// patterns, ranked by how many of them require each tag (ties broken by name),
|
||||
// top 3. These are the candidate tags a dictionary entry for the token should
|
||||
// emit so a narrative mentioning the token can trigger those patterns.
|
||||
func suggestTagsFor(ids []string, byID map[string]iace.HazardPattern) []string {
|
||||
freq := map[string]int{}
|
||||
total := 0
|
||||
for _, id := range ids {
|
||||
p, ok := byID[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
seen := map[string]bool{}
|
||||
for _, tag := range p.RequiredComponentTags {
|
||||
if seen[tag] {
|
||||
continue
|
||||
}
|
||||
seen[tag] = true
|
||||
freq[tag]++
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return nil
|
||||
}
|
||||
type tf struct {
|
||||
tag string
|
||||
n int
|
||||
}
|
||||
ranked := make([]tf, 0, len(freq))
|
||||
for t, n := range freq {
|
||||
ranked = append(ranked, tf{t, n})
|
||||
}
|
||||
sort.Slice(ranked, func(i, j int) bool {
|
||||
if ranked[i].n != ranked[j].n {
|
||||
return ranked[i].n > ranked[j].n
|
||||
}
|
||||
return ranked[i].tag < ranked[j].tag
|
||||
})
|
||||
// Only suggest a tag shared by >= 40% of the naming patterns. Diffuse tokens
|
||||
// (common verbs spread across categories) get no dominant tag and are dropped.
|
||||
var out []string
|
||||
for _, x := range ranked {
|
||||
if float64(x.n)/float64(total) < 0.4 {
|
||||
break
|
||||
}
|
||||
out = append(out, x.tag)
|
||||
if len(out) >= 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
)
|
||||
|
||||
func TestSuggestTagsFor_RanksSharedRequiredTags(t *testing.T) {
|
||||
byID := map[string]iace.HazardPattern{
|
||||
"P1": {ID: "P1", RequiredComponentTags: []string{"backflow_risk", "dom_warewashing"}},
|
||||
"P2": {ID: "P2", RequiredComponentTags: []string{"backflow_risk"}},
|
||||
"P3": {ID: "P3", RequiredComponentTags: []string{"sharp_edge"}},
|
||||
}
|
||||
got := suggestTagsFor([]string{"P1", "P2", "P3"}, byID)
|
||||
if len(got) == 0 || got[0] != "backflow_risk" {
|
||||
t.Fatalf("want backflow_risk ranked first (2 patterns), got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestTagsFor_TopThreeStableAlpha(t *testing.T) {
|
||||
byID := map[string]iace.HazardPattern{
|
||||
"P1": {ID: "P1", RequiredComponentTags: []string{"d", "b", "a", "c"}},
|
||||
}
|
||||
got := suggestTagsFor([]string{"P1"}, byID)
|
||||
if len(got) != 3 || got[0] != "a" || got[1] != "b" || got[2] != "c" {
|
||||
t.Fatalf("want stable alpha top-3 [a b c], got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestTagsFor_UnknownPatternIgnored(t *testing.T) {
|
||||
byID := map[string]iace.HazardPattern{}
|
||||
if got := suggestTagsFor([]string{"missing"}, byID); len(got) != 0 {
|
||||
t.Fatalf("want empty for unknown patterns, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestKistenhub_GTCoverage runs the Kistenhubgeraet ground truth (37 entries)
|
||||
@@ -110,65 +108,6 @@ func TestKistenhub_GTCoverage(t *testing.T) {
|
||||
// patternsToHazardsAndMitigations converts a pattern match output into the
|
||||
// Hazard/Mitigation shapes that CompareBenchmark expects. Mirrors what
|
||||
// iace_handler_init.go does in production but without DB writes.
|
||||
func patternsToHazardsAndMitigations(out *MatchOutput) ([]Hazard, []Mitigation) {
|
||||
hazards := make([]Hazard, 0, len(out.MatchedPatterns))
|
||||
patternToHazard := make(map[string]uuid.UUID, len(out.MatchedPatterns))
|
||||
|
||||
for _, pm := range out.MatchedPatterns {
|
||||
cat := ""
|
||||
if len(pm.HazardCats) > 0 {
|
||||
cat = pm.HazardCats[0]
|
||||
}
|
||||
zone := pm.ZoneDE
|
||||
lifecycle := ""
|
||||
if len(pm.ApplicableLifecycles) > 0 {
|
||||
lifecycle = pm.ApplicableLifecycles[0]
|
||||
}
|
||||
h := Hazard{
|
||||
ID: uuid.New(),
|
||||
Name: pm.ScenarioDE,
|
||||
Category: cat,
|
||||
Description: pm.ScenarioDE,
|
||||
Scenario: pm.ScenarioDE,
|
||||
TriggerEvent: pm.TriggerDE,
|
||||
PossibleHarm: pm.HarmDE,
|
||||
AffectedPerson: pm.AffectedDE,
|
||||
HazardousZone: zone,
|
||||
LifecyclePhase: lifecycle,
|
||||
}
|
||||
if h.Name == "" {
|
||||
h.Name = pm.PatternName
|
||||
}
|
||||
hazards = append(hazards, h)
|
||||
patternToHazard[pm.PatternID] = h.ID
|
||||
}
|
||||
|
||||
measureNames := make(map[string]string)
|
||||
for _, m := range GetProtectiveMeasureLibrary() {
|
||||
measureNames[m.ID] = m.Name
|
||||
}
|
||||
|
||||
var mitigations []Mitigation
|
||||
for _, sm := range out.SuggestedMeasures {
|
||||
name := measureNames[sm.MeasureID]
|
||||
if name == "" {
|
||||
name = sm.MeasureID
|
||||
}
|
||||
for _, srcPattern := range sm.SourcePatterns {
|
||||
hid, ok := patternToHazard[srcPattern]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mitigations = append(mitigations, Mitigation{
|
||||
ID: uuid.New(),
|
||||
HazardID: hid,
|
||||
Name: name,
|
||||
})
|
||||
}
|
||||
}
|
||||
return hazards, mitigations
|
||||
}
|
||||
|
||||
func abbrev(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -45,7 +46,7 @@ var warewashingCyberCategories = map[string]bool{
|
||||
|
||||
// warewashingEngineOutput runs the production chain and returns the filtered
|
||||
// hazards/mitigations the user would see for the UC-M.
|
||||
func warewashingEngineOutput() ([]Hazard, []Mitigation, int) {
|
||||
func warewashingEngineOutput() ([]Hazard, []Mitigation, []PatternMatch) {
|
||||
res := ParseNarrative(warewashingNarrative, "Gewerbliche Untertisch-Geschirrspuelmaschine (vernetzt)")
|
||||
|
||||
var compIDs, compNames []string
|
||||
@@ -94,7 +95,7 @@ func warewashingEngineOutput() ([]Hazard, []Mitigation, int) {
|
||||
filtered := *out
|
||||
filtered.MatchedPatterns = kept
|
||||
hazards, mitigations := patternsToHazardsAndMitigations(&filtered)
|
||||
return hazards, mitigations, len(kept)
|
||||
return hazards, mitigations, kept
|
||||
}
|
||||
|
||||
func TestWarewashing_GTCoverage(t *testing.T) {
|
||||
@@ -119,8 +120,8 @@ func TestWarewashing_GTCoverage(t *testing.T) {
|
||||
t.Logf("Parsed components: %v", cn)
|
||||
}
|
||||
|
||||
hazards, mitigations, nPatterns := warewashingEngineOutput()
|
||||
t.Logf("Engine: %d patterns kept (relevance+cyber filter) -> %d hazards", nPatterns, len(hazards))
|
||||
hazards, mitigations, keptPatterns := warewashingEngineOutput()
|
||||
t.Logf("Engine: %d patterns kept (relevance+cyber filter) -> %d hazards", len(keptPatterns), len(hazards))
|
||||
|
||||
result := CompareBenchmark(>, hazards, mitigations)
|
||||
precision := 0.0
|
||||
@@ -180,3 +181,57 @@ func TestWarewashing_GTCoverage(t *testing.T) {
|
||||
t.Errorf("warewashing recall below 40%% floor: %.1f%%", result.CoverageScore*100)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarewashing_DedupProposer exercises the offline dedup-candidate proposer
|
||||
// end-to-end on the real warewashing engine output: detect candidates, screen
|
||||
// each against the GT, and log the human-review queue. It asserts the WALL is
|
||||
// self-consistent — a PASS verdict may never coincide with a recall drop.
|
||||
func TestWarewashing_DedupProposer(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "ground_truth_warewashing.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read GT: %v", err)
|
||||
}
|
||||
var gt GroundTruth
|
||||
if err := json.Unmarshal(raw, >); err != nil {
|
||||
t.Fatalf("parse GT: %v", err)
|
||||
}
|
||||
|
||||
hazards, mits, kept := warewashingEngineOutput()
|
||||
byID := map[string]PatternMatch{}
|
||||
for _, pm := range kept {
|
||||
byID[pm.PatternID] = pm
|
||||
}
|
||||
// 0.25 is a deliberately permissive candidate threshold: the proposer is meant
|
||||
// to over-surface, because the deterministic GT wall below (and a human, and the
|
||||
// LLM judge) is the precision filter — not the detector.
|
||||
candidates := FindDedupCandidates(kept, 0.25)
|
||||
t.Logf("Proposer: %d dedup candidate(s) from %d fired patterns", len(candidates), len(kept))
|
||||
|
||||
// Deterministic judge in the test; the dev-time CLI swaps in LLMJudge.
|
||||
judge := HeuristicJudge{}
|
||||
var judged []JudgedProposal
|
||||
blocked := 0
|
||||
for _, c := range candidates {
|
||||
sr := ScreenSupersession(>, hazards, mits, c.KeepHazardName, c.DropName)
|
||||
switch {
|
||||
case sr.RecallAfter < sr.RecallBefore:
|
||||
t.Logf("[BLOCK recall-load-bearing] keep %s / drop %s", c.KeepPattern, c.DropPattern)
|
||||
blocked++
|
||||
case sr.DistinctGT:
|
||||
t.Logf("[BLOCK distinct GT %s vs %s] keep %s / drop %s", sr.KeepGT, sr.DropGT, c.KeepPattern, c.DropPattern)
|
||||
blocked++
|
||||
default:
|
||||
if !sr.Safe {
|
||||
t.Errorf("RECALL-SAFE branch but ScreenResult.Safe=false for drop %s", c.DropPattern)
|
||||
}
|
||||
v, conf, rat := judge.Judge(context.Background(), c, byID[c.KeepPattern], byID[c.DropPattern])
|
||||
judged = append(judged, JudgedProposal{
|
||||
Candidate: c, Screen: sr, Verdict: v, Confidence: conf, Rationale: rat, Judge: judge.Name(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("\n%s", RenderProposalQueue("Gewerbliche Geschirrspuelmaschine (vernetzt)", judged))
|
||||
t.Logf("Proposer summary: %d candidate(s) in queue (judge=%s), %d BLOCKED by the GT wall — propose-only, nothing auto-applied",
|
||||
len(judged), judge.Name(), blocked)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package iace
|
||||
|
||||
import "sort"
|
||||
|
||||
// EN ISO 12100 hazard-group ordering for the hazard log. Without it the log is
|
||||
// returned in pattern-firing order, which reads as a jumble. This groups the
|
||||
// hazards top-down by type (A. Mechanisch, B. Elektrisch, C. Thermisch, …),
|
||||
// matching the frontend CATEGORY_LABELS.
|
||||
var isoCategoryRank = map[string]int{
|
||||
// A. Mechanisch
|
||||
"mechanical_hazard": 10, "mechanical": 10, "maintenance_hazard": 11,
|
||||
// B. Elektrisch
|
||||
"electrical_hazard": 20, "electrical": 20, "emc_hazard": 21,
|
||||
// C. Thermisch
|
||||
"thermal_hazard": 30, "thermal": 30, "high_temperature": 31, "fire_explosion": 32,
|
||||
// D. Pneumatik / Hydraulik
|
||||
"pneumatic_hydraulic": 40,
|
||||
// E. Laerm / Vibration
|
||||
"noise_hazard": 50, "noise_vibration": 50, "vibration_hazard": 51,
|
||||
// F. Ergonomie
|
||||
"ergonomic_hazard": 60, "ergonomic": 60,
|
||||
// G. Stoffe / Umwelt
|
||||
"material_environmental": 70, "chemical_risk": 71, "radiation_hazard": 72,
|
||||
// H. Software / Steuerung (funktionale Sicherheit)
|
||||
"software_control": 80, "software_fault": 80, "safety_function_failure": 81,
|
||||
"configuration_error": 82, "sensor_fault": 83, "hmi_error": 84, "mode_confusion": 85,
|
||||
"communication_failure": 86, "update_failure": 87,
|
||||
// I. Cyber / Netzwerk (zur Ordnungs-Vollstaendigkeit; im CE-Log ausgeschlossen)
|
||||
"unauthorized_access": 90, "firmware_corruption": 91, "cyber_resilience": 92,
|
||||
"cyber_network": 93, "logging_audit_failure": 94, "sensor_spoofing": 95,
|
||||
// J. KI-spezifisch
|
||||
"ai_specific": 100, "ai_misclassification": 100, "false_classification": 100,
|
||||
"model_drift": 100, "data_poisoning": 100, "unintended_bias": 100,
|
||||
}
|
||||
|
||||
func categoryRank(cat string) int {
|
||||
if r, ok := isoCategoryRank[cat]; ok {
|
||||
return r
|
||||
}
|
||||
return 999 // unknown categories last
|
||||
}
|
||||
|
||||
// SortHazardsByISO12100 groups hazards by ISO 12100 hazard group. Stable: the
|
||||
// relative order within a group (creation/priority order from the engine) is
|
||||
// preserved.
|
||||
func SortHazardsByISO12100(hazards []Hazard) {
|
||||
sort.SliceStable(hazards, func(i, j int) bool {
|
||||
return categoryRank(hazards[i].Category) < categoryRank(hazards[j].Category)
|
||||
})
|
||||
}
|
||||
@@ -157,7 +157,7 @@ func GetGTBremseHazardPatterns() []HazardPattern {
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
{
|
||||
ID: "HP1717", NameDE: "Verletzung durch unvermittelt austretende pneumatische Restenergie", NameEN: "Injury from unexpectedly released pneumatic stored energy",
|
||||
RequiredComponentTags: []string{"stored_energy"},
|
||||
RequiredComponentTags: []string{"pneumatic_part"},
|
||||
RequiredEnergyTags: []string{"pneumatic_pressure"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M485", "M534", "M527"},
|
||||
|
||||
@@ -375,7 +375,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP753", NameDE: "Thermal Runaway bei Lithium-Batterie", NameEN: "Thermal runaway of lithium battery",
|
||||
RequiredComponentTags: []string{"stored_energy", "high_temperature"},
|
||||
RequiredComponentTags: []string{"battery", "high_temperature"},
|
||||
RequiredEnergyTags: []string{"electrical_energy", "thermal"},
|
||||
GeneratedHazardCats: []string{"thermal_hazard", "electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M005", "M141"},
|
||||
@@ -390,7 +390,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
|
||||
},
|
||||
{
|
||||
ID: "HP754", NameDE: "Ausgasung giftiger Daempfe aus Batterie", NameEN: "Toxic gas emission from battery",
|
||||
RequiredComponentTags: []string{"stored_energy", "chemical_risk"},
|
||||
RequiredComponentTags: []string{"battery", "chemical_risk"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"material_environmental"},
|
||||
SuggestedMeasureIDs: []string{"M005", "M141"},
|
||||
@@ -405,7 +405,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
|
||||
},
|
||||
{
|
||||
ID: "HP755", NameDE: "Elektrischer Schlag an Hochvolt-Batteriespeicher", NameEN: "Electric shock from high-voltage battery storage",
|
||||
RequiredComponentTags: []string{"stored_energy", "electrical_part"},
|
||||
RequiredComponentTags: []string{"battery", "electrical_part"},
|
||||
RequiredEnergyTags: []string{"electrical_energy"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M082", "M141"},
|
||||
|
||||
@@ -137,7 +137,7 @@ func GetKeywordDictionary() []KeywordEntry {
|
||||
{Keywords: []string{"kreiselmaeher", "scheibenmaeher", "maehwerk"}, ExtraTags: []string{"agri_mower"}},
|
||||
{Keywords: []string{"spruehduese", "spritzduese", "spruehkopf"}, ExtraTags: []string{"spray_nozzle"}},
|
||||
{Keywords: []string{"galvanikbad", "tauchbad", "beizbad", "chemiebad"}, ExtraTags: []string{"chemical_bath"}},
|
||||
{Keywords: []string{"batterie", "akku", "akkumulator", "traktionsbatterie"}, ExtraTags: []string{"battery"}},
|
||||
{Keywords: []string{"batterie", "akku", "akkumulator", "traktionsbatterie", "lithium", "batteriespeicher", "hochvoltbatterie", "lithium-batterie"}, ExtraTags: []string{"battery"}},
|
||||
{Keywords: []string{"heizelement", "heizpatrone", "heizband"}, ExtraTags: []string{"heating_element"}},
|
||||
{Keywords: []string{"uv-lampe", "uv-strahler", "uv-c-strahler"}, ExtraTags: []string{"uv_source"}},
|
||||
{Keywords: []string{"roentgen", "radioaktiv", "strahlenquelle", "gammastrahl", "isotop"}, ExtraTags: []string{"radiation_source"}},
|
||||
|
||||
@@ -42,3 +42,29 @@ func guardedLifecycles(p HazardPattern, tagSet map[string]bool) []string {
|
||||
}
|
||||
return p.ApplicableLifecycles
|
||||
}
|
||||
|
||||
// Domain-specific supersession.
|
||||
//
|
||||
// A generic pattern that fires via a broad tag (e.g. high_temperature) can
|
||||
// duplicate a domain-specific pattern that describes the same hazard more
|
||||
// precisely. When the domain is present, the specific pattern wins and the
|
||||
// generic duplicate is dropped. Scoped to the domain tag, so machines outside
|
||||
// the domain keep the generic pattern — regression-safe by construction.
|
||||
//
|
||||
// HP016 (generic hot surfaces) -> HP2201 (Boiler/Tank/Spuelkammer)
|
||||
// HP018 (actuator burn) -> HP2201 (same contact-burn hazard)
|
||||
// HP013 (stored electrical NRG) -> HP144 (residual voltage; HP013's zone is
|
||||
// framed for Batteriefaecher/USV-Anlagen a
|
||||
// dishwasher does not have, HP144 is the
|
||||
// Frequenzumrichter/Zwischenkreis variant)
|
||||
var genericSupersededByWarewashing = map[string]bool{
|
||||
"HP016": true,
|
||||
"HP018": true,
|
||||
"HP013": true,
|
||||
}
|
||||
|
||||
// supersededByDomainSpecific reports whether a generic pattern is replaced by a
|
||||
// more precise equivalent that the project's domain already provides.
|
||||
func supersededByDomainSpecific(p HazardPattern, tagSet map[string]bool) bool {
|
||||
return tagSet["dom_warewashing"] && genericSupersededByWarewashing[p.ID]
|
||||
}
|
||||
|
||||
@@ -416,6 +416,11 @@ func patternMatches(p HazardPattern, tagSet map[string]bool, input MatchInput) b
|
||||
return false
|
||||
}
|
||||
|
||||
// Domain-specific supersession (generic duplicate replaced by a precise one).
|
||||
if supersededByDomainSpecific(p, tagSet) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Coverage blind-spot proposer (P2 slice 6, type 4). DEV-TIME, propose-only.
|
||||
//
|
||||
// Deterministic skeleton: which EN ISO 12100 hazard groups (A-G, the classic CE
|
||||
// groups; H-J are control/CRA and routinely routed elsewhere) did the engine
|
||||
// leave with ZERO hazards for this machine? An empty group is a structural
|
||||
// blind-spot signal — the machine may genuinely lack that hazard, or a pattern
|
||||
// may be missing. The LLM then expands each gap into specific expected-but-missing
|
||||
// hazards a safety assessor would name, for a human to confirm into a new pattern
|
||||
// or GT case. The gaps alone are useful without any model.
|
||||
|
||||
type isoGroup struct {
|
||||
Key string
|
||||
Label string
|
||||
Cats []string
|
||||
}
|
||||
|
||||
var iso12100Groups = []isoGroup{
|
||||
{"mechanical", "A. Mechanisch", []string{"mechanical_hazard", "mechanical", "maintenance_hazard"}},
|
||||
{"electrical", "B. Elektrisch", []string{"electrical_hazard", "electrical", "emc_hazard"}},
|
||||
{"thermal", "C. Thermisch", []string{"thermal_hazard", "thermal", "high_temperature", "fire_explosion"}},
|
||||
{"pneumatic_hydraulic", "D. Pneumatik/Hydraulik", []string{"pneumatic_hydraulic"}},
|
||||
{"noise_vibration", "E. Laerm/Vibration", []string{"noise_hazard", "noise_vibration", "vibration_hazard"}},
|
||||
{"ergonomic", "F. Ergonomie", []string{"ergonomic_hazard", "ergonomic"}},
|
||||
{"material", "G. Stoffe/Umwelt", []string{"material_environmental", "chemical_risk", "radiation_hazard"}},
|
||||
}
|
||||
|
||||
// CoverageGap is an ISO 12100 hazard group with no engine hazard.
|
||||
type CoverageGap struct {
|
||||
Group string `json:"group"`
|
||||
Key string `json:"key"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// FindCoverageGaps returns the A-G hazard groups that produced zero hazards.
|
||||
func FindCoverageGaps(hazards []Hazard) []CoverageGap {
|
||||
present := make(map[string]bool, len(hazards))
|
||||
for _, h := range hazards {
|
||||
present[h.Category] = true
|
||||
}
|
||||
var gaps []CoverageGap
|
||||
for _, g := range iso12100Groups {
|
||||
covered := false
|
||||
for _, c := range g.Cats {
|
||||
if present[c] {
|
||||
covered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !covered {
|
||||
gaps = append(gaps, CoverageGap{
|
||||
Group: g.Label, Key: g.Key,
|
||||
Note: "no engine hazard in this ISO 12100 group — verify the machine truly lacks it, or a pattern is missing",
|
||||
})
|
||||
}
|
||||
}
|
||||
return gaps
|
||||
}
|
||||
|
||||
// MissingHazard is an LLM-proposed hazard a safety assessor would expect.
|
||||
type MissingHazard struct {
|
||||
Group string `json:"group"`
|
||||
Hazard string `json:"hazard"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// ProposeMissingHazards asks the LLM to expand the empty groups into specific
|
||||
// expected hazards. Returns nil without a completer or on any error — propose-only,
|
||||
// never breaks the run.
|
||||
func ProposeMissingHazards(ctx context.Context, completer LLMCompleter, machineClass, narrative string, produced []Hazard, gaps []CoverageGap) []MissingHazard {
|
||||
if completer == nil || len(gaps) == 0 {
|
||||
return nil
|
||||
}
|
||||
system, user := BuildCoveragePrompt(machineClass, narrative, produced, gaps)
|
||||
raw, err := completer.Complete(ctx, system, user)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return parseMissingHazards(raw)
|
||||
}
|
||||
|
||||
// BuildCoveragePrompt frames the "what is missing?" question for the LLM.
|
||||
func BuildCoveragePrompt(machineClass, narrative string, produced []Hazard, gaps []CoverageGap) (system, user string) {
|
||||
system = "Du bist Sachverstaendiger fuer Maschinensicherheit nach EN ISO 12100. " +
|
||||
"Dir werden eine Maschine, die bereits erkannten Gefaehrdungen und Gefaehrdungsgruppen OHNE Eintrag genannt. " +
|
||||
"Nenne nur Gefaehrdungen, die ein Sachverstaendiger fuer DIESE Maschine ERWARTET, die aber FEHLEN. " +
|
||||
"Erfinde nichts Maschinenfremdes. Antworte AUSSCHLIESSLICH als JSON-Array: " +
|
||||
`[{"group":"...","hazard":"...","why":"..."}].`
|
||||
|
||||
var have []string
|
||||
seen := map[string]bool{}
|
||||
for _, h := range produced {
|
||||
if h.Category != "" && !seen[h.Category] {
|
||||
seen[h.Category] = true
|
||||
have = append(have, h.Category)
|
||||
}
|
||||
}
|
||||
var empty []string
|
||||
for _, g := range gaps {
|
||||
empty = append(empty, g.Group)
|
||||
}
|
||||
user = fmt.Sprintf("Maschinenklasse: %s\n\nBeschreibung:\n%s\n\nBereits erkannte Kategorien: %s\n\nGruppen OHNE Eintrag (Fokus): %s\n\nWelche erwarteten Gefaehrdungen fehlen?",
|
||||
machineClass, narrative, strings.Join(have, ", "), strings.Join(empty, ", "))
|
||||
return system, user
|
||||
}
|
||||
|
||||
func parseMissingHazards(raw string) []MissingHazard {
|
||||
start, end := strings.Index(raw, "["), strings.LastIndex(raw, "]")
|
||||
if start < 0 || end <= start {
|
||||
return nil
|
||||
}
|
||||
var out []MissingHazard
|
||||
if err := json.Unmarshal([]byte(raw[start:end+1]), &out); err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RenderCoverageQueue renders the deterministic gaps plus any LLM-proposed missing
|
||||
// hazards as a markdown review queue.
|
||||
func RenderCoverageQueue(machine string, gaps []CoverageGap, missing []MissingHazard) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# Coverage blind-spot queue — %s\n\n", machine)
|
||||
fmt.Fprintf(&b, "%d ISO 12100 group(s) (A-G) have no engine hazard. Propose-only — a human confirms whether the machine truly lacks it or a pattern/GT case is missing.\n\n", len(gaps))
|
||||
for _, g := range gaps {
|
||||
fmt.Fprintf(&b, "- **%s** — %s\n", g.Group, g.Note)
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
fmt.Fprintf(&b, "\n## LLM-proposed expected-but-missing hazards (%d)\n\n", len(missing))
|
||||
for i, m := range missing {
|
||||
fmt.Fprintf(&b, "%d. [%s] %s\n - why: %s\n", i+1, m.Group, m.Hazard, m.Why)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindCoverageGaps(t *testing.T) {
|
||||
hazards := []Hazard{
|
||||
{Category: "mechanical_hazard"},
|
||||
{Category: "thermal_hazard"},
|
||||
{Category: "electrical_hazard"},
|
||||
{Category: "material_environmental"},
|
||||
}
|
||||
gapKeys := map[string]bool{}
|
||||
for _, g := range FindCoverageGaps(hazards) {
|
||||
gapKeys[g.Key] = true
|
||||
}
|
||||
for _, want := range []string{"pneumatic_hydraulic", "noise_vibration", "ergonomic"} {
|
||||
if !gapKeys[want] {
|
||||
t.Errorf("expected gap %s", want)
|
||||
}
|
||||
}
|
||||
for _, notWant := range []string{"mechanical", "thermal", "electrical", "material"} {
|
||||
if gapKeys[notWant] {
|
||||
t.Errorf("did not expect gap %s (covered)", notWant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCoveragePrompt_ContainsContext(t *testing.T) {
|
||||
produced := []Hazard{{Category: "thermal_hazard"}}
|
||||
gaps := []CoverageGap{{Group: "F. Ergonomie", Key: "ergonomic"}}
|
||||
system, user := BuildCoveragePrompt("Geschirrspuelmaschine", "Eine Spuelmaschine mit Tank.", produced, gaps)
|
||||
if !strings.Contains(system, "EN ISO 12100") || !strings.Contains(system, "JSON") {
|
||||
t.Errorf("system prompt missing framing")
|
||||
}
|
||||
for _, want := range []string{"Geschirrspuelmaschine", "thermal_hazard", "F. Ergonomie", "Spuelmaschine mit Tank"} {
|
||||
if !strings.Contains(user, want) {
|
||||
t.Errorf("user prompt missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposeMissingHazards_ParsesAndDegrades(t *testing.T) {
|
||||
gaps := []CoverageGap{{Group: "F. Ergonomie", Key: "ergonomic"}}
|
||||
c := fakeCompleter{out: `Hier: [{"group":"F. Ergonomie","hazard":"Heben schwerer Koerbe","why":"manuelles Beladen"}] fertig`}
|
||||
got := ProposeMissingHazards(context.Background(), c, "x", "n", nil, gaps)
|
||||
if len(got) != 1 || got[0].Hazard != "Heben schwerer Koerbe" {
|
||||
t.Fatalf("parse: got %+v", got)
|
||||
}
|
||||
if ProposeMissingHazards(context.Background(), nil, "x", "n", nil, gaps) != nil {
|
||||
t.Errorf("nil completer must return nil")
|
||||
}
|
||||
if ProposeMissingHazards(context.Background(), fakeCompleter{err: context.DeadlineExceeded}, "x", "n", nil, gaps) != nil {
|
||||
t.Errorf("error must return nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Offline dedup-candidate proposer (P2, type 1). DEV-TIME ONLY.
|
||||
//
|
||||
// It inspects the patterns that fired for one machine and proposes which look
|
||||
// like duplicates, so a human (later an LLM) can decide a supersession/merge. It
|
||||
// NEVER mutates the pattern library or the runtime — it only surfaces candidates.
|
||||
// The deterministic GT screen (ScreenSupersession, proposer_screen.go) is the
|
||||
// wall that proves a proposal is safe before a human ever sees it.
|
||||
//
|
||||
// Detection here is purely structural (category + zone + measure + scenario
|
||||
// overlap) and therefore reproducible. Two safety rules bake in what P1 taught
|
||||
// us about the dishwasher review:
|
||||
// - only patterns with the SAME primary category are ever compared;
|
||||
// - a pair with DIFFERENT operational states is NEVER proposed, because
|
||||
// normal-operation and maintenance are legitimately distinct contexts with
|
||||
// different protective measures (e.g. HP011 vs HP077). Merging them would
|
||||
// erase the maintenance view.
|
||||
|
||||
// DedupCandidate is a proposed near-duplicate pattern pair for one machine class.
|
||||
type DedupCandidate struct {
|
||||
KeepPattern string `json:"keep_pattern"` // higher-priority survivor
|
||||
DropPattern string `json:"drop_pattern"` // supersession target
|
||||
KeepName string `json:"keep_name"`
|
||||
KeepHazardName string `json:"keep_hazard_name"` // keep pattern ScenarioDE (for the GT-distinctness screen)
|
||||
DropName string `json:"drop_name"` // == generated hazard Name (ScenarioDE) of the drop pattern
|
||||
Category string `json:"category"`
|
||||
ZoneJaccard float64 `json:"zone_jaccard"`
|
||||
MeasureJaccard float64 `json:"measure_jaccard"`
|
||||
ScenarioJaccard float64 `json:"scenario_jaccard"`
|
||||
Score float64 `json:"score"`
|
||||
Rationale string `json:"rationale"`
|
||||
}
|
||||
|
||||
// FindDedupCandidates compares the fired patterns pairwise and returns near-dup
|
||||
// candidates whose combined overlap score meets threshold, deterministically
|
||||
// ordered (score desc, then drop-pattern id). The combined score weights measure
|
||||
// overlap highest (shared measures are the strongest duplicate signal), then zone
|
||||
// and scenario equally.
|
||||
func FindDedupCandidates(fired []PatternMatch, threshold float64) []DedupCandidate {
|
||||
var out []DedupCandidate
|
||||
for i := 0; i < len(fired); i++ {
|
||||
for j := i + 1; j < len(fired); j++ {
|
||||
a, b := fired[i], fired[j]
|
||||
ca := primaryCat(a)
|
||||
if ca == "" || ca != primaryCat(b) {
|
||||
continue
|
||||
}
|
||||
if !sameOpStateSet(a.OperationalStates, b.OperationalStates) {
|
||||
continue // legitimate lifecycle variants — never propose a merge
|
||||
}
|
||||
zj := tokenJaccard(zoneTokenSet(a.ZoneDE), zoneTokenSet(b.ZoneDE))
|
||||
mj := tokenJaccard(toSet(a.SuggestedMeasureIDs), toSet(b.SuggestedMeasureIDs))
|
||||
sj := tokenJaccard(wordTokenSet(a.ScenarioDE), wordTokenSet(b.ScenarioDE))
|
||||
score := 0.4*mj + 0.3*zj + 0.3*sj
|
||||
if score < threshold {
|
||||
continue
|
||||
}
|
||||
keep, drop := a, b
|
||||
if b.Priority > a.Priority {
|
||||
keep, drop = b, a
|
||||
}
|
||||
out = append(out, DedupCandidate{
|
||||
KeepPattern: keep.PatternID, DropPattern: drop.PatternID,
|
||||
KeepName: keep.PatternName, KeepHazardName: keep.ScenarioDE, DropName: drop.ScenarioDE,
|
||||
Category: ca, ZoneJaccard: round2(zj), MeasureJaccard: round2(mj),
|
||||
ScenarioJaccard: round2(sj), Score: round2(score),
|
||||
Rationale: fmt.Sprintf(
|
||||
"same category %q · measure overlap %.0f%% · zone overlap %.0f%% · scenario overlap %.0f%% → keep %s (P%d), supersede %s (P%d)",
|
||||
ca, mj*100, zj*100, sj*100, keep.PatternID, keep.Priority, drop.PatternID, drop.Priority),
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Score != out[j].Score {
|
||||
return out[i].Score > out[j].Score
|
||||
}
|
||||
return out[i].DropPattern < out[j].DropPattern
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func primaryCat(pm PatternMatch) string {
|
||||
if len(pm.HazardCats) == 0 {
|
||||
return ""
|
||||
}
|
||||
return pm.HazardCats[0]
|
||||
}
|
||||
|
||||
func sameOpStateSet(a, b []string) bool {
|
||||
sa, sb := toSet(a), toSet(b)
|
||||
if len(sa) != len(sb) {
|
||||
return false
|
||||
}
|
||||
for k := range sa {
|
||||
if !sb[k] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var proposerWordSplit = regexp.MustCompile(`[^\p{L}]+`)
|
||||
|
||||
// zoneTokenSet splits a comma-separated zone string into its component terms.
|
||||
func zoneTokenSet(zone string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, part := range strings.Split(strings.ToLower(zone), ",") {
|
||||
if t := strings.TrimSpace(part); len([]rune(t)) >= 3 {
|
||||
out[t] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wordTokenSet tokenises free text into words of length >= 4 (drops connectives).
|
||||
func wordTokenSet(s string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, w := range proposerWordSplit.Split(strings.ToLower(s), -1) {
|
||||
if len([]rune(w)) >= 4 {
|
||||
out[w] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tokenJaccard(a, b map[string]bool) float64 {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return 0
|
||||
}
|
||||
inter := 0
|
||||
for k := range a {
|
||||
if b[k] {
|
||||
inter++
|
||||
}
|
||||
}
|
||||
union := len(a) + len(b) - inter
|
||||
if union == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(inter) / float64(union)
|
||||
}
|
||||
|
||||
func round2(x float64) float64 { return math.Round(x*100) / 100 }
|
||||
@@ -0,0 +1,67 @@
|
||||
package iace
|
||||
|
||||
import "testing"
|
||||
|
||||
func mkPM(id, cat, zone, scenario string, prio int, measures, opstates []string) PatternMatch {
|
||||
return PatternMatch{
|
||||
PatternID: id, PatternName: id, Priority: prio,
|
||||
HazardCats: []string{cat}, ZoneDE: zone, ScenarioDE: scenario,
|
||||
SuggestedMeasureIDs: measures, OperationalStates: opstates,
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindDedupCandidates_FindsOverlappingPair(t *testing.T) {
|
||||
fired := []PatternMatch{
|
||||
mkPM("HPa", "update_failure", "Steuerung, SPS", "Software-Update der Steuerung scheitert nach Abbruch", 80,
|
||||
[]string{"M138", "M146"}, nil),
|
||||
mkPM("HPb", "update_failure", "Steuerung, Antriebsregler", "Software-Update der Steuerung schlaegt fehl", 75,
|
||||
[]string{"M138", "M146", "M141"}, nil),
|
||||
mkPM("HPc", "mechanical_hazard", "Tuer", "Quetschen der Finger an der Tuer", 70,
|
||||
[]string{"M003"}, nil),
|
||||
}
|
||||
got := FindDedupCandidates(fired, 0.4)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("want 1 candidate, got %d: %+v", len(got), got)
|
||||
}
|
||||
// Higher-priority pattern survives, lower one is the drop target.
|
||||
if got[0].KeepPattern != "HPa" || got[0].DropPattern != "HPb" {
|
||||
t.Errorf("want keep HPa / drop HPb, got keep %s / drop %s", got[0].KeepPattern, got[0].DropPattern)
|
||||
}
|
||||
if got[0].DropName != "Software-Update der Steuerung schlaegt fehl" {
|
||||
t.Errorf("DropName must equal drop pattern ScenarioDE, got %q", got[0].DropName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindDedupCandidates_LifecycleGuard(t *testing.T) {
|
||||
// Same category, zone and measures — but normal-operation vs maintenance.
|
||||
// These are legitimate variants (HP011 vs HP077) and must NOT be proposed.
|
||||
fired := []PatternMatch{
|
||||
mkPM("HP011", "electrical_hazard", "Schaltschrank, Klemmenkasten", "Person beruehrt spannungsfuehrende Teile", 95,
|
||||
[]string{"M481", "M482"}, nil),
|
||||
mkPM("HP077", "electrical_hazard", "Schaltschrank, Klemmenkasten", "Person beruehrt spannungsfuehrende Teile", 80,
|
||||
[]string{"M481", "M482"}, []string{"maintenance"}),
|
||||
}
|
||||
if got := FindDedupCandidates(fired, 0.4); len(got) != 0 {
|
||||
t.Fatalf("lifecycle guard failed: want 0 candidates, got %d: %+v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindDedupCandidates_DifferentCategoryIgnored(t *testing.T) {
|
||||
fired := []PatternMatch{
|
||||
mkPM("HPa", "thermal_hazard", "Boiler", "Heisse Oberflaeche am Boiler", 80, []string{"M071"}, nil),
|
||||
mkPM("HPb", "mechanical_hazard", "Boiler", "Heisse Oberflaeche am Boiler", 80, []string{"M071"}, nil),
|
||||
}
|
||||
if got := FindDedupCandidates(fired, 0.3); len(got) != 0 {
|
||||
t.Fatalf("cross-category pair must not be proposed, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindDedupCandidates_BelowThresholdDropped(t *testing.T) {
|
||||
fired := []PatternMatch{
|
||||
mkPM("HPa", "mechanical_hazard", "Tuer", "Quetschen an der Tuer", 80, []string{"M003"}, nil),
|
||||
mkPM("HPb", "mechanical_hazard", "Foerderband", "Einzug am Foerderband", 80, []string{"M540"}, nil),
|
||||
}
|
||||
if got := FindDedupCandidates(fired, 0.4); len(got) != 0 {
|
||||
t.Fatalf("disjoint pair must be below threshold, got %d: %+v", len(got), got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Foreign-framing proposer (P2 slice 4, type 2). DEV-TIME, propose-only.
|
||||
//
|
||||
// A pattern can fire for a machine yet describe its hazard with a zone text
|
||||
// framed for a DIFFERENT machine (e.g. a dishwasher hazard whose zone names
|
||||
// "Walzen, Transportbaender" or "Bearbeitungszone"). Such foreign framing leaks
|
||||
// through terms that are NOT yet in domainGateTerms — once a term is a gate term,
|
||||
// the ghost-pattern invariant already fences the pattern out. So we surface the
|
||||
// candidates structurally: zone terms a fired pattern names that the machine's
|
||||
// narrative never mentions (minus generic hazard-location vocabulary). A human
|
||||
// (or the LLM) then decides: add a dom_* gate term, or re-frame the zone text.
|
||||
//
|
||||
// This OVER-surfaces by design — the human/LLM is the precision filter, not the
|
||||
// detector (same contract as the dedup proposer).
|
||||
|
||||
// genericHazardStop are hazard-LOCATION words that legitimately appear in zones
|
||||
// without being echoed in a narrative — they are not evidence of foreign framing.
|
||||
var genericHazardStop = map[string]bool{
|
||||
"quetschstelle": true, "einzugstelle": true, "einzugsstelle": true, "scherstelle": true,
|
||||
"schneidstelle": true, "stossstelle": true, "fangstelle": true, "klemmstelle": true,
|
||||
"gefahrbereich": true, "gefahrenbereich": true, "gefahrstelle": true, "gefahrenstelle": true,
|
||||
"arbeitsbereich": true, "wirkbereich": true, "schutzbereich": true, "umgebung": true,
|
||||
"bereich": true, "zugang": true, "oberflaeche": true, "oberflaechen": true,
|
||||
"gehaeuse": true, "bauteil": true, "bauteile": true, "komponente": true, "maschine": true,
|
||||
}
|
||||
|
||||
// FramingCandidate is a fired pattern whose zone text looks foreign for the machine.
|
||||
type FramingCandidate struct {
|
||||
Pattern string `json:"pattern"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Zone string `json:"zone"`
|
||||
OrphanTerms []string `json:"orphan_terms"`
|
||||
OrphanFraction float64 `json:"orphan_fraction"`
|
||||
Verdict string `json:"verdict"` // heuristic lean: foreign | plausible
|
||||
Evidence string `json:"evidence"`
|
||||
}
|
||||
|
||||
// FindFramingCandidates returns fired patterns whose zone is mostly not echoed in
|
||||
// the narrative, sorted by orphan fraction descending (deterministic).
|
||||
func FindFramingCandidates(fired []PatternMatch, narrative string, minFraction float64) []FramingCandidate {
|
||||
nar := strings.ToLower(narrative)
|
||||
var narStems []string
|
||||
for _, w := range proposerWordSplit.Split(nar, -1) {
|
||||
if len([]rune(w)) >= 5 {
|
||||
narStems = append(narStems, w)
|
||||
}
|
||||
}
|
||||
var out []FramingCandidate
|
||||
for _, pm := range fired {
|
||||
parts := zoneParts(pm.ZoneDE)
|
||||
if len(parts) == 0 {
|
||||
continue
|
||||
}
|
||||
var orphans []string
|
||||
for _, p := range parts {
|
||||
if !partEchoed(p, nar, narStems) {
|
||||
orphans = append(orphans, p)
|
||||
}
|
||||
}
|
||||
frac := float64(len(orphans)) / float64(len(parts))
|
||||
if len(orphans) == 0 || frac < minFraction {
|
||||
continue
|
||||
}
|
||||
out = append(out, FramingCandidate{
|
||||
Pattern: pm.PatternID, Name: pm.PatternName, Category: primaryCat(pm),
|
||||
Zone: pm.ZoneDE, OrphanTerms: orphans, OrphanFraction: round2(frac),
|
||||
Verdict: framingHeuristicVerdict(frac),
|
||||
Evidence: fmt.Sprintf("%d/%d zone terms have no narrative echo: %s", len(orphans), len(parts), strings.Join(orphans, ", ")),
|
||||
})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].OrphanFraction != out[j].OrphanFraction {
|
||||
return out[i].OrphanFraction > out[j].OrphanFraction
|
||||
}
|
||||
return out[i].Pattern < out[j].Pattern
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func framingHeuristicVerdict(frac float64) string {
|
||||
if frac >= 0.99 {
|
||||
return "foreign" // nothing in the zone is echoed by the narrative
|
||||
}
|
||||
return "plausible" // partial echo — likely generic vocabulary, human to confirm
|
||||
}
|
||||
|
||||
// zoneParts splits a zone string into significant terms on commas, slashes,
|
||||
// parentheses and semicolons, lowercased, length >= 4.
|
||||
func zoneParts(zone string) []string {
|
||||
fields := strings.FieldsFunc(strings.ToLower(zone), func(r rune) bool {
|
||||
return r == ',' || r == '/' || r == ';' || r == '(' || r == ')'
|
||||
})
|
||||
var out []string
|
||||
for _, f := range fields {
|
||||
if t := strings.TrimSpace(f); len([]rune(t)) >= 4 {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// partEchoed reports whether a zone part is reflected in the narrative. Matching
|
||||
// is bidirectional to survive German compounding: a zone word echoes if it is a
|
||||
// generic hazard term, if it is a substring of the narrative, OR if any narrative
|
||||
// stem (>= 5 chars) is a substring of the zone word (so narrative "Steuerung"
|
||||
// echoes zone "Steuerungssystem").
|
||||
func partEchoed(part, narrative string, narStems []string) bool {
|
||||
for _, w := range strings.Fields(part) {
|
||||
if genericHazardStop[w] {
|
||||
return true
|
||||
}
|
||||
if len([]rune(w)) < 4 {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(narrative, w) {
|
||||
return true
|
||||
}
|
||||
for _, ns := range narStems {
|
||||
if strings.Contains(w, ns) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RenderFramingQueue renders foreign-framing candidates as a markdown review queue.
|
||||
func RenderFramingQueue(machine string, candidates []FramingCandidate) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# Foreign-framing review queue — %s\n\n", machine)
|
||||
fmt.Fprintf(&b, "%d fired pattern(s) name zone terms the narrative never mentions. Propose-only — a human (or the LLM) decides: add a dom_* gate term, or re-frame the zone.\n\n", len(candidates))
|
||||
for i, c := range candidates {
|
||||
fmt.Fprintf(&b, "## %d. %s — %s [%s, orphan %.0f%%]\n", i+1, c.Pattern, c.Name, c.Verdict, c.OrphanFraction*100)
|
||||
fmt.Fprintf(&b, "- category: %s\n- zone: %s\n", c.Category, c.Zone)
|
||||
fmt.Fprintf(&b, "- orphan terms (no narrative echo): %s\n", strings.Join(c.OrphanTerms, ", "))
|
||||
fmt.Fprintf(&b, "- suggested action: %s\n\n", framingAction(c.Verdict))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func framingAction(verdict string) string {
|
||||
if verdict == "foreign" {
|
||||
return "likely foreign-framed — propose a dom_* gate term for the orphan term(s), or re-frame the zone; human confirms + commits + pins a GT case"
|
||||
}
|
||||
return "partial echo — likely generic vocabulary; human to confirm whether any orphan term is a foreign-machine component"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package iace
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFindFramingCandidates_FlagsForeignZone(t *testing.T) {
|
||||
narrative := "Gewerbliche Geschirrspuelmaschine mit Boiler und Tank. Die Tuer ist verriegelt."
|
||||
fired := []PatternMatch{
|
||||
mkPM("HPforeign", "mechanical_hazard", "Walzen, Transportbaender, Bearbeitungszone", "Einzug", 80, nil, nil),
|
||||
mkPM("HPlocal", "thermal_hazard", "Boiler, Tank, Tuer", "Verbrennung", 80, nil, nil),
|
||||
mkPM("HPgeneric", "mechanical_hazard", "Quetschstelle, Gefahrbereich", "Quetschen", 80, nil, nil),
|
||||
}
|
||||
got := FindFramingCandidates(fired, narrative, 0.6)
|
||||
if len(got) != 1 || got[0].Pattern != "HPforeign" {
|
||||
t.Fatalf("want only HPforeign flagged, got %+v", got)
|
||||
}
|
||||
if got[0].Verdict != "foreign" {
|
||||
t.Errorf("fully-orphan zone should be 'foreign', got %s", got[0].Verdict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindFramingCandidates_PartialEchoIsPlausible(t *testing.T) {
|
||||
narrative := "Maschine mit Boiler und Tank."
|
||||
fired := []PatternMatch{
|
||||
mkPM("HPx", "thermal_hazard", "Boiler, Tank, Auspuffleitung", "x", 80, nil, nil),
|
||||
}
|
||||
got := FindFramingCandidates(fired, narrative, 0.3)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("want 1 candidate (1/3 orphan >= 0.3), got %d", len(got))
|
||||
}
|
||||
if got[0].Verdict != "plausible" || len(got[0].OrphanTerms) != 1 || got[0].OrphanTerms[0] != "auspuffleitung" {
|
||||
t.Errorf("want plausible + orphan [auspuffleitung], got %s %v", got[0].Verdict, got[0].OrphanTerms)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package iace
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
// Non-test plumbing for the offline proposer (P2 slice 3): run the engine for a
|
||||
// narrative and produce the fired patterns + the engine-built hazards/mitigations
|
||||
// the dedup proposer and GT screen consume. This is the same pipeline the GT
|
||||
// benchmark tests use, lifted out of test scope so the dev-time CLI can call it.
|
||||
|
||||
// universalLifecyclePhases are appended so patterns gated to a specific lifecycle
|
||||
// (maintenance/cleaning/setup/fault clearing) still fire — the proposer wants the
|
||||
// full hazard picture, not only normal-operation hazards.
|
||||
var universalLifecyclePhases = []string{"normal_operation", "maintenance", "cleaning", "setup", "fault_clearing"}
|
||||
|
||||
// BuildProposerInput parses a narrative, runs the pattern engine, keeps the
|
||||
// narrative-relevant patterns, and returns the hazards, mitigations and fired
|
||||
// patterns. NOTE: it does not apply the CE cyber-category skip, so the proposer
|
||||
// view may include cyber/AI hazards that the CE log excludes — harmless for the
|
||||
// GT recall screen (they match no CE ground-truth entry).
|
||||
func BuildProposerInput(narrative, machineType string, extraMachineTypes []string) ([]Hazard, []Mitigation, []PatternMatch) {
|
||||
res := ParseNarrative(narrative, machineType)
|
||||
|
||||
var compIDs, compNames, energyIDs []string
|
||||
for _, c := range res.Components {
|
||||
if c.Negated {
|
||||
continue
|
||||
}
|
||||
compIDs = append(compIDs, c.LibraryID)
|
||||
compNames = append(compNames, c.NameDE)
|
||||
}
|
||||
for _, e := range res.EnergySources {
|
||||
energyIDs = append(energyIDs, e.SourceID)
|
||||
}
|
||||
|
||||
machineTypes := append([]string{}, extraMachineTypes...)
|
||||
if machineType != "" {
|
||||
machineTypes = append(machineTypes, machineType)
|
||||
}
|
||||
lifecycles := append(append([]string{}, res.LifecyclePhases...), universalLifecyclePhases...)
|
||||
|
||||
out := NewPatternEngine().Match(MatchInput{
|
||||
ComponentLibraryIDs: compIDs,
|
||||
EnergySourceIDs: energyIDs,
|
||||
LifecyclePhases: lifecycles,
|
||||
CustomTags: res.CustomTags,
|
||||
OperationalStates: res.OperationalStates,
|
||||
StateTransitions: res.StateTransitions,
|
||||
HumanRoles: res.Roles,
|
||||
MachineTypes: machineTypes,
|
||||
})
|
||||
|
||||
kept := make([]PatternMatch, 0, len(out.MatchedPatterns))
|
||||
for _, pm := range out.MatchedPatterns {
|
||||
if IsPatternRelevant(pm, narrative, compNames) {
|
||||
kept = append(kept, pm)
|
||||
}
|
||||
}
|
||||
filtered := *out
|
||||
filtered.MatchedPatterns = kept
|
||||
hazards, mits := patternsToHazardsAndMitigations(&filtered)
|
||||
return hazards, mits, kept
|
||||
}
|
||||
|
||||
// patternsToHazardsAndMitigations converts engine output into the hazard/mitigation
|
||||
// entities the benchmark + proposer compare on. Simplified vs InitializeProject
|
||||
// (no risk estimation, no norm refs) — it only needs category/zone/scenario/measures.
|
||||
func patternsToHazardsAndMitigations(out *MatchOutput) ([]Hazard, []Mitigation) {
|
||||
hazards := make([]Hazard, 0, len(out.MatchedPatterns))
|
||||
patternToHazard := make(map[string]uuid.UUID, len(out.MatchedPatterns))
|
||||
|
||||
for _, pm := range out.MatchedPatterns {
|
||||
cat := ""
|
||||
if len(pm.HazardCats) > 0 {
|
||||
cat = pm.HazardCats[0]
|
||||
}
|
||||
lifecycle := ""
|
||||
if len(pm.ApplicableLifecycles) > 0 {
|
||||
lifecycle = pm.ApplicableLifecycles[0]
|
||||
}
|
||||
h := Hazard{
|
||||
ID: uuid.New(),
|
||||
Name: pm.ScenarioDE,
|
||||
Category: cat,
|
||||
Description: pm.ScenarioDE,
|
||||
Scenario: pm.ScenarioDE,
|
||||
TriggerEvent: pm.TriggerDE,
|
||||
PossibleHarm: pm.HarmDE,
|
||||
AffectedPerson: pm.AffectedDE,
|
||||
HazardousZone: pm.ZoneDE,
|
||||
LifecyclePhase: lifecycle,
|
||||
}
|
||||
if h.Name == "" {
|
||||
h.Name = pm.PatternName
|
||||
}
|
||||
hazards = append(hazards, h)
|
||||
patternToHazard[pm.PatternID] = h.ID
|
||||
}
|
||||
|
||||
measureNames := make(map[string]string)
|
||||
for _, m := range GetProtectiveMeasureLibrary() {
|
||||
measureNames[m.ID] = m.Name
|
||||
}
|
||||
|
||||
var mitigations []Mitigation
|
||||
for _, sm := range out.SuggestedMeasures {
|
||||
name := measureNames[sm.MeasureID]
|
||||
if name == "" {
|
||||
name = sm.MeasureID
|
||||
}
|
||||
for _, srcPattern := range sm.SourcePatterns {
|
||||
hid, ok := patternToHazard[srcPattern]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mitigations = append(mitigations, Mitigation{
|
||||
ID: uuid.New(),
|
||||
HazardID: hid,
|
||||
Name: name,
|
||||
})
|
||||
}
|
||||
}
|
||||
return hazards, mitigations
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package iace
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildProposerInput_WarewashingFires(t *testing.T) {
|
||||
hazards, _, fired := BuildProposerInput(
|
||||
warewashingNarrative,
|
||||
"Gewerbliche Untertisch-Geschirrspuelmaschine (vernetzt)",
|
||||
[]string{"food_processing"},
|
||||
)
|
||||
if len(fired) == 0 || len(hazards) == 0 {
|
||||
t.Fatalf("want fired patterns + hazards, got %d patterns / %d hazards", len(fired), len(hazards))
|
||||
}
|
||||
has := func(id string) bool {
|
||||
for _, pm := range fired {
|
||||
if pm.PatternID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !has("HP2201") {
|
||||
t.Errorf("warewashing-specific HP2201 must fire via BuildProposerInput")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
||||
)
|
||||
|
||||
// Semantic judgement over RECALL-SAFE dedup candidates (P2 slice 2). DEV-TIME,
|
||||
// propose-only. The deterministic GT wall (proposer_screen.go) has already
|
||||
// removed candidates that would drop recall or that credit different GT entries;
|
||||
// the judge only adds an opinion on whether the survivors are truly the same
|
||||
// hazard, plus a rationale, for the human review queue. It NEVER mutates anything.
|
||||
//
|
||||
// The judge is pluggable behind CandidateJudge so the runtime/tests stay
|
||||
// deterministic (HeuristicJudge) while the dev-time CLI can plug in the
|
||||
// non-deterministic LLM (LLMJudge over the shared llm.ProviderRegistry).
|
||||
|
||||
const (
|
||||
VerdictDuplicate = "duplicate"
|
||||
VerdictDistinct = "distinct"
|
||||
VerdictUncertain = "uncertain"
|
||||
)
|
||||
|
||||
// JudgedProposal is one candidate with its GT-wall result and the judge's opinion.
|
||||
type JudgedProposal struct {
|
||||
Candidate DedupCandidate `json:"candidate"`
|
||||
Screen ScreenResult `json:"screen"`
|
||||
Verdict string `json:"verdict"`
|
||||
Confidence string `json:"confidence"`
|
||||
Rationale string `json:"rationale"`
|
||||
Judge string `json:"judge"`
|
||||
}
|
||||
|
||||
// CandidateJudge decides whether two near-duplicate patterns are the same hazard.
|
||||
type CandidateJudge interface {
|
||||
Name() string
|
||||
Judge(ctx context.Context, c DedupCandidate, a, b PatternMatch) (verdict, confidence, rationale string)
|
||||
}
|
||||
|
||||
// HeuristicJudge is the deterministic default/fallback. It only ever returns "low"
|
||||
// confidence — it is a placeholder for the LLM, and it deliberately punts to
|
||||
// "uncertain" on the hard cases (low text overlap, shared measures) so the queue
|
||||
// makes clear exactly where the LLM earns its keep.
|
||||
type HeuristicJudge struct{}
|
||||
|
||||
func (HeuristicJudge) Name() string { return "heuristic" }
|
||||
|
||||
func (HeuristicJudge) Judge(_ context.Context, c DedupCandidate, _, _ PatternMatch) (string, string, string) {
|
||||
switch {
|
||||
case c.ScenarioJaccard >= 0.5 || (c.ZoneJaccard >= 0.5 && c.MeasureJaccard >= 0.5):
|
||||
return VerdictDuplicate, "low", "structural: high scenario, or combined zone+measure, overlap"
|
||||
case c.MeasureJaccard >= 0.99 && c.ZoneJaccard == 0 && c.ScenarioJaccard < 0.3:
|
||||
return VerdictDistinct, "low", "structural: identical measures but no zone/scenario overlap — likely distinct hazards sharing generic measures"
|
||||
default:
|
||||
return VerdictUncertain, "low", "structural signal inconclusive — needs the LLM judge"
|
||||
}
|
||||
}
|
||||
|
||||
// LLMJudge asks an offline model to make the semantic call. Non-deterministic, so
|
||||
// it lives only in the dev-time tool, never in tests or the runtime. It degrades
|
||||
// to "uncertain" on any transport or parse error — it must never break the run.
|
||||
type LLMJudge struct {
|
||||
Completer LLMCompleter
|
||||
MachineClass string
|
||||
}
|
||||
|
||||
func (LLMJudge) Name() string { return "llm" }
|
||||
|
||||
func (j LLMJudge) Judge(ctx context.Context, c DedupCandidate, a, b PatternMatch) (string, string, string) {
|
||||
system, user := BuildJudgePrompt(j.MachineClass, a, b)
|
||||
raw, err := j.Completer.Complete(ctx, system, user)
|
||||
if err != nil {
|
||||
return VerdictUncertain, "low", "LLM error: " + err.Error()
|
||||
}
|
||||
return parseJudgeJSON(raw)
|
||||
}
|
||||
|
||||
// BuildJudgePrompt is the real LLM artifact — built and unit-tested deterministically
|
||||
// even though the call itself is not. It frames the ISO 12100 same-vs-distinct
|
||||
// question and forces a JSON answer.
|
||||
func BuildJudgePrompt(machineClass string, a, b PatternMatch) (system, user string) {
|
||||
system = "Du bist Sachverstaendiger fuer Maschinensicherheit nach EN ISO 12100. " +
|
||||
"Entscheide, ob zwei generierte Gefaehrdungen fuer DIESE Maschine DIESELBE Gefaehrdung " +
|
||||
"beschreiben (Dublette) oder fachlich VERSCHIEDENE Gefaehrdungen sind, die nur zufaellig " +
|
||||
"dieselben Schutzmassnahmen teilen. Verschieden, wenn Wirkort, Ausloeser oder " +
|
||||
"Schadensmechanismus abweichen — auch bei gleicher Kategorie und gleichen Massnahmen. " +
|
||||
"Antworte AUSSCHLIESSLICH als JSON: " +
|
||||
`{"verdict":"duplicate|distinct|uncertain","confidence":"high|medium|low","rationale":"..."}.`
|
||||
user = fmt.Sprintf(`Maschinenklasse: %s
|
||||
|
||||
Gefaehrdung A (%s):
|
||||
Name: %s
|
||||
Kategorie: %s
|
||||
Zone: %s
|
||||
Szenario: %s
|
||||
Ausloeser: %s
|
||||
Schaden: %s
|
||||
Massnahmen: %s
|
||||
|
||||
Gefaehrdung B (%s):
|
||||
Name: %s
|
||||
Kategorie: %s
|
||||
Zone: %s
|
||||
Szenario: %s
|
||||
Ausloeser: %s
|
||||
Schaden: %s
|
||||
Massnahmen: %s
|
||||
|
||||
Sind A und B dieselbe Gefaehrdung fuer diese Maschine?`,
|
||||
machineClass,
|
||||
a.PatternID, a.PatternName, primaryCat(a), a.ZoneDE, a.ScenarioDE, a.TriggerDE, a.HarmDE, strings.Join(a.SuggestedMeasureIDs, ", "),
|
||||
b.PatternID, b.PatternName, primaryCat(b), b.ZoneDE, b.ScenarioDE, b.TriggerDE, b.HarmDE, strings.Join(b.SuggestedMeasureIDs, ", "))
|
||||
return system, user
|
||||
}
|
||||
|
||||
func parseJudgeJSON(raw string) (verdict, confidence, rationale string) {
|
||||
start, end := strings.Index(raw, "{"), strings.LastIndex(raw, "}")
|
||||
if start < 0 || end <= start {
|
||||
return VerdictUncertain, "low", "unparseable LLM output"
|
||||
}
|
||||
var v struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Confidence string `json:"confidence"`
|
||||
Rationale string `json:"rationale"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw[start:end+1]), &v); err != nil {
|
||||
return VerdictUncertain, "low", "unparseable LLM JSON: " + err.Error()
|
||||
}
|
||||
switch v.Verdict {
|
||||
case VerdictDuplicate, VerdictDistinct, VerdictUncertain:
|
||||
default:
|
||||
v.Verdict = VerdictUncertain
|
||||
}
|
||||
if v.Confidence == "" {
|
||||
v.Confidence = "low"
|
||||
}
|
||||
return v.Verdict, v.Confidence, v.Rationale
|
||||
}
|
||||
|
||||
// LLMCompleter is the minimal text-in/text-out the LLM judge needs. Tests pass a
|
||||
// stub; the dev-time tool passes a registry-backed adapter (NewRegistryCompleter).
|
||||
type LLMCompleter interface {
|
||||
Complete(ctx context.Context, system, user string) (string, error)
|
||||
}
|
||||
|
||||
type registryCompleter struct {
|
||||
reg *llm.ProviderRegistry
|
||||
model string
|
||||
}
|
||||
|
||||
// NewRegistryCompleter adapts the shared llm.ProviderRegistry to LLMCompleter so
|
||||
// the proposer can reuse the platform's offline model wiring (e.g. self-hosted qwen).
|
||||
func NewRegistryCompleter(reg *llm.ProviderRegistry, model string) LLMCompleter {
|
||||
return ®istryCompleter{reg: reg, model: model}
|
||||
}
|
||||
|
||||
func (rc *registryCompleter) Complete(ctx context.Context, system, user string) (string, error) {
|
||||
resp, err := rc.reg.Chat(ctx, &llm.ChatRequest{
|
||||
Model: rc.model,
|
||||
Messages: []llm.Message{
|
||||
{Role: "system", Content: system},
|
||||
{Role: "user", Content: user},
|
||||
},
|
||||
Temperature: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Message.Content, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHeuristicJudge_Verdicts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
zone, meas float64
|
||||
scenario float64
|
||||
wantVerdict string
|
||||
}{
|
||||
{"high scenario overlap -> duplicate", 0, 0.3, 0.6, VerdictDuplicate},
|
||||
{"high zone+measure -> duplicate", 0.6, 0.6, 0.1, VerdictDuplicate},
|
||||
{"identical measures, no text -> distinct", 0, 1.0, 0.0, VerdictDistinct},
|
||||
{"shared measures, low text -> uncertain", 0, 0.67, 0.19, VerdictUncertain},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := DedupCandidate{ZoneJaccard: tt.zone, MeasureJaccard: tt.meas, ScenarioJaccard: tt.scenario}
|
||||
v, conf, _ := HeuristicJudge{}.Judge(context.Background(), c, PatternMatch{}, PatternMatch{})
|
||||
if v != tt.wantVerdict {
|
||||
t.Errorf("verdict: want %s, got %s", tt.wantVerdict, v)
|
||||
}
|
||||
if conf != "low" {
|
||||
t.Errorf("heuristic confidence must be low, got %s", conf)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildJudgePrompt_ContainsKeyFacts(t *testing.T) {
|
||||
a := PatternMatch{PatternID: "HPa", PatternName: "Heisse Flaeche", HazardCats: []string{"thermal_hazard"},
|
||||
ZoneDE: "Boiler", ScenarioDE: "Beruehrung heisser Boiler", SuggestedMeasureIDs: []string{"M071"}}
|
||||
b := PatternMatch{PatternID: "HPb", PatternName: "Heisses Spuelgut", HazardCats: []string{"thermal_hazard"},
|
||||
ZoneDE: "Spuelgut", ScenarioDE: "Beruehrung heisses Geschirr", SuggestedMeasureIDs: []string{"M071"}}
|
||||
system, user := BuildJudgePrompt("Geschirrspuelmaschine", a, b)
|
||||
|
||||
for _, want := range []string{"EN ISO 12100", "JSON", "verdict"} {
|
||||
if !strings.Contains(system, want) {
|
||||
t.Errorf("system prompt missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"Geschirrspuelmaschine", "HPa", "HPb", "Boiler", "Spuelgut", "thermal_hazard"} {
|
||||
if !strings.Contains(user, want) {
|
||||
t.Errorf("user prompt missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeCompleter struct {
|
||||
out string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeCompleter) Complete(_ context.Context, _, _ string) (string, error) { return f.out, f.err }
|
||||
|
||||
func TestLLMJudge_ParsesAndDegrades(t *testing.T) {
|
||||
cand := DedupCandidate{KeepPattern: "HPa", DropPattern: "HPb"}
|
||||
|
||||
// Well-formed JSON, even wrapped in chatter, parses.
|
||||
j := LLMJudge{Completer: fakeCompleter{out: "Sicher. {\"verdict\":\"distinct\",\"confidence\":\"high\",\"rationale\":\"andere Wirkorte\"}"}, MachineClass: "x"}
|
||||
if v, conf, r := j.Judge(context.Background(), cand, PatternMatch{}, PatternMatch{}); v != VerdictDistinct || conf != "high" || r != "andere Wirkorte" {
|
||||
t.Errorf("parse: got %s/%s/%q", v, conf, r)
|
||||
}
|
||||
|
||||
// Unknown verdict value normalises to uncertain.
|
||||
j2 := LLMJudge{Completer: fakeCompleter{out: `{"verdict":"maybe","confidence":"medium","rationale":"x"}`}}
|
||||
if v, _, _ := j2.Judge(context.Background(), cand, PatternMatch{}, PatternMatch{}); v != VerdictUncertain {
|
||||
t.Errorf("unknown verdict must normalise to uncertain, got %s", v)
|
||||
}
|
||||
|
||||
// Transport error degrades gracefully, never panics.
|
||||
j3 := LLMJudge{Completer: fakeCompleter{err: errors.New("connection refused")}}
|
||||
if v, _, r := j3.Judge(context.Background(), cand, PatternMatch{}, PatternMatch{}); v != VerdictUncertain || !strings.Contains(r, "LLM error") {
|
||||
t.Errorf("error path: got %s / %q", v, r)
|
||||
}
|
||||
|
||||
// Garbage (no JSON) degrades to uncertain.
|
||||
j4 := LLMJudge{Completer: fakeCompleter{out: "no json here"}}
|
||||
if v, _, _ := j4.Judge(context.Background(), cand, PatternMatch{}, PatternMatch{}); v != VerdictUncertain {
|
||||
t.Errorf("garbage must degrade to uncertain, got %s", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderProposalQueue_ShowsActions(t *testing.T) {
|
||||
proposals := []JudgedProposal{
|
||||
{
|
||||
Candidate: DedupCandidate{KeepPattern: "HP807", DropPattern: "HP033", Category: "update_failure", Score: 0.32},
|
||||
Screen: ScreenResult{RecallBefore: 1, RecallAfter: 1},
|
||||
Verdict: VerdictDuplicate, Confidence: "medium", Rationale: "same update failure", Judge: "llm",
|
||||
},
|
||||
}
|
||||
out := RenderProposalQueue("Geschirrspuelmaschine", proposals)
|
||||
for _, want := range []string{"HP807", "HP033", "update_failure", "supersession", "Propose-only"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("queue missing %q\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RenderProposalQueue turns judged dedup proposals into the human-review queue
|
||||
// (markdown). Deterministic. Nothing here applies a change — every entry is a
|
||||
// suggestion for a human to confirm, edit, commit, and pin with a GT case.
|
||||
func RenderProposalQueue(machine string, proposals []JudgedProposal) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# Dedup proposal queue — %s\n\n", machine)
|
||||
fmt.Fprintf(&b, "%d candidate(s) survived the deterministic GT wall. Propose-only — nothing is applied automatically.\n\n", len(proposals))
|
||||
|
||||
for i, p := range proposals {
|
||||
c := p.Candidate
|
||||
fmt.Fprintf(&b, "## %d. keep %s ⊃ drop %s [%s → %s (%s)]\n",
|
||||
i+1, c.KeepPattern, c.DropPattern, p.Judge, p.Verdict, p.Confidence)
|
||||
fmt.Fprintf(&b, "- category %s · score %.2f (measures %.0f%%, zone %.0f%%, scenario %.0f%%)\n",
|
||||
c.Category, c.Score, c.MeasureJaccard*100, c.ZoneJaccard*100, c.ScenarioJaccard*100)
|
||||
fmt.Fprintf(&b, "- GT recall %.1f%% → %.1f%% when %s is dropped (wall: %s)\n",
|
||||
p.Screen.RecallBefore*100, p.Screen.RecallAfter*100, c.DropPattern, wallNote(p.Screen))
|
||||
fmt.Fprintf(&b, "- keep: %s\n- drop: %s\n", c.KeepHazardName, c.DropName)
|
||||
fmt.Fprintf(&b, "- judge rationale: %s\n", p.Rationale)
|
||||
fmt.Fprintf(&b, "- suggested action: %s\n\n", suggestedAction(p))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func wallNote(s ScreenResult) string {
|
||||
if s.DistinctGT {
|
||||
return fmt.Sprintf("distinct GT %s vs %s", s.KeepGT, s.DropGT)
|
||||
}
|
||||
return "recall-safe"
|
||||
}
|
||||
|
||||
func suggestedAction(p JudgedProposal) string {
|
||||
switch p.Verdict {
|
||||
case VerdictDuplicate:
|
||||
return fmt.Sprintf("add %s to a supersession set, then a human confirms + commits + pins a GT case", p.Candidate.DropPattern)
|
||||
case VerdictDistinct:
|
||||
return "keep both — judge considers them distinct hazards"
|
||||
default:
|
||||
return "needs human (or higher-confidence LLM) review — no automatic action"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package iace
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
// ScreenResult is the deterministic GT verdict for one proposed supersession.
|
||||
type ScreenResult struct {
|
||||
RecallBefore float64 `json:"recall_before"`
|
||||
RecallAfter float64 `json:"recall_after"`
|
||||
KeepGT string `json:"keep_gt,omitempty"` // GT entry the keeper credits (if any)
|
||||
DropGT string `json:"drop_gt,omitempty"` // GT entry the drop credits (if any)
|
||||
DistinctGT bool `json:"distinct_gt"` // keep & drop credit DIFFERENT GT entries -> distinct hazards
|
||||
Safe bool `json:"safe"` // recall preserved AND not distinct
|
||||
}
|
||||
|
||||
// ScreenSupersession is the WALL between "propose" and "decide". A proposal is
|
||||
// safe only if BOTH deterministic checks pass:
|
||||
//
|
||||
// 1. RECALL is not reduced when the drop-hazard (and its mitigations) is removed
|
||||
// — otherwise the drop is load-bearing for GT coverage.
|
||||
// 2. The two hazards do NOT credit DIFFERENT ground-truth entries. Recall alone
|
||||
// is necessary but not sufficient: two genuinely distinct hazards that share
|
||||
// the same measures (e.g. hot boiler surface vs hot ware on unloading) keep
|
||||
// recall at 100% when one is dropped, yet must NOT be merged. If keep and
|
||||
// drop each match a different GT entry, they are distinct.
|
||||
//
|
||||
// Whatever survives both is still only RECALL-SAFE — a candidate for a human (and
|
||||
// in slice 2, an LLM) to confirm semantically. Deterministic; reuses
|
||||
// CompareBenchmark; touches neither the library nor the runtime.
|
||||
func ScreenSupersession(gt *GroundTruth, hazards []Hazard, mits []Mitigation, keepHazardName, dropHazardName string) ScreenResult {
|
||||
before := CompareBenchmark(gt, hazards, mits)
|
||||
|
||||
gtOf := map[string]string{}
|
||||
for _, p := range before.MatchedPairs {
|
||||
gtOf[p.EngineHazard.Name] = p.GTEntry.Nr
|
||||
}
|
||||
keepGT, dropGT := gtOf[keepHazardName], gtOf[dropHazardName]
|
||||
distinct := keepGT != "" && dropGT != "" && keepGT != dropGT
|
||||
|
||||
kept := make([]Hazard, 0, len(hazards))
|
||||
dropped := map[uuid.UUID]bool{}
|
||||
for _, h := range hazards {
|
||||
if h.Name == dropHazardName {
|
||||
dropped[h.ID] = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, h)
|
||||
}
|
||||
keptMits := make([]Mitigation, 0, len(mits))
|
||||
for _, m := range mits {
|
||||
if !dropped[m.HazardID] {
|
||||
keptMits = append(keptMits, m)
|
||||
}
|
||||
}
|
||||
after := CompareBenchmark(gt, kept, keptMits)
|
||||
|
||||
return ScreenResult{
|
||||
RecallBefore: before.CoverageScore, RecallAfter: after.CoverageScore,
|
||||
KeepGT: keepGT, DropGT: dropGT, DistinctGT: distinct,
|
||||
Safe: after.CoverageScore >= before.CoverageScore && !distinct,
|
||||
}
|
||||
}
|
||||
@@ -160,6 +160,7 @@ func (s *Store) ListHazards(ctx context.Context, projectID uuid.UUID) ([]Hazard,
|
||||
hazards = append(hazards, h)
|
||||
}
|
||||
|
||||
SortHazardsByISO12100(hazards)
|
||||
return hazards, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package ucca
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// graphCallerRel resolves a path relative to THIS source file (build-time location), so the
|
||||
// graph data is findable under `go test` (cwd = package dir) regardless of working directory.
|
||||
// In a built container the source is gone, so cwd-relative candidates carry the load instead.
|
||||
func graphCallerRel(rel string) string {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(filepath.Dir(file), rel)
|
||||
}
|
||||
|
||||
// firstExisting returns the first candidate path that exists with the requested kind (dir vs
|
||||
// file). Empty candidates (e.g. unset env overrides) are skipped.
|
||||
func firstExisting(candidates []string, wantDir bool) string {
|
||||
for _, p := range candidates {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(p)
|
||||
if err != nil || info.IsDir() != wantDir {
|
||||
continue
|
||||
}
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// LoadComplianceGraph loads the file-backed Compliance Execution Graph: the Registry join-key
|
||||
// contract (obligations/obligation_join_keys.json — owned by the Obligation session) + our
|
||||
// curated, accepted control mappings + evidence requirements. Locations are resolved across
|
||||
// three layouts: dev (cwd = ai-compliance-sdk/, canonical contract at ../obligations), container
|
||||
// (WORKDIR /app, data/ copied in incl. a synced data/obligations/ copy) and `go test`
|
||||
// (cwd = package dir, via graphCallerRel). Fail-closed: a missing/invalid source returns an
|
||||
// error so the handler serves 503 — never a half-built graph.
|
||||
//
|
||||
// NOTE: data/obligations/obligation_join_keys.json is a SYNCED COPY of the repo-root contract
|
||||
// (the canonical owner is the Obligation session). Re-sync it when the Registry grows; dev/test
|
||||
// prefer the canonical repo-root path, only the container falls back to the copy.
|
||||
func LoadComplianceGraph() (*ObligationJoinKeys, *ControlMappingSet, *EvidenceRequirementSet, error) {
|
||||
joinPath := firstExisting([]string{
|
||||
os.Getenv("BP_OBLIGATION_JOIN_KEYS"),
|
||||
"../obligations/obligation_join_keys.json",
|
||||
graphCallerRel("../../../obligations/obligation_join_keys.json"),
|
||||
"data/obligations/obligation_join_keys.json",
|
||||
graphCallerRel("../../data/obligations/obligation_join_keys.json"),
|
||||
}, false)
|
||||
if joinPath == "" {
|
||||
return nil, nil, nil, fmt.Errorf("obligation_join_keys.json not found in any candidate path")
|
||||
}
|
||||
mapDir := firstExisting([]string{
|
||||
os.Getenv("BP_CONTROL_MAPPINGS_DIR"),
|
||||
"data/control_mappings",
|
||||
graphCallerRel("../../data/control_mappings"),
|
||||
}, true)
|
||||
if mapDir == "" {
|
||||
return nil, nil, nil, fmt.Errorf("control_mappings dir not found in any candidate path")
|
||||
}
|
||||
evDir := firstExisting([]string{
|
||||
os.Getenv("BP_EVIDENCE_DIR"),
|
||||
"data/evidence_requirements",
|
||||
graphCallerRel("../../data/evidence_requirements"),
|
||||
}, true)
|
||||
if evDir == "" {
|
||||
return nil, nil, nil, fmt.Errorf("evidence_requirements dir not found in any candidate path")
|
||||
}
|
||||
|
||||
joins, err := LoadObligationJoinKeys(joinPath)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("load join keys (%s): %w", joinPath, err)
|
||||
}
|
||||
mappings, err := LoadControlMappings(mapDir)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("load control mappings (%s): %w", mapDir, err)
|
||||
}
|
||||
evidence, err := LoadEvidenceRequirements(evDir)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("load evidence (%s): %w", evDir, err)
|
||||
}
|
||||
return joins, mappings, evidence, nil
|
||||
}
|
||||
@@ -23,7 +23,7 @@ type ControlMapping struct {
|
||||
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
|
||||
MappingType string `json:"mapping_type"` // primary_implementation | implements | supports | partially_supports | 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
|
||||
@@ -36,7 +36,7 @@ type ControlMapping struct {
|
||||
|
||||
// Allowed enum values — the deterministic "rule" layer that keeps the curated store clean.
|
||||
var (
|
||||
mappingTypeValues = map[string]bool{"supports": true, "partially_supports": true, "implements": true, "related": true, "contradicts": true}
|
||||
mappingTypeValues = map[string]bool{"primary_implementation": true, "implements": true, "supports": true, "partially_supports": true, "related": true, "contradicts": true}
|
||||
mappingStatusValues = map[string]bool{"candidate": true, "accepted": true, "rejected": true, "superseded": true}
|
||||
provenanceValues = map[string]bool{"retriever_candidate": true, "human_curated": true, "rule_based": true}
|
||||
)
|
||||
|
||||
@@ -77,6 +77,7 @@ _ROUTER_MODULES = [
|
||||
"licenses_routes",
|
||||
"template_rule_routes",
|
||||
"specialist_agent_routes",
|
||||
"reasoning_routes",
|
||||
]
|
||||
|
||||
_loaded_count = 0
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""HTTP endpoints for the Regulatory Reasoning Engine (spec §7).
|
||||
|
||||
Thin handlers — all reasoning lives in `compliance.reasoning.*`. No DB, no RAG;
|
||||
pure deterministic rule evaluation.
|
||||
|
||||
POST /reasoning/scope -> which regulations apply + missing facts
|
||||
POST /reasoning/obligations -> obligations, overlaps, multi-evidence
|
||||
POST /reasoning/implementation-reasoning -> claim->obligation mapping (Welt 1, no verdict)
|
||||
POST /reasoning/interpretation-assessment -> verdict on a customer interpretation
|
||||
POST /reasoning/product-scope -> gate on facts, else run discover_scope once
|
||||
POST /reasoning/regulatory-map -> customer-readable read-model over the scope
|
||||
POST /reasoning/interpretation-in-map -> judge a customer interpretation within the map
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from compliance.interpretation_map import (
|
||||
InterpretationInMapRequest,
|
||||
InterpretationInMapResult,
|
||||
interpret_in_map,
|
||||
)
|
||||
from compliance.product_scope import (
|
||||
ProductScopeRequest,
|
||||
ProductScopeResponse,
|
||||
resolve_product_scope,
|
||||
)
|
||||
from compliance.regulatory_map import RegulatoryMap, RegulatoryMapRequest, render_regulatory_map
|
||||
from compliance.reasoning import (
|
||||
assess_interpretation,
|
||||
derive_obligations,
|
||||
discover_scope,
|
||||
reason_implementation_claim,
|
||||
)
|
||||
from compliance.reasoning.schemas import (
|
||||
ImplementationReasoningRequest,
|
||||
ImplementationReasoningResponse,
|
||||
InterpretationRequest,
|
||||
InterpretationResponse,
|
||||
ObligationsRequest,
|
||||
ObligationsResponse,
|
||||
ScopeRequest,
|
||||
ScopeResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/reasoning", tags=["reasoning"])
|
||||
|
||||
|
||||
@router.post("/scope", response_model=ScopeResponse)
|
||||
def scope_discovery(req: ScopeRequest) -> ScopeResponse:
|
||||
scope = discover_scope(req.product_profile)
|
||||
return ScopeResponse(
|
||||
regulatory_scope=scope,
|
||||
missing_facts=scope.missing_facts,
|
||||
confidence=scope.confidence,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/obligations", response_model=ObligationsResponse)
|
||||
def applicable_obligations(req: ObligationsRequest) -> ObligationsResponse:
|
||||
return derive_obligations(req.product_profile, req.regulatory_scope)
|
||||
|
||||
|
||||
@router.post("/implementation-reasoning", response_model=ImplementationReasoningResponse)
|
||||
def implementation_reasoning(req: ImplementationReasoningRequest) -> ImplementationReasoningResponse:
|
||||
return reason_implementation_claim(req.product_profile, req.customer_claim)
|
||||
|
||||
|
||||
@router.post("/product-scope", response_model=ProductScopeResponse)
|
||||
def product_scope(req: ProductScopeRequest) -> ProductScopeResponse:
|
||||
return resolve_product_scope(req.product_profile)
|
||||
|
||||
|
||||
@router.post("/regulatory-map", response_model=RegulatoryMap)
|
||||
def regulatory_map(req: RegulatoryMapRequest) -> RegulatoryMap:
|
||||
return render_regulatory_map(req.product_profile)
|
||||
|
||||
|
||||
@router.post("/interpretation-in-map", response_model=InterpretationInMapResult)
|
||||
def interpretation_in_map(req: InterpretationInMapRequest) -> InterpretationInMapResult:
|
||||
reg_map = render_regulatory_map(req.product_profile)
|
||||
return interpret_in_map(reg_map, req.customer_interpretation)
|
||||
|
||||
|
||||
@router.post("/interpretation-assessment", response_model=InterpretationResponse)
|
||||
def interpretation_assessment(req: InterpretationRequest) -> InterpretationResponse:
|
||||
result = assess_interpretation(req.customer_interpretation, req.product_profile)
|
||||
return InterpretationResponse(
|
||||
assessment=result.assessment,
|
||||
affected_regulations=result.affected_regulations,
|
||||
affected_obligations=result.affected_obligations,
|
||||
corrected_interpretation=result.corrected_interpretation,
|
||||
risks=result.risks,
|
||||
legal_basis_refs=result.legal_basis_refs,
|
||||
explanation=result.explanation,
|
||||
confidence=result.confidence,
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Company Intelligence (Phase 2A) — Company Capability Profile foundation.
|
||||
|
||||
The HEAD of the spine Company -> Capability -> Product -> Regulation -> Obligation
|
||||
-> Procedure -> Evidence. Builds a CompanyContext into a CompanyCapabilityProfile
|
||||
with a four-state trust model (declared/inferred/confirmed/unknown). A certification
|
||||
yields at most an INFERRED candidate — never "erfuellt".
|
||||
|
||||
Reasoning OWNS the container + trust-state; it CONSUMES the Certification->Capability
|
||||
mapping (Execution-owned) via an injected contract — no mapping data in product code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .contract import CapabilityMappingEntry, CertificationCapabilityMap, EMPTY_MAPPING
|
||||
from .engine import build_company_profile
|
||||
from .schemas import (
|
||||
CapabilityEvidence,
|
||||
Certification,
|
||||
CompanyCapabilityProfile,
|
||||
CompanyContext,
|
||||
Declaration,
|
||||
ExistingEvidence,
|
||||
ExistingProcess,
|
||||
ExistingSystem,
|
||||
OperationalCapability,
|
||||
OperationalCapabilityCandidate,
|
||||
VerificationStatus,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"build_company_profile",
|
||||
"CompanyContext",
|
||||
"CompanyCapabilityProfile",
|
||||
"Certification",
|
||||
"Declaration",
|
||||
"ExistingProcess",
|
||||
"ExistingSystem",
|
||||
"ExistingEvidence",
|
||||
"CapabilityEvidence",
|
||||
"OperationalCapabilityCandidate",
|
||||
"OperationalCapability",
|
||||
"VerificationStatus",
|
||||
"CapabilityMappingEntry",
|
||||
"CertificationCapabilityMap",
|
||||
"EMPTY_MAPPING",
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Consumption contract for the Certification -> Capability mapping.
|
||||
|
||||
OWNERSHIP BOUNDARY (hard): the Capability Registry, CapabilityDefinition and the
|
||||
Certification->Capability / Feature->Capability mapping RULES live in the Compliance
|
||||
Execution domain. This Reasoning layer defines ONLY the shape it consumes and never
|
||||
ships mapping DATA in product code — tests inject mocks, so the real table can only
|
||||
ever live in Execution.
|
||||
|
||||
Execution will eventually provide CapabilityRegistry / CapabilityMapping /
|
||||
CapabilityDefinition; Reasoning consumes exactly `OperationalCapabilityCandidate`
|
||||
{capability_id, source, confidence, verification_status} (see schemas.py) and the
|
||||
minimal mapping SHAPE below — nothing more.
|
||||
|
||||
Python 3.9 compatible (no `|` unions).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from compliance.reasoning.enums import Confidence
|
||||
|
||||
|
||||
class CapabilityMappingEntry(BaseModel):
|
||||
"""One mapping rule SHAPE: a certification implies candidate capabilities.
|
||||
|
||||
Contract type only. The actual table (which capabilities ISO27001 implies) is
|
||||
Execution's DATA and MUST NOT be hard-coded here or anywhere in product code.
|
||||
"""
|
||||
|
||||
capability_ids: List[str] = Field(default_factory=list)
|
||||
confidence: Confidence = Confidence.MEDIUM
|
||||
|
||||
|
||||
# certification_id -> entry. Injected at call time; product code holds NO entries.
|
||||
CertificationCapabilityMap = Dict[str, CapabilityMappingEntry]
|
||||
|
||||
# Intentionally empty: without an injected mapping there are zero inferred
|
||||
# candidates. This is the architectural guarantee that the registry lives only in
|
||||
# the Compliance Execution domain.
|
||||
EMPTY_MAPPING: CertificationCapabilityMap = {}
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Company Intelligence engine (Phase 2A) — build the Company Capability Profile.
|
||||
|
||||
Deterministic, no LLM/RAG. Turns a raw CompanyContext into capability evidence,
|
||||
candidates and (only via explicit verification) confirmed capabilities.
|
||||
|
||||
HARD RULE enforced here: a certification yields at most an INFERRED candidate; it
|
||||
can NEVER produce a CONFIRMED capability on its own. Only real ExistingEvidence
|
||||
(`proves_capability_id`) promotes a capability to CONFIRMED. Certifications without
|
||||
a known mapping yield evidence-of-claim but NO inferred capability (the mapping is
|
||||
Execution's data, injected — never hard-coded here).
|
||||
|
||||
Python 3.9 compatible (no `|` unions).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from compliance.reasoning.enums import Confidence
|
||||
|
||||
from .contract import EMPTY_MAPPING, CertificationCapabilityMap
|
||||
from .schemas import (
|
||||
CapabilityEvidence,
|
||||
CompanyCapabilityProfile,
|
||||
CompanyContext,
|
||||
OperationalCapability,
|
||||
OperationalCapabilityCandidate,
|
||||
VerificationStatus,
|
||||
)
|
||||
|
||||
|
||||
def _declared(context: CompanyContext) -> List[OperationalCapabilityCandidate]:
|
||||
out: List[OperationalCapabilityCandidate] = []
|
||||
for d in context.declarations:
|
||||
out.append(
|
||||
OperationalCapabilityCandidate(
|
||||
capability_id=d.capability_id,
|
||||
source="declaration:%s" % context.company_id,
|
||||
confidence=Confidence.MEDIUM,
|
||||
verification_status=VerificationStatus.DECLARED,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _from_certifications(
|
||||
context: CompanyContext, mapping: CertificationCapabilityMap
|
||||
) -> Tuple[List[CapabilityEvidence], List[OperationalCapabilityCandidate]]:
|
||||
# refinement 1: certification -> evidence-of-capability (claim) -> inferred candidate
|
||||
evidence: List[CapabilityEvidence] = []
|
||||
inferred: List[OperationalCapabilityCandidate] = []
|
||||
for cert in context.certifications:
|
||||
source = "certification:%s" % cert.certification_id
|
||||
evidence.append(
|
||||
CapabilityEvidence(
|
||||
source=source,
|
||||
claim="Company holds %s" % (cert.name or cert.certification_id),
|
||||
certification_id=cert.certification_id,
|
||||
)
|
||||
)
|
||||
entry = mapping.get(cert.certification_id)
|
||||
if entry is None:
|
||||
continue # no mapping known -> NO inferred capability (data is Execution's)
|
||||
for cap_id in entry.capability_ids:
|
||||
inferred.append(
|
||||
OperationalCapabilityCandidate(
|
||||
capability_id=cap_id,
|
||||
source=source,
|
||||
confidence=entry.confidence,
|
||||
verification_status=VerificationStatus.INFERRED,
|
||||
)
|
||||
)
|
||||
return evidence, inferred
|
||||
|
||||
|
||||
def _confirmed_from_evidence(context: CompanyContext) -> List[OperationalCapability]:
|
||||
proven: Dict[str, List[str]] = {}
|
||||
for ev in context.evidence:
|
||||
cap = ev.proves_capability_id
|
||||
if not cap:
|
||||
continue
|
||||
proven.setdefault(cap, []).append(ev.evidence_id)
|
||||
return [
|
||||
OperationalCapability(
|
||||
capability_id=cap,
|
||||
verification_status=VerificationStatus.CONFIRMED,
|
||||
confidence=Confidence.HIGH,
|
||||
sources=sources,
|
||||
)
|
||||
for cap, sources in proven.items()
|
||||
]
|
||||
|
||||
|
||||
def build_company_profile(
|
||||
context: CompanyContext, mapping: Optional[CertificationCapabilityMap] = None
|
||||
) -> CompanyCapabilityProfile:
|
||||
"""Build the Company Capability Profile from raw context + an injected mapping.
|
||||
|
||||
`mapping` defaults to EMPTY (no inferred candidates) so that the cert->capability
|
||||
table can only ever come from the Compliance Execution domain.
|
||||
"""
|
||||
mapping = EMPTY_MAPPING if mapping is None else mapping
|
||||
evidence, inferred = _from_certifications(context, mapping)
|
||||
declared = _declared(context)
|
||||
confirmed = _confirmed_from_evidence(context)
|
||||
confirmed_ids = {oc.capability_id for oc in confirmed}
|
||||
# a confirmed capability is no longer a mere candidate
|
||||
candidates = [c for c in (declared + inferred) if c.capability_id not in confirmed_ids]
|
||||
return CompanyCapabilityProfile(
|
||||
company_id=context.company_id,
|
||||
capability_evidence=evidence,
|
||||
candidate_capabilities=candidates,
|
||||
confirmed_capabilities=confirmed,
|
||||
)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Company Intelligence (Phase 2A) — Company Capability Profile (domain objects).
|
||||
|
||||
This is the HEAD of the spine
|
||||
|
||||
Company -> (Operational) Capability -> Product -> Applicable Regulation ->
|
||||
Obligation -> Procedure -> Evidence
|
||||
|
||||
and answers a DIFFERENT question than Regulatory Intelligence: not "which laws
|
||||
apply to my product" but "which capabilities does my company already have, and
|
||||
which regulatory obligations might they already cover".
|
||||
|
||||
HARD RULE (structural, not convention): a capability derived from a certification
|
||||
is at most INFERRED — never CONFIRMED, never "erfuellt". A certification produces
|
||||
EVIDENCE for a capability, an inference produces a CANDIDATE, and only checked
|
||||
evidence produces a CONFIRMED capability. This keeps the company side inside
|
||||
Welt 1 (potential), mirroring `ClaimCoverage` on the obligation side; it is NOT a
|
||||
conformity verdict (`ComplianceStatus`, Welt 2, owned by Compliance Execution).
|
||||
|
||||
OWNERSHIP: Reasoning OWNS this CompanyContext container + the trust-state machine.
|
||||
It does NOT own the Certification->Capability mapping RULES — those are the same
|
||||
kind of rule as Feature->Capability and belong to the Compliance Execution
|
||||
Capability Registry. This layer only CONSUMES `OperationalCapabilityCandidate`
|
||||
{capability_id, source, confidence, verification_status} via an injected mapping
|
||||
(see contract.py). No mapping DATA lives in product code (tests inject mocks).
|
||||
|
||||
Application/reasoning types, NOT compliance-meta-model classes (architecture
|
||||
freeze v1.0 untouched). Python 3.9 compatible (no `|` unions).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from compliance.reasoning.enums import Confidence
|
||||
|
||||
|
||||
class VerificationStatus(str, Enum):
|
||||
"""Trust state of an operational capability — a FOURTH vocabulary.
|
||||
|
||||
Disjoint from ClaimCoverage (Welt 1, customer claim vs obligation),
|
||||
ComplianceStatus (Welt 2, verified conformity) and DeltaType (RCI). It says
|
||||
only how well-established a company CAPABILITY is, never whether an obligation
|
||||
is met. Progression: DECLARED (customer says) -> INFERRED (a certification
|
||||
implies it) -> CONFIRMED (checked against real evidence); UNKNOWN = no signal.
|
||||
"""
|
||||
|
||||
DECLARED = "declared"
|
||||
INFERRED = "inferred"
|
||||
CONFIRMED = "confirmed"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
# ── raw company inputs (the CompanyContext children) ──────────────────────
|
||||
class Certification(BaseModel):
|
||||
certification_id: str # e.g. "ISO27001"
|
||||
name: str = ""
|
||||
scope: str = "" # what the cert covers, customer-stated
|
||||
|
||||
|
||||
class Declaration(BaseModel):
|
||||
"""A customer statement that they have a capability ("we do patch management")."""
|
||||
|
||||
capability_id: str
|
||||
statement: str = ""
|
||||
|
||||
|
||||
class ExistingProcess(BaseModel):
|
||||
process_id: str
|
||||
name: str = ""
|
||||
|
||||
|
||||
class ExistingSystem(BaseModel):
|
||||
system_id: str
|
||||
name: str = ""
|
||||
|
||||
|
||||
class ExistingEvidence(BaseModel):
|
||||
"""A concrete artefact the company already holds (policy, audit log, SBOM ...).
|
||||
|
||||
`proves_capability_id` is the ONLY thing that may lift a capability to
|
||||
CONFIRMED — and only when a human/engine has attached real evidence.
|
||||
"""
|
||||
|
||||
evidence_id: str
|
||||
evidence_type: str = "" # config_export/test_report/policy/audit_log/...
|
||||
proves_capability_id: Optional[str] = None
|
||||
|
||||
|
||||
# ── intermediate: certification -> evidence-of-capability (refinement 1) ──
|
||||
class CapabilityEvidence(BaseModel):
|
||||
"""A certification does not yield a capability directly — only EVIDENCE for one.
|
||||
|
||||
"Company holds a certified ISMS" is the evidence/claim; capabilities are then
|
||||
INFERRED from it via the injected (Execution-owned) mapping, never directly.
|
||||
"""
|
||||
|
||||
source: str # provenance, e.g. "certification:ISO27001"
|
||||
claim: str = ""
|
||||
certification_id: str = ""
|
||||
|
||||
|
||||
# ── consumed contract type (refinement 2) ─────────────────────────────────
|
||||
class OperationalCapabilityCandidate(BaseModel):
|
||||
"""The ONLY thing Reasoning consumes from Execution's capability mapping.
|
||||
|
||||
Named "operational" (organisational ability) to stay distinct from later
|
||||
Product/AI/Safety capabilities. A candidate is always Welt 1 — DECLARED or
|
||||
INFERRED — and never CONFIRMED on its own.
|
||||
"""
|
||||
|
||||
capability_id: str
|
||||
source: str
|
||||
confidence: Confidence = Confidence.MEDIUM
|
||||
verification_status: VerificationStatus = VerificationStatus.INFERRED
|
||||
|
||||
|
||||
class OperationalCapability(BaseModel):
|
||||
"""A capability the company actually has, CONFIRMED against real evidence."""
|
||||
|
||||
capability_id: str
|
||||
verification_status: VerificationStatus
|
||||
confidence: Confidence = Confidence.MEDIUM
|
||||
sources: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ── the container Reasoning OWNS (raw inputs) ─────────────────────────────
|
||||
class CompanyContext(BaseModel):
|
||||
company_id: str
|
||||
certifications: List[Certification] = Field(default_factory=list)
|
||||
declarations: List[Declaration] = Field(default_factory=list)
|
||||
processes: List[ExistingProcess] = Field(default_factory=list)
|
||||
systems: List[ExistingSystem] = Field(default_factory=list)
|
||||
evidence: List[ExistingEvidence] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ── derived view (the Company Capability Profile) ─────────────────────────
|
||||
class CompanyCapabilityProfile(BaseModel):
|
||||
"""Derived: capability evidence + candidates (declared/inferred) + confirmed.
|
||||
|
||||
`candidate_capabilities` NEVER auto-promote to `confirmed_capabilities`; only
|
||||
explicit ExistingEvidence does that. The hard rule is enforced in engine.py.
|
||||
"""
|
||||
|
||||
company_id: str
|
||||
capability_evidence: List[CapabilityEvidence] = Field(default_factory=list)
|
||||
candidate_capabilities: List[OperationalCapabilityCandidate] = Field(default_factory=list)
|
||||
confirmed_capabilities: List[OperationalCapability] = Field(default_factory=list)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Interpretation-in-Map — evaluate a customer interpretation within the map.
|
||||
|
||||
Thin adapter over the existing `assess_interpretation`: it judges the customer's
|
||||
reading against the regulations/obligations actually present in the product's
|
||||
RegulatoryMap, and flags touched unsupported domains as future_corpus_needed
|
||||
instead of pseudo-evaluating them. No new legal reasoning, no RCI, no UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .adapter import interpret_in_map
|
||||
from .schemas import InterpretationInMapRequest, InterpretationInMapResult
|
||||
|
||||
__all__ = [
|
||||
"interpret_in_map",
|
||||
"InterpretationInMapRequest",
|
||||
"InterpretationInMapResult",
|
||||
]
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Interpretation-in-Map adapter (step 5).
|
||||
|
||||
Evaluates a customer interpretation WITHIN the already-built RegulatoryMap. It
|
||||
reuses the existing `assess_interpretation` (no new legal engine), restricts the
|
||||
affected regulations/obligations to those present in the map, and reports any
|
||||
touched unsupported domain (wastewater/chemicals/...) as future_corpus_needed
|
||||
rather than pseudo-evaluating it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from compliance.reasoning.enums import InterpretationVerdict
|
||||
from compliance.reasoning.interpretation_engine import assess_interpretation
|
||||
from compliance.regulatory_map.schemas import RegulatoryMap
|
||||
|
||||
from .schemas import InterpretationInMapResult
|
||||
|
||||
_LABEL: Dict[InterpretationVerdict, str] = {
|
||||
InterpretationVerdict.PLAUSIBLE: "plausibel",
|
||||
InterpretationVerdict.TOO_NARROW: "zu eng",
|
||||
InterpretationVerdict.TOO_BROAD: "zu weit",
|
||||
InterpretationVerdict.PARTIALLY_CORRECT: "teilweise korrekt",
|
||||
InterpretationVerdict.UNSUPPORTED: "nicht belegt",
|
||||
InterpretationVerdict.UNCERTAIN: "unsicher",
|
||||
}
|
||||
|
||||
# domain -> keywords that signal the interpretation is ABOUT that (uncovered) domain.
|
||||
_ENV_KEYWORDS: Dict[str, List[str]] = {
|
||||
"environment_water": ["abwasser", "wastewater", "gewässer", "gewaesser", "einleitung", "abfluss"],
|
||||
"chemicals": ["chemikalie", "reach", "clp", "reinigungsmittel", "biozid", "gefahrstoff", "detergenz", "lösemittel", "loesemittel"],
|
||||
"environment_air": ["luft", "emission", "voc", "immission", "abluft", "verbrennung"],
|
||||
"waste": ["abfall", "entsorgung", "weee", "recycling"],
|
||||
"energy_resources": ["energie", "ökodesign", "oekodesign", "verbrauch"],
|
||||
}
|
||||
|
||||
|
||||
def _touches(text: str, domain: str) -> bool:
|
||||
low = text.lower()
|
||||
return any(kw in low for kw in _ENV_KEYWORDS.get(domain, []))
|
||||
|
||||
|
||||
def _explain(label: str, detail: str, affected_regs: List[str], future_domains: List[str], in_scope: bool) -> str:
|
||||
base = "Ihre Interpretation ist wahrscheinlich %s." % label
|
||||
if detail:
|
||||
base += " " + detail
|
||||
if affected_regs:
|
||||
base += " Betroffen in Ihrer Map: %s." % ", ".join(affected_regs)
|
||||
if future_domains:
|
||||
base += (
|
||||
" Für %s liegt noch kein Regelkorpus vor — diese Aspekte werden nicht bewertet (future_corpus_needed)."
|
||||
% ", ".join(future_domains)
|
||||
)
|
||||
if not in_scope and not future_domains:
|
||||
base += " Diese Auslegung betrifft kein Regelwerk Ihrer aktuellen Produkt-Map."
|
||||
return base
|
||||
|
||||
|
||||
def interpret_in_map(reg_map: RegulatoryMap, interpretation: str) -> InterpretationInMapResult:
|
||||
a = assess_interpretation(interpretation) # existing engine — no new reasoning
|
||||
|
||||
map_reg_ids = (
|
||||
{v.regulation_id for v in reg_map.applicable_regulations}
|
||||
| {v.regulation_id for v in reg_map.uncertain_regulations}
|
||||
| {v.regulation_id for v in reg_map.excluded_regulations}
|
||||
)
|
||||
map_ob_ids = {o.obligation_id for v in reg_map.applicable_regulations for o in v.obligations}
|
||||
uncertain_ids = {v.regulation_id for v in reg_map.uncertain_regulations}
|
||||
|
||||
affected_regs = [r for r in a.affected_regulations if r in map_reg_ids]
|
||||
affected_obs = [o for o in a.affected_obligations if o in map_ob_ids]
|
||||
related_unc = [r for r in a.affected_regulations if r in uncertain_ids]
|
||||
future = [d for d in reg_map.unsupported_domains if _touches(interpretation, d.domain)]
|
||||
in_scope = bool(affected_regs or affected_obs)
|
||||
|
||||
return InterpretationInMapResult(
|
||||
raw_interpretation=interpretation,
|
||||
assessment=a.assessment,
|
||||
in_scope_of_map=in_scope,
|
||||
affected_regulations=affected_regs,
|
||||
affected_obligations=affected_obs,
|
||||
related_uncertainties=related_unc,
|
||||
future_corpus_domains=future,
|
||||
corrected_interpretation=a.corrected_interpretation,
|
||||
risks=a.risks,
|
||||
legal_basis_refs=a.legal_basis_refs,
|
||||
explanation=_explain(_LABEL[a.assessment], a.explanation, affected_regs, [d.domain for d in future], in_scope),
|
||||
confidence=a.confidence,
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Schemas for Interpretation-in-Map (step 5).
|
||||
|
||||
A thin adapter that evaluates a customer interpretation WITHIN the already-built
|
||||
RegulatoryMap — it does not assess abstract legal questions. Application types
|
||||
only; no compliance-meta-model classes (freeze v1.0 untouched).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from compliance.product_scope.schemas import UnsupportedDomain
|
||||
from compliance.profile.canonical import CanonicalProductRegulatoryProfile
|
||||
from compliance.reasoning.enums import Confidence, InterpretationVerdict
|
||||
|
||||
|
||||
class InterpretationInMapRequest(BaseModel):
|
||||
product_profile: CanonicalProductRegulatoryProfile
|
||||
customer_interpretation: str
|
||||
|
||||
|
||||
class InterpretationInMapResult(BaseModel):
|
||||
raw_interpretation: str
|
||||
assessment: InterpretationVerdict
|
||||
in_scope_of_map: bool # True if it touches a regulation/obligation present in the map
|
||||
affected_regulations: List[str] = Field(default_factory=list) # intersected with the map
|
||||
affected_obligations: List[str] = Field(default_factory=list) # intersected (registry-linked)
|
||||
related_uncertainties: List[str] = Field(default_factory=list) # map-uncertain regs it touches
|
||||
future_corpus_domains: List[UnsupportedDomain] = Field(default_factory=list) # NOT evaluated
|
||||
corrected_interpretation: str = ""
|
||||
risks: List[str] = Field(default_factory=list)
|
||||
legal_basis_refs: List[str] = Field(default_factory=list)
|
||||
explanation: str = ""
|
||||
confidence: Confidence = Confidence.MEDIUM
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Product Regulatory Navigator — thin missing-facts layer.
|
||||
|
||||
Sits above the CanonicalProductRegulatoryProfile (prefilled from company-profile /
|
||||
ProductWizard) and reports only which facts are still missing + prioritized
|
||||
questions to collect them. It decides which facts are needed, NOT what regulation
|
||||
applies — that stays with the Scope Engine (step 3). No regulation logic, no UI,
|
||||
no Go, no RAG.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .engine import CompletenessSummary, NavigatorResult, apply_answers, navigate
|
||||
from .questions import (
|
||||
QUESTION_CATALOG,
|
||||
AnswerType,
|
||||
NavigatorQuestion,
|
||||
QuestionPriority,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"navigate",
|
||||
"apply_answers",
|
||||
"NavigatorResult",
|
||||
"CompletenessSummary",
|
||||
"NavigatorQuestion",
|
||||
"AnswerType",
|
||||
"QuestionPriority",
|
||||
"QUESTION_CATALOG",
|
||||
]
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Product Regulatory Navigator engine — missing-facts only.
|
||||
|
||||
`navigate(profile)` reports which canonical fields are still unknown and the
|
||||
prioritized questions to fill them. `apply_answers(profile, answers)` returns the
|
||||
updated profile. It NEVER decides what applies — that is the Scope Engine (step 3).
|
||||
Pure field-presence checking; no scope-engine import, no regulation evaluation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Type
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from compliance.profile.canonical import (
|
||||
CanonicalLifecyclePhase,
|
||||
CanonicalProductRegulatoryProfile,
|
||||
EconomicOperatorRole,
|
||||
ProductComponent,
|
||||
)
|
||||
|
||||
from .questions import QUESTION_CATALOG, NavigatorQuestion, QuestionPriority
|
||||
|
||||
_ENUM_FIELDS: Dict[str, Type[Any]] = {
|
||||
"economic_operator_role": EconomicOperatorRole,
|
||||
"lifecycle_phase": CanonicalLifecyclePhase,
|
||||
}
|
||||
|
||||
|
||||
class CompletenessSummary(BaseModel):
|
||||
total_relevant: int
|
||||
answered: int
|
||||
missing: int
|
||||
missing_by_priority: Dict[str, int] = Field(default_factory=dict)
|
||||
ready_for_scope: bool # True once no P0 fact is missing
|
||||
note: str = ""
|
||||
|
||||
|
||||
class NavigatorResult(BaseModel):
|
||||
missing_facts: List[str] = Field(default_factory=list) # canonical target fields
|
||||
suggested_questions: List[NavigatorQuestion] = Field(default_factory=list)
|
||||
completeness_summary: CompletenessSummary
|
||||
|
||||
|
||||
def _value(profile: CanonicalProductRegulatoryProfile, dotted: str) -> Any:
|
||||
if "." in dotted:
|
||||
head, tail = dotted.split(".", 1)
|
||||
return getattr(getattr(profile, head), tail, None)
|
||||
return getattr(profile, dotted, None)
|
||||
|
||||
|
||||
def _is_unknown(profile: CanonicalProductRegulatoryProfile, q: NavigatorQuestion) -> bool:
|
||||
value = _value(profile, q.target_field)
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, list) and not value:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def navigate(profile: CanonicalProductRegulatoryProfile) -> NavigatorResult:
|
||||
missing = [q for q in QUESTION_CATALOG if _is_unknown(profile, q)]
|
||||
missing.sort(key=lambda q: q.order())
|
||||
|
||||
by_priority: Dict[str, int] = {}
|
||||
for q in missing:
|
||||
by_priority[q.priority.value] = by_priority.get(q.priority.value, 0) + 1
|
||||
ready = QuestionPriority.P0.value not in by_priority
|
||||
|
||||
total = len(QUESTION_CATALOG)
|
||||
summary = CompletenessSummary(
|
||||
total_relevant=total,
|
||||
answered=total - len(missing),
|
||||
missing=len(missing),
|
||||
missing_by_priority=by_priority,
|
||||
ready_for_scope=ready,
|
||||
note=(
|
||||
"%d von %d Fakten vorhanden; %d offen. Scope-Engine startklar: %s."
|
||||
% (total - len(missing), total, len(missing), "ja" if ready else "nein (P0 fehlt)")
|
||||
),
|
||||
)
|
||||
return NavigatorResult(
|
||||
missing_facts=[q.target_field for q in missing],
|
||||
suggested_questions=missing,
|
||||
completeness_summary=summary,
|
||||
)
|
||||
|
||||
|
||||
def _coerce(q: NavigatorQuestion, value: Any) -> Any:
|
||||
if q.target_field in _ENUM_FIELDS:
|
||||
return _ENUM_FIELDS[q.target_field](value)
|
||||
if q.target_field == "components":
|
||||
return [c if isinstance(c, ProductComponent) else ProductComponent(**c) for c in (value or [])]
|
||||
if q.answer_type.value in {"country_list", "multiselect"}:
|
||||
return list(value or [])
|
||||
if q.answer_type.value == "bool":
|
||||
return bool(value)
|
||||
return value
|
||||
|
||||
|
||||
def apply_answers(
|
||||
profile: CanonicalProductRegulatoryProfile, answers: Dict[str, Any]
|
||||
) -> CanonicalProductRegulatoryProfile:
|
||||
updated = profile.model_copy(deep=True)
|
||||
by_id = {q.question_id: q for q in QUESTION_CATALOG}
|
||||
for question_id, raw in answers.items():
|
||||
q = by_id.get(question_id)
|
||||
if q is None or raw is None:
|
||||
continue
|
||||
value = _coerce(q, raw)
|
||||
if "." in q.target_field:
|
||||
head, tail = q.target_field.split(".", 1)
|
||||
setattr(getattr(updated, head), tail, value)
|
||||
else:
|
||||
setattr(updated, q.target_field, value)
|
||||
return updated
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Product Regulatory Navigator — question catalog.
|
||||
|
||||
The Navigator is a THIN missing-facts layer over CanonicalProductRegulatoryProfile.
|
||||
It does NOT decide what applies — `regulatory_domains_unblocked` is static metadata
|
||||
(which domains a fact would help the Scope Engine decide later), never an
|
||||
evaluation. No regulation logic, no UI, no Go, no RAG.
|
||||
|
||||
`NavigatorQuestion` is an interaction type, NOT a compliance-meta-model class
|
||||
(architecture freeze v1.0 untouched).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from compliance.profile.canonical import CanonicalLifecyclePhase, EconomicOperatorRole
|
||||
|
||||
|
||||
class AnswerType(str, Enum):
|
||||
BOOL = "bool"
|
||||
ENUM = "enum"
|
||||
MULTISELECT = "multiselect"
|
||||
TEXT = "text"
|
||||
COUNTRY_LIST = "country_list"
|
||||
COMPONENT_LIST = "component_list"
|
||||
|
||||
|
||||
class QuestionPriority(str, Enum):
|
||||
P0 = "P0" # blocks scope: EU-vs-not, role, lifecycle, machine/component
|
||||
P1 = "P1" # unblocks a specific domain: RED, Data Act, environment, security
|
||||
P2 = "P2" # refinement: structured BOM
|
||||
|
||||
|
||||
_PRIORITY_ORDER = {QuestionPriority.P0: 0, QuestionPriority.P1: 1, QuestionPriority.P2: 2}
|
||||
|
||||
|
||||
class NavigatorQuestion(BaseModel):
|
||||
question_id: str
|
||||
target_field: str # dotted path into the canonical profile
|
||||
label: str
|
||||
why_needed: str
|
||||
regulatory_domains_unblocked: List[str] = Field(default_factory=list)
|
||||
answer_type: AnswerType
|
||||
options: List[str] = Field(default_factory=list)
|
||||
priority: QuestionPriority
|
||||
|
||||
def order(self) -> int:
|
||||
return _PRIORITY_ORDER[self.priority]
|
||||
|
||||
|
||||
_ROLE_OPTIONS = [e.value for e in EconomicOperatorRole]
|
||||
_PHASE_OPTIONS = [e.value for e in CanonicalLifecyclePhase]
|
||||
|
||||
QUESTION_CATALOG: List[NavigatorQuestion] = [
|
||||
# ── P0: block the scope decision itself ───────────────────────────
|
||||
NavigatorQuestion(
|
||||
question_id="markets",
|
||||
target_field="markets",
|
||||
label="In welche Märkte / Länder liefern Sie das Produkt?",
|
||||
why_needed="Bestimmt EU- vs. Nicht-EU-Anwendbarkeit und nationale Pflichten.",
|
||||
regulatory_domains_unblocked=["cyber", "machine_safety", "data", "radio", "emv", "environment"],
|
||||
answer_type=AnswerType.COUNTRY_LIST,
|
||||
priority=QuestionPriority.P0,
|
||||
),
|
||||
NavigatorQuestion(
|
||||
question_id="economic_operator_role",
|
||||
target_field="economic_operator_role",
|
||||
label="Welche Rolle nehmen Sie ein?",
|
||||
why_needed="Pflichten hängen von der Rolle ab (Hersteller/Importeur/Händler/Betreiber/Service).",
|
||||
regulatory_domains_unblocked=["cyber", "machine_safety", "data"],
|
||||
answer_type=AnswerType.ENUM,
|
||||
options=_ROLE_OPTIONS,
|
||||
priority=QuestionPriority.P0,
|
||||
),
|
||||
NavigatorQuestion(
|
||||
question_id="lifecycle_phase",
|
||||
target_field="lifecycle_phase",
|
||||
label="In welcher Lebenszyklusphase betrachten Sie das Produkt?",
|
||||
why_needed="Manche Pflichten greifen nur beim Inverkehrbringen oder in der Wartung.",
|
||||
regulatory_domains_unblocked=["cyber", "machine_safety"],
|
||||
answer_type=AnswerType.ENUM,
|
||||
options=_PHASE_OPTIONS,
|
||||
priority=QuestionPriority.P0,
|
||||
),
|
||||
NavigatorQuestion(
|
||||
question_id="is_machine",
|
||||
target_field="is_machine",
|
||||
label="Ist das Produkt eine (vollständige) Maschine?",
|
||||
why_needed="Entscheidet die Anwendbarkeit der Maschinenverordnung.",
|
||||
regulatory_domains_unblocked=["machine_safety"],
|
||||
answer_type=AnswerType.BOOL,
|
||||
priority=QuestionPriority.P0,
|
||||
),
|
||||
NavigatorQuestion(
|
||||
question_id="is_component",
|
||||
target_field="is_component",
|
||||
label="Ist das Produkt ein Bauteil / eine unvollständige Maschine?",
|
||||
why_needed="Sicherheitsbauteil vs. vollständige Maschine ändert die Pflichten.",
|
||||
regulatory_domains_unblocked=["machine_safety"],
|
||||
answer_type=AnswerType.BOOL,
|
||||
priority=QuestionPriority.P0,
|
||||
),
|
||||
# ── P1: unblock one specific domain ───────────────────────────────
|
||||
NavigatorQuestion(
|
||||
question_id="has_radio_module",
|
||||
target_field="has_radio_module",
|
||||
label="Enthält das Produkt ein Funkmodul (WLAN/Bluetooth/Mobilfunk)?",
|
||||
why_needed="Ein Funkmodul löst die Funkanlagen-Richtlinie (RED) aus.",
|
||||
regulatory_domains_unblocked=["radio"],
|
||||
answer_type=AnswerType.BOOL,
|
||||
priority=QuestionPriority.P1,
|
||||
),
|
||||
NavigatorQuestion(
|
||||
question_id="generates_usage_data",
|
||||
target_field="generates_usage_data",
|
||||
label="Erzeugt das vernetzte Produkt nutzbare Produkt-/Nutzungsdaten?",
|
||||
why_needed="Erzeugte Nutzungsdaten entscheiden über Data-Act-Pflichten.",
|
||||
regulatory_domains_unblocked=["data"],
|
||||
answer_type=AnswerType.BOOL,
|
||||
priority=QuestionPriority.P1,
|
||||
),
|
||||
NavigatorQuestion(
|
||||
question_id="has_security_function",
|
||||
target_field="has_security_function",
|
||||
label="Hat das Produkt eine dedizierte Security-Funktion (gegen böswillige Akteure)?",
|
||||
why_needed="Trennt Security- von Safety-Funktion (CRA vs. MaschinenVO).",
|
||||
regulatory_domains_unblocked=["cyber", "machine_safety"],
|
||||
answer_type=AnswerType.BOOL,
|
||||
priority=QuestionPriority.P1,
|
||||
),
|
||||
NavigatorQuestion(
|
||||
question_id="env_wastewater",
|
||||
target_field="environmental.discharges_to_wastewater",
|
||||
label="Gibt das Produkt Stoffe an Wasser / Abwasser ab?",
|
||||
why_needed="Abwassereinleitung löst Abwasser-/Gewässerrecht aus.",
|
||||
regulatory_domains_unblocked=["environment_water"],
|
||||
answer_type=AnswerType.BOOL,
|
||||
priority=QuestionPriority.P1,
|
||||
),
|
||||
NavigatorQuestion(
|
||||
question_id="env_air",
|
||||
target_field="environmental.emits_to_air",
|
||||
label="Entstehen Luftemissionen (VOC, Staub, Verbrennung, Aerosole)?",
|
||||
why_needed="Luftemissionen lösen Immissionsschutzrecht aus.",
|
||||
regulatory_domains_unblocked=["environment_air"],
|
||||
answer_type=AnswerType.BOOL,
|
||||
priority=QuestionPriority.P1,
|
||||
),
|
||||
NavigatorQuestion(
|
||||
question_id="env_chemicals",
|
||||
target_field="environmental.uses_cleaning_chemicals",
|
||||
label="Werden Reinigungs-, Desinfektions- oder Biozidmittel verwendet/mitgeliefert?",
|
||||
why_needed="Chemikalien lösen REACH/CLP/Detergenzien-/Biozidrecht aus.",
|
||||
regulatory_domains_unblocked=["chemicals"],
|
||||
answer_type=AnswerType.BOOL,
|
||||
priority=QuestionPriority.P1,
|
||||
),
|
||||
# ── P2: refinement ────────────────────────────────────────────────
|
||||
NavigatorQuestion(
|
||||
question_id="components",
|
||||
target_field="components",
|
||||
label="Aus welchen wesentlichen Komponenten besteht das Produkt?",
|
||||
why_needed="Eine strukturierte Stückliste verfeinert komponenten-abgeleitete Pflichten.",
|
||||
regulatory_domains_unblocked=["radio", "emv", "environment_water", "chemicals"],
|
||||
answer_type=AnswerType.COMPONENT_LIST,
|
||||
priority=QuestionPriority.P2,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Product-scope orchestration (step 3).
|
||||
|
||||
Connects the Navigator's fact-gate to the existing reasoning `discover_scope`:
|
||||
decide regulatory scope only once the minimum (P0) facts are present, otherwise
|
||||
return the missing facts. Reuses discover_scope unchanged — no new scope logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .orchestrator import resolve_product_scope
|
||||
from .schemas import (
|
||||
ProductScopeRequest,
|
||||
ProductScopeResponse,
|
||||
RegulatoryScopeResult,
|
||||
ScopeStatus,
|
||||
UnsupportedDomain,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"resolve_product_scope",
|
||||
"ProductScopeRequest",
|
||||
"ProductScopeResponse",
|
||||
"RegulatoryScopeResult",
|
||||
"UnsupportedDomain",
|
||||
"ScopeStatus",
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Product-scope orchestrator (step 3) — gate, then reuse discover_scope.
|
||||
|
||||
THE rule: the Scope Engine decides only once the Navigator has released the
|
||||
minimum facts. If P0 facts are missing, return the missing facts/questions and do
|
||||
NOT run discover_scope. Otherwise project the canonical into the reasoning profile
|
||||
and run the EXISTING `discover_scope` exactly once.
|
||||
|
||||
No new scope rules, no new regulations, no environmental-law evaluation (those
|
||||
domains are surfaced only as unsupported_domains / future_corpus_needed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Tuple
|
||||
|
||||
from compliance.navigator.engine import navigate
|
||||
from compliance.profile.canonical import CanonicalProductRegulatoryProfile
|
||||
from compliance.profile.to_reasoning import to_reasoning_profile
|
||||
from compliance.reasoning.scope_engine import discover_scope
|
||||
|
||||
from .schemas import (
|
||||
ProductScopeResponse,
|
||||
RegulatoryScopeResult,
|
||||
ScopeStatus,
|
||||
UnsupportedDomain,
|
||||
)
|
||||
|
||||
# environmental trigger field -> (domain, note). Transparency only — not a verdict.
|
||||
_ENV_DOMAINS: List[Tuple[str, str, str]] = [
|
||||
("discharges_to_wastewater", "environment_water", "Abwasser-/Gewässerrecht (z. B. AbwV, WRRL) — noch nicht im Korpus."),
|
||||
("has_cooling_or_spraying_water", "environment_water", "Wasserbezogene Anforderungen — noch nicht im Korpus."),
|
||||
("emits_to_air", "environment_air", "Immissionsschutz-/Luftreinhalterecht (z. B. BImSchG, IED) — noch nicht im Korpus."),
|
||||
("uses_solvents", "environment_air", "Lösemittel-/VOC-Recht (z. B. 31. BImSchV) — noch nicht im Korpus."),
|
||||
("uses_cleaning_chemicals", "chemicals", "Chemikalienrecht (REACH/CLP/Detergenzien/Biozide) — noch nicht im Korpus."),
|
||||
("supplies_chemicals", "chemicals", "Chemikalienrecht (REACH/CLP) — noch nicht im Korpus."),
|
||||
("contains_restricted_substances", "chemicals", "Stoffbeschränkungen (REACH/RoHS) — noch nicht im Korpus."),
|
||||
("creates_waste", "waste", "Abfall-/Entsorgungsrecht (u. a. WEEE) — noch nicht im Korpus."),
|
||||
("consumes_energy_or_water", "energy_resources", "Energie-/Ökodesign-Recht — noch nicht im Korpus."),
|
||||
]
|
||||
|
||||
|
||||
def _unsupported_domains(profile: CanonicalProductRegulatoryProfile) -> List[UnsupportedDomain]:
|
||||
env = profile.environmental
|
||||
seen = set()
|
||||
out: List[UnsupportedDomain] = []
|
||||
for field, domain, note in _ENV_DOMAINS:
|
||||
if getattr(env, field) is True and domain not in seen:
|
||||
seen.add(domain)
|
||||
out.append(UnsupportedDomain(domain=domain, trigger=field, note=note))
|
||||
return out
|
||||
|
||||
|
||||
def resolve_product_scope(profile: CanonicalProductRegulatoryProfile) -> ProductScopeResponse:
|
||||
nav = navigate(profile)
|
||||
|
||||
if not nav.completeness_summary.ready_for_scope:
|
||||
return ProductScopeResponse(
|
||||
status=ScopeStatus.NEEDS_FACTS,
|
||||
completeness_summary=nav.completeness_summary,
|
||||
missing_facts=nav.missing_facts,
|
||||
suggested_questions=nav.suggested_questions,
|
||||
)
|
||||
|
||||
scope = discover_scope(to_reasoning_profile(profile)) # exactly once
|
||||
result = RegulatoryScopeResult(
|
||||
applicable_regulations=scope.applicable_regulations,
|
||||
excluded_regulations=scope.excluded_regulations,
|
||||
uncertain_regulations=scope.uncertain_regulations,
|
||||
unsupported_domains=_unsupported_domains(profile),
|
||||
reasoning_summary=scope.reasoning_summary,
|
||||
confidence=scope.confidence,
|
||||
)
|
||||
return ProductScopeResponse(
|
||||
status=ScopeStatus.RESOLVED,
|
||||
completeness_summary=nav.completeness_summary,
|
||||
regulatory_scope=result,
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Response schemas for the product-scope orchestrator (step 3).
|
||||
|
||||
These are application/API types — NOT compliance-meta-model classes (architecture
|
||||
freeze v1.0 untouched). The scope verdict itself is produced by the existing
|
||||
`discover_scope`; nothing here adds scope rules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from compliance.navigator.engine import CompletenessSummary
|
||||
from compliance.navigator.questions import NavigatorQuestion
|
||||
from compliance.profile.canonical import CanonicalProductRegulatoryProfile
|
||||
from compliance.reasoning.enums import Confidence
|
||||
from compliance.reasoning.schemas import (
|
||||
ApplicableRegulation,
|
||||
ExcludedRegulation,
|
||||
UncertainRegulation,
|
||||
)
|
||||
|
||||
|
||||
class ScopeStatus(str, Enum):
|
||||
NEEDS_FACTS = "needs_facts" # P0 facts missing -> ask, do not decide
|
||||
RESOLVED = "resolved" # minimum facts present -> scope decided
|
||||
|
||||
|
||||
class UnsupportedDomain(BaseModel):
|
||||
"""A domain the product triggers but the corpus does not yet cover.
|
||||
|
||||
Surfaced for transparency (no false completeness) — NEVER a legal evaluation.
|
||||
"""
|
||||
|
||||
domain: str
|
||||
trigger: str
|
||||
status: str = "future_corpus_needed"
|
||||
note: str = ""
|
||||
|
||||
|
||||
class RegulatoryScopeResult(BaseModel):
|
||||
applicable_regulations: List[ApplicableRegulation] = Field(default_factory=list)
|
||||
excluded_regulations: List[ExcludedRegulation] = Field(default_factory=list)
|
||||
uncertain_regulations: List[UncertainRegulation] = Field(default_factory=list)
|
||||
unsupported_domains: List[UnsupportedDomain] = Field(default_factory=list)
|
||||
reasoning_summary: str = ""
|
||||
confidence: Confidence = Confidence.MEDIUM
|
||||
|
||||
|
||||
class ProductScopeRequest(BaseModel):
|
||||
product_profile: CanonicalProductRegulatoryProfile
|
||||
|
||||
|
||||
class ProductScopeResponse(BaseModel):
|
||||
status: ScopeStatus
|
||||
completeness_summary: CompletenessSummary
|
||||
# case NEEDS_FACTS
|
||||
missing_facts: List[str] = Field(default_factory=list)
|
||||
suggested_questions: List[NavigatorQuestion] = Field(default_factory=list)
|
||||
# case RESOLVED
|
||||
regulatory_scope: Optional[RegulatoryScopeResult] = None
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Product profile convergence layer.
|
||||
|
||||
ONE canonical product profile (`CanonicalProductRegulatoryProfile`) that the Go
|
||||
gap engine and the Python reasoning engine both project from — so "SPS mit
|
||||
Remote Access" means the same thing everywhere. gap.ProductProfile leads; the
|
||||
reasoning ProductProfile is an adapter/DTO. Types + mappers only — no regulation
|
||||
logic, no UI, no new questions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .canonical import (
|
||||
CanonicalLifecyclePhase,
|
||||
CanonicalProductRegulatoryProfile,
|
||||
CanonicalProductType,
|
||||
ComponentKind,
|
||||
EconomicOperatorRole,
|
||||
EnvironmentalImpact,
|
||||
ProductComponent,
|
||||
)
|
||||
from .from_company_profile import from_company_profile
|
||||
from .from_product_wizard import from_product_wizard
|
||||
from .to_gap import to_gap_profile
|
||||
from .to_reasoning import to_reasoning_profile
|
||||
|
||||
__all__ = [
|
||||
"CanonicalProductRegulatoryProfile",
|
||||
"CanonicalProductType",
|
||||
"EconomicOperatorRole",
|
||||
"CanonicalLifecyclePhase",
|
||||
"ComponentKind",
|
||||
"ProductComponent",
|
||||
"EnvironmentalImpact",
|
||||
"from_product_wizard",
|
||||
"from_company_profile",
|
||||
"to_gap_profile",
|
||||
"to_reasoning_profile",
|
||||
]
|
||||
@@ -0,0 +1,158 @@
|
||||
"""CanonicalProductRegulatoryProfile — the single semantic product profile.
|
||||
|
||||
Convergence layer (spec 2026-06-26): instead of letting the Go `gap.ProductProfile`
|
||||
and the Python reasoning `ProductProfile` drift, ONE canonical type is the source
|
||||
of truth. The Go gap engine LEADS (it carries real engine logic), so the canonical
|
||||
mirrors gap's field names and adds the Navigator gaps the audit found missing
|
||||
(economic-operator role, radio module, generates_usage_data, lifecycle phase,
|
||||
structured BOM, safety-vs-security split, machine-vs-component) plus a
|
||||
forward-looking Environmental-Impact domain.
|
||||
|
||||
No regulation logic lives here — types only. Mappers live in sibling modules.
|
||||
Python 3.9 compatible (no `|` unions).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CanonicalProductType(str, Enum): # mirrors gap.ProductType
|
||||
SOFTWARE = "software"
|
||||
HARDWARE = "hardware"
|
||||
IOT = "iot"
|
||||
SAAS = "saas"
|
||||
EXCHANGE = "exchange"
|
||||
MEDICAL_DEVICE = "medical_device"
|
||||
MACHINERY = "machinery"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class EconomicOperatorRole(str, Enum): # CE/CRA role — gap.ProductProfile has none
|
||||
MANUFACTURER = "manufacturer"
|
||||
IMPORTER = "importer"
|
||||
DISTRIBUTOR = "distributor"
|
||||
INTEGRATOR = "integrator"
|
||||
OPERATOR = "operator"
|
||||
SERVICE_PROVIDER = "service_provider"
|
||||
|
||||
|
||||
class CanonicalLifecyclePhase(str, Enum):
|
||||
DEVELOPMENT = "development"
|
||||
PLACING_ON_MARKET = "placing_on_market"
|
||||
OPERATION = "operation"
|
||||
MAINTENANCE = "maintenance"
|
||||
UPDATE = "update"
|
||||
END_OF_LIFE = "end_of_life"
|
||||
|
||||
|
||||
class ComponentKind(str, Enum):
|
||||
MOTOR = "motor"
|
||||
PUMP = "pump"
|
||||
HEATING = "heating"
|
||||
COOLING = "cooling"
|
||||
CONTROLLER = "controller"
|
||||
PLC = "plc"
|
||||
HMI = "hmi"
|
||||
SENSOR = "sensor"
|
||||
ACTUATOR = "actuator"
|
||||
CAMERA = "camera"
|
||||
NETWORK_INTERFACE = "network_interface"
|
||||
RADIO_MODULE = "radio_module"
|
||||
CHEMICAL_DOSING = "chemical_dosing"
|
||||
WATER_INLET = "water_inlet"
|
||||
WASTEWATER_OUTLET = "wastewater_outlet"
|
||||
BATTERY = "battery"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class ProductComponent(BaseModel):
|
||||
"""One structured BOM node — these nodes are what later trigger domains."""
|
||||
|
||||
name: str
|
||||
kind: ComponentKind = ComponentKind.OTHER
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class EnvironmentalImpact(BaseModel):
|
||||
"""Forward-looking Umweltmedien-Trigger (own Navigator domain).
|
||||
|
||||
No regulation logic consumes these yet — profile fields only, so the model
|
||||
is not blind to wastewater/air/chemicals/waste questions when that domain
|
||||
is wired later (AbwV/WRRL/REACH/CLP/IED/BImSchG ...).
|
||||
"""
|
||||
|
||||
discharges_to_wastewater: Optional[bool] = None
|
||||
uses_cleaning_chemicals: Optional[bool] = None
|
||||
supplies_chemicals: Optional[bool] = None
|
||||
emits_to_air: Optional[bool] = None
|
||||
uses_solvents: Optional[bool] = None
|
||||
creates_waste: Optional[bool] = None
|
||||
contains_restricted_substances: Optional[bool] = None
|
||||
consumes_energy_or_water: Optional[bool] = None
|
||||
has_cooling_or_spraying_water: Optional[bool] = None
|
||||
|
||||
|
||||
class CanonicalProductRegulatoryProfile(BaseModel):
|
||||
# --- identity ---
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
product_type: Optional[CanonicalProductType] = None
|
||||
product_profile_id: Optional[str] = None
|
||||
tenant_id: Optional[str] = None
|
||||
iace_project_id: Optional[str] = None
|
||||
|
||||
# --- gap-native lists ---
|
||||
technologies: List[str] = Field(default_factory=list)
|
||||
data_processing: List[str] = Field(default_factory=list)
|
||||
markets: List[str] = Field(default_factory=list) # real list — never hardcoded ['EU']
|
||||
existing_certifications: List[str] = Field(default_factory=list)
|
||||
applied_norms: List[str] = Field(default_factory=list)
|
||||
|
||||
# --- gap-native product / IST-state booleans (tri-state: None = unknown) ---
|
||||
connected_to_internet: Optional[bool] = None
|
||||
has_software_updates: Optional[bool] = None
|
||||
uses_ai: Optional[bool] = None
|
||||
processes_personal_data: Optional[bool] = None
|
||||
is_critical_infra_supplier: Optional[bool] = None
|
||||
has_risk_assessment: Optional[bool] = None
|
||||
has_technical_file: Optional[bool] = None
|
||||
has_operating_manual: Optional[bool] = None
|
||||
has_sbom: Optional[bool] = None
|
||||
has_vuln_management: Optional[bool] = None
|
||||
has_update_mechanism: Optional[bool] = None
|
||||
has_incident_response: Optional[bool] = None
|
||||
has_supply_chain_mgmt: Optional[bool] = None
|
||||
ce_marking_since: Optional[str] = None
|
||||
product_age: Optional[str] = None
|
||||
|
||||
# --- NEW Navigator-gap fields (audit 2026-06-26) ---
|
||||
economic_operator_role: Optional[EconomicOperatorRole] = None
|
||||
has_radio_module: Optional[bool] = None
|
||||
generates_usage_data: Optional[bool] = None
|
||||
lifecycle_phase: Optional[CanonicalLifecyclePhase] = None
|
||||
components: List[ProductComponent] = Field(default_factory=list)
|
||||
has_safety_function: Optional[bool] = None
|
||||
safety_function_description: Optional[str] = None
|
||||
has_security_function: Optional[bool] = None # safety vs security split
|
||||
has_remote_access: Optional[bool] = None
|
||||
has_embedded_software: Optional[bool] = None
|
||||
is_machine: Optional[bool] = None
|
||||
is_component: Optional[bool] = None
|
||||
is_spare_part: Optional[bool] = None
|
||||
|
||||
# --- company / market context (NIS2 + scope; from company-profile) ---
|
||||
b2b_or_b2c: Optional[str] = None
|
||||
sector_industry: Optional[str] = None
|
||||
company_size: Optional[str] = None
|
||||
primary_jurisdiction: Optional[str] = None
|
||||
|
||||
# --- AI context (classification stays delegated to ai-act/ucca) ---
|
||||
ai_integration_type: List[str] = Field(default_factory=list)
|
||||
human_oversight_level: Optional[str] = None
|
||||
|
||||
# --- forward-looking environmental domain ---
|
||||
environmental: EnvironmentalImpact = Field(default_factory=EnvironmentalImpact)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""company-profile -> CanonicalProductRegulatoryProfile (prefill, acceptance #2).
|
||||
|
||||
Pulls master data (industry, business model, size, markets) and the conditional
|
||||
`machine_builder` block (camelCase JSONB keys, defined frontend-side) so the user
|
||||
re-answers nothing. The machineBuilder block is the richest product/safety/
|
||||
connectivity source — note it is industry-gated in the UI, so a prefill may find
|
||||
it empty; that is fine (fields stay None = unknown).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .canonical import CanonicalProductRegulatoryProfile
|
||||
|
||||
_EU_MEMBER_HINTS = {"DE", "AT", "FR", "IT", "NL", "LU", "LI", "EU", "EWR", "EEA", "DACH"}
|
||||
|
||||
|
||||
def _markets(p: Dict[str, Any], mb: Dict[str, Any]) -> List[str]:
|
||||
out: List[str] = []
|
||||
for source in (p.get("target_markets"), mb.get("exportMarkets"), [p.get("primary_jurisdiction")], [p.get("headquarters_country")]):
|
||||
for m in source or []:
|
||||
if m and m not in out:
|
||||
out.append(m)
|
||||
return out
|
||||
|
||||
|
||||
def _is_machine(mb: Dict[str, Any]) -> Any:
|
||||
types = mb.get("productTypes")
|
||||
if types:
|
||||
return True
|
||||
return None
|
||||
|
||||
|
||||
def from_company_profile(profile: Dict[str, Any]) -> CanonicalProductRegulatoryProfile:
|
||||
p = profile
|
||||
mb = p.get("machine_builder") or {}
|
||||
contains_ai = mb.get("containsAI")
|
||||
uses_ai = contains_ai if contains_ai is not None else p.get("uses_ai")
|
||||
return CanonicalProductRegulatoryProfile(
|
||||
description=mb.get("productDescription") or "",
|
||||
sector_industry=p.get("industry") or None,
|
||||
b2b_or_b2c=p.get("business_model") or None,
|
||||
company_size=p.get("company_size") or None,
|
||||
primary_jurisdiction=p.get("primary_jurisdiction") or None,
|
||||
markets=_markets(p, mb),
|
||||
uses_ai=uses_ai,
|
||||
ai_integration_type=list(mb.get("aiIntegrationType") or []),
|
||||
human_oversight_level=mb.get("humanOversightLevel") or None,
|
||||
has_embedded_software=mb.get("containsFirmware"),
|
||||
has_safety_function=mb.get("hasSafetyFunction"),
|
||||
safety_function_description=mb.get("safetyFunctionDescription") or None,
|
||||
has_remote_access=mb.get("hasRemoteAccess"),
|
||||
connected_to_internet=mb.get("isNetworked"),
|
||||
has_software_updates=mb.get("hasOTAUpdates"),
|
||||
has_risk_assessment=mb.get("hasRiskAssessment"),
|
||||
is_machine=_is_machine(mb),
|
||||
is_critical_infra_supplier=mb.get("criticalSectorClients"),
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""ProductWizard payload -> CanonicalProductRegulatoryProfile (lossless).
|
||||
|
||||
The gap-analysis ProductWizard POSTs exactly the gap.ProductProfile JSON shape
|
||||
(see admin-compliance/.../ProductWizard.tsx handleSubmit). This mapper copies
|
||||
every gap field verbatim so that `to_gap_profile(from_product_wizard(p))`
|
||||
reproduces the gap subset of `p` byte-for-byte (acceptance #1). New Navigator
|
||||
fields the wizard does not ask stay None.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .canonical import CanonicalProductRegulatoryProfile, CanonicalProductType
|
||||
|
||||
|
||||
def _as_product_type(value: Any) -> Optional[CanonicalProductType]:
|
||||
try:
|
||||
return CanonicalProductType(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def from_product_wizard(payload: Dict[str, Any]) -> CanonicalProductRegulatoryProfile:
|
||||
g = payload.get
|
||||
return CanonicalProductRegulatoryProfile(
|
||||
name=g("name", ""),
|
||||
description=g("description", ""),
|
||||
product_type=_as_product_type(g("product_type")),
|
||||
technologies=list(g("technologies") or []),
|
||||
data_processing=list(g("data_processing") or []),
|
||||
markets=list(g("markets") or []),
|
||||
existing_certifications=list(g("existing_certifications") or []),
|
||||
applied_norms=list(g("applied_norms") or []),
|
||||
connected_to_internet=g("connected_to_internet"),
|
||||
has_software_updates=g("has_software_updates"),
|
||||
uses_ai=g("uses_ai"),
|
||||
processes_personal_data=g("processes_personal_data"),
|
||||
is_critical_infra_supplier=g("is_critical_infra_supplier"),
|
||||
has_risk_assessment=g("has_risk_assessment"),
|
||||
has_technical_file=g("has_technical_file"),
|
||||
has_operating_manual=g("has_operating_manual"),
|
||||
has_sbom=g("has_sbom"),
|
||||
has_vuln_management=g("has_vuln_management"),
|
||||
has_update_mechanism=g("has_update_mechanism"),
|
||||
has_incident_response=g("has_incident_response"),
|
||||
has_supply_chain_mgmt=g("has_supply_chain_mgmt"),
|
||||
ce_marking_since=g("ce_marking_since"),
|
||||
product_age=g("product_age"),
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""CanonicalProductRegulatoryProfile -> gap.ProductProfile JSON shape.
|
||||
|
||||
Emits exactly the keys the Go gap engine already consumes (gap/models.go json
|
||||
tags), so the gap engine runs UNCHANGED — the canonical is a superset and gap is
|
||||
its lossless projection. Canonical-only fields (role/radio/components/...) are
|
||||
intentionally not emitted here; they reach the reasoning side via to_reasoning.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from .canonical import CanonicalProductRegulatoryProfile
|
||||
|
||||
|
||||
def to_gap_profile(c: CanonicalProductRegulatoryProfile) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": c.name,
|
||||
"description": c.description,
|
||||
"product_type": c.product_type.value if c.product_type else "",
|
||||
"technologies": list(c.technologies),
|
||||
"data_processing": list(c.data_processing),
|
||||
"markets": list(c.markets),
|
||||
"existing_certifications": list(c.existing_certifications),
|
||||
"applied_norms": list(c.applied_norms),
|
||||
"connected_to_internet": bool(c.connected_to_internet),
|
||||
"has_software_updates": bool(c.has_software_updates),
|
||||
"uses_ai": bool(c.uses_ai),
|
||||
"processes_personal_data": bool(c.processes_personal_data),
|
||||
"is_critical_infra_supplier": bool(c.is_critical_infra_supplier),
|
||||
"has_risk_assessment": bool(c.has_risk_assessment),
|
||||
"has_technical_file": bool(c.has_technical_file),
|
||||
"has_operating_manual": bool(c.has_operating_manual),
|
||||
"has_sbom": bool(c.has_sbom),
|
||||
"has_vuln_management": bool(c.has_vuln_management),
|
||||
"has_update_mechanism": bool(c.has_update_mechanism),
|
||||
"has_incident_response": bool(c.has_incident_response),
|
||||
"has_supply_chain_mgmt": bool(c.has_supply_chain_mgmt),
|
||||
"ce_marking_since": c.ce_marking_since if c.ce_marking_since is not None else "",
|
||||
"product_age": c.product_age if c.product_age is not None else "",
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"""CanonicalProductRegulatoryProfile -> reasoning ProductProfile (adapter/DTO).
|
||||
|
||||
The reasoning engine stays the consumer, never the source of truth (spec): the
|
||||
canonical leads, this projects it into the Python reasoning ProductProfile so the
|
||||
Reasoning engine and the Go gap engine run off ONE semantic profile (acceptance
|
||||
#10). AI classification is NOT done here — only `uses_ai` is forwarded; risk
|
||||
classification stays delegated to ai-act/ucca (acceptance #3).
|
||||
|
||||
This is the ONLY one-way coupling profile -> reasoning; reasoning never imports
|
||||
profile, so the reasoning layer stays hermetic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from compliance.reasoning.enums import ManufacturerRole, MarketModel, ProductLifecyclePhase
|
||||
from compliance.reasoning.schemas import ProductProfile
|
||||
|
||||
from .canonical import CanonicalProductRegulatoryProfile, CanonicalProductType
|
||||
|
||||
_SOFTWARE_TYPES = {CanonicalProductType.SOFTWARE, CanonicalProductType.SAAS, CanonicalProductType.IOT}
|
||||
_SOFTWARE_TECH = {"ai", "api", "database", "encryption", "ota_updates", "cloud", "blockchain"}
|
||||
_EU_HINTS = {"DE", "AT", "FR", "IT", "NL", "LU", "LI", "EU", "EWR", "EEA", "DACH"}
|
||||
_B2X = {"B2B": MarketModel.B2B, "B2C": MarketModel.B2C, "B2B_B2C": MarketModel.BOTH, "B2B2C": MarketModel.BOTH}
|
||||
|
||||
|
||||
def _or_none(*values: Optional[bool]) -> Optional[bool]:
|
||||
"""True if any value is truthy; None if all are None/absent; else False."""
|
||||
if any(v is True for v in values):
|
||||
return True
|
||||
if all(v is None for v in values):
|
||||
return None
|
||||
return False
|
||||
|
||||
|
||||
def _has_software(c: CanonicalProductRegulatoryProfile) -> Optional[bool]:
|
||||
type_sig = True if c.product_type in _SOFTWARE_TYPES else None
|
||||
tech_sig = True if (set(c.technologies) & _SOFTWARE_TECH) else None
|
||||
return _or_none(c.has_embedded_software, c.has_software_updates, c.uses_ai, type_sig, tech_sig)
|
||||
|
||||
|
||||
def _eu_market(markets: List[str]) -> Optional[bool]:
|
||||
if not markets:
|
||||
return None
|
||||
return True if (set(markets) & _EU_HINTS) else False
|
||||
|
||||
|
||||
def _has_radio(c: CanonicalProductRegulatoryProfile) -> Optional[bool]:
|
||||
if c.has_radio_module is not None:
|
||||
return c.has_radio_module
|
||||
if any(comp.kind.value == "radio_module" for comp in c.components):
|
||||
return True
|
||||
return None
|
||||
|
||||
|
||||
def to_reasoning_profile(c: CanonicalProductRegulatoryProfile) -> ProductProfile:
|
||||
role = ManufacturerRole(c.economic_operator_role.value) if c.economic_operator_role else None
|
||||
phase = ProductLifecyclePhase(c.lifecycle_phase.value) if c.lifecycle_phase else None
|
||||
b2x = _B2X.get(c.b2b_or_b2c) if c.b2b_or_b2c else None
|
||||
is_machine = c.is_machine if c.is_machine is not None else (
|
||||
True if c.product_type == CanonicalProductType.MACHINERY else None
|
||||
)
|
||||
generates_data = c.generates_usage_data if c.generates_usage_data is not None else (
|
||||
True if "telemetry" in c.data_processing else None
|
||||
)
|
||||
return ProductProfile(
|
||||
product_name=c.name or "Produkt",
|
||||
product_profile_id=c.product_profile_id,
|
||||
manufacturer_role=role,
|
||||
product_type=[c.product_type.value] if c.product_type else [],
|
||||
has_software=_has_software(c),
|
||||
has_embedded_software=c.has_embedded_software,
|
||||
has_remote_access=c.has_remote_access,
|
||||
has_cloud_connection=True if "cloud" in c.technologies else None,
|
||||
has_ai_functionality=c.uses_ai,
|
||||
has_radio_module=_has_radio(c),
|
||||
has_safety_function=c.has_safety_function,
|
||||
generates_usage_data=generates_data,
|
||||
is_machine=is_machine,
|
||||
is_component=c.is_component,
|
||||
is_spare_part=c.is_spare_part,
|
||||
eu_market=_eu_market(c.markets),
|
||||
b2b_or_b2c=b2x,
|
||||
lifecycle_phase=phase,
|
||||
company_size=c.company_size,
|
||||
sector=c.sector_industry,
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Regulatory Change Intelligence (RCI) — delta layer over the product-first map.
|
||||
|
||||
Answers "what changes relative to my existing Regulatory Map?" — NOT "what does
|
||||
the new law say in general". Snapshot the pipeline into a ComplianceBaseline, then
|
||||
assess a (simulated/provided) RegulatoryChange into per-obligation deltas + a
|
||||
management ChangeImpactSummary. Read/reasoning only — no UI, no ingestion, no RAG,
|
||||
no new regulations/controls, no legal evaluation outside the stored map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .baseline import create_baseline
|
||||
from .delta_engine import assess_change
|
||||
from .schemas import (
|
||||
ChangeAssessment,
|
||||
ChangeImpactSummary,
|
||||
ChangeType,
|
||||
ComplianceBaseline,
|
||||
DeltaType,
|
||||
ObligationDelta,
|
||||
RegulatoryChange,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"create_baseline",
|
||||
"assess_change",
|
||||
"ComplianceBaseline",
|
||||
"RegulatoryChange",
|
||||
"ObligationDelta",
|
||||
"ChangeImpactSummary",
|
||||
"ChangeAssessment",
|
||||
"DeltaType",
|
||||
"ChangeType",
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Snapshot the current product-first pipeline into a ComplianceBaseline.
|
||||
|
||||
This is the ONLY place RCI runs the pipeline — to freeze a point-in-time map +
|
||||
registry-linked obligations + their required evidence. Everything downstream
|
||||
(delta computation) works purely against this snapshot, never re-evaluating.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from compliance.profile.canonical import CanonicalProductRegulatoryProfile
|
||||
from compliance.profile.to_reasoning import to_reasoning_profile
|
||||
from compliance.reasoning.obligation_engine import derive_obligations
|
||||
from compliance.regulatory_map.renderer import render_regulatory_map
|
||||
|
||||
from .schemas import ComplianceBaseline
|
||||
|
||||
|
||||
def create_baseline(
|
||||
profile: CanonicalProductRegulatoryProfile,
|
||||
evidence_refs: Optional[Dict[str, List[str]]] = None,
|
||||
baseline_id: str = "baseline",
|
||||
created_at: Optional[str] = None,
|
||||
) -> ComplianceBaseline:
|
||||
reg_map = render_regulatory_map(profile)
|
||||
obligations = derive_obligations(to_reasoning_profile(profile)).applicable_obligations
|
||||
|
||||
applicable: List[str] = []
|
||||
required: Dict[str, List[str]] = {}
|
||||
for ob in obligations:
|
||||
if ob.registry_anchor: # only registry-linked obligations enter the baseline
|
||||
applicable.append(ob.obligation_id)
|
||||
required[ob.obligation_id] = list(ob.required_evidence)
|
||||
|
||||
return ComplianceBaseline(
|
||||
baseline_id=baseline_id,
|
||||
product_profile_snapshot=profile,
|
||||
regulatory_map_snapshot=reg_map,
|
||||
applicable_obligations=applicable,
|
||||
obligation_evidence_required=required,
|
||||
evidence_refs=dict(evidence_refs or {}),
|
||||
created_at=created_at,
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""RCI delta engine — assess a RegulatoryChange against a ComplianceBaseline.
|
||||
|
||||
Answers "what changes relative to my existing Map?" deterministically, working
|
||||
ONLY against the stored baseline (no re-evaluation of scope, no new legal
|
||||
assessment outside the map). Per-obligation classification -> ObligationDelta;
|
||||
aggregate -> ChangeImpactSummary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Tuple
|
||||
|
||||
from compliance.reasoning.enums import Confidence
|
||||
|
||||
from .schemas import (
|
||||
ChangeAssessment,
|
||||
ChangeImpactSummary,
|
||||
ChangeType,
|
||||
ComplianceBaseline,
|
||||
DeltaType,
|
||||
ObligationDelta,
|
||||
RegulatoryChange,
|
||||
)
|
||||
|
||||
_ACTION = {DeltaType.NEW, DeltaType.CHANGED, DeltaType.NEEDS_REVIEW}
|
||||
|
||||
|
||||
def _classify(
|
||||
in_base: bool, has_ev: bool, change_type: ChangeType, rel_app: bool, rel_unc: bool
|
||||
) -> Tuple[DeltaType, str, Confidence]:
|
||||
if not (rel_app or rel_unc):
|
||||
return DeltaType.NOT_APPLICABLE, "Die Änderung betrifft kein Regelwerk Ihrer Map.", Confidence.HIGH
|
||||
if rel_unc and not rel_app:
|
||||
return (
|
||||
DeltaType.NEEDS_REVIEW,
|
||||
"Betrifft ein für Ihr Produkt noch UNSICHERES Regelwerk — erst Anwendbarkeit klären.",
|
||||
Confidence.LOW,
|
||||
)
|
||||
if change_type == ChangeType.REPEAL:
|
||||
if in_base:
|
||||
return DeltaType.REMOVED, "Regelwerk/Pflicht aufgehoben — entfällt für Ihr Produkt.", Confidence.HIGH
|
||||
return DeltaType.NOT_APPLICABLE, "Aufhebung betrifft keine Ihrer bestehenden Pflichten.", Confidence.HIGH
|
||||
if not in_base:
|
||||
return DeltaType.NEW, "Neue Pflicht durch die Änderung — bisher nicht in Ihrer Map.", Confidence.MEDIUM
|
||||
if change_type == ChangeType.GUIDANCE_UPDATE:
|
||||
if has_ev:
|
||||
return (
|
||||
DeltaType.ALREADY_COVERED,
|
||||
"Bestehende Pflicht mit vorhandenen Nachweisen — Leitlinien-Update vermutlich abgedeckt.",
|
||||
Confidence.MEDIUM,
|
||||
)
|
||||
return DeltaType.NEEDS_REVIEW, "Bestehende Pflicht ohne Nachweis — Leitlinien-Update prüfen.", Confidence.MEDIUM
|
||||
return DeltaType.CHANGED, "Bestehende Pflicht inhaltlich geändert — Umsetzung und Nachweis prüfen.", Confidence.MEDIUM
|
||||
|
||||
|
||||
def assess_change(baseline: ComplianceBaseline, change: RegulatoryChange) -> ChangeAssessment:
|
||||
snap = baseline.regulatory_map_snapshot
|
||||
app_regs = {v.regulation_id for v in snap.applicable_regulations}
|
||||
unc_regs = {v.regulation_id for v in snap.uncertain_regulations}
|
||||
base_obs = set(baseline.applicable_obligations)
|
||||
|
||||
affected = set(change.affected_regulations)
|
||||
rel_app = bool(affected & app_regs)
|
||||
rel_unc = bool(affected & unc_regs)
|
||||
affects_product = rel_app or rel_unc
|
||||
|
||||
deltas: List[ObligationDelta] = []
|
||||
for ob in change.affected_obligations:
|
||||
present = baseline.evidence_refs.get(ob, [])
|
||||
required = baseline.obligation_evidence_required.get(ob, [])
|
||||
dt, reason, conf = _classify(ob in base_obs, bool(present), change.change_type, rel_app, rel_unc)
|
||||
missing = [e for e in required if e not in present] if dt in _ACTION else []
|
||||
deltas.append(
|
||||
ObligationDelta(
|
||||
obligation_id=ob,
|
||||
delta_type=dt,
|
||||
reason=reason,
|
||||
affected_evidence=list(present),
|
||||
missing_evidence=missing,
|
||||
confidence=conf,
|
||||
)
|
||||
)
|
||||
|
||||
return ChangeAssessment(
|
||||
change_id=change.change_id,
|
||||
affects_product=affects_product,
|
||||
deltas=deltas,
|
||||
summary=_summary(deltas, [d.domain for d in snap.unsupported_domains]),
|
||||
)
|
||||
|
||||
|
||||
def _ids(deltas: List[ObligationDelta], *types: DeltaType) -> List[str]:
|
||||
wanted = set(types)
|
||||
return [d.obligation_id for d in deltas if d.delta_type in wanted]
|
||||
|
||||
|
||||
def _summary(deltas: List[ObligationDelta], unsupported: List[str]) -> ChangeImpactSummary:
|
||||
n_new = len(_ids(deltas, DeltaType.NEW))
|
||||
n_changed = len(_ids(deltas, DeltaType.CHANGED))
|
||||
n_removed = len(_ids(deltas, DeltaType.REMOVED))
|
||||
n_covered = len(_ids(deltas, DeltaType.ALREADY_COVERED))
|
||||
n_review = len(_ids(deltas, DeltaType.NEEDS_REVIEW, DeltaType.CHANGED))
|
||||
n_na = len(_ids(deltas, DeltaType.NOT_APPLICABLE))
|
||||
return ChangeImpactSummary(
|
||||
what_changed=(
|
||||
"%d neu, %d geändert, %d entfällt, %d bereits abgedeckt, %d zu prüfen, %d nicht relevant."
|
||||
% (n_new, n_changed, n_removed, n_covered, n_review, n_na)
|
||||
),
|
||||
what_matters_for_this_product=_ids(deltas, *_ACTION),
|
||||
already_covered=_ids(deltas, DeltaType.ALREADY_COVERED),
|
||||
needs_review=_ids(deltas, DeltaType.NEEDS_REVIEW, DeltaType.CHANGED),
|
||||
not_relevant=_ids(deltas, DeltaType.NOT_APPLICABLE),
|
||||
unsupported_domains=unsupported,
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Regulatory Change Intelligence (RCI) — domain objects.
|
||||
|
||||
RCI is a read-/reasoning layer ON TOP of the product-first pipeline. It answers
|
||||
"what changes relative to my existing Regulatory Map?" — NOT "what does the new
|
||||
law say in general". A RegulatoryChange is simulated/provided INPUT (no ingestion,
|
||||
no newsletter/mailbox, no RAG); the delta is computed against a stored
|
||||
ComplianceBaseline (snapshot of the map).
|
||||
|
||||
`delta_type` is a THIRD vocabulary — distinct from `ClaimCoverage` (Welt 1, what
|
||||
the customer claims) and `ComplianceStatus` (Welt 2, verified evidence). The three
|
||||
must never be conflated. These are application/reasoning types, NOT
|
||||
compliance-meta-model classes (architecture freeze v1.0 untouched).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from compliance.profile.canonical import CanonicalProductRegulatoryProfile
|
||||
from compliance.reasoning.enums import AuthorityLevel, Confidence
|
||||
from compliance.regulatory_map.schemas import RegulatoryMap
|
||||
|
||||
|
||||
class DeltaType(str, Enum):
|
||||
NEW = "new" # obligation now applies that was not in the baseline
|
||||
CHANGED = "changed" # existing obligation substantively modified
|
||||
REMOVED = "removed" # obligation no longer applies (repeal)
|
||||
ALREADY_COVERED = "already_covered" # existing obligation, evidence likely suffices
|
||||
NEEDS_REVIEW = "needs_review" # a human must check
|
||||
NOT_APPLICABLE = "not_applicable" # change does not touch this product's map
|
||||
|
||||
|
||||
class ChangeType(str, Enum):
|
||||
NEW_REGULATION = "new_regulation"
|
||||
AMENDMENT = "amendment"
|
||||
REPEAL = "repeal"
|
||||
GUIDANCE_UPDATE = "guidance_update"
|
||||
|
||||
|
||||
# ── stored snapshot ──────────────────────────────────────────────────────
|
||||
class ComplianceBaseline(BaseModel):
|
||||
baseline_id: str
|
||||
product_profile_snapshot: CanonicalProductRegulatoryProfile
|
||||
regulatory_map_snapshot: RegulatoryMap
|
||||
applicable_obligations: List[str] = Field(default_factory=list) # registry-linked obligation_ids
|
||||
# required evidence per obligation (derived) — to compute missing_evidence
|
||||
obligation_evidence_required: Dict[str, List[str]] = Field(default_factory=dict)
|
||||
# evidence the customer ALREADY has, per obligation (provided)
|
||||
evidence_refs: Dict[str, List[str]] = Field(default_factory=dict)
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
# ── simulated/provided change (INPUT — never ingested) ───────────────────
|
||||
class RegulatoryChange(BaseModel):
|
||||
change_id: str
|
||||
source: str = "simulated"
|
||||
affected_regulations: List[str] = Field(default_factory=list)
|
||||
affected_obligations: List[str] = Field(default_factory=list)
|
||||
change_type: ChangeType
|
||||
effective_date: Optional[str] = None
|
||||
authority_level: AuthorityLevel = AuthorityLevel.LEGAL_TEXT
|
||||
summary: str = ""
|
||||
|
||||
|
||||
# ── per-obligation delta ─────────────────────────────────────────────────
|
||||
class ObligationDelta(BaseModel):
|
||||
obligation_id: str
|
||||
delta_type: DeltaType
|
||||
reason: str
|
||||
affected_evidence: List[str] = Field(default_factory=list) # evidence already present for it
|
||||
missing_evidence: List[str] = Field(default_factory=list) # required but not yet present
|
||||
confidence: Confidence
|
||||
|
||||
|
||||
# ── management-level summary ──────────────────────────────────────────────
|
||||
class ChangeImpactSummary(BaseModel):
|
||||
what_changed: str = ""
|
||||
what_matters_for_this_product: List[str] = Field(default_factory=list) # need action
|
||||
already_covered: List[str] = Field(default_factory=list)
|
||||
needs_review: List[str] = Field(default_factory=list)
|
||||
not_relevant: List[str] = Field(default_factory=list)
|
||||
unsupported_domains: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ChangeAssessment(BaseModel):
|
||||
change_id: str
|
||||
affects_product: bool
|
||||
deltas: List[ObligationDelta] = Field(default_factory=list)
|
||||
summary: ChangeImpactSummary
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Regulatory Reasoning Engine.
|
||||
|
||||
A deterministic reasoning layer ON TOP of the Legal Knowledge Graph (obligation
|
||||
registry) and the Compliance Execution Graph (control mapping / evidence). It
|
||||
answers, for a concrete product: which regulations apply, which obligations
|
||||
follow, whether the customer's implementation covers them, and whether a
|
||||
customer interpretation is legally sound.
|
||||
|
||||
No new RAG, no new controls, no DB schema changes — scope & reasoning metamodel
|
||||
only (spec §14).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .claim_normalizer import normalize_claim
|
||||
from .implementation_engine import reason_implementation_claim
|
||||
from .interpretation_engine import assess_interpretation
|
||||
from .obligation_engine import derive_obligations
|
||||
from .scope_engine import discover_scope
|
||||
|
||||
__all__ = [
|
||||
"discover_scope",
|
||||
"derive_obligations",
|
||||
"normalize_claim",
|
||||
"reason_implementation_claim",
|
||||
"assess_interpretation",
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Customer implementation claim normaliser (spec §4.6).
|
||||
|
||||
Turns a free-text statement ("Wir haben einen Update-Prozess.") into structured
|
||||
capabilities + related topics + weakness qualifiers. Deterministic substring
|
||||
matching — the claim_id is a stable hash so the same statement always maps to
|
||||
the same id (no randomness, replay-safe).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import List, Optional
|
||||
|
||||
from .schemas import CustomerImplementationClaim
|
||||
from .taxonomy_claims import match_capabilities, match_qualifiers, topics_for
|
||||
|
||||
|
||||
def _claim_id(raw_statement: str) -> str:
|
||||
digest = hashlib.sha1(raw_statement.strip().lower().encode("utf-8")).hexdigest()
|
||||
return "claim_%s" % digest[:10]
|
||||
|
||||
|
||||
def _normalized(capabilities: List[str], qualifiers: List[str]) -> str:
|
||||
if not capabilities:
|
||||
return "Keine bekannte Compliance-Fähigkeit aus der Aussage ableitbar."
|
||||
text = "Fähigkeiten: " + ", ".join(capabilities)
|
||||
if qualifiers:
|
||||
text += " | Einschränkungen: " + ", ".join(qualifiers)
|
||||
return text
|
||||
|
||||
|
||||
def normalize_claim(
|
||||
raw_statement: str, claim_id: Optional[str] = None, evidence_refs: Optional[List[str]] = None
|
||||
) -> CustomerImplementationClaim:
|
||||
capabilities = match_capabilities(raw_statement)
|
||||
qualifiers = match_qualifiers(raw_statement)
|
||||
return CustomerImplementationClaim(
|
||||
claim_id=claim_id or _claim_id(raw_statement),
|
||||
raw_statement=raw_statement,
|
||||
normalized_claim=_normalized(capabilities, qualifiers),
|
||||
claimed_capability=capabilities,
|
||||
related_topics=topics_for(capabilities),
|
||||
qualifiers=qualifiers,
|
||||
evidence_refs=evidence_refs or [],
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Enumerations for the Regulatory Reasoning Engine.
|
||||
|
||||
Kept dependency-free and Python 3.9 compatible (str-Enums, no `|` unions).
|
||||
The reasoning layer sits ON TOP of the Legal Knowledge Graph (obligation
|
||||
registry) and the Compliance Execution Graph (control mapping / evidence).
|
||||
See memory `project_compliance_graph.md` for the cross-session contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ManufacturerRole(str, Enum):
|
||||
MANUFACTURER = "manufacturer"
|
||||
IMPORTER = "importer"
|
||||
DISTRIBUTOR = "distributor"
|
||||
INTEGRATOR = "integrator"
|
||||
OPERATOR = "operator"
|
||||
SERVICE_PROVIDER = "service_provider"
|
||||
|
||||
|
||||
class ProductLifecyclePhase(str, Enum):
|
||||
DEVELOPMENT = "development"
|
||||
PLACING_ON_MARKET = "placing_on_market"
|
||||
OPERATION = "operation"
|
||||
MAINTENANCE = "maintenance"
|
||||
UPDATE = "update"
|
||||
END_OF_LIFE = "end_of_life"
|
||||
|
||||
|
||||
class MarketModel(str, Enum):
|
||||
B2B = "b2b"
|
||||
B2C = "b2c"
|
||||
BOTH = "both"
|
||||
|
||||
|
||||
class ApplicabilityStatus(str, Enum):
|
||||
APPLICABLE = "applicable"
|
||||
PARTIALLY_APPLICABLE = "partially_applicable"
|
||||
UNCERTAIN = "uncertain"
|
||||
NOT_APPLICABLE = "not_applicable"
|
||||
|
||||
|
||||
class Confidence(str, Enum):
|
||||
HIGH = "high"
|
||||
MEDIUM = "medium"
|
||||
LOW = "low"
|
||||
|
||||
|
||||
class AuthorityLevel(str, Enum):
|
||||
"""How binding a statement is — answers MUST visibly separate these."""
|
||||
|
||||
LEGAL_TEXT = "legal_text"
|
||||
RECITAL = "recital"
|
||||
GUIDANCE = "guidance"
|
||||
HARMONIZED_STANDARD = "harmonized_standard"
|
||||
TECHNICAL_STANDARD = "technical_standard"
|
||||
BEST_PRACTICE = "best_practice"
|
||||
INTERNAL_INTERPRETATION = "internal_interpretation"
|
||||
|
||||
|
||||
class OverlapType(str, Enum):
|
||||
IDENTICAL = "identical"
|
||||
SIMILAR = "similar"
|
||||
COMPLEMENTARY = "complementary"
|
||||
CONFLICTING = "conflicting"
|
||||
DIFFERENT_SCOPE = "different_scope"
|
||||
|
||||
|
||||
class ClaimCoverage(str, Enum):
|
||||
"""How a customer's *claim* relates to an obligation — Welt 1 (reasoning).
|
||||
|
||||
This is NOT a conformity verdict. It judges only the customer's statement,
|
||||
never whether the obligation is actually met. The real compliance verdict
|
||||
(erfüllt/offen/unklar from verified evidence) is `ComplianceStatus`, owned by
|
||||
the Compliance Execution Graph — the two must never be conflated.
|
||||
"""
|
||||
|
||||
POTENTIALLY_ADDRESSES = "potentially_addresses"
|
||||
PARTIALLY_ADDRESSES = "partially_addresses"
|
||||
DOES_NOT_ADDRESS = "does_not_address"
|
||||
INSUFFICIENT_INFORMATION = "insufficient_information"
|
||||
|
||||
|
||||
class InterpretationVerdict(str, Enum):
|
||||
PLAUSIBLE = "plausible"
|
||||
TOO_NARROW = "too_narrow"
|
||||
TOO_BROAD = "too_broad"
|
||||
PARTIALLY_CORRECT = "partially_correct"
|
||||
UNSUPPORTED = "unsupported"
|
||||
UNCERTAIN = "uncertain"
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Implementation reasoning (spec Modus 3) — Welt 1 only.
|
||||
|
||||
Maps a free-text claim ("Wir haben SBOMs und machen Updates, wenn Kunden Fehler
|
||||
melden.") onto the product's applicable obligations and reports, per obligation,
|
||||
whether the *claim* potentially/partially/does-not address it — plus the
|
||||
evidence that WOULD be needed to prove real implementation.
|
||||
|
||||
This is NOT a conformity verdict. It judges the customer's statement, never
|
||||
whether the obligation is met. The real verdict (ComplianceStatus: erfüllt/
|
||||
offen/unklar from verified evidence) lives in the Compliance Execution Graph.
|
||||
The four reasoning layers: claim -> interpretation (capabilities/topics on the
|
||||
claim) -> potential obligation coverage (`claim_coverage`) -> evidence required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from .claim_normalizer import normalize_claim
|
||||
from .enums import ClaimCoverage, Confidence
|
||||
from .obligation_engine import derive_obligations
|
||||
from .schemas import (
|
||||
ClaimObligationMapping,
|
||||
CustomerImplementationClaim,
|
||||
ImplementationReasoningResponse,
|
||||
ProductProfile,
|
||||
)
|
||||
from .taxonomy_claims import topics_for
|
||||
|
||||
DISCLAIMER = (
|
||||
"Diese Auswertung interpretiert ausschließlich die Kundenaussage (ClaimCoverage, Welt 1). "
|
||||
"Sie ist KEINE Konformitätsaussage — der tatsächliche Compliance-Status (ComplianceStatus, "
|
||||
"Welt 2) ergibt sich erst aus geprüften Nachweisen im Compliance Execution Graph."
|
||||
)
|
||||
|
||||
# Typical sub-elements a capability still misses when only partially claimed.
|
||||
STANDARD_GAPS: Dict[str, List[str]] = {
|
||||
"software_bill_of_materials": [
|
||||
"Vulnerability-Monitoring der Komponenten",
|
||||
"Bewertung betroffener Komponenten",
|
||||
"Lieferantenprozess",
|
||||
],
|
||||
"secure_updates": [
|
||||
"aktive Schwachstellenüberwachung",
|
||||
"Patch-Bewertung",
|
||||
"Fristen und Verantwortlichkeiten",
|
||||
"Nachweis der Updatefähigkeit",
|
||||
],
|
||||
"vulnerability_management": [
|
||||
"definierter Vulnerability-Handling-Prozess",
|
||||
"Priorisierung und Fristen",
|
||||
],
|
||||
"authentication": ["MFA für privilegierte Zugänge", "keine Standard-Zugangsdaten"],
|
||||
"security_logging": ["Schutz der Logs vor Manipulation", "Monitoring/Alerting"],
|
||||
"software_integrity": ["Signierung der Updates", "Verifikation der Update-Signatur"],
|
||||
"secure_by_default": ["Härtung der Auslieferungskonfiguration", "Minimierung der Angriffsfläche"],
|
||||
"secure_communication": ["verschlüsselte Übertragung", "Integritätsschutz der Verbindung"],
|
||||
"risk_assessment": ["dokumentierte Risikobewertung", "Aufnahme in die technische Doku"],
|
||||
"technical_documentation": ["vollständige technische Unterlagen", "Aktualisierung über den Lebenszyklus"],
|
||||
}
|
||||
|
||||
|
||||
def _missing_for(capabilities: List[str]) -> List[str]:
|
||||
out: List[str] = []
|
||||
for cap in capabilities:
|
||||
for gap in STANDARD_GAPS.get(cap, []):
|
||||
if gap not in out:
|
||||
out.append(gap)
|
||||
return out
|
||||
|
||||
|
||||
def _coverage(required: List[str], claimed: List[str], qualifiers: List[str]) -> ClaimCoverage:
|
||||
if not required:
|
||||
return ClaimCoverage.INSUFFICIENT_INFORMATION
|
||||
req, have = set(required), set(claimed)
|
||||
hit = req & have
|
||||
if not hit:
|
||||
return ClaimCoverage.DOES_NOT_ADDRESS
|
||||
if "absent" in qualifiers or "planned" in qualifiers:
|
||||
return ClaimCoverage.DOES_NOT_ADDRESS
|
||||
if "reactive" in qualifiers and hit & {"secure_updates", "vulnerability_management"}:
|
||||
return ClaimCoverage.PARTIALLY_ADDRESSES
|
||||
if req <= have:
|
||||
return ClaimCoverage.POTENTIALLY_ADDRESSES
|
||||
return ClaimCoverage.PARTIALLY_ADDRESSES
|
||||
|
||||
|
||||
def reason_implementation_claim(
|
||||
profile: ProductProfile, customer_claim: str
|
||||
) -> ImplementationReasoningResponse:
|
||||
claim = normalize_claim(customer_claim)
|
||||
obligations = derive_obligations(profile).applicable_obligations
|
||||
claimed = claim.claimed_capability
|
||||
claim_topics = set(claim.related_topics) | set(claimed)
|
||||
|
||||
mappings: List[ClaimObligationMapping] = []
|
||||
missing_evidence: List[str] = []
|
||||
|
||||
for ob in obligations:
|
||||
from .rules_obligations import obligation_rule
|
||||
|
||||
rule = obligation_rule(ob.obligation_id)
|
||||
required_caps = rule.required_capabilities if rule else []
|
||||
ob_topics = set(topics_for(required_caps)) | set(required_caps)
|
||||
directly_claimed = bool(set(required_caps) & set(claimed))
|
||||
related = bool(ob_topics & claim_topics)
|
||||
if not directly_claimed and not related:
|
||||
continue # unrelated to the claim -> don't reason about it
|
||||
|
||||
coverage = _coverage(required_caps, claimed, claim.qualifiers)
|
||||
missing = [] if coverage == ClaimCoverage.POTENTIALLY_ADDRESSES else _missing_for(required_caps)
|
||||
if coverage != ClaimCoverage.POTENTIALLY_ADDRESSES:
|
||||
for ev in ob.required_evidence:
|
||||
if ev not in missing_evidence:
|
||||
missing_evidence.append(ev)
|
||||
mappings.append(
|
||||
ClaimObligationMapping(
|
||||
claim_id=claim.claim_id,
|
||||
obligation_id=ob.obligation_id,
|
||||
claim_coverage=coverage,
|
||||
missing_elements=missing,
|
||||
required_evidence=ob.required_evidence,
|
||||
explanation=_explain(coverage, ob.title, claim.qualifiers),
|
||||
confidence=Confidence.MEDIUM,
|
||||
)
|
||||
)
|
||||
|
||||
return ImplementationReasoningResponse(
|
||||
claim=claim,
|
||||
mappings=mappings,
|
||||
missing_evidence=missing_evidence,
|
||||
summary=_summary(claim, mappings),
|
||||
disclaimer=DISCLAIMER,
|
||||
)
|
||||
|
||||
|
||||
def _explain(coverage: ClaimCoverage, title: str, qualifiers: List[str]) -> str:
|
||||
if coverage == ClaimCoverage.POTENTIALLY_ADDRESSES:
|
||||
return "Die Aussage adressiert die Pflicht '%s' direkt — Nachweise erforderlich für eine Bewertung der Umsetzung." % title
|
||||
if coverage == ClaimCoverage.PARTIALLY_ADDRESSES:
|
||||
extra = " Der beschriebene Prozess wirkt reaktiv." if "reactive" in qualifiers else ""
|
||||
return "Die Aussage adressiert die Pflicht '%s' nur teilweise.%s" % (title, extra)
|
||||
if coverage == ClaimCoverage.DOES_NOT_ADDRESS:
|
||||
return "Die Aussage adressiert die Pflicht '%s' nicht." % title
|
||||
return "Zur Pflicht '%s' liegen zu wenige Angaben für eine Einordnung vor." % title
|
||||
|
||||
|
||||
def _summary(claim: CustomerImplementationClaim, mappings: List[ClaimObligationMapping]) -> str:
|
||||
if not claim.claimed_capability:
|
||||
return "Die Aussage ist zu unspezifisch — bitte konkretisieren, was umgesetzt wurde."
|
||||
full = sum(1 for m in mappings if m.claim_coverage == ClaimCoverage.POTENTIALLY_ADDRESSES)
|
||||
partial = sum(1 for m in mappings if m.claim_coverage == ClaimCoverage.PARTIALLY_ADDRESSES)
|
||||
none = sum(1 for m in mappings if m.claim_coverage == ClaimCoverage.DOES_NOT_ADDRESS)
|
||||
return (
|
||||
"Die beschriebene Maßnahme adressiert wahrscheinlich %d Pflicht(en) direkt und %d "
|
||||
"teilweise; %d werden durch die Aussage nicht berührt. Für eine Bewertung der tatsächlichen "
|
||||
"Umsetzung sind Nachweise erforderlich. Dies ist keine Konformitätsaussage." % (full, partial, none)
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Interpretation review engine (spec Modus 4).
|
||||
|
||||
Evaluates whether a customer's legal interpretation is plausible, too narrow,
|
||||
too broad, etc. Matches the interpretation against a curated pattern library;
|
||||
no match -> `uncertain` plus a request for the missing context (never invent a
|
||||
verdict, spec §6.3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
|
||||
from .enums import Confidence, InterpretationVerdict
|
||||
from .schemas import InterpretationAssessment, ProductProfile
|
||||
from .taxonomy_interpretations import INTERPRETATION_PATTERNS, InterpretationPattern
|
||||
|
||||
|
||||
def _interpretation_id(raw: str) -> str:
|
||||
digest = hashlib.sha1(raw.strip().lower().encode("utf-8")).hexdigest()
|
||||
return "interp_%s" % digest[:10]
|
||||
|
||||
|
||||
def _best_match(text: str) -> Optional[InterpretationPattern]:
|
||||
low = text.lower()
|
||||
best: Optional[InterpretationPattern] = None
|
||||
best_score = 0
|
||||
for pattern in INTERPRETATION_PATTERNS:
|
||||
score = sum(1 for t in pattern.triggers if t in low)
|
||||
if score > best_score:
|
||||
best, best_score = pattern, score
|
||||
return best
|
||||
|
||||
|
||||
def assess_interpretation(
|
||||
raw_interpretation: str, profile: Optional[ProductProfile] = None
|
||||
) -> InterpretationAssessment:
|
||||
interp_id = _interpretation_id(raw_interpretation)
|
||||
pattern = _best_match(raw_interpretation)
|
||||
|
||||
if pattern is None:
|
||||
return InterpretationAssessment(
|
||||
interpretation_id=interp_id,
|
||||
raw_interpretation=raw_interpretation,
|
||||
assessment=InterpretationVerdict.UNCERTAIN,
|
||||
corrected_interpretation=(
|
||||
"Diese Auslegung lässt sich ohne weitere Angaben nicht bewerten. Bitte Produkt, "
|
||||
"Rolle, Marktzugang und die konkret betroffene Pflicht benennen."
|
||||
),
|
||||
explanation="Kein bekanntes Auslegungsmuster erkannt — bewusst keine Scheinsicherheit.",
|
||||
confidence=Confidence.LOW,
|
||||
)
|
||||
|
||||
return InterpretationAssessment(
|
||||
interpretation_id=interp_id,
|
||||
raw_interpretation=raw_interpretation,
|
||||
affected_regulations=pattern.affected_regulations,
|
||||
affected_obligations=pattern.affected_obligations,
|
||||
assessment=pattern.verdict,
|
||||
risks=pattern.risks,
|
||||
corrected_interpretation=pattern.corrected_interpretation,
|
||||
legal_basis_refs=pattern.legal_basis_refs,
|
||||
explanation=pattern.explanation,
|
||||
confidence=pattern.confidence,
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Applicable-obligation engine (spec Modus 2).
|
||||
|
||||
Maps a product profile (optionally a precomputed scope) to the concrete legal
|
||||
obligations, the overlaps between them, and which evidence types satisfy more
|
||||
than one obligation at once (the core USP, spec §16).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .predicates import evaluate, true_leaves
|
||||
from .rules_obligations import ALL_OBLIGATIONS
|
||||
from .rules_overlaps import OVERLAP_GROUPS
|
||||
from .rules_regulations import FIELD_LABELS
|
||||
from .rules_types import ObligationRule
|
||||
from .schemas import (
|
||||
ApplicableObligation,
|
||||
ObligationOverlap,
|
||||
ObligationsResponse,
|
||||
ProductProfile,
|
||||
RegulatoryScope,
|
||||
)
|
||||
from .scope_engine import discover_scope
|
||||
|
||||
|
||||
def _applicable_regulation_ids(profile: ProductProfile, scope: Optional[RegulatoryScope]) -> List[str]:
|
||||
if scope is None:
|
||||
scope = discover_scope(profile)
|
||||
return [r.regulation_id for r in scope.applicable_regulations]
|
||||
|
||||
|
||||
def _applies_because(rule: ObligationRule, profile: ProductProfile) -> List[str]:
|
||||
labels: List[str] = []
|
||||
for leaf in true_leaves(rule.applies_if, profile):
|
||||
label = FIELD_LABELS.get(leaf[0])
|
||||
if label and label not in labels:
|
||||
labels.append(label)
|
||||
if not labels:
|
||||
labels.append("%s ist für dieses Produkt anwendbar." % rule.source_regulation)
|
||||
return labels
|
||||
|
||||
|
||||
def _role_ok(rule: ObligationRule, profile: ProductProfile) -> bool:
|
||||
role = profile.manufacturer_role
|
||||
if role is None:
|
||||
return True # unknown role -> do not exclude
|
||||
return role.value in rule.applies_to_role
|
||||
|
||||
|
||||
def derive_obligations(
|
||||
profile: ProductProfile, scope: Optional[RegulatoryScope] = None
|
||||
) -> ObligationsResponse:
|
||||
active_regs = set(_applicable_regulation_ids(profile, scope))
|
||||
response = ObligationsResponse()
|
||||
applied_ids: List[str] = []
|
||||
|
||||
for rule in ALL_OBLIGATIONS:
|
||||
if rule.source_regulation not in active_regs:
|
||||
continue
|
||||
if rule.applies_unless is not None and evaluate(rule.applies_unless, profile) is True:
|
||||
continue
|
||||
verdict = evaluate(rule.applies_if, profile)
|
||||
if verdict is not True or not _role_ok(rule, profile):
|
||||
if verdict is False:
|
||||
response.excluded_obligations.append(rule.obligation_id)
|
||||
continue
|
||||
applied_ids.append(rule.obligation_id)
|
||||
response.applicable_obligations.append(
|
||||
ApplicableObligation(
|
||||
obligation_id=rule.obligation_id,
|
||||
title=rule.title,
|
||||
source_regulation=rule.source_regulation,
|
||||
legal_basis_refs=rule.legal_basis_refs,
|
||||
obligation_text=rule.obligation_text,
|
||||
authority_level=rule.authority_level,
|
||||
applies_because=_applies_because(rule, profile),
|
||||
applies_to_role=rule.applies_to_role,
|
||||
lifecycle_phase=rule.lifecycle_phase,
|
||||
overlap_group_id=rule.overlap_group_id,
|
||||
required_evidence=rule.required_evidence,
|
||||
confidence=rule.base_confidence,
|
||||
registry_anchor=rule.registry_anchor,
|
||||
proposed=rule.proposed,
|
||||
)
|
||||
)
|
||||
|
||||
response.overlaps = _overlaps(applied_ids)
|
||||
response.evidence_for_multiple = _evidence_for_multiple(response.applicable_obligations)
|
||||
return response
|
||||
|
||||
|
||||
def _overlaps(applied_ids: List[str]) -> List[ObligationOverlap]:
|
||||
applied = set(applied_ids)
|
||||
out: List[ObligationOverlap] = []
|
||||
for group in OVERLAP_GROUPS:
|
||||
present = [m for m in group.members if m in applied]
|
||||
if len(present) >= 2:
|
||||
out.append(
|
||||
ObligationOverlap(
|
||||
overlap_group_id=group.overlap_group_id,
|
||||
obligations=present,
|
||||
overlap_type=group.overlap_type,
|
||||
canonical_obligation_id=group.canonical_obligation_id,
|
||||
explanation=group.explanation,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _evidence_for_multiple(obligations: List[ApplicableObligation]) -> Dict[str, List[str]]:
|
||||
by_evidence: Dict[str, List[str]] = {}
|
||||
for ob in obligations:
|
||||
for ev in ob.required_evidence:
|
||||
by_evidence.setdefault(ev, []).append(ob.obligation_id)
|
||||
return {ev: ids for ev, ids in by_evidence.items() if len(ids) > 1}
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Safe, tri-state condition evaluator for applicability rules.
|
||||
|
||||
Conditions are plain data (no `eval`): a *leaf* is a 3-tuple
|
||||
``(field, op, value)``; a *composite* is ``{"all": [...]}`` or
|
||||
``{"any": [...]}``. Evaluation is tri-state — ``True`` / ``False`` /
|
||||
``None`` (unknown) — so a missing product fact yields *uncertain*, never a
|
||||
false negative.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
Leaf = Tuple[str, str, Any]
|
||||
Condition = Union[Leaf, Dict[str, Any]]
|
||||
|
||||
|
||||
def _attr(profile: Any, field: str) -> Any:
|
||||
value = getattr(profile, field, None)
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
return value
|
||||
|
||||
|
||||
def _eval_leaf(leaf: Leaf, profile: Any) -> Optional[bool]:
|
||||
field, op, expected = leaf
|
||||
actual = _attr(profile, field)
|
||||
|
||||
if op == "not_none":
|
||||
return actual is not None
|
||||
if op == "is_none":
|
||||
return actual is None
|
||||
|
||||
if op == "contains_any":
|
||||
# list-valued field (e.g. product_type); empty list = known-empty.
|
||||
items = actual or []
|
||||
hay = " ".join(str(x).lower() for x in items)
|
||||
return any(str(k).lower() in hay for k in expected)
|
||||
|
||||
if actual is None:
|
||||
return None # unknown fact -> unknown result
|
||||
|
||||
if op == "eq":
|
||||
return bool(actual == expected)
|
||||
if op == "ne":
|
||||
return bool(actual != expected)
|
||||
if op == "truthy":
|
||||
return bool(actual)
|
||||
if op == "falsy":
|
||||
return not bool(actual)
|
||||
if op == "in":
|
||||
return bool(actual in expected)
|
||||
if op == "not_in":
|
||||
return bool(actual not in expected)
|
||||
if op == "date_after":
|
||||
return bool(actual > expected)
|
||||
raise ValueError("unknown predicate op: %r" % (op,))
|
||||
|
||||
|
||||
def evaluate(condition: Optional[Condition], profile: Any) -> Optional[bool]:
|
||||
"""Return True/False/None(unknown) for a condition tree."""
|
||||
if condition is None:
|
||||
return True
|
||||
if isinstance(condition, tuple):
|
||||
return _eval_leaf(condition, profile)
|
||||
|
||||
if "all" in condition:
|
||||
results = [evaluate(c, profile) for c in condition["all"]]
|
||||
if any(r is False for r in results):
|
||||
return False
|
||||
if any(r is None for r in results):
|
||||
return None
|
||||
return True
|
||||
if "any" in condition:
|
||||
results = [evaluate(c, profile) for c in condition["any"]]
|
||||
if any(r is True for r in results):
|
||||
return True
|
||||
if any(r is None for r in results):
|
||||
return None
|
||||
return False
|
||||
raise ValueError("malformed condition: %r" % (condition,))
|
||||
|
||||
|
||||
def true_leaves(condition: Optional[Condition], profile: Any) -> List[Leaf]:
|
||||
"""Collect the leaf conditions that evaluated True (for trigger_facts)."""
|
||||
if condition is None:
|
||||
return []
|
||||
if isinstance(condition, tuple):
|
||||
return [condition] if _eval_leaf(condition, profile) is True else []
|
||||
members = condition.get("all") or condition.get("any") or []
|
||||
out: List[Leaf] = []
|
||||
for c in members:
|
||||
out.extend(true_leaves(c, profile))
|
||||
return out
|
||||
|
||||
|
||||
def unknown_fields(fields: List[str], profile: Any) -> List[str]:
|
||||
"""Subset of `fields` whose value on the profile is None (unknown)."""
|
||||
return [f for f in fields if _attr(profile, f) is None]
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Aggregated obligation scope rules + lookup helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .rules_obligations_cra import CRA_OBLIGATIONS
|
||||
from .rules_obligations_machine_data import DATA_ACT_OBLIGATIONS, MACHINE_OBLIGATIONS
|
||||
from .rules_types import ObligationRule
|
||||
|
||||
ALL_OBLIGATIONS: List[ObligationRule] = (
|
||||
CRA_OBLIGATIONS + MACHINE_OBLIGATIONS + DATA_ACT_OBLIGATIONS
|
||||
)
|
||||
|
||||
_BY_ID: Dict[str, ObligationRule] = {o.obligation_id: o for o in ALL_OBLIGATIONS}
|
||||
|
||||
|
||||
def obligation_rule(obligation_id: str) -> Optional[ObligationRule]:
|
||||
return _BY_ID.get(obligation_id)
|
||||
|
||||
|
||||
def obligations_for_regulation(regulation_id: str) -> List[ObligationRule]:
|
||||
return [o for o in ALL_OBLIGATIONS if o.source_regulation == regulation_id]
|
||||
@@ -0,0 +1,271 @@
|
||||
"""CRA obligation scope rules.
|
||||
|
||||
`obligation_id`s in the six CRA-P1 families (sbom/vuln/authentication/logging/
|
||||
remote_access/updates) are RE-USED verbatim from the Legal-KG registry
|
||||
(`obligations/obligation_join_keys.json`) — never re-minted (control_uuid trap,
|
||||
memory `project_compliance_graph.md`). Cross-cutting CRA *process* obligations
|
||||
(risk assessment, technical documentation, CE, instructions, secure-by-design
|
||||
umbrella) are not yet in the registry and are flagged `proposed=True`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from .enums import AuthorityLevel, Confidence
|
||||
from .rules_types import ObligationRule
|
||||
|
||||
_HAS_SW = ("has_software", "eq", True)
|
||||
_EU = ("eu_market", "eq", True)
|
||||
_REMOTE_OR_CLOUD = {"any": [("has_remote_access", "eq", True), ("has_cloud_connection", "eq", True)]}
|
||||
_LM = AuthorityLevel.LEGAL_TEXT
|
||||
|
||||
CRA_OBLIGATIONS: List[ObligationRule] = [
|
||||
ObligationRule(
|
||||
obligation_id="sbom_creation",
|
||||
title="Software Bill of Materials erstellen",
|
||||
source_regulation="CRA",
|
||||
obligation_text="Eine SBOM erstellen, die mindestens die obersten Abhängigkeiten des Produkts dokumentiert.",
|
||||
legal_basis_refs=["CRA Annex I Part II (1)"],
|
||||
authority_level=_LM,
|
||||
family="sbom",
|
||||
applies_if={"all": [_HAS_SW, _EU]},
|
||||
required_capabilities=["software_bill_of_materials"],
|
||||
required_evidence=["sbom", "repo_scan"],
|
||||
lifecycle_phase=["development", "placing_on_market", "maintenance"],
|
||||
registry_anchor=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="provide_security_updates",
|
||||
title="Sicherheitsupdates bereitstellen",
|
||||
source_regulation="CRA",
|
||||
obligation_text="Sicherheitsrelevante Updates zeitnah und über den Supportzeitraum bereitstellen.",
|
||||
legal_basis_refs=["CRA Annex I (2)(c)", "CRA Art. 13"],
|
||||
authority_level=_LM,
|
||||
family="updates",
|
||||
applies_if={"all": [_HAS_SW, _EU]},
|
||||
required_capabilities=["secure_updates"],
|
||||
required_evidence=["policy", "ticket", "test_report"],
|
||||
lifecycle_phase=["maintenance", "update"],
|
||||
overlap_group_id="SECURITY_UPDATES",
|
||||
registry_anchor=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="support_period_maintenance",
|
||||
title="Supportzeitraum definieren und einhalten",
|
||||
source_regulation="CRA",
|
||||
obligation_text="Einen angemessenen Supportzeitraum festlegen, in dem Schwachstellen behandelt werden.",
|
||||
legal_basis_refs=["CRA Art. 13(8)"],
|
||||
authority_level=_LM,
|
||||
family="updates",
|
||||
applies_if={"all": [_HAS_SW, _EU]},
|
||||
required_capabilities=["secure_updates"],
|
||||
required_evidence=["policy"],
|
||||
lifecycle_phase=["placing_on_market", "maintenance", "update"],
|
||||
registry_anchor=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="signed_update_integrity",
|
||||
title="Integrität von Updates sicherstellen",
|
||||
source_regulation="CRA",
|
||||
obligation_text="Updates signieren und ihre Integrität bei der Verteilung verifizieren.",
|
||||
legal_basis_refs=["CRA Annex I (1)(3)(f)"],
|
||||
authority_level=_LM,
|
||||
family="updates",
|
||||
applies_if={"all": [_HAS_SW, _EU]},
|
||||
required_capabilities=["software_integrity"],
|
||||
required_evidence=["config_export", "test_report"],
|
||||
lifecycle_phase=["development", "maintenance", "update"],
|
||||
overlap_group_id="SECURITY_UPDATES",
|
||||
registry_anchor=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="vuln_handling_process",
|
||||
title="Schwachstellenbehandlungs-Prozess",
|
||||
source_regulation="CRA",
|
||||
obligation_text="Einen dokumentierten Prozess zur Identifikation, Bewertung und Behebung von Schwachstellen betreiben.",
|
||||
legal_basis_refs=["CRA Art. 13(8)", "CRA Annex VII"],
|
||||
authority_level=_LM,
|
||||
family="vuln",
|
||||
applies_if={"all": [_HAS_SW, _EU]},
|
||||
required_capabilities=["vulnerability_management"],
|
||||
required_evidence=["policy", "ticket"],
|
||||
lifecycle_phase=["development", "operation", "maintenance"],
|
||||
overlap_group_id="VULNERABILITY_HANDLING",
|
||||
registry_anchor=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="coordinated_vulnerability_disclosure",
|
||||
title="Coordinated Vulnerability Disclosure",
|
||||
source_regulation="CRA",
|
||||
obligation_text="Eine Richtlinie zur koordinierten Offenlegung von Schwachstellen bereitstellen.",
|
||||
legal_basis_refs=["CRA Annex I Part II (5)"],
|
||||
authority_level=_LM,
|
||||
family="vuln",
|
||||
applies_if={"all": [_HAS_SW, _EU]},
|
||||
required_capabilities=["coordinated_disclosure"],
|
||||
required_evidence=["policy"],
|
||||
lifecycle_phase=["operation", "maintenance"],
|
||||
overlap_group_id="VULNERABILITY_HANDLING",
|
||||
registry_anchor=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="exploited_vuln_reporting_authorities",
|
||||
title="Meldung aktiv ausgenutzter Schwachstellen / Vorfälle",
|
||||
source_regulation="CRA",
|
||||
obligation_text="Aktiv ausgenutzte Schwachstellen und schwerwiegende Vorfälle an die zuständigen Behörden melden.",
|
||||
legal_basis_refs=["CRA Art. 14", "CRA Art. 16"],
|
||||
authority_level=_LM,
|
||||
family="vuln",
|
||||
applies_if={"all": [_HAS_SW, _EU]},
|
||||
required_capabilities=["incident_reporting"],
|
||||
required_evidence=["policy", "ticket"],
|
||||
lifecycle_phase=["operation", "maintenance"],
|
||||
registry_anchor=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="user_authentication_required",
|
||||
title="Authentifizierung vorsehen",
|
||||
source_regulation="CRA",
|
||||
obligation_text="Den Zugang über einen geeigneten Authentifizierungsmechanismus schützen.",
|
||||
legal_basis_refs=["CRA Annex I (2)(d)"],
|
||||
authority_level=_LM,
|
||||
family="authentication",
|
||||
applies_if={"all": [_HAS_SW, _EU]},
|
||||
required_capabilities=["authentication"],
|
||||
required_evidence=["config_export", "pentest"],
|
||||
lifecycle_phase=["development", "operation"],
|
||||
registry_anchor=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="no_default_credentials",
|
||||
title="Keine unveränderlichen Standard-Zugangsdaten",
|
||||
source_regulation="CRA",
|
||||
obligation_text="Sichere Standardkonfiguration; keine fest hinterlegten oder unveränderlichen Standard-Passwörter.",
|
||||
legal_basis_refs=["CRA Annex I (2)(a)", "CRA Annex I (2)(b)"],
|
||||
authority_level=_LM,
|
||||
family="authentication",
|
||||
applies_if={"all": [_HAS_SW, _EU]},
|
||||
required_capabilities=["secure_by_default"],
|
||||
required_evidence=["config_export", "test_report"],
|
||||
lifecycle_phase=["development", "placing_on_market"],
|
||||
registry_anchor=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="event_logging_security_events",
|
||||
title="Sicherheitsrelevante Ereignisse protokollieren",
|
||||
source_regulation="CRA",
|
||||
obligation_text="Sicherheitsrelevante Ereignisse und Zugriffe aufzeichnen, um Vorfälle nachvollziehen zu können.",
|
||||
legal_basis_refs=["CRA Annex I Part I (2)(k)"],
|
||||
authority_level=_LM,
|
||||
family="logging",
|
||||
applies_if={"all": [_HAS_SW, _EU]},
|
||||
required_capabilities=["security_logging"],
|
||||
required_evidence=["config_export", "audit_log"],
|
||||
lifecycle_phase=["operation", "maintenance"],
|
||||
registry_anchor=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="remote_access_attack_surface_min",
|
||||
title="Angriffsfläche minimieren",
|
||||
source_regulation="CRA",
|
||||
obligation_text="Die Angriffsfläche begrenzen, insbesondere exponierte Remote-/Cloud-Schnittstellen.",
|
||||
legal_basis_refs=["CRA Annex I (1)(2)(a)"],
|
||||
authority_level=_LM,
|
||||
family="remote_access",
|
||||
applies_if={"all": [_REMOTE_OR_CLOUD, _EU]},
|
||||
required_capabilities=["secure_by_default"],
|
||||
required_evidence=["config_export", "repo_scan", "pentest"],
|
||||
lifecycle_phase=["development", "operation"],
|
||||
registry_anchor=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="remote_access_confidentiality_integrity",
|
||||
title="Vertraulichkeit/Integrität der Fernverbindung",
|
||||
source_regulation="CRA",
|
||||
obligation_text="Daten bei Fernzugriff/Cloud-Anbindung verschlüsselt und integritätsgeschützt übertragen.",
|
||||
legal_basis_refs=["CRA Annex I (1)(2)(b)", "CRA Annex I (1)(2)(c)"],
|
||||
authority_level=_LM,
|
||||
family="remote_access",
|
||||
applies_if={"all": [_REMOTE_OR_CLOUD, _EU]},
|
||||
required_capabilities=["secure_communication"],
|
||||
required_evidence=["config_export", "pentest"],
|
||||
lifecycle_phase=["operation"],
|
||||
registry_anchor=True,
|
||||
),
|
||||
# --- Cross-cutting CRA process obligations (not yet in registry) ---------
|
||||
ObligationRule(
|
||||
obligation_id="cra_secure_by_design",
|
||||
title="Security by Design",
|
||||
source_regulation="CRA",
|
||||
obligation_text="Das Produkt so entwerfen, entwickeln und herstellen, dass ein angemessenes Cybersicherheitsniveau gewährleistet ist.",
|
||||
legal_basis_refs=["CRA Annex I Part I (1)"],
|
||||
authority_level=_LM,
|
||||
family="cra_process",
|
||||
applies_if={"all": [_HAS_SW, _EU]},
|
||||
required_capabilities=["secure_by_default", "risk_assessment"],
|
||||
required_evidence=["policy", "test_report"],
|
||||
lifecycle_phase=["development", "placing_on_market"],
|
||||
proposed=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="cra_risk_assessment",
|
||||
title="Cybersicherheits-Risikobewertung",
|
||||
source_regulation="CRA",
|
||||
obligation_text="Eine Cybersicherheits-Risikobewertung durchführen und dokumentieren; in die technische Dokumentation aufnehmen.",
|
||||
legal_basis_refs=["CRA Art. 13(2)", "CRA Annex I Part I (1)"],
|
||||
authority_level=_LM,
|
||||
family="cra_process",
|
||||
applies_if={"all": [_HAS_SW, _EU]},
|
||||
required_capabilities=["risk_assessment"],
|
||||
required_evidence=["policy"],
|
||||
lifecycle_phase=["development", "placing_on_market"],
|
||||
overlap_group_id="RISK_ASSESSMENT",
|
||||
proposed=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="cra_technical_documentation",
|
||||
title="Technische Dokumentation",
|
||||
source_regulation="CRA",
|
||||
obligation_text="Technische Dokumentation erstellen und aktuell halten, die Konformität mit den Anforderungen belegt.",
|
||||
legal_basis_refs=["CRA Art. 31", "CRA Annex VII"],
|
||||
authority_level=_LM,
|
||||
family="cra_process",
|
||||
applies_if={"all": [_HAS_SW, _EU]},
|
||||
required_capabilities=["technical_documentation"],
|
||||
required_evidence=["policy"],
|
||||
lifecycle_phase=["placing_on_market", "maintenance"],
|
||||
overlap_group_id="TECHNICAL_DOCUMENTATION",
|
||||
proposed=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="cra_ce_conformity_assessment",
|
||||
title="Konformitätsbewertung / CE-Kennzeichnung",
|
||||
source_regulation="CRA",
|
||||
obligation_text="Vor dem Inverkehrbringen das passende Konformitätsbewertungsverfahren durchlaufen und CE kennzeichnen.",
|
||||
legal_basis_refs=["CRA Art. 32", "CRA Art. 28"],
|
||||
authority_level=_LM,
|
||||
family="cra_process",
|
||||
applies_if={"all": [_HAS_SW, _EU]},
|
||||
required_capabilities=["conformity_assessment"],
|
||||
required_evidence=["test_report", "policy"],
|
||||
lifecycle_phase=["placing_on_market"],
|
||||
overlap_group_id="CE_CONFORMITY",
|
||||
proposed=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="cra_instructions_for_use",
|
||||
title="Informationen und Anweisungen für Nutzer",
|
||||
source_regulation="CRA",
|
||||
obligation_text="Nutzern verständliche Sicherheitsinformationen und -anweisungen bereitstellen (z. B. zu Updates und Support-Ende).",
|
||||
legal_basis_refs=["CRA Annex II"],
|
||||
authority_level=_LM,
|
||||
family="cra_process",
|
||||
applies_if={"all": [_HAS_SW, _EU]},
|
||||
required_capabilities=["technical_documentation"],
|
||||
required_evidence=["policy"],
|
||||
lifecycle_phase=["placing_on_market"],
|
||||
overlap_group_id="INSTRUCTIONS_FOR_USE",
|
||||
proposed=True,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,139 @@
|
||||
"""MaschinenVO and Data Act obligation scope rules.
|
||||
|
||||
These regulations are NOT yet in the Legal-KG registry (which currently covers
|
||||
the six CRA-P1 families). Every obligation here is therefore `proposed=True`:
|
||||
the reasoning layer proposes the snake_case id, the Obligation Registry session
|
||||
remains the only authority that may canonicalise it (re-link, never re-mint).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from .enums import AuthorityLevel, Confidence
|
||||
from .rules_types import ObligationRule
|
||||
|
||||
_EU = ("eu_market", "eq", True)
|
||||
_IS_MACHINE = ("is_machine", "eq", True)
|
||||
_LM = AuthorityLevel.LEGAL_TEXT
|
||||
|
||||
MACHINE_OBLIGATIONS: List[ObligationRule] = [
|
||||
ObligationRule(
|
||||
obligation_id="machine_risk_assessment",
|
||||
title="Maschinen-Risikobeurteilung",
|
||||
source_regulation="MaschinenVO",
|
||||
obligation_text="Eine Risikobeurteilung der Maschine durchführen, um Gefährdungen zu ermitteln und zu mindern.",
|
||||
legal_basis_refs=["MaschinenVO (EU) 2023/1230 Anhang III (1.1.1)", "EN ISO 12100"],
|
||||
authority_level=_LM,
|
||||
family="machine_safety",
|
||||
applies_if={"all": [_IS_MACHINE, _EU]},
|
||||
required_capabilities=["risk_assessment"],
|
||||
required_evidence=["policy"],
|
||||
lifecycle_phase=["development", "placing_on_market"],
|
||||
overlap_group_id="RISK_ASSESSMENT",
|
||||
proposed=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="machine_safety_control_systems",
|
||||
title="Sichere Steuerungssysteme",
|
||||
source_regulation="MaschinenVO",
|
||||
obligation_text="Sicherheitsbezogene Teile der Steuerung so auslegen, dass Ausfälle nicht zu gefährlichen Zuständen führen.",
|
||||
legal_basis_refs=["MaschinenVO (EU) 2023/1230 Anhang III (1.2.1)", "EN ISO 13849-1"],
|
||||
authority_level=_LM,
|
||||
family="machine_safety",
|
||||
applies_if={"all": [_IS_MACHINE, ("has_safety_function", "eq", True), _EU]},
|
||||
required_capabilities=["functional_safety"],
|
||||
required_evidence=["test_report", "policy"],
|
||||
lifecycle_phase=["development", "placing_on_market"],
|
||||
proposed=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="machine_protection_against_corruption",
|
||||
title="Schutz gegen Korrumpierung sicherheitsrelevanter Funktionen",
|
||||
source_regulation="MaschinenVO",
|
||||
obligation_text="Sicherstellen, dass eine (auch beabsichtigte) Korrumpierung der Software/Verbindung keine gefährliche Situation auslöst.",
|
||||
legal_basis_refs=["MaschinenVO (EU) 2023/1230 Anhang III (1.1.9)"],
|
||||
authority_level=_LM,
|
||||
family="machine_safety",
|
||||
applies_if={
|
||||
"all": [
|
||||
_IS_MACHINE,
|
||||
("has_safety_function", "eq", True),
|
||||
{"any": [("has_remote_access", "eq", True), ("has_software", "eq", True)]},
|
||||
_EU,
|
||||
]
|
||||
},
|
||||
required_capabilities=["software_integrity", "secure_by_default"],
|
||||
required_evidence=["test_report", "config_export"],
|
||||
lifecycle_phase=["development", "operation", "maintenance"],
|
||||
overlap_group_id="VULNERABILITY_HANDLING",
|
||||
proposed=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="machine_instructions_for_use",
|
||||
title="Betriebsanleitung",
|
||||
source_regulation="MaschinenVO",
|
||||
obligation_text="Eine vollständige Betriebsanleitung mit Sicherheitshinweisen bereitstellen.",
|
||||
legal_basis_refs=["MaschinenVO (EU) 2023/1230 Anhang III (1.7.4)"],
|
||||
authority_level=_LM,
|
||||
family="machine_safety",
|
||||
applies_if={"all": [_IS_MACHINE, _EU]},
|
||||
required_capabilities=["technical_documentation"],
|
||||
required_evidence=["policy"],
|
||||
lifecycle_phase=["placing_on_market"],
|
||||
overlap_group_id="INSTRUCTIONS_FOR_USE",
|
||||
proposed=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="machine_ce_conformity",
|
||||
title="Konformitätsbewertung / CE (Maschine)",
|
||||
source_regulation="MaschinenVO",
|
||||
obligation_text="Das passende Konformitätsbewertungsverfahren der MaschinenVO durchlaufen und CE kennzeichnen.",
|
||||
legal_basis_refs=["MaschinenVO (EU) 2023/1230 Art. 25", "Anhang IV"],
|
||||
authority_level=_LM,
|
||||
family="machine_safety",
|
||||
applies_if={"all": [_IS_MACHINE, _EU]},
|
||||
required_capabilities=["conformity_assessment"],
|
||||
required_evidence=["test_report", "policy"],
|
||||
lifecycle_phase=["placing_on_market"],
|
||||
overlap_group_id="CE_CONFORMITY",
|
||||
proposed=True,
|
||||
),
|
||||
]
|
||||
|
||||
DATA_ACT_OBLIGATIONS: List[ObligationRule] = [
|
||||
ObligationRule(
|
||||
obligation_id="data_act_data_access_by_design",
|
||||
title="Datenzugang by design",
|
||||
source_regulation="DataAct",
|
||||
obligation_text="Vernetzte Produkte so gestalten, dass die erzeugten Produktdaten standardmäßig zugänglich sind.",
|
||||
legal_basis_refs=["Data Act (EU) 2023/2854 Art. 3"],
|
||||
authority_level=_LM,
|
||||
family="data_act",
|
||||
applies_if={
|
||||
"all": [
|
||||
("generates_usage_data", "eq", True),
|
||||
{"any": [("has_cloud_connection", "eq", True), ("has_remote_access", "eq", True)]},
|
||||
_EU,
|
||||
]
|
||||
},
|
||||
required_capabilities=["data_access_provision"],
|
||||
required_evidence=["config_export", "policy"],
|
||||
lifecycle_phase=["development", "placing_on_market"],
|
||||
proposed=True,
|
||||
),
|
||||
ObligationRule(
|
||||
obligation_id="data_act_user_data_access",
|
||||
title="Datenzugang für Nutzer",
|
||||
source_regulation="DataAct",
|
||||
obligation_text="Nutzern Zugang zu den von ihnen erzeugten Daten gewähren und Weitergabe an Dritte ermöglichen.",
|
||||
legal_basis_refs=["Data Act (EU) 2023/2854 Art. 4", "Art. 5"],
|
||||
authority_level=_LM,
|
||||
family="data_act",
|
||||
applies_if={"all": [("generates_usage_data", "eq", True), _EU]},
|
||||
required_capabilities=["data_access_provision"],
|
||||
required_evidence=["policy"],
|
||||
lifecycle_phase=["operation"],
|
||||
proposed=True,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Obligation overlap groups (spec §4.5 / Modus 2).
|
||||
|
||||
Overlaps are emitted only for the members that are actually applicable to the
|
||||
product. `canonical_obligation_id` points at the strongest / most specific
|
||||
obligation in the group (preferring a registry-anchored CRA id).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
from .enums import OverlapType
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OverlapGroup:
|
||||
overlap_group_id: str
|
||||
members: List[str]
|
||||
overlap_type: OverlapType
|
||||
canonical_obligation_id: str
|
||||
explanation: str
|
||||
|
||||
|
||||
OVERLAP_GROUPS: List[OverlapGroup] = [
|
||||
OverlapGroup(
|
||||
overlap_group_id="VULNERABILITY_HANDLING",
|
||||
members=[
|
||||
"vuln_handling_process",
|
||||
"coordinated_vulnerability_disclosure",
|
||||
"machine_protection_against_corruption",
|
||||
],
|
||||
overlap_type=OverlapType.COMPLEMENTARY,
|
||||
canonical_obligation_id="vuln_handling_process",
|
||||
explanation=(
|
||||
"CRA adressiert die Schwachstellenbehandlung des Produkts. Die MaschinenVO wird "
|
||||
"komplementär relevant, sobald eine Cyber-Schwachstelle eine Sicherheitsfunktion "
|
||||
"beeinflussen kann (Anhang III 1.1.9). Nicht identisch, aber gemeinsam zu erfüllen."
|
||||
),
|
||||
),
|
||||
OverlapGroup(
|
||||
overlap_group_id="SECURITY_UPDATES",
|
||||
members=["provide_security_updates", "signed_update_integrity"],
|
||||
overlap_type=OverlapType.COMPLEMENTARY,
|
||||
canonical_obligation_id="provide_security_updates",
|
||||
explanation=(
|
||||
"Updates bereitstellen und ihre Integrität sichern sind zwei Seiten desselben "
|
||||
"Update-Prozesses; ein Nachweis (Update-Policy, Release Notes) deckt teils beide ab."
|
||||
),
|
||||
),
|
||||
OverlapGroup(
|
||||
overlap_group_id="RISK_ASSESSMENT",
|
||||
members=["cra_risk_assessment", "machine_risk_assessment"],
|
||||
overlap_type=OverlapType.DIFFERENT_SCOPE,
|
||||
canonical_obligation_id="cra_risk_assessment",
|
||||
explanation=(
|
||||
"Zwei getrennte Risikobetrachtungen: CRA = Cybersicherheits-Risiko, MaschinenVO = "
|
||||
"Sicherheits-/Gefährdungsbeurteilung. Methodisch verwandt, inhaltlich unterschiedlich."
|
||||
),
|
||||
),
|
||||
OverlapGroup(
|
||||
overlap_group_id="TECHNICAL_DOCUMENTATION",
|
||||
members=["cra_technical_documentation", "machine_risk_assessment"],
|
||||
overlap_type=OverlapType.SIMILAR,
|
||||
canonical_obligation_id="cra_technical_documentation",
|
||||
explanation=(
|
||||
"Beide Regime verlangen eine technische Dokumentation; Teile (Risikobetrachtung, "
|
||||
"Konstruktionsunterlagen) lassen sich in einem konsolidierten technischen Dossier führen."
|
||||
),
|
||||
),
|
||||
OverlapGroup(
|
||||
overlap_group_id="CE_CONFORMITY",
|
||||
members=["cra_ce_conformity_assessment", "machine_ce_conformity"],
|
||||
overlap_type=OverlapType.COMPLEMENTARY,
|
||||
canonical_obligation_id="machine_ce_conformity",
|
||||
explanation=(
|
||||
"Ein Produkt kann zwei CE-Regime gleichzeitig erfüllen müssen (MaschinenVO + CRA). "
|
||||
"Eine gemeinsame CE-Kennzeichnung, aber getrennte Konformitätsbewertungen."
|
||||
),
|
||||
),
|
||||
OverlapGroup(
|
||||
overlap_group_id="INSTRUCTIONS_FOR_USE",
|
||||
members=["cra_instructions_for_use", "machine_instructions_for_use"],
|
||||
overlap_type=OverlapType.SIMILAR,
|
||||
canonical_obligation_id="machine_instructions_for_use",
|
||||
explanation=(
|
||||
"Betriebsanleitung (MaschinenVO) und Sicherheitsinformationen (CRA) überschneiden sich; "
|
||||
"ein integriertes Anleitungsdokument kann beide Pflichten bedienen."
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Regulation-level applicability trigger rules (scope discovery, spec Modus 1).
|
||||
|
||||
Each rule is pure data consumed by `scope_engine`. Triggers reference
|
||||
`ProductProfile` fields through the safe predicate evaluator. `required_facts`
|
||||
that are unknown turn the verdict *uncertain* and surface `fact_prompts`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .enums import Confidence
|
||||
from .predicates import Condition
|
||||
|
||||
# Positive, human-readable label per profile fact (for trigger_facts output).
|
||||
FIELD_LABELS: Dict[str, str] = {
|
||||
"has_software": "Produkt enthält Software / digitale Elemente",
|
||||
"has_embedded_software": "Produkt enthält eingebettete Software",
|
||||
"has_remote_access": "Produkt besitzt Fernzugriff / Fernwartung",
|
||||
"has_cloud_connection": "Produkt ist mit einer Cloud verbunden",
|
||||
"has_radio_module": "Produkt enthält ein Funkmodul",
|
||||
"has_safety_function": "Produkt erfüllt eine Sicherheitsfunktion",
|
||||
"generates_usage_data": "Vernetztes Produkt erzeugt nutzbare Produktdaten",
|
||||
"is_machine": "Produkt ist eine Maschine",
|
||||
"is_component": "Produkt ist ein (Sicherheits-)Bauteil",
|
||||
"eu_market": "Produkt wird auf dem EU-Markt bereitgestellt",
|
||||
"is_essential_or_important_entity": "Unternehmen ist wesentliche/wichtige Einrichtung",
|
||||
"manufacturer_role": "Wirtschaftsakteur-Rolle (Hersteller/Importeur/Händler)",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegulationRule:
|
||||
regulation_id: str
|
||||
name: str
|
||||
trigger: Condition
|
||||
required_facts: List[str]
|
||||
fact_prompts: Dict[str, str]
|
||||
legal_basis_refs: List[str]
|
||||
summary: str
|
||||
confidence_when_applicable: Confidence = Confidence.HIGH
|
||||
exclusion: Optional[Condition] = None
|
||||
# Status is downgraded to PARTIALLY_APPLICABLE / MEDIUM when the trigger
|
||||
# fires only via inference rather than a directly stated fact.
|
||||
inferred: bool = False
|
||||
excludable_roles: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
_ECONOMIC_ROLES = ["manufacturer", "importer", "distributor"]
|
||||
|
||||
REGULATION_RULES: List[RegulationRule] = [
|
||||
RegulationRule(
|
||||
regulation_id="CRA",
|
||||
name="Cyber Resilience Act (EU) 2024/2847",
|
||||
trigger={
|
||||
"all": [
|
||||
{"any": [("has_software", "eq", True), ("has_embedded_software", "eq", True)]},
|
||||
("eu_market", "eq", True),
|
||||
]
|
||||
},
|
||||
required_facts=["has_software", "eu_market", "manufacturer_role"],
|
||||
fact_prompts={
|
||||
"has_software": "Enthält das Produkt Software / digitale Elemente?",
|
||||
"eu_market": "Wird das Produkt auf dem EU-Markt bereitgestellt oder in Verkehr gebracht?",
|
||||
"manufacturer_role": "Welche Rolle nehmen Sie ein (Hersteller / Importeur / Händler)?",
|
||||
},
|
||||
legal_basis_refs=["CRA Art. 2(1)", "CRA Art. 3(1)"],
|
||||
summary="Produkte mit digitalen Elementen, die auf dem EU-Markt bereitgestellt werden.",
|
||||
confidence_when_applicable=Confidence.HIGH,
|
||||
excludable_roles=["operator"],
|
||||
),
|
||||
RegulationRule(
|
||||
regulation_id="MaschinenVO",
|
||||
name="Maschinenverordnung (EU) 2023/1230",
|
||||
trigger={
|
||||
"any": [
|
||||
("is_machine", "eq", True),
|
||||
{"all": [("is_component", "eq", True), ("has_safety_function", "eq", True)]},
|
||||
]
|
||||
},
|
||||
required_facts=["is_machine", "eu_market"],
|
||||
fact_prompts={
|
||||
"is_machine": "Ist das Produkt eine Maschine oder ein Sicherheitsbauteil?",
|
||||
"has_safety_function": "Erfüllt das Bauteil eine Sicherheitsfunktion?",
|
||||
},
|
||||
legal_basis_refs=["MaschinenVO (EU) 2023/1230 Art. 2", "Anhang III"],
|
||||
summary="Maschinen oder Sicherheitsbauteile, ggf. mit sicherheitsrelevanter Steuerung.",
|
||||
confidence_when_applicable=Confidence.MEDIUM,
|
||||
),
|
||||
RegulationRule(
|
||||
regulation_id="RED",
|
||||
name="Radio Equipment Directive 2014/53/EU",
|
||||
trigger=("has_radio_module", "eq", True),
|
||||
required_facts=["has_radio_module"],
|
||||
fact_prompts={
|
||||
"has_radio_module": "Besitzt das Produkt ein Funkmodul (WLAN, Bluetooth, Mobilfunk)?",
|
||||
},
|
||||
legal_basis_refs=["RED 2014/53/EU Art. 1", "Art. 3(3)(d-f)"],
|
||||
summary="Funkanlagen; Art. 3(3) deckt zusätzlich Cybersecurity-Anforderungen ab.",
|
||||
confidence_when_applicable=Confidence.HIGH,
|
||||
),
|
||||
RegulationRule(
|
||||
regulation_id="EMV",
|
||||
name="EMV-Richtlinie 2014/30/EU",
|
||||
trigger={
|
||||
"any": [
|
||||
("has_software", "eq", True),
|
||||
("has_embedded_software", "eq", True),
|
||||
("has_radio_module", "eq", True),
|
||||
]
|
||||
},
|
||||
required_facts=[],
|
||||
fact_prompts={
|
||||
"is_electrical": "Ist das Produkt ein elektrisches / elektronisches Betriebsmittel?",
|
||||
},
|
||||
legal_basis_refs=["EMV-RL 2014/30/EU Art. 2"],
|
||||
summary="Elektrische/elektronische Betriebsmittel (hier aus den digitalen Elementen abgeleitet).",
|
||||
confidence_when_applicable=Confidence.MEDIUM,
|
||||
inferred=True,
|
||||
),
|
||||
RegulationRule(
|
||||
regulation_id="DataAct",
|
||||
name="Data Act (EU) 2023/2854",
|
||||
trigger={
|
||||
"all": [
|
||||
{"any": [("has_cloud_connection", "eq", True), ("has_remote_access", "eq", True)]},
|
||||
("generates_usage_data", "eq", True),
|
||||
]
|
||||
},
|
||||
required_facts=["generates_usage_data"],
|
||||
fact_prompts={
|
||||
"generates_usage_data": "Erzeugt das vernetzte Produkt nutzbare Produkt-/Nutzungsdaten?",
|
||||
},
|
||||
legal_basis_refs=["Data Act (EU) 2023/2854 Art. 2(5)", "Art. 3-5"],
|
||||
summary="Vernetzte Produkte, die Nutzungsdaten erzeugen und zugänglich machen.",
|
||||
confidence_when_applicable=Confidence.HIGH,
|
||||
),
|
||||
RegulationRule(
|
||||
regulation_id="NIS2",
|
||||
name="NIS2-Richtlinie (EU) 2022/2555",
|
||||
trigger=("is_essential_or_important_entity", "eq", True),
|
||||
required_facts=["company_size", "sector", "is_essential_or_important_entity"],
|
||||
fact_prompts={
|
||||
"company_size": "Unternehmensgröße (Mitarbeiterzahl / Umsatz)?",
|
||||
"sector": "In welchem Sektor ist das Unternehmen tätig (Anhang I/II)?",
|
||||
"is_essential_or_important_entity": "Fällt das Unternehmen als wesentliche/wichtige Einrichtung unter NIS2?",
|
||||
},
|
||||
legal_basis_refs=["NIS2-RL (EU) 2022/2555 Art. 2", "Art. 3"],
|
||||
summary="Adressiert die ORGANISATION (Größe/Sektor/Rolle), nicht das Produkt.",
|
||||
confidence_when_applicable=Confidence.MEDIUM,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def regulation_rule(regulation_id: str) -> Optional[RegulationRule]:
|
||||
for rule in REGULATION_RULES:
|
||||
if rule.regulation_id == regulation_id:
|
||||
return rule
|
||||
return None
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Shared types for obligation scope rules.
|
||||
|
||||
`required_evidence` MUST draw from the framework-AGNOSTIC evidence catalog
|
||||
owned by the Compliance Execution Graph (memory `project_compliance_graph.md`,
|
||||
User-Direktive 2026-06-25). Do not invent framework-specific evidence types.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
from .enums import AuthorityLevel, Confidence
|
||||
from .predicates import Condition
|
||||
|
||||
# Framework-agnostic shared evidence catalog (the only allowed tokens).
|
||||
EVIDENCE_CATALOG = frozenset(
|
||||
{
|
||||
"config_export",
|
||||
"test_report",
|
||||
"repo_scan",
|
||||
"sbom",
|
||||
"policy",
|
||||
"audit_log",
|
||||
"pentest",
|
||||
"ticket",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ObligationRule:
|
||||
obligation_id: str
|
||||
title: str
|
||||
source_regulation: str
|
||||
obligation_text: str
|
||||
legal_basis_refs: List[str]
|
||||
authority_level: AuthorityLevel
|
||||
family: str
|
||||
applies_if: Condition
|
||||
required_capabilities: List[str]
|
||||
required_evidence: List[str]
|
||||
base_confidence: Confidence = Confidence.HIGH
|
||||
applies_unless: Optional[Condition] = None
|
||||
lifecycle_phase: List[str] = field(default_factory=list)
|
||||
applies_to_role: List[str] = field(default_factory=lambda: ["manufacturer", "importer"])
|
||||
overlap_group_id: Optional[str] = None
|
||||
# True => obligation_id is owned by the Legal-KG registry (re-link, never re-mint).
|
||||
registry_anchor: bool = False
|
||||
# True => Machine/Data-Act obligation the registry has not canonicalised yet.
|
||||
proposed: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
bad = [e for e in self.required_evidence if e not in EVIDENCE_CATALOG]
|
||||
if bad:
|
||||
raise ValueError(
|
||||
"obligation %s uses non-catalog evidence %r" % (self.obligation_id, bad)
|
||||
)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Pydantic domain objects for the Regulatory Reasoning Engine.
|
||||
|
||||
Trigger facts that drive scope are tri-state (`Optional[bool] = None`): `None`
|
||||
means "fact unknown" and produces an *uncertain* verdict plus a concrete
|
||||
missing-fact prompt — never silent false security (spec §6.3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .enums import (
|
||||
ApplicabilityStatus,
|
||||
AuthorityLevel,
|
||||
ClaimCoverage,
|
||||
Confidence,
|
||||
InterpretationVerdict,
|
||||
ManufacturerRole,
|
||||
MarketModel,
|
||||
OverlapType,
|
||||
ProductLifecyclePhase,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input
|
||||
# ---------------------------------------------------------------------------
|
||||
class ProductProfile(BaseModel):
|
||||
"""The customer's product / system. Tri-state booleans => unknown facts."""
|
||||
|
||||
product_name: str
|
||||
product_profile_id: Optional[str] = None
|
||||
manufacturer_role: Optional[ManufacturerRole] = None
|
||||
product_type: List[str] = Field(default_factory=list)
|
||||
|
||||
has_software: Optional[bool] = None
|
||||
has_embedded_software: Optional[bool] = None
|
||||
has_remote_access: Optional[bool] = None
|
||||
has_cloud_connection: Optional[bool] = None
|
||||
has_ai_functionality: Optional[bool] = None
|
||||
has_radio_module: Optional[bool] = None
|
||||
has_safety_function: Optional[bool] = None
|
||||
generates_usage_data: Optional[bool] = None
|
||||
|
||||
is_machine: Optional[bool] = None
|
||||
is_component: Optional[bool] = None
|
||||
is_spare_part: Optional[bool] = None
|
||||
|
||||
placed_on_market_after: Optional[date] = None
|
||||
intended_use: Optional[str] = None
|
||||
eu_market: Optional[bool] = None
|
||||
b2b_or_b2c: Optional[MarketModel] = None
|
||||
lifecycle_phase: Optional[ProductLifecyclePhase] = None
|
||||
|
||||
# Organisation context — only needed for NIS2 (not a product fact).
|
||||
company_size: Optional[str] = None
|
||||
sector: Optional[str] = None
|
||||
is_essential_or_important_entity: Optional[bool] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scope
|
||||
# ---------------------------------------------------------------------------
|
||||
class ApplicableRegulation(BaseModel):
|
||||
regulation_id: str
|
||||
name: str
|
||||
applicability_status: ApplicabilityStatus
|
||||
trigger_facts: List[str] = Field(default_factory=list)
|
||||
legal_basis_refs: List[str] = Field(default_factory=list)
|
||||
confidence: Confidence
|
||||
explanation: str
|
||||
|
||||
|
||||
class ExcludedRegulation(BaseModel):
|
||||
regulation_id: str
|
||||
name: str
|
||||
reason: str
|
||||
|
||||
|
||||
class UncertainRegulation(BaseModel):
|
||||
regulation_id: str
|
||||
name: str
|
||||
missing_facts: List[str] = Field(default_factory=list)
|
||||
explanation: str
|
||||
|
||||
|
||||
class RegulatoryScope(BaseModel):
|
||||
product_profile_id: Optional[str] = None
|
||||
applicable_regulations: List[ApplicableRegulation] = Field(default_factory=list)
|
||||
excluded_regulations: List[ExcludedRegulation] = Field(default_factory=list)
|
||||
uncertain_regulations: List[UncertainRegulation] = Field(default_factory=list)
|
||||
missing_facts: List[str] = Field(default_factory=list)
|
||||
confidence: Confidence = Confidence.MEDIUM
|
||||
reasoning_summary: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Obligations
|
||||
# ---------------------------------------------------------------------------
|
||||
class ApplicableObligation(BaseModel):
|
||||
obligation_id: str
|
||||
title: str
|
||||
source_regulation: str
|
||||
legal_basis_refs: List[str] = Field(default_factory=list)
|
||||
obligation_text: str
|
||||
authority_level: AuthorityLevel
|
||||
applies_because: List[str] = Field(default_factory=list)
|
||||
applies_to_role: List[str] = Field(default_factory=list)
|
||||
lifecycle_phase: List[str] = Field(default_factory=list)
|
||||
overlap_group_id: Optional[str] = None
|
||||
required_evidence: List[str] = Field(default_factory=list)
|
||||
confidence: Confidence
|
||||
# True only when obligation_id is owned by the Legal-KG registry (CRA P1).
|
||||
registry_anchor: bool = False
|
||||
# Machine/Data-Act obligations the registry has not canonicalised yet.
|
||||
proposed: bool = False
|
||||
|
||||
|
||||
class ObligationOverlap(BaseModel):
|
||||
overlap_group_id: str
|
||||
obligations: List[str] = Field(default_factory=list)
|
||||
overlap_type: OverlapType
|
||||
canonical_obligation_id: str
|
||||
explanation: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Customer claims & assessments
|
||||
# ---------------------------------------------------------------------------
|
||||
class CustomerImplementationClaim(BaseModel):
|
||||
claim_id: str
|
||||
raw_statement: str
|
||||
normalized_claim: str = ""
|
||||
claimed_capability: List[str] = Field(default_factory=list)
|
||||
related_topics: List[str] = Field(default_factory=list)
|
||||
qualifiers: List[str] = Field(default_factory=list)
|
||||
evidence_refs: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ClaimObligationMapping(BaseModel):
|
||||
"""One row of Welt-1 reasoning: how a customer claim relates to an obligation.
|
||||
|
||||
Layers (spec / architect): claim -> interpretation (on the claim object) ->
|
||||
*potential* obligation coverage (`claim_coverage`) -> evidence required.
|
||||
Carries NO compliance verdict.
|
||||
"""
|
||||
|
||||
claim_id: str
|
||||
obligation_id: str
|
||||
claim_coverage: ClaimCoverage
|
||||
missing_elements: List[str] = Field(default_factory=list)
|
||||
required_evidence: List[str] = Field(default_factory=list)
|
||||
explanation: str
|
||||
confidence: Confidence
|
||||
|
||||
|
||||
class InterpretationAssessment(BaseModel):
|
||||
interpretation_id: str
|
||||
raw_interpretation: str
|
||||
affected_regulations: List[str] = Field(default_factory=list)
|
||||
affected_obligations: List[str] = Field(default_factory=list)
|
||||
assessment: InterpretationVerdict
|
||||
risks: List[str] = Field(default_factory=list)
|
||||
corrected_interpretation: str = ""
|
||||
legal_basis_refs: List[str] = Field(default_factory=list)
|
||||
explanation: str
|
||||
confidence: Confidence
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API request / response envelopes
|
||||
# ---------------------------------------------------------------------------
|
||||
class ScopeRequest(BaseModel):
|
||||
product_profile: ProductProfile
|
||||
|
||||
|
||||
class ScopeResponse(BaseModel):
|
||||
regulatory_scope: RegulatoryScope
|
||||
missing_facts: List[str] = Field(default_factory=list)
|
||||
confidence: Confidence
|
||||
|
||||
|
||||
class ObligationsRequest(BaseModel):
|
||||
product_profile: ProductProfile
|
||||
regulatory_scope: Optional[RegulatoryScope] = None
|
||||
|
||||
|
||||
class ObligationsResponse(BaseModel):
|
||||
applicable_obligations: List[ApplicableObligation] = Field(default_factory=list)
|
||||
overlaps: List[ObligationOverlap] = Field(default_factory=list)
|
||||
excluded_obligations: List[str] = Field(default_factory=list)
|
||||
evidence_for_multiple: Dict[str, List[str]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ImplementationReasoningRequest(BaseModel):
|
||||
product_profile: ProductProfile
|
||||
customer_claim: str
|
||||
|
||||
|
||||
class ImplementationReasoningResponse(BaseModel):
|
||||
claim: CustomerImplementationClaim
|
||||
mappings: List[ClaimObligationMapping] = Field(default_factory=list)
|
||||
missing_evidence: List[str] = Field(default_factory=list)
|
||||
summary: str = ""
|
||||
# Makes the Welt-1 boundary explicit: this is advisory claim-mapping, not a
|
||||
# conformity verdict (that is ComplianceStatus in the Execution Graph).
|
||||
disclaimer: str = ""
|
||||
|
||||
|
||||
class InterpretationRequest(BaseModel):
|
||||
product_profile: Optional[ProductProfile] = None
|
||||
customer_interpretation: str
|
||||
|
||||
|
||||
class InterpretationResponse(BaseModel):
|
||||
assessment: InterpretationVerdict
|
||||
affected_regulations: List[str] = Field(default_factory=list)
|
||||
affected_obligations: List[str] = Field(default_factory=list)
|
||||
corrected_interpretation: str = ""
|
||||
risks: List[str] = Field(default_factory=list)
|
||||
legal_basis_refs: List[str] = Field(default_factory=list)
|
||||
explanation: str = ""
|
||||
confidence: Confidence = Confidence.MEDIUM
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Scope discovery engine (spec Modus 1).
|
||||
|
||||
Answers "which regulations apply to my product?" — and, crucially, never says
|
||||
"X applies" without the triggers, and never hides a missing fact behind a false
|
||||
verdict. Pure rule evaluation, deterministic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from .enums import ApplicabilityStatus, Confidence
|
||||
from .predicates import Condition, evaluate, true_leaves, unknown_fields
|
||||
from .rules_regulations import REGULATION_RULES, FIELD_LABELS, RegulationRule
|
||||
from .schemas import (
|
||||
ApplicableRegulation,
|
||||
ExcludedRegulation,
|
||||
ProductProfile,
|
||||
RegulatoryScope,
|
||||
UncertainRegulation,
|
||||
)
|
||||
|
||||
_DOWNGRADE = {Confidence.HIGH: Confidence.MEDIUM, Confidence.MEDIUM: Confidence.LOW, Confidence.LOW: Confidence.LOW}
|
||||
|
||||
|
||||
def _fields_in(condition: Optional[Condition]) -> List[str]:
|
||||
if condition is None:
|
||||
return []
|
||||
if isinstance(condition, tuple):
|
||||
return [condition[0]]
|
||||
out: List[str] = []
|
||||
for c in condition.get("all") or condition.get("any") or []:
|
||||
out.extend(_fields_in(c))
|
||||
return out
|
||||
|
||||
|
||||
def _trigger_facts(rule: RegulationRule, profile: ProductProfile) -> List[str]:
|
||||
labels: List[str] = []
|
||||
for leaf in true_leaves(rule.trigger, profile):
|
||||
label = FIELD_LABELS.get(leaf[0])
|
||||
if label and label not in labels:
|
||||
labels.append(label)
|
||||
return labels
|
||||
|
||||
|
||||
def _missing_prompts(rule: RegulationRule, profile: ProductProfile) -> List[str]:
|
||||
fields = list(dict.fromkeys(rule.required_facts + _fields_in(rule.trigger)))
|
||||
unknown = unknown_fields(fields, profile)
|
||||
prompts: List[str] = []
|
||||
for f in unknown:
|
||||
prompt = rule.fact_prompts.get(f)
|
||||
if prompt and prompt not in prompts:
|
||||
prompts.append(prompt)
|
||||
return prompts
|
||||
|
||||
|
||||
def discover_scope(profile: ProductProfile) -> RegulatoryScope:
|
||||
scope = RegulatoryScope(product_profile_id=profile.product_profile_id)
|
||||
|
||||
for rule in REGULATION_RULES:
|
||||
role_value = profile.manufacturer_role.value if profile.manufacturer_role is not None else None
|
||||
role_excluded = role_value is not None and role_value in rule.excludable_roles
|
||||
trig = evaluate(rule.trigger, profile)
|
||||
missing = _missing_prompts(rule, profile)
|
||||
|
||||
if role_excluded:
|
||||
scope.excluded_regulations.append(
|
||||
ExcludedRegulation(
|
||||
regulation_id=rule.regulation_id,
|
||||
name=rule.name,
|
||||
reason="Rolle '%s' ist von dieser Regulierung nicht unmittelbar adressiert." % role_value,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if trig is True:
|
||||
conf = Confidence.MEDIUM if rule.inferred else rule.confidence_when_applicable
|
||||
status = (
|
||||
ApplicabilityStatus.PARTIALLY_APPLICABLE if rule.inferred else ApplicabilityStatus.APPLICABLE
|
||||
)
|
||||
unresolved = unknown_fields(rule.required_facts, profile)
|
||||
if unresolved:
|
||||
conf = _DOWNGRADE[conf]
|
||||
for f in unresolved:
|
||||
prompt = rule.fact_prompts.get(f)
|
||||
if prompt and prompt not in scope.missing_facts:
|
||||
scope.missing_facts.append(prompt)
|
||||
scope.applicable_regulations.append(
|
||||
ApplicableRegulation(
|
||||
regulation_id=rule.regulation_id,
|
||||
name=rule.name,
|
||||
applicability_status=status,
|
||||
trigger_facts=_trigger_facts(rule, profile),
|
||||
legal_basis_refs=rule.legal_basis_refs,
|
||||
confidence=conf,
|
||||
explanation=rule.summary,
|
||||
)
|
||||
)
|
||||
elif trig is None:
|
||||
scope.uncertain_regulations.append(
|
||||
UncertainRegulation(
|
||||
regulation_id=rule.regulation_id,
|
||||
name=rule.name,
|
||||
missing_facts=missing,
|
||||
explanation=rule.summary,
|
||||
)
|
||||
)
|
||||
for m in missing:
|
||||
if m not in scope.missing_facts:
|
||||
scope.missing_facts.append(m)
|
||||
else: # trig is False -> definitively excluded by a known fact
|
||||
scope.excluded_regulations.append(
|
||||
ExcludedRegulation(
|
||||
regulation_id=rule.regulation_id,
|
||||
name=rule.name,
|
||||
reason="Auslösende Voraussetzungen sind anhand der bekannten Fakten nicht erfüllt.",
|
||||
)
|
||||
)
|
||||
|
||||
scope.confidence = _overall_confidence(scope)
|
||||
scope.reasoning_summary = _summary(scope)
|
||||
return scope
|
||||
|
||||
|
||||
def _overall_confidence(scope: RegulatoryScope) -> Confidence:
|
||||
if scope.applicable_regulations and not scope.uncertain_regulations and not scope.missing_facts:
|
||||
return Confidence.HIGH
|
||||
if scope.applicable_regulations:
|
||||
return Confidence.MEDIUM
|
||||
return Confidence.LOW
|
||||
|
||||
|
||||
def _summary(scope: RegulatoryScope) -> str:
|
||||
applicable = ", ".join(r.regulation_id for r in scope.applicable_regulations) or "—"
|
||||
uncertain = ", ".join(r.regulation_id for r in scope.uncertain_regulations) or "—"
|
||||
return "Wahrscheinlich anwendbar: %s. Unsicher (fehlende Fakten): %s." % (applicable, uncertain)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Deterministic taxonomy for normalising free-text customer claims.
|
||||
|
||||
Capability names echo the planned Obligation -> Capability layer of the
|
||||
Compliance Execution Graph (memory `project_compliance_graph.md`), so the
|
||||
reasoning layer's claim capabilities line up with the registry's capabilities.
|
||||
Matching is lowercase substring matching — deterministic, no LLM, no RAG.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
# capability -> trigger substrings (German + English), matched lowercase.
|
||||
CAPABILITY_KEYWORDS: Dict[str, List[str]] = {
|
||||
"software_bill_of_materials": [
|
||||
"sbom", "stückliste", "stueckliste", "bill of materials", "komponentenliste",
|
||||
],
|
||||
"secure_updates": ["update", "patch", "aktualisier", "release", "rollout"],
|
||||
"software_integrity": ["signier", "signatur", "signed", "integrität", "integritaet", "hash"],
|
||||
"vulnerability_management": [
|
||||
"schwachstelle", "vulnerab", "cve", "schwachstellenmanagement", "vuln",
|
||||
],
|
||||
"coordinated_disclosure": [
|
||||
"disclosure", "offenlegung", "security.txt", "responsible disclosure",
|
||||
],
|
||||
"incident_reporting": [
|
||||
"incident", "vorfall", "behörde", "behoerde", "csirt", "meldepflicht", "an die behörde",
|
||||
],
|
||||
"authentication": [
|
||||
"authentifizier", "login", "passwort", "password", "mfa", "2fa", "anmeldung",
|
||||
],
|
||||
"secure_by_default": [
|
||||
"härtung", "haertung", "hardening", "default", "standardkonfig",
|
||||
"sichere konfiguration", "angriffsfläche", "angriffsflaeche",
|
||||
],
|
||||
"security_logging": ["logging", "log ", "logs", "protokoll", "audit-trail", "ereignisprotokoll"],
|
||||
"secure_communication": ["verschlüssel", "verschluessel", "encryption", "tls", "vpn", "ssl"],
|
||||
"risk_assessment": [
|
||||
"risikoanalyse", "risikobeurteil", "risk assessment", "gefährdungsbeurteil",
|
||||
"gefaehrdungsbeurteil", "bedrohungsanalyse", "threat model",
|
||||
],
|
||||
"technical_documentation": [
|
||||
"dokumentation", "technische unterlagen", "betriebsanleitung", "handbuch", "documentation",
|
||||
],
|
||||
"conformity_assessment": ["konformität", "konformitaet", "conformity", "baumuster", "ce-kenn"],
|
||||
"functional_safety": [
|
||||
"performance level", "sil ", "iso 13849", "funktionale sicherheit", "safety control",
|
||||
],
|
||||
"data_access_provision": [
|
||||
"datenzugang", "data access", "datenportabilität", "datenexport", "data export",
|
||||
],
|
||||
}
|
||||
|
||||
# capability -> broader compliance topics it touches (spec related_topics).
|
||||
CAPABILITY_TOPICS: Dict[str, List[str]] = {
|
||||
"software_bill_of_materials": ["component_transparency", "supply_chain", "vulnerability_management"],
|
||||
"secure_updates": ["secure_updates", "vulnerability_remediation", "release_management"],
|
||||
"software_integrity": ["secure_updates", "supply_chain", "tamper_protection"],
|
||||
"vulnerability_management": ["vulnerability_handling", "monitoring", "patch_management"],
|
||||
"coordinated_disclosure": ["vulnerability_handling", "transparency"],
|
||||
"incident_reporting": ["incident_handling", "authority_notification"],
|
||||
"authentication": ["access_control", "identity"],
|
||||
"secure_by_default": ["hardening", "attack_surface", "configuration"],
|
||||
"security_logging": ["monitoring", "forensics", "incident_handling"],
|
||||
"secure_communication": ["confidentiality", "integrity", "remote_access"],
|
||||
"risk_assessment": ["risk_management", "secure_by_design"],
|
||||
"technical_documentation": ["documentation", "conformity"],
|
||||
"conformity_assessment": ["conformity", "ce_marking"],
|
||||
"functional_safety": ["machine_safety", "control_systems"],
|
||||
"data_access_provision": ["data_sharing", "portability"],
|
||||
}
|
||||
|
||||
# qualifier -> substrings that signal a weak/incomplete implementation.
|
||||
QUALIFIER_KEYWORDS: Dict[str, List[str]] = {
|
||||
"reactive": [
|
||||
"wenn kunden", "wenn ein kunde", "nach meldung", "auf anfrage", "auf nachfrage",
|
||||
"nur wenn", "reaktiv", "wenn fehler", "when customers", "on request", "when reported",
|
||||
"ad hoc", "ad-hoc", "bei bedarf",
|
||||
],
|
||||
"manual": ["manuell", "von hand", "manual", "händisch", "haendisch"],
|
||||
"planned": [
|
||||
"geplant", "in planung", "wollen wir", "planen wir", "noch nicht", "zukünftig", "künftig",
|
||||
],
|
||||
"absent": ["haben wir nicht", "gibt es nicht", "nicht vorhanden", "keinen prozess", "keine"],
|
||||
}
|
||||
|
||||
|
||||
def match_capabilities(text: str) -> List[str]:
|
||||
low = text.lower()
|
||||
return [cap for cap, kws in CAPABILITY_KEYWORDS.items() if any(k in low for k in kws)]
|
||||
|
||||
|
||||
def match_qualifiers(text: str) -> List[str]:
|
||||
low = text.lower()
|
||||
return [q for q, kws in QUALIFIER_KEYWORDS.items() if any(k in low for k in kws)]
|
||||
|
||||
|
||||
def topics_for(capabilities: List[str]) -> List[str]:
|
||||
out: List[str] = []
|
||||
for cap in capabilities:
|
||||
for t in CAPABILITY_TOPICS.get(cap, []):
|
||||
if t not in out:
|
||||
out.append(t)
|
||||
return out
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Known customer interpretation patterns (spec Modus 4).
|
||||
|
||||
Deterministic: a customer interpretation is matched by lowercase substring
|
||||
triggers against a curated library of common misconceptions. No match ->
|
||||
the engine returns `uncertain` and asks for the missing context (no false
|
||||
security, spec §6.3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
|
||||
from .enums import Confidence, InterpretationVerdict
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InterpretationPattern:
|
||||
pattern_id: str
|
||||
triggers: List[str]
|
||||
verdict: InterpretationVerdict
|
||||
corrected_interpretation: str
|
||||
explanation: str
|
||||
affected_regulations: List[str] = field(default_factory=list)
|
||||
affected_obligations: List[str] = field(default_factory=list)
|
||||
risks: List[str] = field(default_factory=list)
|
||||
legal_basis_refs: List[str] = field(default_factory=list)
|
||||
confidence: Confidence = Confidence.MEDIUM
|
||||
|
||||
|
||||
INTERPRETATION_PATTERNS: List[InterpretationPattern] = [
|
||||
InterpretationPattern(
|
||||
pattern_id="cra_only_new_products",
|
||||
triggers=[
|
||||
"nur für neue", "nur fuer neue", "nur neu entwickelt", "nur neuentwicklung",
|
||||
"nur bei neuentwicklung", "only new product", "gilt nur für neue produkte",
|
||||
],
|
||||
verdict=InterpretationVerdict.TOO_NARROW,
|
||||
corrected_interpretation=(
|
||||
"CRA-Pflichten knüpfen primär an Produkt, Rolle, Marktzugang, Bereitstellung und "
|
||||
"Übergangsfristen an, nicht nur an Neuentwicklung. Ein fertig entwickeltes "
|
||||
"Katalogprodukt kann betroffen sein, wenn es nach dem maßgeblichen Zeitpunkt weiter "
|
||||
"auf dem EU-Markt bereitgestellt wird."
|
||||
),
|
||||
explanation=(
|
||||
"Die relevante Frage ist nicht nur, ob das Produkt neu entwickelt wurde, sondern ob es "
|
||||
"nach dem Anwendungszeitpunkt weiterhin bereitgestellt oder in Verkehr gebracht wird."
|
||||
),
|
||||
affected_regulations=["CRA"],
|
||||
risks=["Katalog-/Bestandsprodukt fällt trotz abgeschlossener Entwicklung unter den CRA."],
|
||||
legal_basis_refs=["CRA Art. 2", "CRA Art. 69 (Übergangsbestimmungen)"],
|
||||
confidence=Confidence.HIGH,
|
||||
),
|
||||
InterpretationPattern(
|
||||
pattern_id="cra_b2b_exempt",
|
||||
triggers=[
|
||||
"gilt nicht für b2b", "nur für verbraucher", "nur b2c", "nicht im b2b",
|
||||
"only consumer", "b2b ist ausgenommen",
|
||||
],
|
||||
verdict=InterpretationVerdict.TOO_NARROW,
|
||||
corrected_interpretation=(
|
||||
"Der CRA gilt produkt- und marktbezogen, unabhängig von B2B oder B2C. Eine generelle "
|
||||
"B2B-Ausnahme existiert nicht; Industrieprodukte mit digitalen Elementen sind erfasst."
|
||||
),
|
||||
explanation="Der Anwendungsbereich knüpft an 'Produkte mit digitalen Elementen' an, nicht an die Kundengruppe.",
|
||||
affected_regulations=["CRA"],
|
||||
risks=["Industrielle B2B-Steuerungen werden fälschlich als ausgenommen behandelt."],
|
||||
legal_basis_refs=["CRA Art. 2", "CRA Art. 3(1)"],
|
||||
confidence=Confidence.HIGH,
|
||||
),
|
||||
InterpretationPattern(
|
||||
pattern_id="sbom_is_enough",
|
||||
triggers=[
|
||||
"sbom reicht", "mit sbom sind wir", "sbom genügt", "sbom genuegt", "nur eine sbom",
|
||||
"sbom allein",
|
||||
],
|
||||
verdict=InterpretationVerdict.TOO_NARROW,
|
||||
corrected_interpretation=(
|
||||
"Eine SBOM erfüllt nur einen Teil der Komponenten-Transparenz. Schwachstellen-"
|
||||
"überwachung, Update-/Patch-Prozess und technische Dokumentation bleiben eigenständige Pflichten."
|
||||
),
|
||||
explanation="SBOM ist Voraussetzung, ersetzt aber nicht Vulnerability-Handling und Updates.",
|
||||
affected_regulations=["CRA"],
|
||||
affected_obligations=["sbom_creation", "vuln_handling_process", "provide_security_updates"],
|
||||
risks=["Falsche Annahme vollständiger Erfüllung trotz fehlendem Vulnerability-Prozess."],
|
||||
legal_basis_refs=["CRA Annex I Part II (1)", "CRA Annex I Part II (2)"],
|
||||
confidence=Confidence.HIGH,
|
||||
),
|
||||
InterpretationPattern(
|
||||
pattern_id="open_source_exempt",
|
||||
triggers=[
|
||||
"open source ist ausgenommen", "open-source ist ausgenommen", "oss ist ausgenommen",
|
||||
"freie software ist ausgenommen", "open source fällt nicht",
|
||||
],
|
||||
verdict=InterpretationVerdict.PARTIALLY_CORRECT,
|
||||
corrected_interpretation=(
|
||||
"Nur nicht-kommerziell bereitgestellte Open-Source-Software ist ausgenommen. Sobald OSS "
|
||||
"kommerziell in ein Produkt integriert und auf dem Markt bereitgestellt wird, greift der CRA."
|
||||
),
|
||||
explanation="Die Ausnahme zielt auf nicht-kommerzielle OSS-Bereitstellung, nicht auf kommerzielle Produktintegration.",
|
||||
affected_regulations=["CRA"],
|
||||
risks=["Kommerziell integrierte OSS-Komponenten werden fälschlich als ausgenommen behandelt."],
|
||||
legal_basis_refs=["CRA Art. 2", "CRA Erwägungsgründe (Open-Source-Stewards)"],
|
||||
confidence=Confidence.MEDIUM,
|
||||
),
|
||||
InterpretationPattern(
|
||||
pattern_id="reactive_updates_ok",
|
||||
triggers=[
|
||||
"updates nur wenn", "reaktive updates reichen", "wenn kunden melden reicht",
|
||||
"updates wenn fehler gemeldet",
|
||||
],
|
||||
verdict=InterpretationVerdict.TOO_NARROW,
|
||||
corrected_interpretation=(
|
||||
"Der CRA verlangt aktive Schwachstellenüberwachung und zeitnahe Sicherheitsupdates über "
|
||||
"den Supportzeitraum, nicht nur reaktive Updates nach Kundenmeldung."
|
||||
),
|
||||
explanation="Ein rein reaktiver Updateprozess erfüllt die Pflicht zur aktiven Schwachstellenbehandlung nicht.",
|
||||
affected_regulations=["CRA"],
|
||||
affected_obligations=["provide_security_updates", "vuln_handling_process"],
|
||||
risks=["Verzögerte Reaktion auf öffentlich bekannte Schwachstellen; Pflichtverletzung."],
|
||||
legal_basis_refs=["CRA Annex I Part II (1)", "CRA Annex I (2)(c)"],
|
||||
confidence=Confidence.HIGH,
|
||||
),
|
||||
InterpretationPattern(
|
||||
pattern_id="machinery_covers_cyber",
|
||||
triggers=[
|
||||
"maschinenrichtlinie deckt cyber", "maschinenvo deckt alles", "ce der maschine reicht",
|
||||
"ce maschine reicht für cyber", "maschinen-ce reicht",
|
||||
],
|
||||
verdict=InterpretationVerdict.PARTIALLY_CORRECT,
|
||||
corrected_interpretation=(
|
||||
"Die MaschinenVO deckt die sicherheitsrelevante Korrumpierung ab (Anhang III 1.1.9), "
|
||||
"ersetzt aber nicht die produktbezogenen CRA-Security-Pflichten. Beide Regime gelten parallel."
|
||||
),
|
||||
explanation="Maschinen-CE und CRA überschneiden sich nur dort, wo Cyber eine Sicherheitsfunktion betrifft.",
|
||||
affected_regulations=["CRA", "MaschinenVO"],
|
||||
affected_obligations=["machine_protection_against_corruption", "vuln_handling_process"],
|
||||
risks=["CRA-Pflichten werden übersehen, weil die Maschine bereits CE-gekennzeichnet ist."],
|
||||
legal_basis_refs=["MaschinenVO Anhang III (1.1.9)", "CRA Art. 13"],
|
||||
confidence=Confidence.MEDIUM,
|
||||
),
|
||||
InterpretationPattern(
|
||||
pattern_id="no_radio_no_cyber",
|
||||
triggers=[
|
||||
"ohne funkmodul kein cyber", "kein funk also kein cra", "ohne funk keine security",
|
||||
"ohne funkmodul keine cyber",
|
||||
],
|
||||
verdict=InterpretationVerdict.TOO_NARROW,
|
||||
corrected_interpretation=(
|
||||
"Der CRA knüpft an digitale Elemente an, nicht an ein Funkmodul. Ohne Funk entfällt die "
|
||||
"RED, der CRA bleibt jedoch anwendbar, sobald Software vorhanden ist."
|
||||
),
|
||||
explanation="Funkmodul ist nur für die RED relevant; die CRA-Anwendbarkeit folgt aus der Software.",
|
||||
affected_regulations=["CRA", "RED"],
|
||||
risks=["CRA wird fälschlich verneint, weil kein Funkmodul vorhanden ist."],
|
||||
legal_basis_refs=["CRA Art. 3(1)", "RED 2014/53/EU Art. 1"],
|
||||
confidence=Confidence.HIGH,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Regulatory Map — customer-readable read-model over the engine's scope output.
|
||||
|
||||
Composes scope + registry-linked obligations + overlaps into one map:
|
||||
product -> trigger facts -> applicable / uncertain / excluded regulations ->
|
||||
obligations -> overlaps -> unsupported domains -> executive summary. Explains the
|
||||
engine's state, never extends it. No new logic, no UI, no RAG, no percentage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .renderer import render_regulatory_map
|
||||
from .schemas import (
|
||||
ApplicableRegulationView,
|
||||
ExcludedRegulationView,
|
||||
ObligationRef,
|
||||
OverlapView,
|
||||
RegulatoryMap,
|
||||
RegulatoryMapRequest,
|
||||
UncertainRegulationView,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"render_regulatory_map",
|
||||
"RegulatoryMap",
|
||||
"RegulatoryMapRequest",
|
||||
"ApplicableRegulationView",
|
||||
"UncertainRegulationView",
|
||||
"ExcludedRegulationView",
|
||||
"OverlapView",
|
||||
"ObligationRef",
|
||||
]
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Regulatory Map renderer (step 4) — pure composition, no new logic.
|
||||
|
||||
It explains the engine's state, it does not extend it: every statement comes
|
||||
from `resolve_product_scope` (scope verdict) or `derive_obligations` (registry-
|
||||
linked obligations + overlaps). No legal decisions here; obligations are shown
|
||||
ONLY where a registry id is linkable (registry_anchor); the executive summary
|
||||
carries counts but NO percentage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from compliance.navigator.engine import navigate
|
||||
from compliance.product_scope.orchestrator import resolve_product_scope
|
||||
from compliance.product_scope.schemas import RegulatoryScopeResult, ScopeStatus
|
||||
from compliance.profile.canonical import CanonicalProductRegulatoryProfile
|
||||
from compliance.profile.to_reasoning import to_reasoning_profile
|
||||
from compliance.reasoning.obligation_engine import derive_obligations
|
||||
|
||||
from .schemas import (
|
||||
ApplicableRegulationView,
|
||||
ExcludedRegulationView,
|
||||
ObligationRef,
|
||||
OverlapView,
|
||||
RegulatoryMap,
|
||||
UncertainRegulationView,
|
||||
)
|
||||
|
||||
_DOMAIN_BY_REG = {
|
||||
"CRA": "cyber",
|
||||
"MaschinenVO": "machine_safety",
|
||||
"RED": "radio",
|
||||
"DataAct": "data",
|
||||
"EMV": "emv",
|
||||
"NIS2": None,
|
||||
}
|
||||
|
||||
|
||||
def _product_summary(c: CanonicalProductRegulatoryProfile) -> str:
|
||||
bits: List[str] = [c.name or "Produkt"]
|
||||
if c.product_type:
|
||||
bits.append("(%s)" % c.product_type.value)
|
||||
sig: List[str] = []
|
||||
if c.is_machine:
|
||||
sig.append("Maschine")
|
||||
if c.has_remote_access or c.connected_to_internet or "cloud" in c.technologies:
|
||||
sig.append("vernetzt")
|
||||
if c.has_embedded_software:
|
||||
sig.append("Firmware")
|
||||
if c.economic_operator_role:
|
||||
sig.append("Rolle: %s" % c.economic_operator_role.value)
|
||||
if c.markets:
|
||||
sig.append("Märkte: %s" % ", ".join(c.markets))
|
||||
if sig:
|
||||
bits.append("— " + "; ".join(sig))
|
||||
return " ".join(bits)
|
||||
|
||||
|
||||
def render_regulatory_map(profile: CanonicalProductRegulatoryProfile) -> RegulatoryMap:
|
||||
scope_resp = resolve_product_scope(profile)
|
||||
summary = _product_summary(profile)
|
||||
|
||||
if scope_resp.status == ScopeStatus.NEEDS_FACTS:
|
||||
return RegulatoryMap(
|
||||
scope_resolved=False,
|
||||
product_summary=summary,
|
||||
executive_summary=(
|
||||
"Regulatorischer Scope noch nicht bestimmbar — zuerst Mindestfakten klären: "
|
||||
+ "; ".join(scope_resp.missing_facts[:6])
|
||||
+ "."
|
||||
),
|
||||
)
|
||||
|
||||
scope = scope_resp.regulatory_scope
|
||||
assert scope is not None
|
||||
obligations = derive_obligations(to_reasoning_profile(profile))
|
||||
nav_questions = navigate(profile).suggested_questions
|
||||
|
||||
linked_ids = {o.obligation_id for o in obligations.applicable_obligations if o.registry_anchor}
|
||||
by_reg: Dict[str, List[ObligationRef]] = {}
|
||||
shared_ev: Dict[str, List[str]] = {}
|
||||
for o in obligations.applicable_obligations:
|
||||
if not o.registry_anchor:
|
||||
continue
|
||||
by_reg.setdefault(o.source_regulation, []).append(
|
||||
ObligationRef(
|
||||
obligation_id=o.obligation_id,
|
||||
title=o.title,
|
||||
legal_basis_refs=o.legal_basis_refs,
|
||||
authority_level=o.authority_level,
|
||||
)
|
||||
)
|
||||
for ev in o.required_evidence:
|
||||
shared_ev.setdefault(ev, []).append(o.obligation_id)
|
||||
|
||||
applicable_views = []
|
||||
for r in scope.applicable_regulations:
|
||||
obs = by_reg.get(r.regulation_id, [])
|
||||
applicable_views.append(
|
||||
ApplicableRegulationView(
|
||||
regulation_id=r.regulation_id,
|
||||
name=r.name,
|
||||
why_applicable=r.explanation,
|
||||
triggered_by=r.trigger_facts,
|
||||
obligations=obs,
|
||||
obligations_note="" if obs else "Pflichten für dieses Regelwerk sind noch nicht registry-verlinkt.",
|
||||
confidence=r.confidence,
|
||||
)
|
||||
)
|
||||
|
||||
uncertain_views = []
|
||||
for u in scope.uncertain_regulations:
|
||||
domain = _DOMAIN_BY_REG.get(u.regulation_id)
|
||||
qrefs = [q.question_id for q in nav_questions if domain and domain in q.regulatory_domains_unblocked]
|
||||
uncertain_views.append(
|
||||
UncertainRegulationView(
|
||||
regulation_id=u.regulation_id, name=u.name, missing_facts=u.missing_facts, question_refs=qrefs
|
||||
)
|
||||
)
|
||||
|
||||
overlap_views = []
|
||||
for ov in obligations.overlaps:
|
||||
members = [m for m in ov.obligations if m in linked_ids]
|
||||
if len(members) >= 2:
|
||||
overlap_views.append(
|
||||
OverlapView(overlap_group_id=ov.overlap_group_id, shared_obligations=members, explanation=ov.explanation)
|
||||
)
|
||||
|
||||
trigger_facts: List[str] = []
|
||||
for v in applicable_views:
|
||||
for t in v.triggered_by:
|
||||
if t not in trigger_facts:
|
||||
trigger_facts.append(t)
|
||||
|
||||
return RegulatoryMap(
|
||||
scope_resolved=True,
|
||||
product_summary=summary,
|
||||
trigger_facts=trigger_facts,
|
||||
applicable_regulations=applicable_views,
|
||||
uncertain_regulations=uncertain_views,
|
||||
excluded_regulations=[
|
||||
ExcludedRegulationView(regulation_id=e.regulation_id, name=e.name, exclusion_reason=e.reason)
|
||||
for e in scope.excluded_regulations
|
||||
],
|
||||
unsupported_domains=scope.unsupported_domains,
|
||||
overlaps=overlap_views,
|
||||
shared_evidence={ev: ids for ev, ids in shared_ev.items() if len(ids) > 1},
|
||||
executive_summary=_executive_summary(summary, applicable_views, uncertain_views, scope, len(linked_ids)),
|
||||
)
|
||||
|
||||
|
||||
def _executive_summary(
|
||||
summary: str,
|
||||
applicable: List[ApplicableRegulationView],
|
||||
uncertain: List[UncertainRegulationView],
|
||||
scope: RegulatoryScopeResult,
|
||||
n_obligations: int,
|
||||
) -> str:
|
||||
appl = ", ".join(v.regulation_id for v in applicable) or "—"
|
||||
unc = ", ".join(v.regulation_id for v in uncertain) or "keine"
|
||||
exc = ", ".join(e.regulation_id for e in scope.excluded_regulations) or "keine"
|
||||
uns = ", ".join(d.domain for d in scope.unsupported_domains) or "keine"
|
||||
return (
|
||||
"Für %s gelten nach derzeitigem Stand wahrscheinlich: %s. Unsicher (fehlende Fakten): %s. "
|
||||
"Ausgeschlossen: %s. Nicht abgedeckt (Regelkorpus fehlt): %s. Ermittelt: %d registry-verlinkte "
|
||||
"Pflichten. Es wurden keine weiteren Regelwerke im aktuellen Korpus identifiziert."
|
||||
% (summary, appl, unc, exc, uns, n_obligations)
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Read-model for the Regulatory Map (step 4).
|
||||
|
||||
A customer-readable view that COMPOSES what the engine already computed (scope +
|
||||
obligations + overlaps). It adds no scope/obligation logic. All fields are
|
||||
application-level presentation types — NOT compliance-meta-model classes
|
||||
(architecture freeze v1.0 untouched).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from compliance.product_scope.schemas import UnsupportedDomain
|
||||
from compliance.profile.canonical import CanonicalProductRegulatoryProfile
|
||||
from compliance.reasoning.enums import AuthorityLevel, Confidence
|
||||
|
||||
|
||||
class RegulatoryMapRequest(BaseModel):
|
||||
product_profile: CanonicalProductRegulatoryProfile
|
||||
|
||||
|
||||
class ObligationRef(BaseModel):
|
||||
obligation_id: str
|
||||
title: str
|
||||
legal_basis_refs: List[str] = Field(default_factory=list)
|
||||
authority_level: AuthorityLevel
|
||||
|
||||
|
||||
class ApplicableRegulationView(BaseModel):
|
||||
regulation_id: str
|
||||
name: str
|
||||
why_applicable: str
|
||||
triggered_by: List[str] = Field(default_factory=list)
|
||||
obligations: List[ObligationRef] = Field(default_factory=list)
|
||||
obligations_note: str = "" # set when obligations are not yet registry-linkable
|
||||
confidence: Confidence
|
||||
|
||||
|
||||
class UncertainRegulationView(BaseModel):
|
||||
regulation_id: str
|
||||
name: str
|
||||
missing_facts: List[str] = Field(default_factory=list)
|
||||
question_refs: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ExcludedRegulationView(BaseModel):
|
||||
regulation_id: str
|
||||
name: str
|
||||
exclusion_reason: str
|
||||
|
||||
|
||||
class OverlapView(BaseModel):
|
||||
overlap_group_id: str
|
||||
shared_obligations: List[str] = Field(default_factory=list)
|
||||
explanation: str = ""
|
||||
|
||||
|
||||
class RegulatoryMap(BaseModel):
|
||||
scope_resolved: bool
|
||||
product_summary: str
|
||||
trigger_facts: List[str] = Field(default_factory=list)
|
||||
applicable_regulations: List[ApplicableRegulationView] = Field(default_factory=list)
|
||||
uncertain_regulations: List[UncertainRegulationView] = Field(default_factory=list)
|
||||
excluded_regulations: List[ExcludedRegulationView] = Field(default_factory=list)
|
||||
unsupported_domains: List[UnsupportedDomain] = Field(default_factory=list)
|
||||
overlaps: List[OverlapView] = Field(default_factory=list)
|
||||
shared_evidence: Dict[str, List[str]] = Field(default_factory=dict)
|
||||
executive_summary: str = ""
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Tests for Company Intelligence (Phase 2A) — Company Capability Profile.
|
||||
|
||||
Acceptance: from a CompanyContext (certifications, declarations, evidence) the
|
||||
engine derives operational capabilities with a four-state trust model and a HARD
|
||||
RULE: a certification is NEVER auto-treated as "erfuellt" — at most INFERRED.
|
||||
|
||||
The Certification->Capability mapping is Execution's domain. It is injected here as
|
||||
a MOCK (the yaml-like dict below lives ONLY in tests); product code ships no table.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from compliance.company import (
|
||||
CapabilityMappingEntry,
|
||||
Certification,
|
||||
CompanyContext,
|
||||
Declaration,
|
||||
ExistingEvidence,
|
||||
VerificationStatus,
|
||||
build_company_profile,
|
||||
)
|
||||
from compliance.reasoning.enums import Confidence
|
||||
|
||||
# --- MOCK mapping (Execution-owned in reality; here only for the tests) -------
|
||||
# mapping:
|
||||
# ISO27001 -> [cap_patch_management, cap_supplier_management]
|
||||
MOCK_MAPPING = {
|
||||
"ISO27001": CapabilityMappingEntry(
|
||||
capability_ids=["cap_patch_management", "cap_supplier_management"],
|
||||
confidence=Confidence.MEDIUM,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _candidate(profile, capability_id):
|
||||
return [c for c in profile.candidate_capabilities if c.capability_id == capability_id]
|
||||
|
||||
|
||||
def _confirmed_ids(profile):
|
||||
return {c.capability_id for c in profile.confirmed_capabilities}
|
||||
|
||||
|
||||
# A certification yields INFERRED candidates via the injected mapping.
|
||||
def test_certification_infers_candidates_via_injected_mapping():
|
||||
ctx = CompanyContext(company_id="acme", certifications=[Certification(certification_id="ISO27001")])
|
||||
profile = build_company_profile(ctx, MOCK_MAPPING)
|
||||
ids = {c.capability_id for c in profile.candidate_capabilities}
|
||||
assert ids == {"cap_patch_management", "cap_supplier_management"}
|
||||
for c in profile.candidate_capabilities:
|
||||
assert c.verification_status == VerificationStatus.INFERRED
|
||||
assert c.source == "certification:ISO27001"
|
||||
|
||||
|
||||
# Without an injected mapping there are NO inferred capabilities — only the claim.
|
||||
# This is the architectural guarantee that the table lives only in Execution.
|
||||
def test_no_mapping_no_inferred_capabilities():
|
||||
ctx = CompanyContext(company_id="acme", certifications=[Certification(certification_id="ISO27001")])
|
||||
profile = build_company_profile(ctx) # default EMPTY mapping
|
||||
assert profile.candidate_capabilities == []
|
||||
# the certification still produced evidence-of-claim (refinement 1)
|
||||
assert len(profile.capability_evidence) == 1
|
||||
assert profile.capability_evidence[0].source == "certification:ISO27001"
|
||||
assert profile.capability_evidence[0].certification_id == "ISO27001"
|
||||
|
||||
|
||||
# A customer declaration yields a DECLARED candidate.
|
||||
def test_declaration_yields_declared_candidate():
|
||||
ctx = CompanyContext(company_id="acme", declarations=[Declaration(capability_id="cap_patch_management")])
|
||||
profile = build_company_profile(ctx, MOCK_MAPPING)
|
||||
cands = _candidate(profile, "cap_patch_management")
|
||||
assert len(cands) == 1
|
||||
assert cands[0].verification_status == VerificationStatus.DECLARED
|
||||
|
||||
|
||||
# declared + inferred coexist as distinct signals for the same capability.
|
||||
def test_declared_and_inferred_coexist():
|
||||
ctx = CompanyContext(
|
||||
company_id="acme",
|
||||
certifications=[Certification(certification_id="ISO27001")],
|
||||
declarations=[Declaration(capability_id="cap_patch_management")],
|
||||
)
|
||||
profile = build_company_profile(ctx, MOCK_MAPPING)
|
||||
statuses = {c.verification_status for c in _candidate(profile, "cap_patch_management")}
|
||||
assert statuses == {VerificationStatus.DECLARED, VerificationStatus.INFERRED}
|
||||
|
||||
|
||||
# HARD RULE: a certification alone NEVER yields a confirmed capability.
|
||||
def test_hard_rule_certification_never_confirmed():
|
||||
ctx = CompanyContext(company_id="acme", certifications=[Certification(certification_id="ISO27001")])
|
||||
profile = build_company_profile(ctx, MOCK_MAPPING)
|
||||
assert _confirmed_ids(profile) == set()
|
||||
for c in profile.candidate_capabilities:
|
||||
assert c.verification_status != VerificationStatus.CONFIRMED
|
||||
|
||||
|
||||
# Only real evidence confirms a capability — and it leaves the candidate list.
|
||||
def test_evidence_confirms_capability():
|
||||
ctx = CompanyContext(
|
||||
company_id="acme",
|
||||
certifications=[Certification(certification_id="ISO27001")],
|
||||
evidence=[ExistingEvidence(evidence_id="pol-1", evidence_type="policy", proves_capability_id="cap_patch_management")],
|
||||
)
|
||||
profile = build_company_profile(ctx, MOCK_MAPPING)
|
||||
assert "cap_patch_management" in _confirmed_ids(profile)
|
||||
confirmed = [c for c in profile.confirmed_capabilities if c.capability_id == "cap_patch_management"][0]
|
||||
assert confirmed.verification_status == VerificationStatus.CONFIRMED
|
||||
assert confirmed.confidence == Confidence.HIGH
|
||||
assert confirmed.sources == ["pol-1"]
|
||||
# a confirmed capability is no longer a mere candidate
|
||||
assert _candidate(profile, "cap_patch_management") == []
|
||||
# the un-proven capability stays an inferred candidate
|
||||
assert _candidate(profile, "cap_supplier_management")[0].verification_status == VerificationStatus.INFERRED
|
||||
|
||||
|
||||
# The four-state vocabulary exists and is ordered declared->inferred->confirmed (+unknown).
|
||||
def test_four_states_present():
|
||||
assert {s.value for s in VerificationStatus} == {"declared", "inferred", "confirmed", "unknown"}
|
||||
|
||||
|
||||
# verification_status is a FOURTH vocabulary, disjoint from ClaimCoverage and DeltaType.
|
||||
def test_verification_status_distinct_vocabulary():
|
||||
from compliance.rci.schemas import DeltaType
|
||||
from compliance.reasoning.enums import ClaimCoverage
|
||||
|
||||
verif = {s.value for s in VerificationStatus}
|
||||
assert verif.isdisjoint({c.value for c in ClaimCoverage})
|
||||
assert verif.isdisjoint({d.value for d in DeltaType})
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Tests for Interpretation-in-Map (step 5).
|
||||
|
||||
Acceptance: a customer interpretation is judged against the existing map, using
|
||||
only assess_interpretation; affected regulations/obligations are referenced from
|
||||
the map; unsupported domains (wastewater/chemicals) are flagged
|
||||
future_corpus_needed, not pseudo-evaluated; output is customer-readable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from compliance.interpretation_map import interpret_in_map
|
||||
from compliance.profile.canonical import (
|
||||
CanonicalLifecyclePhase,
|
||||
CanonicalProductRegulatoryProfile,
|
||||
CanonicalProductType,
|
||||
EconomicOperatorRole,
|
||||
EnvironmentalImpact,
|
||||
)
|
||||
from compliance.reasoning.enums import InterpretationVerdict
|
||||
from compliance.reasoning.interpretation_engine import assess_interpretation
|
||||
from compliance.regulatory_map import render_regulatory_map
|
||||
|
||||
|
||||
def ready_profile(**ov) -> CanonicalProductRegulatoryProfile:
|
||||
base = dict(
|
||||
name="Industriespülmaschine",
|
||||
product_type=CanonicalProductType.MACHINERY,
|
||||
markets=["EU", "DE"],
|
||||
economic_operator_role=EconomicOperatorRole.MANUFACTURER,
|
||||
lifecycle_phase=CanonicalLifecyclePhase.PLACING_ON_MARKET,
|
||||
is_machine=True,
|
||||
is_component=False,
|
||||
has_software_updates=True,
|
||||
has_embedded_software=True,
|
||||
has_remote_access=True,
|
||||
technologies=["cloud", "ota_updates"],
|
||||
)
|
||||
base.update(ov)
|
||||
return CanonicalProductRegulatoryProfile(**base)
|
||||
|
||||
|
||||
def _map(**ov):
|
||||
return render_regulatory_map(ready_profile(**ov))
|
||||
|
||||
|
||||
# 1 + 2. evaluated against the map, using ONLY assess_interpretation.
|
||||
def test_uses_assess_interpretation_verdict():
|
||||
text = "Wir glauben, der CRA gilt nur für neue Produkte."
|
||||
result = interpret_in_map(_map(), text)
|
||||
assert result.assessment == assess_interpretation(text).assessment == InterpretationVerdict.TOO_NARROW
|
||||
assert "CRA" in result.affected_regulations # CRA is in the map
|
||||
assert result.in_scope_of_map is True
|
||||
|
||||
|
||||
# 3. the six verdict values pass through unchanged.
|
||||
def test_verdict_values():
|
||||
m = _map()
|
||||
assert interpret_in_map(m, "CRA gilt nur für neue Produkte.").assessment == InterpretationVerdict.TOO_NARROW
|
||||
assert interpret_in_map(m, "Open Source ist ausgenommen, also betrifft uns der CRA nicht.").assessment == InterpretationVerdict.PARTIALLY_CORRECT
|
||||
assert interpret_in_map(m, "Der Mond beeinflusst unsere Updatezyklen.").assessment == InterpretationVerdict.UNCERTAIN
|
||||
|
||||
|
||||
# 4. affected regulations/obligations are referenced FROM the map.
|
||||
def test_affected_refs_from_map():
|
||||
m = _map()
|
||||
result = interpret_in_map(m, "Eine SBOM reicht, dann sind wir fertig.")
|
||||
map_ob_ids = {o.obligation_id for v in m.applicable_regulations for o in v.obligations}
|
||||
map_reg_ids = {v.regulation_id for v in m.applicable_regulations} | {v.regulation_id for v in m.uncertain_regulations}
|
||||
assert "sbom_creation" in result.affected_obligations
|
||||
assert set(result.affected_obligations) <= map_ob_ids
|
||||
assert set(result.affected_regulations) <= map_reg_ids
|
||||
|
||||
|
||||
# 5. environmental aspects are NOT pseudo-evaluated.
|
||||
def test_environmental_not_pseudo_evaluated():
|
||||
m = _map(environmental=EnvironmentalImpact(discharges_to_wastewater=True))
|
||||
result = interpret_in_map(m, "Beim Abwasser sind wir nicht betroffen, das spielt für uns keine Rolle.")
|
||||
domains = {d.domain for d in result.future_corpus_domains}
|
||||
assert "environment_water" in domains
|
||||
assert "future_corpus_needed" in result.explanation
|
||||
|
||||
|
||||
# 6. output is customer-readable.
|
||||
def test_customer_readable():
|
||||
result = interpret_in_map(_map(), "Der CRA gilt nur für neue Produkte.")
|
||||
assert "zu eng" in result.explanation
|
||||
assert result.explanation.startswith("Ihre Interpretation ist wahrscheinlich")
|
||||
|
||||
|
||||
# affected refs never leave the map (no abstract legal questions).
|
||||
def test_affected_regs_never_outside_map():
|
||||
m = _map()
|
||||
map_reg_ids = (
|
||||
{v.regulation_id for v in m.applicable_regulations}
|
||||
| {v.regulation_id for v in m.uncertain_regulations}
|
||||
| {v.regulation_id for v in m.excluded_regulations}
|
||||
)
|
||||
for text in ["CRA gilt nur für neue Produkte.", "Ohne Funkmodul keine Cyber-Pflichten.", "SBOM reicht."]:
|
||||
result = interpret_in_map(m, text)
|
||||
assert set(result.affected_regulations) <= map_reg_ids
|
||||
|
||||
|
||||
# endpoint smoke.
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
from compliance.api.reasoning_routes import router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_endpoint_interpretation_in_map(client):
|
||||
r = client.post(
|
||||
"/reasoning/interpretation-in-map",
|
||||
json={
|
||||
"product_profile": {
|
||||
"name": "M",
|
||||
"product_type": "machinery",
|
||||
"markets": ["EU"],
|
||||
"economic_operator_role": "manufacturer",
|
||||
"lifecycle_phase": "placing_on_market",
|
||||
"is_machine": True,
|
||||
"is_component": False,
|
||||
"has_software_updates": True,
|
||||
"has_embedded_software": True,
|
||||
"has_remote_access": True,
|
||||
"technologies": ["cloud"],
|
||||
},
|
||||
"customer_interpretation": "Der CRA gilt nur für neue Produkte.",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["assessment"] == "too_narrow"
|
||||
assert "CRA" in body["affected_regulations"]
|
||||
assert "zu eng" in body["explanation"]
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Tests for the Product Regulatory Navigator (missing-facts layer).
|
||||
|
||||
Acceptance: a well-filled company-profile yields <= 10 questions; known facts are
|
||||
not re-asked; environmental questions are trigger-only (no law evaluation); the
|
||||
Navigator decides which facts are missing, NOT what applies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from compliance.navigator import NavigatorResult, apply_answers, navigate
|
||||
from compliance.navigator.questions import QUESTION_CATALOG, QuestionPriority
|
||||
from compliance.profile import from_company_profile
|
||||
from compliance.profile.canonical import CanonicalProductRegulatoryProfile, EconomicOperatorRole
|
||||
|
||||
COMPANY = {
|
||||
"industry": "Maschinenbau",
|
||||
"business_model": "B2B",
|
||||
"company_size": "medium",
|
||||
"target_markets": ["DE", "EU"],
|
||||
"primary_jurisdiction": "DE",
|
||||
"machine_builder": {
|
||||
"productTypes": ["special_machine"],
|
||||
"containsFirmware": True,
|
||||
"hasSafetyFunction": True,
|
||||
"isNetworked": True,
|
||||
"hasRemoteAccess": True,
|
||||
"hasOTAUpdates": True,
|
||||
"hasRiskAssessment": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _empty() -> CanonicalProductRegulatoryProfile:
|
||||
return CanonicalProductRegulatoryProfile(name="X")
|
||||
|
||||
|
||||
# 1. well-filled company-profile -> at most 10 questions.
|
||||
def test_filled_company_profile_at_most_10_questions():
|
||||
result = navigate(from_company_profile(COMPANY))
|
||||
assert len(result.suggested_questions) <= 10
|
||||
|
||||
|
||||
# 2. known facts (markets, is_machine) are not re-asked; true gaps still are.
|
||||
def test_known_facts_not_reasked():
|
||||
result = navigate(from_company_profile(COMPANY))
|
||||
assert "markets" not in result.missing_facts
|
||||
assert "is_machine" not in result.missing_facts
|
||||
# genuine gaps the company-profile cannot provide are still surfaced
|
||||
assert "economic_operator_role" in result.missing_facts
|
||||
assert "has_radio_module" in result.missing_facts
|
||||
|
||||
|
||||
# 3. environmental questions are trigger-only — no environmental-law evaluation.
|
||||
def test_environmental_questions_are_triggers_only():
|
||||
result = navigate(_empty())
|
||||
env = [q for q in result.suggested_questions if q.target_field.startswith("environmental.")]
|
||||
assert len(env) >= 3
|
||||
assert all(q.answer_type.value == "bool" for q in env)
|
||||
|
||||
|
||||
# 4. the Navigator decides only missing facts, never what applies.
|
||||
def test_navigator_decides_only_missing_facts():
|
||||
assert set(NavigatorResult.model_fields.keys()) == {
|
||||
"missing_facts",
|
||||
"suggested_questions",
|
||||
"completeness_summary",
|
||||
}
|
||||
# no question carries a verdict — only metadata about what it would unblock
|
||||
for q in QUESTION_CATALOG:
|
||||
assert q.regulatory_domains_unblocked # metadata, not a decision
|
||||
assert hasattr(q, "answer_type")
|
||||
|
||||
|
||||
# 5. apply_answers updates the profile; answered facts drop out of missing.
|
||||
def test_apply_answers_updates_profile():
|
||||
profile = from_company_profile(COMPANY)
|
||||
updated = apply_answers(
|
||||
profile,
|
||||
{
|
||||
"economic_operator_role": "manufacturer",
|
||||
"markets": ["DE", "US"],
|
||||
"has_radio_module": True,
|
||||
"env_wastewater": True,
|
||||
},
|
||||
)
|
||||
assert updated.economic_operator_role == EconomicOperatorRole.MANUFACTURER
|
||||
assert updated.markets == ["DE", "US"]
|
||||
assert updated.has_radio_module is True
|
||||
assert updated.environmental.discharges_to_wastewater is True
|
||||
|
||||
after = navigate(updated)
|
||||
assert "economic_operator_role" not in after.missing_facts
|
||||
assert "has_radio_module" not in after.missing_facts
|
||||
assert "environmental.discharges_to_wastewater" not in after.missing_facts
|
||||
|
||||
|
||||
# 6. questions are ordered P0 -> P1 -> P2.
|
||||
def test_priority_ordering():
|
||||
questions = navigate(_empty()).suggested_questions
|
||||
orders = [q.order() for q in questions]
|
||||
assert orders == sorted(orders)
|
||||
assert questions[0].priority == QuestionPriority.P0
|
||||
|
||||
|
||||
# 7. ready_for_scope flips once all P0 facts are answered.
|
||||
def test_ready_for_scope_after_p0():
|
||||
profile = _empty()
|
||||
assert navigate(profile).completeness_summary.ready_for_scope is False
|
||||
answered = apply_answers(
|
||||
profile,
|
||||
{
|
||||
"markets": ["DE"],
|
||||
"economic_operator_role": "manufacturer",
|
||||
"lifecycle_phase": "placing_on_market",
|
||||
"is_machine": True,
|
||||
"is_component": False,
|
||||
},
|
||||
)
|
||||
summary = navigate(answered).completeness_summary
|
||||
assert summary.ready_for_scope is True
|
||||
|
||||
|
||||
# 8. empty profile asks the full (bounded) catalog.
|
||||
def test_empty_profile_bounded_catalog():
|
||||
result = navigate(_empty())
|
||||
assert len(result.suggested_questions) == len(QUESTION_CATALOG)
|
||||
assert result.completeness_summary.total_relevant == len(QUESTION_CATALOG)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tests for the product-scope orchestrator (step 3).
|
||||
|
||||
Acceptance: missing P0 facts -> discover_scope NOT run; ready -> run exactly once;
|
||||
response separates applicable/excluded/uncertain; environmental triggers appear
|
||||
only as unsupported_domain (future_corpus_needed), never as a legal evaluation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import compliance.product_scope.orchestrator as orch
|
||||
from compliance.product_scope import ScopeStatus, resolve_product_scope
|
||||
from compliance.profile.canonical import (
|
||||
CanonicalLifecyclePhase,
|
||||
CanonicalProductRegulatoryProfile,
|
||||
CanonicalProductType,
|
||||
EconomicOperatorRole,
|
||||
EnvironmentalImpact,
|
||||
)
|
||||
|
||||
_KNOWN_REGS = {"CRA", "MaschinenVO", "RED", "EMV", "DataAct", "NIS2"}
|
||||
|
||||
|
||||
def ready_profile(**ov) -> CanonicalProductRegulatoryProfile:
|
||||
base = dict(
|
||||
name="Industriespülmaschine",
|
||||
product_type=CanonicalProductType.MACHINERY,
|
||||
markets=["EU", "DE"],
|
||||
economic_operator_role=EconomicOperatorRole.MANUFACTURER,
|
||||
lifecycle_phase=CanonicalLifecyclePhase.PLACING_ON_MARKET,
|
||||
is_machine=True,
|
||||
is_component=False,
|
||||
has_software_updates=True,
|
||||
has_embedded_software=True,
|
||||
has_remote_access=True,
|
||||
has_safety_function=True,
|
||||
technologies=["cloud", "ota_updates"],
|
||||
)
|
||||
base.update(ov)
|
||||
return CanonicalProductRegulatoryProfile(**base)
|
||||
|
||||
|
||||
def _spy(monkeypatch):
|
||||
calls = {"n": 0}
|
||||
real = orch.discover_scope
|
||||
|
||||
def counting(profile):
|
||||
calls["n"] += 1
|
||||
return real(profile)
|
||||
|
||||
monkeypatch.setattr(orch, "discover_scope", counting)
|
||||
return calls
|
||||
|
||||
|
||||
# 1. missing P0 facts -> discover_scope is NOT executed.
|
||||
def test_needs_facts_does_not_run_scope(monkeypatch):
|
||||
calls = _spy(monkeypatch)
|
||||
resp = resolve_product_scope(CanonicalProductRegulatoryProfile(name="X"))
|
||||
assert resp.status == ScopeStatus.NEEDS_FACTS
|
||||
assert resp.regulatory_scope is None
|
||||
assert resp.missing_facts
|
||||
assert calls["n"] == 0
|
||||
|
||||
|
||||
# 2. ready_for_scope -> discover_scope runs exactly once.
|
||||
def test_ready_runs_scope_once(monkeypatch):
|
||||
calls = _spy(monkeypatch)
|
||||
resp = resolve_product_scope(ready_profile())
|
||||
assert resp.status == ScopeStatus.RESOLVED
|
||||
assert resp.regulatory_scope is not None
|
||||
assert calls["n"] == 1
|
||||
applicable = {r.regulation_id for r in resp.regulatory_scope.applicable_regulations}
|
||||
assert "CRA" in applicable and "MaschinenVO" in applicable
|
||||
|
||||
|
||||
# 3. the response separates the regulation categories.
|
||||
def test_response_separates_categories():
|
||||
scope = resolve_product_scope(ready_profile()).regulatory_scope
|
||||
assert scope is not None
|
||||
# all three buckets exist and only carry known regulation ids
|
||||
for bucket in (scope.applicable_regulations, scope.excluded_regulations, scope.uncertain_regulations):
|
||||
for r in bucket:
|
||||
assert r.regulation_id in _KNOWN_REGS
|
||||
assert scope.uncertain_regulations # e.g. RED/DataAct/NIS2 with unknown facts
|
||||
|
||||
|
||||
# 4. environmental triggers surface ONLY as unsupported_domain, never as law.
|
||||
def test_environmental_only_unsupported_domain():
|
||||
profile = ready_profile(
|
||||
environmental=EnvironmentalImpact(discharges_to_wastewater=True, uses_cleaning_chemicals=True)
|
||||
)
|
||||
scope = resolve_product_scope(profile).regulatory_scope
|
||||
assert scope is not None
|
||||
domains = {d.domain for d in scope.unsupported_domains}
|
||||
assert "environment_water" in domains and "chemicals" in domains
|
||||
assert all(d.status == "future_corpus_needed" for d in scope.unsupported_domains)
|
||||
# no environmental "regulation" leaked into the scope verdict
|
||||
all_regs = (
|
||||
scope.applicable_regulations + scope.excluded_regulations + scope.uncertain_regulations
|
||||
)
|
||||
assert all(r.regulation_id in _KNOWN_REGS for r in all_regs)
|
||||
|
||||
|
||||
# 5. endpoint smoke — both cases.
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
from compliance.api.reasoning_routes import router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_endpoint_needs_facts(client):
|
||||
r = client.post("/reasoning/product-scope", json={"product_profile": {"name": "X"}})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "needs_facts"
|
||||
assert body["regulatory_scope"] is None
|
||||
assert body["missing_facts"]
|
||||
|
||||
|
||||
def test_endpoint_resolved(client):
|
||||
r = client.post(
|
||||
"/reasoning/product-scope",
|
||||
json={
|
||||
"product_profile": {
|
||||
"name": "M",
|
||||
"product_type": "machinery",
|
||||
"markets": ["EU"],
|
||||
"economic_operator_role": "manufacturer",
|
||||
"lifecycle_phase": "placing_on_market",
|
||||
"is_machine": True,
|
||||
"is_component": False,
|
||||
"has_software_updates": True,
|
||||
"has_embedded_software": True,
|
||||
"has_remote_access": True,
|
||||
"technologies": ["cloud"],
|
||||
}
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "resolved"
|
||||
applicable = {x["regulation_id"] for x in body["regulatory_scope"]["applicable_regulations"]}
|
||||
assert "CRA" in applicable and "MaschinenVO" in applicable
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Tests for the Product Profile convergence layer.
|
||||
|
||||
Covers the 10 acceptance criteria of the CanonicalProductRegulatoryProfile spec:
|
||||
lossless ProductWizard mapping, company-profile prefill, AI stays delegated,
|
||||
markets no longer hardcoded, and the new Navigator fields (role/radio/usage-data/
|
||||
lifecycle/BOM) plus one-semantic-profile across reasoning + gap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from compliance.profile import (
|
||||
CanonicalLifecyclePhase,
|
||||
CanonicalProductRegulatoryProfile,
|
||||
CanonicalProductType,
|
||||
ComponentKind,
|
||||
EconomicOperatorRole,
|
||||
ProductComponent,
|
||||
from_company_profile,
|
||||
from_product_wizard,
|
||||
to_gap_profile,
|
||||
to_reasoning_profile,
|
||||
)
|
||||
from compliance.reasoning import discover_scope
|
||||
from compliance.reasoning.enums import ManufacturerRole, ProductLifecyclePhase
|
||||
|
||||
# A realistic ProductWizard payload — exactly the gap.ProductProfile JSON shape.
|
||||
WIZARD = {
|
||||
"name": "Industriespülmaschine",
|
||||
"description": "vernetzte Spülmaschine",
|
||||
"product_type": "machinery",
|
||||
"technologies": ["cloud", "ota_updates", "sensor", "actuator"],
|
||||
"data_processing": ["telemetry"],
|
||||
"markets": ["EU"],
|
||||
"connected_to_internet": True,
|
||||
"has_software_updates": True,
|
||||
"uses_ai": False,
|
||||
"processes_personal_data": False,
|
||||
"is_critical_infra_supplier": False,
|
||||
"existing_certifications": ["CE"],
|
||||
"applied_norms": ["ISO12100"],
|
||||
"has_risk_assessment": True,
|
||||
"has_technical_file": True,
|
||||
"has_operating_manual": True,
|
||||
"has_sbom": False,
|
||||
"has_vuln_management": False,
|
||||
"has_update_mechanism": True,
|
||||
"has_incident_response": False,
|
||||
"has_supply_chain_mgmt": False,
|
||||
"ce_marking_since": "",
|
||||
"product_age": "5",
|
||||
}
|
||||
|
||||
COMPANY = {
|
||||
"company_name": "ACME Maschinen GmbH",
|
||||
"industry": "Maschinenbau",
|
||||
"business_model": "B2B",
|
||||
"company_size": "medium",
|
||||
"target_markets": ["DE", "EU"],
|
||||
"primary_jurisdiction": "DE",
|
||||
"headquarters_country": "DE",
|
||||
"uses_ai": False,
|
||||
"is_data_controller": True,
|
||||
"machine_builder": {
|
||||
"productDescription": "Industriespülmaschine",
|
||||
"productTypes": ["special_machine"],
|
||||
"containsSoftware": True,
|
||||
"containsFirmware": True,
|
||||
"containsAI": False,
|
||||
"hasSafetyFunction": True,
|
||||
"safetyFunctionDescription": "Türverriegelung",
|
||||
"isNetworked": True,
|
||||
"hasRemoteAccess": True,
|
||||
"hasOTAUpdates": True,
|
||||
"hasRiskAssessment": True,
|
||||
"criticalSectorClients": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# 1. ProductWizard data maps losslessly into the canonical and back to gap shape.
|
||||
def test_product_wizard_lossless_roundtrip():
|
||||
canonical = from_product_wizard(WIZARD)
|
||||
assert to_gap_profile(canonical) == WIZARD
|
||||
|
||||
|
||||
# 2. company-profile can prefill the canonical profile.
|
||||
def test_company_profile_prefill():
|
||||
c = from_company_profile(COMPANY)
|
||||
assert c.sector_industry == "Maschinenbau"
|
||||
assert c.b2b_or_b2c == "B2B"
|
||||
assert c.company_size == "medium"
|
||||
assert "DE" in c.markets and "EU" in c.markets
|
||||
assert c.has_safety_function is True
|
||||
assert c.has_remote_access is True
|
||||
assert c.has_embedded_software is True
|
||||
assert c.is_machine is True
|
||||
assert c.description == "Industriespülmaschine"
|
||||
|
||||
|
||||
# 3. AI-Act/ucca stays delegated — only uses_ai is forwarded, no risk classification.
|
||||
def test_ai_classification_stays_delegated():
|
||||
c = CanonicalProductRegulatoryProfile(name="X", uses_ai=True)
|
||||
rp = to_reasoning_profile(c)
|
||||
assert rp.has_ai_functionality is True
|
||||
assert not hasattr(rp, "ai_risk_category") # no AI classification produced here
|
||||
|
||||
|
||||
# 4. markets are a real list, never hardcoded ['EU'].
|
||||
def test_markets_not_hardcoded_eu():
|
||||
assert CanonicalProductRegulatoryProfile(name="X").markets == []
|
||||
c = from_product_wizard({**WIZARD, "markets": ["US", "JP", "CA"]})
|
||||
assert c.markets == ["US", "JP", "CA"]
|
||||
assert to_gap_profile(c)["markets"] == ["US", "JP", "CA"]
|
||||
assert to_reasoning_profile(c).eu_market is False # non-EU markets -> not EU
|
||||
|
||||
|
||||
# 5. economic-operator role exists and maps to the reasoning role.
|
||||
def test_economic_operator_role_exists():
|
||||
c = CanonicalProductRegulatoryProfile(name="X", economic_operator_role=EconomicOperatorRole.IMPORTER)
|
||||
assert to_reasoning_profile(c).manufacturer_role == ManufacturerRole.IMPORTER
|
||||
|
||||
|
||||
# 6. radio_module exists (direct + inferred from a BOM component).
|
||||
def test_radio_module_exists():
|
||||
assert to_reasoning_profile(CanonicalProductRegulatoryProfile(name="X", has_radio_module=True)).has_radio_module is True
|
||||
c = CanonicalProductRegulatoryProfile(name="X", components=[ProductComponent(name="WLAN", kind=ComponentKind.RADIO_MODULE)])
|
||||
assert to_reasoning_profile(c).has_radio_module is True
|
||||
|
||||
|
||||
# 7. generates_usage_data exists (direct + inferred from telemetry).
|
||||
def test_generates_usage_data_exists():
|
||||
c = CanonicalProductRegulatoryProfile(name="X", generates_usage_data=True)
|
||||
assert to_reasoning_profile(c).generates_usage_data is True
|
||||
inferred = from_product_wizard(WIZARD) # data_processing has telemetry
|
||||
assert to_reasoning_profile(inferred).generates_usage_data is True
|
||||
|
||||
|
||||
# 8. lifecycle_phase exists and maps.
|
||||
def test_lifecycle_phase_exists():
|
||||
c = CanonicalProductRegulatoryProfile(name="X", lifecycle_phase=CanonicalLifecyclePhase.MAINTENANCE)
|
||||
assert to_reasoning_profile(c).lifecycle_phase == ProductLifecyclePhase.MAINTENANCE
|
||||
|
||||
|
||||
# 9. BOM components are structured.
|
||||
def test_bom_components_structured():
|
||||
c = CanonicalProductRegulatoryProfile(
|
||||
name="Spülmaschine",
|
||||
components=[
|
||||
ProductComponent(name="Umwälzpumpe", kind=ComponentKind.PUMP),
|
||||
ProductComponent(name="Heizung", kind=ComponentKind.HEATING),
|
||||
ProductComponent(name="SPS", kind=ComponentKind.PLC),
|
||||
ProductComponent(name="Abwasserablauf", kind=ComponentKind.WASTEWATER_OUTLET),
|
||||
],
|
||||
)
|
||||
kinds = {comp.kind for comp in c.components}
|
||||
assert ComponentKind.PLC in kinds and ComponentKind.WASTEWATER_OUTLET in kinds
|
||||
|
||||
|
||||
# 10. reasoning engine + gap engine run off ONE semantic profile.
|
||||
def test_one_semantic_profile_reasoning_and_gap():
|
||||
canonical = CanonicalProductRegulatoryProfile(
|
||||
name="Industriespülmaschine",
|
||||
product_type=CanonicalProductType.MACHINERY,
|
||||
economic_operator_role=EconomicOperatorRole.MANUFACTURER,
|
||||
markets=["EU", "DE"],
|
||||
is_machine=True,
|
||||
has_safety_function=True,
|
||||
has_remote_access=True,
|
||||
has_software_updates=True,
|
||||
has_embedded_software=True,
|
||||
technologies=["cloud", "ota_updates"],
|
||||
)
|
||||
gap = to_gap_profile(canonical)
|
||||
rp = to_reasoning_profile(canonical)
|
||||
|
||||
# same facts, two projections
|
||||
assert gap["markets"] == ["EU", "DE"]
|
||||
assert rp.eu_market is True
|
||||
assert rp.has_remote_access is True
|
||||
assert rp.has_cloud_connection is True
|
||||
assert rp.is_machine is True
|
||||
assert rp.manufacturer_role == ManufacturerRole.MANUFACTURER
|
||||
|
||||
# the projected reasoning profile actually drives the reasoning engine
|
||||
scope = discover_scope(rp)
|
||||
applicable = {r.regulation_id for r in scope.applicable_regulations}
|
||||
assert "CRA" in applicable
|
||||
assert "MaschinenVO" in applicable
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Tests for Regulatory Change Intelligence (RCI).
|
||||
|
||||
Acceptance: a simulated RegulatoryChange against a stored ComplianceBaseline can
|
||||
answer: (1) does it affect this product? (2) which obligations are new/changed?
|
||||
(3) which are likely already covered by existing evidence? (4) what must a human
|
||||
review? (5) what is not relevant?
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from compliance.profile.canonical import (
|
||||
CanonicalLifecyclePhase,
|
||||
CanonicalProductRegulatoryProfile,
|
||||
CanonicalProductType,
|
||||
EconomicOperatorRole,
|
||||
)
|
||||
from compliance.rci import (
|
||||
ChangeType,
|
||||
DeltaType,
|
||||
RegulatoryChange,
|
||||
assess_change,
|
||||
create_baseline,
|
||||
)
|
||||
|
||||
PROFILE = CanonicalProductRegulatoryProfile(
|
||||
name="Industriespülmaschine",
|
||||
product_type=CanonicalProductType.MACHINERY,
|
||||
markets=["EU", "DE"],
|
||||
economic_operator_role=EconomicOperatorRole.MANUFACTURER,
|
||||
lifecycle_phase=CanonicalLifecyclePhase.PLACING_ON_MARKET,
|
||||
is_machine=True,
|
||||
is_component=False,
|
||||
has_software_updates=True,
|
||||
has_embedded_software=True,
|
||||
has_remote_access=True,
|
||||
technologies=["cloud", "ota_updates"],
|
||||
)
|
||||
|
||||
# Evidence the customer already has, per obligation.
|
||||
EVIDENCE = {"provide_security_updates": ["policy", "ticket"], "sbom_creation": ["sbom"]}
|
||||
|
||||
BASELINE = create_baseline(PROFILE, EVIDENCE, baseline_id="b1")
|
||||
|
||||
|
||||
def _change(obs, regs=("CRA",), ctype=ChangeType.AMENDMENT, cid="c"):
|
||||
return RegulatoryChange(
|
||||
change_id=cid, affected_regulations=list(regs), affected_obligations=list(obs), change_type=ctype
|
||||
)
|
||||
|
||||
|
||||
def _by_id(assessment):
|
||||
return {d.obligation_id: d for d in assessment.deltas}
|
||||
|
||||
|
||||
# Baseline snapshots the registry-linked obligations from the frozen map.
|
||||
def test_baseline_snapshots_registry_obligations():
|
||||
assert "sbom_creation" in BASELINE.applicable_obligations
|
||||
assert "provide_security_updates" in BASELINE.applicable_obligations
|
||||
assert BASELINE.regulatory_map_snapshot.scope_resolved is True
|
||||
|
||||
|
||||
# 1 + 2. affects the product + flags a NEW obligation.
|
||||
def test_affects_product_and_new_obligation():
|
||||
a = assess_change(BASELINE, _change(["cra_new_requirement_xyz"], cid="c1"))
|
||||
assert a.affects_product is True
|
||||
assert _by_id(a)["cra_new_requirement_xyz"].delta_type == DeltaType.NEW
|
||||
|
||||
|
||||
# 2. an existing obligation amended -> CHANGED.
|
||||
def test_existing_obligation_changed():
|
||||
a = assess_change(BASELINE, _change(["sbom_creation"], cid="c2"))
|
||||
assert _by_id(a)["sbom_creation"].delta_type == DeltaType.CHANGED
|
||||
|
||||
|
||||
# 3. existing obligation with evidence + guidance update -> ALREADY_COVERED.
|
||||
def test_already_covered_by_evidence():
|
||||
a = assess_change(BASELINE, _change(["provide_security_updates"], ctype=ChangeType.GUIDANCE_UPDATE, cid="c3"))
|
||||
assert _by_id(a)["provide_security_updates"].delta_type == DeltaType.ALREADY_COVERED
|
||||
assert a.summary.already_covered == ["provide_security_updates"]
|
||||
|
||||
|
||||
# 4. what a human must review (existing obligation without evidence).
|
||||
def test_needs_review():
|
||||
a = assess_change(BASELINE, _change(["vuln_handling_process"], ctype=ChangeType.GUIDANCE_UPDATE, cid="c4"))
|
||||
assert _by_id(a)["vuln_handling_process"].delta_type == DeltaType.NEEDS_REVIEW
|
||||
assert "vuln_handling_process" in a.summary.needs_review
|
||||
assert "vuln_handling_process" in a.summary.what_matters_for_this_product
|
||||
|
||||
|
||||
# 5. a change to a regulation NOT in the map -> not relevant.
|
||||
def test_not_relevant_offmap_regulation():
|
||||
a = assess_change(BASELINE, _change(["psd2_strong_customer_auth"], regs=["PSD2"], ctype=ChangeType.NEW_REGULATION, cid="c5"))
|
||||
assert a.affects_product is False
|
||||
assert _by_id(a)["psd2_strong_customer_auth"].delta_type == DeltaType.NOT_APPLICABLE
|
||||
assert a.summary.not_relevant == ["psd2_strong_customer_auth"]
|
||||
|
||||
|
||||
# repeal removes an existing obligation.
|
||||
def test_repeal_removes_existing():
|
||||
a = assess_change(BASELINE, _change(["sbom_creation"], ctype=ChangeType.REPEAL, cid="c6"))
|
||||
assert _by_id(a)["sbom_creation"].delta_type == DeltaType.REMOVED
|
||||
|
||||
|
||||
# missing evidence is computed against the obligation's required evidence.
|
||||
def test_missing_evidence_on_changed():
|
||||
a = assess_change(BASELINE, _change(["sbom_creation"], cid="c7")) # requires sbom+repo_scan, has sbom
|
||||
d = _by_id(a)["sbom_creation"]
|
||||
assert "sbom" in d.affected_evidence
|
||||
assert "repo_scan" in d.missing_evidence
|
||||
|
||||
|
||||
# a change to an UNCERTAIN regulation -> needs review (resolve applicability first).
|
||||
def test_uncertain_regulation_needs_review():
|
||||
a = assess_change(BASELINE, _change(["red_cyber_req"], regs=["RED"], cid="c8"))
|
||||
assert a.affects_product is True # RED is in the map's uncertain bucket
|
||||
assert _by_id(a)["red_cyber_req"].delta_type == DeltaType.NEEDS_REVIEW
|
||||
|
||||
|
||||
# RCI answers "vs my map", not "what does the law say" — and works only on the snapshot.
|
||||
def test_works_against_stored_map_no_reevaluation():
|
||||
# a change with no affected_obligations still resolves affects_product from the map
|
||||
a = assess_change(BASELINE, RegulatoryChange(change_id="c9", affected_regulations=["CRA"], affected_obligations=[], change_type=ChangeType.AMENDMENT))
|
||||
assert a.affects_product is True
|
||||
assert a.deltas == []
|
||||
|
||||
|
||||
# delta_type is a THIRD vocabulary, disjoint from ClaimCoverage (Welt 1).
|
||||
def test_delta_vocabulary_distinct_from_claimcoverage():
|
||||
from compliance.reasoning.enums import ClaimCoverage
|
||||
|
||||
assert {d.value for d in DeltaType}.isdisjoint({c.value for c in ClaimCoverage})
|
||||
|
||||
|
||||
# the management summary aggregates the five buckets coherently.
|
||||
def test_summary_buckets():
|
||||
a = assess_change(
|
||||
BASELINE,
|
||||
RegulatoryChange(
|
||||
change_id="c10",
|
||||
affected_regulations=["CRA"],
|
||||
affected_obligations=["cra_new_one", "sbom_creation", "provide_security_updates"],
|
||||
change_type=ChangeType.AMENDMENT,
|
||||
),
|
||||
)
|
||||
s = a.summary
|
||||
assert "cra_new_one" in s.what_matters_for_this_product # NEW
|
||||
assert "sbom_creation" in s.needs_review # CHANGED -> review
|
||||
assert s.what_changed # non-empty management line
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Tests for the Regulatory Reasoning Engine.
|
||||
|
||||
Covers the five typical machine-builder scenarios and the ten acceptance
|
||||
questions from the build spec (§15). Engine tests are pure (no DB); the
|
||||
endpoint smoke tests mount only the reasoning router.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from compliance.reasoning import (
|
||||
assess_interpretation,
|
||||
derive_obligations,
|
||||
discover_scope,
|
||||
normalize_claim,
|
||||
reason_implementation_claim,
|
||||
)
|
||||
from compliance.reasoning.enums import (
|
||||
ApplicabilityStatus,
|
||||
ClaimCoverage,
|
||||
InterpretationVerdict,
|
||||
)
|
||||
from compliance.reasoning.schemas import ProductProfile
|
||||
from compliance.reasoning.enums import ManufacturerRole
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures / builders
|
||||
# ---------------------------------------------------------------------------
|
||||
def sps_profile(**overrides) -> ProductProfile:
|
||||
base = dict(
|
||||
product_name="SPS mit HMI",
|
||||
product_type=["SPS", "HMI", "Schaltschrank"],
|
||||
has_software=True,
|
||||
has_remote_access=True,
|
||||
has_cloud_connection=True,
|
||||
eu_market=True,
|
||||
manufacturer_role=ManufacturerRole.MANUFACTURER,
|
||||
)
|
||||
base.update(overrides)
|
||||
return ProductProfile(**base)
|
||||
|
||||
|
||||
def _reg_ids(scope, attr):
|
||||
return [getattr(r, "regulation_id") for r in getattr(scope, attr)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Gilt CRA für eine SPS mit Fernwartung?
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_cra_applies_to_sps_with_remote_access():
|
||||
scope = discover_scope(sps_profile())
|
||||
cra = [r for r in scope.applicable_regulations if r.regulation_id == "CRA"]
|
||||
assert cra and cra[0].applicability_status == ApplicabilityStatus.APPLICABLE
|
||||
assert cra[0].confidence.value == "high"
|
||||
assert any("digitale Elemente" in f or "Fernzugriff" in f for f in cra[0].trigger_facts) or cra[0].trigger_facts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Katalogprodukt 2027 weiter verkauft -> CRA gilt; "nur neue Produkte" zu eng
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_cra_applies_to_finished_catalog_product():
|
||||
profile = sps_profile(placed_on_market_after=date(2027, 1, 1), lifecycle_phase="placing_on_market")
|
||||
scope = discover_scope(profile)
|
||||
assert "CRA" in _reg_ids(scope, "applicable_regulations")
|
||||
|
||||
|
||||
def test_interpretation_only_new_products_is_too_narrow():
|
||||
result = assess_interpretation("Wir glauben, der CRA gilt nur für neue Produkte.")
|
||||
assert result.assessment == InterpretationVerdict.TOO_NARROW
|
||||
assert "CRA" in result.affected_regulations
|
||||
assert result.corrected_interpretation
|
||||
assert result.legal_basis_refs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Reicht eine SBOM allein? -> nein, nur teilweise
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_sbom_alone_is_not_enough():
|
||||
resp = reason_implementation_claim(sps_profile(), "Wir haben SBOMs.")
|
||||
sbom = [m for m in resp.mappings if m.obligation_id == "sbom_creation"]
|
||||
assert sbom and sbom[0].claim_coverage == ClaimCoverage.POTENTIALLY_ADDRESSES
|
||||
# but other obligations are surfaced as gaps -> claim does not address everything
|
||||
assert any(m.claim_coverage != ClaimCoverage.POTENTIALLY_ADDRESSES for m in resp.mappings)
|
||||
assert "Nachweise" in resp.summary
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Ist ein reaktiver Updateprozess ausreichend? -> nur teilweise
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_reactive_update_process_is_partial():
|
||||
resp = reason_implementation_claim(
|
||||
sps_profile(), "Wir machen Updates, wenn Kunden Fehler melden."
|
||||
)
|
||||
upd = [m for m in resp.mappings if m.obligation_id == "provide_security_updates"]
|
||||
assert upd and upd[0].claim_coverage == ClaimCoverage.PARTIALLY_ADDRESSES
|
||||
assert "reactive" in resp.claim.qualifiers
|
||||
assert any("Schwachstellenüberwachung" in e for e in upd[0].missing_elements)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Wann überschneiden sich CRA und MaschinenVO?
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_cra_and_machinery_overlap_on_cyber_safety():
|
||||
profile = sps_profile(is_machine=True, has_safety_function=True)
|
||||
resp = derive_obligations(profile)
|
||||
ids = [o.obligation_id for o in resp.applicable_obligations]
|
||||
assert "machine_protection_against_corruption" in ids
|
||||
assert "vuln_handling_process" in ids
|
||||
vuln_overlap = [o for o in resp.overlaps if o.overlap_group_id == "VULNERABILITY_HANDLING"]
|
||||
assert vuln_overlap
|
||||
assert "machine_protection_against_corruption" in vuln_overlap[0].obligations
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Wann ist Data Act zusätzlich relevant?
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_data_act_relevant_when_product_generates_data():
|
||||
scope = discover_scope(sps_profile(generates_usage_data=True))
|
||||
assert "DataAct" in _reg_ids(scope, "applicable_regulations")
|
||||
obs = derive_obligations(sps_profile(generates_usage_data=True))
|
||||
assert any(o.source_regulation == "DataAct" for o in obs.applicable_obligations)
|
||||
|
||||
|
||||
def test_data_act_uncertain_when_data_unknown():
|
||||
scope = discover_scope(sps_profile()) # generates_usage_data=None
|
||||
assert "DataAct" in _reg_ids(scope, "uncertain_regulations")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Welche Pflichten gelten nicht ohne Funkmodul?
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_no_radio_module_excludes_red():
|
||||
scope = discover_scope(sps_profile(has_radio_module=False))
|
||||
assert "RED" in _reg_ids(scope, "excluded_regulations")
|
||||
assert "RED" not in _reg_ids(scope, "applicable_regulations")
|
||||
|
||||
|
||||
def test_radio_unknown_makes_red_uncertain():
|
||||
scope = discover_scope(sps_profile()) # has_radio_module=None
|
||||
assert "RED" in _reg_ids(scope, "uncertain_regulations")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Welche Fakten fehlen für eine NIS2-Bewertung?
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_nis2_missing_facts():
|
||||
scope = discover_scope(sps_profile())
|
||||
nis2 = [r for r in scope.uncertain_regulations if r.regulation_id == "NIS2"]
|
||||
assert nis2
|
||||
joined = " ".join(nis2[0].missing_facts).lower()
|
||||
assert "unternehmensgröße" in joined and "sektor" in joined
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Welche Nachweise decken mehrere Pflichten gleichzeitig? (USP)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_evidence_covers_multiple_obligations():
|
||||
resp = derive_obligations(sps_profile())
|
||||
multi = resp.evidence_for_multiple
|
||||
assert multi # at least one evidence type spans >1 obligation
|
||||
assert all(len(ids) > 1 for ids in multi.values())
|
||||
assert "policy" in multi # the CRA process docs share a policy evidence
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. Auslegungen: zu eng / zu weit / plausibel / unbekannt
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_interpretation_unknown_returns_uncertain():
|
||||
result = assess_interpretation("Der Mond beeinflusst unsere Updatezyklen.")
|
||||
assert result.assessment == InterpretationVerdict.UNCERTAIN
|
||||
assert result.corrected_interpretation
|
||||
|
||||
|
||||
def test_interpretation_open_source_partially_correct():
|
||||
result = assess_interpretation("Open Source ist ausgenommen, also betrifft uns der CRA nicht.")
|
||||
assert result.assessment == InterpretationVerdict.PARTIALLY_CORRECT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry-alignment + contract guards
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_cra_obligations_reuse_registry_ids_not_minted():
|
||||
resp = derive_obligations(sps_profile())
|
||||
anchored = [o for o in resp.applicable_obligations if o.registry_anchor]
|
||||
assert "sbom_creation" in [o.obligation_id for o in anchored]
|
||||
assert "provide_security_updates" in [o.obligation_id for o in anchored]
|
||||
# machine obligations are proposed, never claimed as registry-owned
|
||||
machine = [o for o in resp.applicable_obligations if o.source_regulation == "MaschinenVO"]
|
||||
assert all(o.proposed and not o.registry_anchor for o in machine)
|
||||
|
||||
|
||||
def test_required_evidence_only_uses_shared_catalog():
|
||||
from compliance.reasoning.rules_types import EVIDENCE_CATALOG
|
||||
from compliance.reasoning.rules_obligations import ALL_OBLIGATIONS
|
||||
|
||||
for rule in ALL_OBLIGATIONS:
|
||||
assert set(rule.required_evidence) <= EVIDENCE_CATALOG
|
||||
|
||||
|
||||
def test_claim_normalizer_is_deterministic():
|
||||
a = normalize_claim("Wir haben einen Update-Prozess.")
|
||||
b = normalize_claim("Wir haben einen Update-Prozess.")
|
||||
assert a.claim_id == b.claim_id
|
||||
assert "secure_updates" in a.claimed_capability
|
||||
|
||||
|
||||
def test_unspecific_claim_asks_for_detail():
|
||||
resp = reason_implementation_claim(sps_profile(), "Wir sind sicher aufgestellt.")
|
||||
assert resp.mappings == [] or all(
|
||||
m.claim_coverage == ClaimCoverage.INSUFFICIENT_INFORMATION for m in resp.mappings
|
||||
)
|
||||
assert "unspezifisch" in resp.summary.lower()
|
||||
|
||||
|
||||
def test_claim_reasoning_carries_no_compliance_verdict():
|
||||
"""Welt-1 boundary: claim mapping must never read as a conformity verdict."""
|
||||
resp = reason_implementation_claim(
|
||||
sps_profile(), "Wir haben SBOMs und einen Update-Prozess."
|
||||
)
|
||||
# claim-relative vocabulary only
|
||||
for m in resp.mappings:
|
||||
assert m.claim_coverage in set(ClaimCoverage)
|
||||
# no compliance wording leaks into summary or explanations
|
||||
assert "erfüllt" not in resp.summary
|
||||
assert all("erfüllt" not in m.explanation for m in resp.mappings)
|
||||
# explicit disclaimer separating ClaimCoverage (Welt 1) from ComplianceStatus (Welt 2)
|
||||
assert resp.disclaimer
|
||||
assert "ComplianceStatus" in resp.disclaimer and "Nachweis" in resp.disclaimer
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint smoke tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
from compliance.api.reasoning_routes import router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_endpoint_scope(client):
|
||||
r = client.post("/reasoning/scope", json={"product_profile": {"product_name": "X", "has_software": True, "eu_market": True, "manufacturer_role": "manufacturer"}})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "CRA" in [x["regulation_id"] for x in body["regulatory_scope"]["applicable_regulations"]]
|
||||
|
||||
|
||||
def test_endpoint_obligations(client):
|
||||
r = client.post(
|
||||
"/reasoning/obligations",
|
||||
json={"product_profile": {"product_name": "X", "has_software": True, "has_remote_access": True, "eu_market": True, "manufacturer_role": "manufacturer"}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["applicable_obligations"]
|
||||
|
||||
|
||||
def test_endpoint_implementation(client):
|
||||
r = client.post(
|
||||
"/reasoning/implementation-reasoning",
|
||||
json={"product_profile": {"product_name": "X", "has_software": True, "eu_market": True, "manufacturer_role": "manufacturer"}, "customer_claim": "Wir haben SBOMs."},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["mappings"]
|
||||
assert body["disclaimer"]
|
||||
|
||||
|
||||
def test_endpoint_interpretation(client):
|
||||
r = client.post(
|
||||
"/reasoning/interpretation-assessment",
|
||||
json={"customer_interpretation": "CRA gilt nur für neue Produkte."},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["assessment"] == "too_narrow"
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Tests for the Regulatory Map renderer (step 4).
|
||||
|
||||
Acceptance: the renderer makes no own legal decisions (it composes the scope +
|
||||
registry-linked obligations); CRA/MaschVO/EMV are separate; RED/DataAct/NIS2 are
|
||||
uncertain; environmental is unsupported (not applicable); obligations appear only
|
||||
when registry-linkable; the executive summary has no percentage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from compliance.product_scope import resolve_product_scope
|
||||
from compliance.profile.canonical import (
|
||||
CanonicalLifecyclePhase,
|
||||
CanonicalProductRegulatoryProfile,
|
||||
CanonicalProductType,
|
||||
EconomicOperatorRole,
|
||||
EnvironmentalImpact,
|
||||
)
|
||||
from compliance.regulatory_map import render_regulatory_map
|
||||
|
||||
_PROPOSED_IDS = {
|
||||
"machine_risk_assessment", "machine_safety_control_systems", "machine_protection_against_corruption",
|
||||
"machine_instructions_for_use", "machine_ce_conformity", "data_act_data_access_by_design",
|
||||
"data_act_user_data_access", "cra_secure_by_design", "cra_risk_assessment",
|
||||
"cra_technical_documentation", "cra_ce_conformity_assessment", "cra_instructions_for_use",
|
||||
}
|
||||
|
||||
|
||||
def ready_profile(**ov) -> CanonicalProductRegulatoryProfile:
|
||||
base = dict(
|
||||
name="Industriespülmaschine",
|
||||
product_type=CanonicalProductType.MACHINERY,
|
||||
markets=["EU", "DE"],
|
||||
economic_operator_role=EconomicOperatorRole.MANUFACTURER,
|
||||
lifecycle_phase=CanonicalLifecyclePhase.PLACING_ON_MARKET,
|
||||
is_machine=True,
|
||||
is_component=False,
|
||||
has_software_updates=True,
|
||||
has_embedded_software=True,
|
||||
has_remote_access=True,
|
||||
technologies=["cloud", "ota_updates"],
|
||||
)
|
||||
base.update(ov)
|
||||
return CanonicalProductRegulatoryProfile(**base)
|
||||
|
||||
|
||||
# 1. renderer makes no own decisions — it mirrors the scope verdict exactly.
|
||||
def test_no_own_legal_decisions():
|
||||
p = ready_profile()
|
||||
m = render_regulatory_map(p)
|
||||
scope = resolve_product_scope(p).regulatory_scope
|
||||
assert {v.regulation_id for v in m.applicable_regulations} == {
|
||||
r.regulation_id for r in scope.applicable_regulations
|
||||
}
|
||||
assert {v.regulation_id for v in m.uncertain_regulations} == {
|
||||
r.regulation_id for r in scope.uncertain_regulations
|
||||
}
|
||||
|
||||
|
||||
# 2/3/5. CRA/MaschVO/EMV separate applicable; RED/DataAct/NIS2 uncertain.
|
||||
def test_regulation_separation():
|
||||
m = render_regulatory_map(ready_profile())
|
||||
applicable = {v.regulation_id for v in m.applicable_regulations}
|
||||
uncertain = {v.regulation_id for v in m.uncertain_regulations}
|
||||
assert {"CRA", "MaschinenVO", "EMV"} <= applicable
|
||||
assert {"RED", "DataAct", "NIS2"} <= uncertain
|
||||
|
||||
|
||||
# 4. environmental triggers surface as unsupported_domain, never applicable.
|
||||
def test_environmental_unsupported_not_applicable():
|
||||
p = ready_profile(environmental=EnvironmentalImpact(discharges_to_wastewater=True, uses_cleaning_chemicals=True))
|
||||
m = render_regulatory_map(p)
|
||||
domains = {d.domain for d in m.unsupported_domains}
|
||||
assert "environment_water" in domains and "chemicals" in domains
|
||||
assert all(v.regulation_id in {"CRA", "MaschinenVO", "RED", "DataAct", "EMV", "NIS2"} for v in m.applicable_regulations)
|
||||
|
||||
|
||||
# 6. obligations are shown only when a registry id is linkable.
|
||||
def test_obligations_only_registry_linkable():
|
||||
m = render_regulatory_map(ready_profile())
|
||||
shown = {o.obligation_id for v in m.applicable_regulations for o in v.obligations}
|
||||
assert shown # CRA registry obligations are shown
|
||||
assert "sbom_creation" in shown
|
||||
assert not (shown & _PROPOSED_IDS) # no proposed (non-registry) obligation leaks in
|
||||
# MaschinenVO is applicable but its obligations are proposed -> empty + note
|
||||
machvo = next(v for v in m.applicable_regulations if v.regulation_id == "MaschinenVO")
|
||||
assert machvo.obligations == []
|
||||
assert machvo.obligations_note
|
||||
|
||||
|
||||
# 7. executive summary contains no percentage.
|
||||
def test_executive_summary_no_percent():
|
||||
m = render_regulatory_map(ready_profile())
|
||||
assert "%" not in m.executive_summary
|
||||
assert "prozent" not in m.executive_summary.lower()
|
||||
|
||||
|
||||
# 8. output is customer-readable and structured.
|
||||
def test_customer_readable():
|
||||
m = render_regulatory_map(ready_profile())
|
||||
assert m.product_summary
|
||||
assert "wahrscheinlich" in m.executive_summary
|
||||
assert "Unsicher" in m.executive_summary
|
||||
assert m.trigger_facts
|
||||
|
||||
|
||||
# needs-facts profile -> map says scope not yet resolved.
|
||||
def test_needs_facts_map():
|
||||
m = render_regulatory_map(CanonicalProductRegulatoryProfile(name="X"))
|
||||
assert m.scope_resolved is False
|
||||
assert "Mindestfakten" in m.executive_summary
|
||||
assert m.applicable_regulations == []
|
||||
|
||||
|
||||
# uncertain RED links to the radio navigator question.
|
||||
def test_uncertain_links_to_navigator_question():
|
||||
m = render_regulatory_map(ready_profile())
|
||||
red = next(v for v in m.uncertain_regulations if v.regulation_id == "RED")
|
||||
assert "has_radio_module" in red.question_refs
|
||||
|
||||
|
||||
# endpoint smoke.
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
from compliance.api.reasoning_routes import router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_endpoint_regulatory_map(client):
|
||||
r = client.post(
|
||||
"/reasoning/regulatory-map",
|
||||
json={
|
||||
"product_profile": {
|
||||
"name": "M",
|
||||
"product_type": "machinery",
|
||||
"markets": ["EU"],
|
||||
"economic_operator_role": "manufacturer",
|
||||
"lifecycle_phase": "placing_on_market",
|
||||
"is_machine": True,
|
||||
"is_component": False,
|
||||
"has_software_updates": True,
|
||||
"has_embedded_software": True,
|
||||
"has_remote_access": True,
|
||||
"technologies": ["cloud"],
|
||||
}
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["scope_resolved"] is True
|
||||
assert {v["regulation_id"] for v in body["applicable_regulations"]} >= {"CRA", "MaschinenVO"}
|
||||
assert "%" not in body["executive_summary"]
|
||||
@@ -0,0 +1,203 @@
|
||||
# Capability Model v1 — Objektarten & Beziehungstypen (Schema-Papier, NICHT materialisiert)
|
||||
|
||||
Status: **OFFEN / Entscheidung erforderlich (2026-06-26).** Dies ist Schritt **#5a** (Papier).
|
||||
Schritt **#5b** (Materialisierung: `capabilities.json`, Migration, Obligation→Capability-Links,
|
||||
Guidance-Mapping, Runtime) ist **GEGATED** auf die Annahme dieses Papiers. **Es wurde noch keine
|
||||
Zeile Daten verschoben.**
|
||||
|
||||
Baut auf [legal_obligation_layer_v1.md](legal_obligation_layer_v1.md),
|
||||
[obligation_registry_v1.md](obligation_registry_v1.md) und dem Cross-Domain-Review
|
||||
(`obligations/cross_domain_relationships.json`, Commit `ed31fdc0`).
|
||||
|
||||
---
|
||||
|
||||
## 0. Warum ein Papier statt `capabilities.json`
|
||||
|
||||
Die Plattform hat drei empirische Architektur-Sprünge gemacht:
|
||||
1. **Control ≠ Wissensobjekt** → Legal Obligation (sofort implementiert, datenbestätigt).
|
||||
2. **Procedure ist eigenständig** (implementiert: `cra_procedures.json`).
|
||||
3. **Capabilities tauchen domänenübergreifend wieder auf** (Cross-Domain-Review).
|
||||
|
||||
(1) und (2) waren breit datenbelegt → sofort umgesetzt. Bei (3) ist die **Objektart selbst noch
|
||||
nicht definiert.** Wir wissen NICHT genau, was eine Capability ist. Materialisieren wir jetzt,
|
||||
riskieren wir, in drei Wochen festzustellen: „Attack Surface war gar keine Capability" → Umbau.
|
||||
|
||||
---
|
||||
|
||||
## 1. Der Auslöser: die 8 „Capabilities" sind NICHT eine Objektart
|
||||
|
||||
Der Cross-Domain-Review fand 16 `SHARED_CAPABILITY`-Paare → 8 Cluster. Bei Inspektion zerfallen
|
||||
sie in **zwei verschiedene Objektarten**:
|
||||
|
||||
| Cluster (Opus-Benennung) | Art | Begründung |
|
||||
|---|---|---|
|
||||
| `mfa` | **Capability** | implementierbar als Funktion |
|
||||
| `session_management` | **Capability** | implementierbar |
|
||||
| `transport_encryption` (tls/mutual_tls/cert) | **Capability** | implementierbar (vom Klassifikator fein gesplittet → 1 Capability) |
|
||||
| `code_signing` | **Capability** | implementierbar |
|
||||
| `anomaly_detection` | **Capability** | implementierbar |
|
||||
| `access_control` | **Ziel** (schwach) | abstraktes Ziel, kein Baustein — eher OVERLAP (siehe Konsolidierung) |
|
||||
|
||||
Dazu die **zwei Gap-„Obligations" aus Handoff #4** (NIST SI-7/CM-7 waren breiter als jeder
|
||||
einzelne Treffer):
|
||||
|
||||
| Kandidat | Art | Begründung |
|
||||
|---|---|---|
|
||||
| `software_integrity_protection` (SI-7) | **Sicherheitsziel** | wird NICHT direkt gebaut; erreicht durch code_signing + hash_verification + secure_boot |
|
||||
| `attack_surface_minimization` (CM-7) | **Sicherheitsziel** | erreicht durch least_functionality + Port-Deaktivierung + Interface-Reduktion |
|
||||
|
||||
**Kernbeobachtung (User):** Es gibt **Typ 1 — technische Fähigkeiten** (implementierbar) und
|
||||
**Typ 2 — Sicherheitsziele** (nicht direkt implementierbar, durch mehrere Capabilities erreicht).
|
||||
Sie in eine `capabilities.json` zu werfen wäre der Fehler.
|
||||
|
||||
```
|
||||
Integrity Protection (Ziel) Access Protection (Ziel)
|
||||
↑ erreicht durch ↑ erreicht durch
|
||||
code_signing · hash_verification · mfa · session_management ·
|
||||
secure_boot (Capabilities) credential_storage (Capabilities)
|
||||
```
|
||||
|
||||
Das erklärt rückwirkend auch das **systematische Synth-Über-Tiering** (Auth 14→6, Remote 14→5):
|
||||
das LLM mischte ziel-nahe Obligations mit fähigkeits-nahen Mechanismen, weil die Modellsprache
|
||||
die Ebenen nicht trennte.
|
||||
|
||||
---
|
||||
|
||||
## 2. Kandidat-Objektarten
|
||||
|
||||
| Objektart | Definition | Diskriminator-Test |
|
||||
|---|---|---|
|
||||
| **Regulation** | Rechtsakt (CRA, NIS2, DSGVO, MaschVO) | „Ist es ein Gesetz/VO?" |
|
||||
| **Legal Obligation** | rechtlich verankerte Pflicht. **CORE** (abstrakt, oft = Sicherheitsziel) ⊇ **DOMAIN** (spezialisiert) — die CORE/DOMAIN-Achse existiert bereits (portability). | „Steht das so (sinngemäß) im Recht? Kann ein Prüfer FEHLT/ERFÜLLT sagen?" |
|
||||
| **Capability** *(NEU)* | implementierbare, **regulierungs-agnostische** technische Funktion, als Einheit baubar & testbar | „Kann ein Hersteller GENAU DAS bauen/konfigurieren?" → ja |
|
||||
| **Procedure** | wiederholbarer operativer Prozess, der eine Capability ausbringt/erhält (bereits modelliert) | „Ist es eine Tätigkeit/ein Ablauf?" |
|
||||
| **Control** | testbare Prüfanweisung | „Kann man es prüfen (pass/fail)?" |
|
||||
| **Evidence** | Nachweis-Artefakt (Audit-Log, SBOM, Release Notes) | „Ist es ein Beleg-Dokument/Datum?" |
|
||||
| **Guidance** *(quer)* | externe Empfehlung WIE (NIST/OWASP/ENISA/BSI). **Nicht-bindend.** | „Beschreibt es eine empfohlene Umsetzung, kein Primärrecht?" |
|
||||
|
||||
---
|
||||
|
||||
## 3. DER ZENTRALE KNACKPUNKT: Ist „Security Objective" eine eigene Klasse?
|
||||
|
||||
### Modell A — flach (Objektive = Obligations)
|
||||
```
|
||||
Regulation → Legal Obligation → Capability → Procedure → Control → Evidence
|
||||
```
|
||||
Sicherheitsziele sind einfach **CORE Legal Obligations**; domänen-scoped Pflichten sind DOMAIN-
|
||||
Obligations, die per `specializes` an die CORE hängen.
|
||||
|
||||
### Modell B — mit eigener Security-Objective-Klasse
|
||||
```
|
||||
Regulation → Legal Obligation → Security Objective → Capability → Procedure → Control → Evidence
|
||||
```
|
||||
|
||||
### Modell C — hybrid (Capability als einzige neue Klasse) ← **EMPFEHLUNG**
|
||||
```
|
||||
Regulation → Legal Obligation (CORE ⊇ DOMAIN) --realized_by--> Capability → Procedure → Control → Evidence
|
||||
▲ ▲
|
||||
└── specializes (DOMAIN→CORE) └── described_by ── Guidance (NIST/OWASP/…)
|
||||
```
|
||||
|
||||
**Empfehlung: Modell C.** Begründung aus den Daten:
|
||||
- Die „Sicherheitsziele" (`software_integrity_protection`, `attack_surface_minimization`, CIA,
|
||||
access-protection) **SIND im CRA bindende Pflichten** (Annex I (2)(a–m) ist Primärrecht). Ein
|
||||
Sicherheitsziel ist also eine **CORE Legal Obligation**, kein neuer Objekttyp.
|
||||
- Die **CORE/DOMAIN-Achse existiert schon** (portability_core ⊇ health/data_act). `attack_surface_
|
||||
minimization` (CORE) ⊇ `remote_access_attack_surface_min` (DOMAIN) ist exakt dasselbe Muster.
|
||||
→ keine neue Klasse, nur konsequente Nutzung des Vorhandenen.
|
||||
- **Genau EINE** wirklich neue Klasse (**Capability**) ist sparsam und niedrig-risiko.
|
||||
- Modell B verdoppelt die normative Ebene (Obligation vs Objective), die im CRA 1:1 zusammenfällt
|
||||
→ Klasse, die niemand sauber befüllt.
|
||||
|
||||
**Konsequenz für die #4-Gap:** `software_integrity_protection` + `attack_surface_minimization`
|
||||
werden als **CORE Legal Obligations** angelegt (nicht als Capabilities), und die domänen-scoped
|
||||
Treffer (`signed_update_integrity`, `remote_access_attack_surface_min`) `specializes` → CORE.
|
||||
NIST SI-7/CM-7 mappen dann `primary_implementation` auf die CORE.
|
||||
|
||||
**Offen für den User:** Modell C akzeptieren? Oder ist die regulierungs-AGNOSTISCHE Vereinheitlichung
|
||||
(eine „confidentiality" über CRA+NIS2+ISO) so wertvoll, dass „Security Objective" doch eine eigene
|
||||
Klasse verdient (Modell B)? Das ist die einzige wirklich offene Architekturentscheidung.
|
||||
|
||||
---
|
||||
|
||||
## 4. Beziehungstypen — das Modell ist ein GRAPH, keine flache Ebene
|
||||
|
||||
Der Review fand **vier distinkte Cross-Domain-Strukturrelationen** (nicht eine):
|
||||
`SUPPORTED_BY` 23 · `SHARED_CAPABILITY` 16 · `SHARED_EVIDENCE` 7 · `SHARED_PROCEDURE` 5 (+ 1 Merge).
|
||||
Das ist kein Baum. Vorgeschlagenes gerichtetes Kanten-Vokabular:
|
||||
|
||||
| Kante | von → nach | aus Review-Relation |
|
||||
|---|---|---|
|
||||
| `specializes` | DOMAIN-Obligation → CORE-Obligation | (SUPPORTED_BY, Spezialfall) |
|
||||
| `contributes_to` | Obligation → Obligation | (SUPPORTED_BY, Beitrag) |
|
||||
| `realized_by` | Obligation → Capability | (SHARED_CAPABILITY ⇒ 2 Obl. teilen 1 Capability) |
|
||||
| `deployed_via` | Capability → Procedure | (SHARED_PROCEDURE) |
|
||||
| `verified_by` | Procedure/Capability → Control | — |
|
||||
| `produces` | Procedure → Evidence | (SHARED_EVIDENCE ⇒ 2 Obl. teilen 1 Nachweis) |
|
||||
| `described_by` | Capability → Guidance | (guidance_basis) |
|
||||
| `same_as` | Obligation ↔ Obligation | (SAME_OBLIGATION, Merge) |
|
||||
|
||||
`SHARED_CAPABILITY`/`SHARED_EVIDENCE`/`SHARED_PROCEDURE` sind also **keine Obligation-Obligation-
|
||||
Kanten**, sondern Belege, dass zwei Obligations **denselben Knoten einer tieferen Ebene** teilen
|
||||
(Capability / Evidence / Procedure). Genau das ist der Mehrwert gegenüber „sieht ähnlich aus".
|
||||
|
||||
---
|
||||
|
||||
## 5. Die 8 offenen Fragen (Antwort + Tradeoff)
|
||||
|
||||
1. **Was ist eine Capability?** Eine implementierbare, regulierungs-agnostische technische Funktion,
|
||||
als Einheit baubar/konfigurierbar/testbar (MFA, TLS, Code Signing, Session-Mgmt, Anomaly-Detection).
|
||||
2. **Unterschied zur Obligation?** Obligation = rechtliche Pflicht (WAS das Recht verlangt, regulierungs-
|
||||
verankert, normativ). Capability = technisches Mittel (WIE man sie erfüllt, agnostisch). n:m.
|
||||
3. **Unterschied zum Security Objective?** Ziel = erwünschter Sicherheitszustand (CIA, attack-surface-min);
|
||||
Capability = Mittel dorthin. **Empfehlung (Modell C):** das Ziel ist eine CORE Obligation, kein
|
||||
eigener Typ → Unterschied reduziert sich auf Obligation(abstrakt) vs Capability(Mittel).
|
||||
4. **Wann Guidance?** Wenn es eine **nicht-bindende externe Empfehlung zur Umsetzung** ist (NIST AC-12,
|
||||
OWASP ASVS V6). Hängt an der **Capability** (meist) bzw. Procedure — NIE als `legal_basis` einer
|
||||
LEGAL_MINIMUM-Obligation (Primärrecht-Regel bleibt).
|
||||
5. **Wann Procedure?** Wenn es ein **wiederholbarer operativer Ablauf** ist, der eine Capability
|
||||
ausbringt/erhält (MFA konfigurieren, Schlüssel rotieren, Patch-Zyklus fahren).
|
||||
6. **Capability → mehrere Obligations?** **JA, belegt:** `mfa` erfüllt 6 Obligations (auth+remote),
|
||||
`code_signing` 2 (auth+updates). n:m.
|
||||
7. **Obligation → mehrere Capabilities?** **JA, belegt:** access-protection ← mfa + session_management
|
||||
+ credential_storage. n:m.
|
||||
8. **Wo hängen NIST/OWASP/ENISA/BSI?** Primär an der **Capability** (sie beschreiben deren Umsetzung),
|
||||
teils an Procedure. **Das erklärt, warum die über-getierten BP-Obligations `guidance_basis` trugen:
|
||||
sie waren in Wahrheit Capabilities.** Sauberer Sitz von `guidance_basis` = Capability.
|
||||
|
||||
---
|
||||
|
||||
## 6. Worked Examples (4 Domänen, echte IDs)
|
||||
|
||||
**Authentication** — `user_authentication_required` (Obl, CORE: access-protection)
|
||||
`--realized_by-->` { `mfa`, `session_management`, `credential_storage` } (Capabilities)
|
||||
`--described_by-->` NIST IA-2 / OWASP ASVS V6 (Guidance).
|
||||
|
||||
**Updates** — `provide_security_updates` (Obl, LEGAL_MINIMUM) `--realized_by-->`
|
||||
{ `code_signing` (= signed_update_integrity-Capability), `automatic_update_delivery`, `rollback` }
|
||||
— exakt die `capability_candidate`-Marker aus `cra_updates.json`.
|
||||
|
||||
**Remote Access** — CORE `attack_surface_minimization` (NEU, = CM-7-Ziel) `⊇ specializes ⊇`
|
||||
`remote_access_attack_surface_min` (DOMAIN) `--realized_by-->` { `least_functionality`, `port_disabling` }.
|
||||
|
||||
**SBOM** — Sonderfall: die SBOM-Familie ist im Cross-Review der **Evidence-/Procedure-Input** für
|
||||
`vuln_identification_inventory` (5× SUPPORTED_BY-Hub), weniger Capability. → bestätigt, dass nicht
|
||||
jede Domäne primär Capabilities beisteuert; manche liefern **Evidence**. Stützt den Graph-Charakter.
|
||||
|
||||
---
|
||||
|
||||
## 7. Entscheidung, die ich vom User brauche (vor #5b)
|
||||
|
||||
1. **Modell C** (Capability = einzige neue Klasse; Sicherheitsziele = CORE-Obligations) — akzeptiert?
|
||||
Oder Modell B (Security Objective als eigene Klasse für regulierungs-agnostische Vereinheitlichung)?
|
||||
2. **Kanten-Vokabular** aus §4 — so einfrieren?
|
||||
3. **`guidance_basis` wandert konzeptionell an die Capability** — einverstanden? (Bricht nichts sofort;
|
||||
die Obligations behalten den Verweis bis #5b.)
|
||||
4. Erst danach **#5b**: `capabilities.json` (capability_id, fulfills_obligations[] via `realized_by`,
|
||||
guidance_basis hochgezogen), die 2 CORE-Gap-Obligations, der Merge (`vuln_remediation_patching` ≈
|
||||
`provide_security_updates`), und die 2 Remote-Grenzfälle final tiern.
|
||||
|
||||
## 8. Bewusst NICHT in #5a (gegated)
|
||||
|
||||
Keine `capabilities.json`, keine Migration, kein Obligation-Rewrite, kein Guidance-Move, kein Runtime.
|
||||
Erst Modell-Annahme, dann Daten. „Erst das Schema, dann verschieben."
|
||||
@@ -0,0 +1,108 @@
|
||||
# Compliance Operating System — Meta Model v1.0 (FROZEN)
|
||||
|
||||
> **STATUS: EINGEFROREN (2026-06-26). ARCHITEKTUR-FREEZE IN KRAFT.**
|
||||
> Ab v1.0 dürfen neue Regulierungen das Modell **nicht mehr verändern** — sie müssen sich
|
||||
> **einfügen**. Das Modell wird nur wieder geöffnet, wenn eine Regulierung **nachweislich
|
||||
> scheitert** (eine Anforderung lässt sich ohne neue Objektklasse nicht abbilden).
|
||||
> Validiert gegen 5 Regulierungsarten: DSGVO · CRA · MaschVO · Data Act · NIS2.
|
||||
|
||||
Konsolidiert + friert ein: [legal_obligation_layer_v1.md](legal_obligation_layer_v1.md),
|
||||
[capability_model_v1.md](capability_model_v1.md) (Modell C), [meta_model_validation_v1.md](meta_model_validation_v1.md).
|
||||
Was hier eingefroren wird, ist **ausschließlich die Meta-Semantik** — NICHT die Registry, NICHT die
|
||||
Capabilities-Liste, NICHT die Procedures (diese wachsen als Daten weiter).
|
||||
|
||||
## 1. Objektklassen (6 + Guidance) — eingefroren
|
||||
|
||||
| Klasse | Was | Regulierungs-Bindung |
|
||||
|---|---|---|
|
||||
| **Regulation** | Rechtsakt | — |
|
||||
| **Legal Obligation** | rechtlich verankerte Pflicht; **CORE ⊇ DOMAIN** | regulierungs-anchored |
|
||||
| **Capability** | implementierbare technische Faehigkeit (OPTIONAL für eine Obligation) | **agnostisch** (n:m über Regulierungen) |
|
||||
| **Procedure** | wiederholbarer operativer Prozess | agnostisch |
|
||||
| **Control** | testbare Prüfanweisung | agnostisch |
|
||||
| **Evidence** | Nachweis-Artefakt | agnostisch |
|
||||
| **Guidance** *(quer)* | externe nicht-bindende Empfehlung (NIST/OWASP/ISO/BSI) — hängt an der **Capability** | agnostisch |
|
||||
|
||||
## 2. Die Kette + kanonisches Kanten-Vokabular — eingefroren
|
||||
|
||||
```
|
||||
Regulation
|
||||
↓ definiert
|
||||
Legal Obligation (CORE ⊇ DOMAIN)
|
||||
↓ realized_by (OPTIONAL — rein prozessuale/dokumentarische Obligations überspringen Capability)
|
||||
Capability
|
||||
↓ deployed_via (alias: operationalized_by)
|
||||
Procedure
|
||||
↓ verified_by
|
||||
Control
|
||||
↓ produces (alias: produces_evidence_for)
|
||||
Evidence
|
||||
→ Produktstatus
|
||||
```
|
||||
|
||||
Kanten (gerichtet, eingefroren):
|
||||
`specializes` (DOMAIN→CORE) · `realized_by` (Obligation→Capability) · `deployed_via` (Capability→Procedure) ·
|
||||
`verified_by` (Procedure/Capability→Control) · `produces` (Procedure→Evidence) · `described_by` (Capability→Guidance) ·
|
||||
`supports` / `depends_on` / `contributes_to` (Obligation↔Obligation) · `same_as` (Merge/Alias).
|
||||
**Das Modell ist ein GRAPH, kein Baum** (n:m an realized_by, supports, produces).
|
||||
|
||||
## 3. Attribute (KEINE Klassen) — eingefroren
|
||||
|
||||
`applicability` · `tier` (LEGAL_MINIMUM/BEST_PRACTICE) · `legal_basis` (Primärrecht) ·
|
||||
`guidance_basis` (NIST/OWASP/…, kanonisch an der Capability) · `objective_tags`
|
||||
(integrity/confidentiality/attack_surface/… — Vorwärts-Kompat zu einer späteren Security-Objective-
|
||||
Klasse) · `risk_level` · `deadline` · **`hazard` (Attribut, KEINE Klasse)**.
|
||||
|
||||
**Watch-Point (bewusste Nicht-Klasse):** `Hazard/Threat` bleibt ein Risiko-Treiber-Attribut. Es wird
|
||||
*erst dann* eine eigene Klasse, wenn quantitatives Risiko (FMEA: Hazard→Risiko→Maßnahme) als
|
||||
First-Class-Graph-Knoten modelliert werden soll — das ist die einzige bekannte künftige Öffnungs-Ursache.
|
||||
|
||||
## 4. Architektur-Freeze-Policy
|
||||
|
||||
1. **Neue Regulierung = Daten, nicht Architektur.** Sie läuft durch `Parser → Discovery-Pipeline →
|
||||
Review → Registry` und fügt Obligations/Capabilities/Procedures/Evidence hinzu.
|
||||
2. **Eine neue Objektklasse ist eine Architektur-Änderung** und erfordert explizite Wieder-Öffnung +
|
||||
Begründung (nachgewiesenes Scheitern der Abbildung). Default-Erwartung: **0 neue Klassen.**
|
||||
3. Verfeinerungen an Attributen (neues `*_tag`, neues risk-Attribut) sind erlaubt, solange keine
|
||||
neue Klasse entsteht.
|
||||
|
||||
## 5. Reuse-Metrik (KPI je neuer Regulierung) — der Wissens-Akkumulations-Beweis
|
||||
|
||||
Für jede neue Regulierung gemessen (Baseline = der jeweils vorhandene Bestand):
|
||||
|
||||
| Kennzahl | Soll/Bedeutung |
|
||||
|---|---|
|
||||
| **Neue Objektklassen** | **= 0** (Invariante; sonst Freeze gebrochen) |
|
||||
| Neue Capabilities | additiv (z.B. +8) |
|
||||
| **Wiederverwendete Capabilities %** | Kern-KPI (z.B. NIS2 ~70–80 % erwartet) |
|
||||
| Wiederverwendete Procedures % | (z.B. 58 %) |
|
||||
| Wiederverwendete Evidence % | (z.B. 81 %) |
|
||||
| Neue Obligations | additiv (z.B. +42) |
|
||||
|
||||
Zielaussage: *„Beim AI Act: 0 neue Objektklassen, 12 neue Capabilities, 41 neue Obligations,
|
||||
78 % der vorhandenen Capabilities wiederverwendet."* → belegt, dass das System **Wissen akkumuliert**,
|
||||
statt je Regulierung neu gebaut zu werden. (Tool zur Berechnung folgt mit dem ersten Live-Durchlauf.)
|
||||
|
||||
## 6. Der Burggraben (warum das mehr ist als ein Advisor / RAG)
|
||||
|
||||
Der Kunde denkt nicht in Artikeln, sondern: *„Wir haben Remote-Updates / signierte Firmware / einen
|
||||
Vuln-Prozess."* Über die Capability-Schicht bildet das System diese Aussagen auf **alle betroffenen
|
||||
Obligations mehrerer Regulierungen** ab und beantwortet die eigentliche Frage aus dem Kundengespräch:
|
||||
> **„Habe ich das Gesetz richtig verstanden, und reicht das, was wir umgesetzt haben?"**
|
||||
|
||||
Das ist regel-/wissensgestütztes Reasoning über ein gemeinsames Modell — keine RAG-Aufgabe.
|
||||
(Die Reasoning-Session hält dabei die Welt-Grenze: `ClaimCoverage` „potenziell relevant" ⊥
|
||||
`ComplianceStatus` „erfüllt aus Nachweisen".)
|
||||
|
||||
## 7. Was NICHT eingefroren ist (wächst weiter als Daten)
|
||||
|
||||
Registry-Inhalte (Obligations je Regulierung), die Capabilities-Liste, Procedures, Evidence-Typen,
|
||||
Applicability-Prädikate, Citation-Spans. Diese Schicht ist **Wissensaufbau** — explizit erwünschtes
|
||||
Wachstum gegen das eingefrorene Modell.
|
||||
|
||||
## 8. Erster Live-Durchlauf (User-Priorität nach Informationswert)
|
||||
|
||||
1. **MaschVO** ⭐⭐⭐⭐⭐ — beweist „Compliance-OS ≠ Cybersecurity" (physische Safety, CE, Restgefahren).
|
||||
2. **NIS2** ⭐⭐⭐⭐ — misst maximalen Capability-Reuse (erwartet 70–80 %).
|
||||
3. **AI Act** ⭐⭐⭐⭐ — Risikoklassifizierung/Governance, vermutlich 0 neue Klassen.
|
||||
4. **Data Act** ⭐⭐⭐ — bestätigt „Capability optional".
|
||||
@@ -0,0 +1,159 @@
|
||||
# Meta-Model Validation v1 — Ist das Modell regulierungsunabhängig?
|
||||
|
||||
Status: **Phase 6 — Meta-Validierung (2026-06-26). KEIN neues Coding, KEINE Regulierung ingestiert.**
|
||||
Dieses Dokument ist der Stresstest VOR der nächsten Regulierung. Baut auf
|
||||
[capability_model_v1.md](capability_model_v1.md) (Modell C, #5b materialisiert) +
|
||||
[legal_obligation_layer_v1.md](legal_obligation_layer_v1.md).
|
||||
|
||||
## Die eigentliche Frage
|
||||
|
||||
Nicht „welche Regulierung kommt als nächstes?", sondern:
|
||||
|
||||
> **Kann eine völlig neue Regulierung in dieses Modell eingeordnet werden, OHNE eine neue
|
||||
> Objektklasse einzuführen?**
|
||||
|
||||
Wenn ja für MaschVO + Data Act + AI Act + NIS2 → das ist kein CRA-Graph mehr, sondern ein
|
||||
**Compliance Meta Model**. Ab dann bringt jede Regulierung primär *Daten*, nicht *Architektur*.
|
||||
|
||||
## Das zu testende Modell (6 Klassen + Attribute, KEINE weitere Klasse erlaubt)
|
||||
|
||||
```
|
||||
Regulation
|
||||
↓ definiert
|
||||
Legal Obligation (CORE ⊇ DOMAIN; tier=LEGAL_MINIMUM/BEST_PRACTICE; objective_tags[]; applicability)
|
||||
↓ realized_by (OPTIONAL)
|
||||
Capability (regulierungs-agnostische technische Faehigkeit; guidance_basis hier)
|
||||
↓ deployed_via
|
||||
Procedure
|
||||
↓ verified_by
|
||||
Control
|
||||
↓ produces
|
||||
Evidence
|
||||
```
|
||||
Quer: **Guidance** (NIST/OWASP/ISO/BSI) hängt an der Capability. **Attribute** (keine Klassen):
|
||||
`tier`, `objective_tags`, `applicability`, später `deadline`/`risk_level`/`severity`.
|
||||
Kanten: realized_by · specializes · contributes_to · deployed_via · verified_by · produces · described_by · same_as.
|
||||
|
||||
---
|
||||
|
||||
## Test 1 — Maschinenverordnung (EU) 2023/1230
|
||||
|
||||
| Modell-Klasse | MaschVO-Inhalt |
|
||||
|---|---|
|
||||
| Legal Obligation (CORE) | `hazard_minimization` (Sicherheits-Analogon zu attack_surface_minimization), `safe_control_systems`, `machine_risk_assessment`, `ce_conformity`, `instructions_for_use` — exakt die `machine_*`-Obligations, die die Reasoning-Session bereits unabhängig geprägt hat. |
|
||||
| Capability | **physische Sicherheitsfunktionen**: `emergency_stop`, `safety_interlock`, `two_hand_control`, `guarding`, `safe_torque_off`. → die **Capability-Klasse generalisiert von Cyber auf physische Safety** (gleiche Klasse, andere Domäne). |
|
||||
| Procedure | Risikobeurteilung (ISO 12100), CE-Konformitätsbewertung. |
|
||||
| Evidence | Technische Unterlagen, Risikobeurteilungsbericht, Konformitätserklärung. |
|
||||
|
||||
**Stress-Punkt:** „**Hazard**" (mechanisch/elektrisch/thermisch) = Schadensquelle — weder Obligation
|
||||
noch Capability. Kandidat für eine neue Klasse? → **Nein, für die Repräsentation:** ein Hazard ist
|
||||
ein *Risiko-Treiber* (Attribut/Applicability der Risikobeurteilungs-Procedure); eine Capability
|
||||
*mitigiert* einen Hazard, genau wie eine Cyber-Capability eine (implizite) Bedrohung kontert. `PL/SIL`
|
||||
= Attribut (wie `tier`). **Hazard wird erst dann eine Klasse, wenn ihr quantitatives FMEA-Risiko als
|
||||
First-Class-Graph-Knoten wollt** (vgl. [[project-fmea-safety-direction]]) — nicht für Compliance-Abbildung.
|
||||
|
||||
**Verdikt: KEINE neue Klasse.** (Stärkstes Ergebnis: die Capability-Klasse trägt von Cyber zu Safety.)
|
||||
|
||||
---
|
||||
|
||||
## Test 2 — Data Act (EU) 2023/2854
|
||||
|
||||
| Modell-Klasse | Data-Act-Inhalt |
|
||||
|---|---|
|
||||
| Legal Obligation | `data_act_data_access_by_design`, `data_act_user_data_access`, `data_portability_switching`, `fair_contract_terms` (FRAND), `interoperability` — deckt sich mit den `data_act_*`-Obligations der Reasoning-Session. |
|
||||
| Capability | `data_export_api`, `interoperability_interface`, `access_control` (**Reuse**). ABER: `fair_contract_terms` hat **KEINE technische Capability**. |
|
||||
| Procedure | FRAND-Klauseln entwerfen; Switching-Prozess. |
|
||||
| Evidence | Vertrag/Klauselwerk, API-Doku. |
|
||||
|
||||
**Stress-Punkt:** **vertraglich-rechtliche Pflichten** (FRAND, Verbot unfairer Klauseln) haben kein
|
||||
technisches Mittel. → Beleg, dass **`realized_by Capability` OPTIONAL ist**: manche Obligations werden
|
||||
rein über **Procedure (Entwurf) + Evidence (Vertrag)** erfüllt. Das ist KEINE neue Klasse — wir haben
|
||||
es schon gesehen (SBOM-Familie war Evidence-/Procedure-lastig, kaum Capability).
|
||||
|
||||
**Verdikt: KEINE neue Klasse.** Verfeinerung: Capability ist optional (Obligation → Procedure → Evidence
|
||||
ohne Capability ist gültig).
|
||||
|
||||
---
|
||||
|
||||
## Test 3 — AI Act (EU) 2024/1689
|
||||
|
||||
| Modell-Klasse | AI-Act-Inhalt |
|
||||
|---|---|
|
||||
| Legal Obligation | `ai_risk_management_system`, `ai_data_governance`, `ai_technical_documentation`, `ai_transparency_disclosure`, `human_oversight`, `accuracy_robustness`, `fundamental_rights_assessment`. |
|
||||
| Capability | `event_logging` (**Reuse**!), `bias_detection`, `accuracy_testing`, `human_oversight_mechanism`, `ai_transparency_notice`. |
|
||||
| Procedure | Risikomanagement-Prozess; FRIA-Durchführung; Human-Oversight-Prozess. |
|
||||
| Evidence | Technische Dokumentation, FRIA-Bericht, Logs. |
|
||||
|
||||
**Stress-Punkt:** **Risiko-Klassifikation** (unacceptable/high/limited/minimal) bestimmt, WELCHE
|
||||
Obligations gelten. → das ist **Applicability** (existiert bereits; analog zu CRA-Produktklasse).
|
||||
`human_oversight` = Procedure + Capability (Oversight-UI). `transparency` = Disclosure (Capability/Evidence,
|
||||
wie Cookie/DSE-Offenlegung). `FRIA` = Procedure + Evidence.
|
||||
|
||||
**Verdikt: KEINE neue Klasse.** Verfeinerung: Risiko-Tier = Applicability-Attribut (vorhanden).
|
||||
|
||||
---
|
||||
|
||||
## Test 4 — NIS2 (EU) 2022/2555
|
||||
|
||||
| Modell-Klasse | NIS2-Inhalt |
|
||||
|---|---|
|
||||
| Legal Obligation | `nis2_risk_management_measures`, `nis2_incident_reporting`, `supply_chain_security`, `governance_accountability`, `business_continuity`. |
|
||||
| Capability | **MFA, transport_encryption, security_monitoring_alerting, patch/update, backup** — **dieselben Capabilities wie CRA**. Das ist die Auszahlung: NIS2-Obligations `realized_by` die bereits gebaute Capability-Schicht. |
|
||||
| Procedure | Incident-Response-Prozess; Lieferketten-Audit; Governance-Prozess. |
|
||||
| Evidence | Incident-Reports, Audit-Logs, Vorstandsprotokolle. |
|
||||
|
||||
**Stress-Punkt:** **Meldefristen** (24h/72h/1 Monat) = zeitgebundene Procedure → `deadline` = Attribut.
|
||||
`governance_accountability` (Management-Haftung) = organisatorische Obligation → Procedure + Evidence.
|
||||
|
||||
**Verdikt: KEINE neue Klasse.** Stärkster Reuse-Fall (teilt die CRA-Capability-Schicht vollständig).
|
||||
|
||||
---
|
||||
|
||||
## Ergebnis: 4 × NEIN → das Metamodell steht
|
||||
|
||||
Alle vier Regulierungen passen in die 6 Klassen **ohne neue Objektklasse** — unter zwei
|
||||
Verfeinerungen, die der Test selbst aufdeckt (beide sind KEINE neuen Klassen):
|
||||
|
||||
1. **`realized_by Capability` ist OPTIONAL.** Vertraglich/dokumentarisch/prozessuale Obligations
|
||||
(Data-Act-FRAND, NIS2-Governance, AI-Act-FRIA) werden rein über Procedure + Evidence erfüllt.
|
||||
2. **Risiko-Niveau / Frist / Hazard-Schwere / Risiko-Tier sind ATTRIBUTE**, keine Klassen
|
||||
(`tier`-Muster: `deadline`, `risk_level`, `severity`, `risk_tier`).
|
||||
|
||||
**Der einzige Watch-Point:** **Hazard / Threat.** Heute implizit (Obligations existieren, um sie zu
|
||||
kontern). Eine eigene Klasse wird *erst* nötig, wenn ihr **quantitatives Risiko first-class** modelliert
|
||||
(FMEA: Hazard→Risiko→Maßnahme als Graph-Knoten). Für die reine Compliance-Abbildung: nicht nötig.
|
||||
→ Das ist die präzise Antwort auf „wo wäre erstmals eine neue Klasse nötig?".
|
||||
|
||||
## Empirische Stütze (nicht nur Theorie)
|
||||
|
||||
Die 3. Session (Reasoning Engine) hat **unabhängig** `proposed=True`-Obligations für MaschVO
|
||||
(`machine_*`) und Data Act (`data_act_*`) geprägt — und brauchte dafür **keine neue Objektklasse**,
|
||||
nur Obligation-IDs. Zwei Sessions kommen unabhängig zum selben Schluss.
|
||||
|
||||
## Konsequenz für die Reasoning-Schicht (Produktvision)
|
||||
|
||||
Heute: `Product → Applicable Regulations → Applicable Obligations`.
|
||||
Mit der Capability-Schicht wird daraus:
|
||||
```
|
||||
Applicable Capabilities → Required Procedures → Expected Evidence
|
||||
```
|
||||
Antwort auf die Kundenaussage „Ich habe X umgesetzt" ist dann nicht „CRA Artikel …", sondern:
|
||||
```
|
||||
✓ Capability A ✓ Capability B ✗ Capability C
|
||||
↓
|
||||
erfüllt CRA, MaschVO, NIS2 (teilweise)
|
||||
```
|
||||
Eine Capability erfüllt Obligations über *mehrere Regulierungen* (n:m) → eine Umsetzung wird gegen
|
||||
das gesamte Regelwerk bewertet. Das ist qualitativ ein anderes Produkt als RAG.
|
||||
|
||||
## Entscheidung / nächster Schritt
|
||||
|
||||
Wenn dieses Dokument akzeptiert ist („keine weitere Klasse nötig"), verschiebt sich die Arbeit von
|
||||
**Architektur** zu **Wissensaufbau**: jede neue Regulierung läuft durch
|
||||
`Parser → Discovery-Pipeline → Review → Registry` (vorhandene Tooling), statt das Modell zu ändern.
|
||||
Offen für den User: (a) Metamodell als stabil einfrieren? (b) den Hazard/Threat-Watch-Point als
|
||||
bewusste Nicht-Klasse dokumentieren (bis FMEA-Quantifizierung)? (c) dann erste Regulierung als DATEN.
|
||||
|
||||
## Bewusst NICHT in diesem Schritt
|
||||
|
||||
Kein Code, keine Regulierung ingestiert, keine neue Klasse angelegt. Reiner Modell-Stresstest.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Session Ownership Model v1 — Arbeitsteilung nach Modell-Besitz
|
||||
|
||||
Status: **Vorschlag/Vertrag (2026-06-26).** Antwort auf „Wie verteilen wir die Arbeit?":
|
||||
**nach BESITZ der Datenmodelle, NICHT nach Regulierung.** Ergänzt
|
||||
[compliance_meta_model_v1.md](compliance_meta_model_v1.md) (Architektur-Freeze v1.0).
|
||||
|
||||
## Leitregel
|
||||
|
||||
> **Jede Session besitzt genau EIN Datenmodell. Andere Sessions dürfen es LESEN, nie SCHREIBEN.**
|
||||
|
||||
Verteilung nach Regulierung wäre instabil (jede Reg. zieht durch alle Schichten). Verteilung nach
|
||||
Modell-Besitz ist stabil: drei Wissenswelten, die BreakPilot zusammenführt — **Recht · Produkt · Compliance**.
|
||||
|
||||
## Die drei Domänen
|
||||
|
||||
### Domäne 1 — Legal Knowledge Graph („Was steht im Recht?")
|
||||
Besitzt: Dokumente · Parser · CELLAR · Chunk/Span · **citation_span** · Authority · `source_class` ·
|
||||
`source_role` · Explainability · Retriever.
|
||||
Kennt NICHT: Capabilities, Procedures, Produktfeatures.
|
||||
Liefert: `citation_span → legal_basis → authority`.
|
||||
|
||||
### Domäne 2 — Compliance Execution Graph („Wie wird eine Pflicht erfüllt?")
|
||||
Besitzt: **Obligation Registry · Capability Registry · Procedures · Controls · Evidence** ·
|
||||
Discovery-Pipeline · Reuse-Metrik · Cross-Regulation · Runtime (obligation-status).
|
||||
Kennt NICHT: Dokumente/Parser/Spans, Produktfeatures.
|
||||
Modell: `Obligation → Capability → Procedure → Control → Evidence` (Meta-Model v1.0, eingefroren).
|
||||
|
||||
### Domäne 3 — Product Knowledge Graph („Was hat der Kunde gebaut?")
|
||||
Besitzt: Produktmodell · Komponenten · **Business Features** · **Feature → Capability** ·
|
||||
Product Profile (`CanonicalProductRegulatoryProfile`) · Scope Discovery · Missing-Facts (Navigator).
|
||||
Kennt NICHT: Paragraphen, Controls.
|
||||
Beispiel-Features: SPS · HMI · Cloud · MQTT · OPC-UA · Fernwartung · VPN · WLAN · Ethernet ·
|
||||
Bluetooth · USB · Kamera · KI · Mobile App · OTA · Sensorik · Aktorik.
|
||||
|
||||
## Die drei öffentlichen Verträge (die EINZIGE Kopplung)
|
||||
|
||||
```
|
||||
1. Legal → Compliance citation_span → legal_basis (Recht hängt an der Obligation)
|
||||
2. Product → Compliance Business Feature → Capability ← WICHTIGSTE Schnittstelle des Systems
|
||||
3. Compliance → Legal obligation_id → legal_basis (jede Pflicht ist begründbar)
|
||||
```
|
||||
|
||||
**Vertrag 2 (`Feature → Capability`) ist die Innovation.** Er macht aus Kundensprache Regulierungs-
|
||||
sprache: „Wir haben Fernwartung" → Capabilities {transport_encryption, multi_factor_authentication,
|
||||
least_privilege_access_control} → Obligations über CRA + MaschVO + NIS2 → fehlende Nachweise.
|
||||
**Owner des Mappings: Domäne 3** (liest die Capability Registry von Domäne 2 read-only).
|
||||
|
||||
## Der vollständige Fluss (das Kundengespräch)
|
||||
|
||||
```
|
||||
Produktbeschreibung → Product Graph → Capabilities → Compliance Graph → Legal Graph → Antwort
|
||||
```
|
||||
beantwortet: „Wir bauen diese Maschine mit diesen Funktionen — welche Gesetze gelten, was erfüllen
|
||||
wir, was fehlt, wo interpretieren wir falsch?"
|
||||
|
||||
## Mapping auf aktuelle Branches + OFFENE FRAGEN (User/Team entscheidet)
|
||||
|
||||
| Domäne | Kandidat-Branch heute | Klärungsbedarf |
|
||||
|---|---|---|
|
||||
| 1 Legal Knowledge | (Re-Ingest/Span-Arbeit — Owner benennen) | **Wer besitzt Parser/CELLAR/Span?** noch nicht eindeutig einer Branch zugeordnet |
|
||||
| 2 Compliance Execution | `feat/obligation-aggregation` (Registry/Capability/Discovery) **+** `feat/advisor-status` (Controls/Evidence/Endpoint) | **Domäne 2 liegt aktuell auf ZWEI Branches** → zusammenführen oder klare Subteilung |
|
||||
| 3 Product Knowledge | `feat/regulatory-reasoning-engine` (Reasoning **→ umfokussieren** auf Product Graph) | Reasoning besitzt schon `CanonicalProductRegulatoryProfile` + Navigator → wird Domäne 3 |
|
||||
| — | `feat/iace-gt-warewashing` (IACE Hazard-Engine-Qualität) | **4. Session existiert.** User-Prinzip „keine 4. Session" → IACE als Sub-Track von Domäne 2 (Hazard→Obligation) einordnen ODER bewusst separater Engine-Quality-Track |
|
||||
|
||||
## Erste Aufgaben je Domäne
|
||||
|
||||
- **Domäne 1:** Re-Ingest fertig · Span-Anker stabil · `obligation_id` im Legal Graph joinbar (über
|
||||
Vertrag, NICHT selbst vergeben) · zitierfähige API.
|
||||
- **Domäne 2:** Capability Registry ausbauen · Procedure Registry erweitern · Runtime auf Capability-
|
||||
Ebene · `Obligation↔Capability↔Procedure↔Evidence` stabilisieren.
|
||||
- **Domäne 3 (wichtigster neuer Block):** Feature-Katalog (~150–300 Features Maschinen-/Anlagenbau) ·
|
||||
`Feature → Capability` kuratieren · Produktprofil ableiten · Missing-Facts-Engine.
|
||||
|
||||
## Nicht jetzt
|
||||
NIS2/AI-Act/Data-Act-Runs verschoben (liefern Reuse-Kennzahlen, aber keine neue Produktfrage). KEINE
|
||||
weitere Datenmodell-Klasse (Freeze v1.0). Product Knowledge Graph hat Vorrang — er schließt die Lücke
|
||||
zwischen Kunden- und Regulierungssprache.
|
||||
|
||||
## RESOLVED (2026-06-26, User-Entscheidung) — die 3 offenen Fragen geklärt, Vertrag final
|
||||
|
||||
1. **Legal Knowledge Owner = die Re-Ingest-/Knowledge-Session.** Besitzt Parser/CELLAR/Span/Authority/
|
||||
Retrieval/Citation-API. **Vergibt KEINE `obligation_id`** — liefert nur `citation_span → legal_basis`;
|
||||
die `obligation_id` entsteht im Compliance-Graph. Verhindert, dass dieselbe Pflicht zweimal modelliert wird.
|
||||
2. **4. Session NICHT auflösen → umbenennen in „Quality & Validation".** Besitzt KEINE Daten/Registry —
|
||||
NUR Tests: Golden/Regression/Precision/Recall/Halluzination/Benchmark/Hazard-Qualität/FMEA-Validierung.
|
||||
Darf produktive Modelle NIE verändern; sagt nur „funktioniert / funktioniert nicht". → **4 Verantwort-
|
||||
lichkeiten:** Legal *liefert* Wissen · Compliance *modelliert* Wissen · Product *liefert* Kontext ·
|
||||
Quality *prüft* alles.
|
||||
3. **Compliance Execution bleibt 2 Branches (dauerhaft getrennt, NICHT mergen):**
|
||||
- **Branch A** (`feat/obligation-aggregation`) = **BUILD**: Registry · Discovery · Ontology · Capabilities ·
|
||||
Procedures · Graph (ändert sich ~wöchentlich).
|
||||
- **Branch B** (`feat/advisor-status`) = **RUNTIME / Execution Engine**: API · Advisor · Endpoint · Status ·
|
||||
Evidence · Reasoning (ändert sich ~täglich).
|
||||
Unterschiedliche Geschwindigkeit → bewusst getrennt.
|
||||
|
||||
**Plattform-Zielbild: 4 Bibliotheken** — `Legal Library → Product Library → Capability Library →
|
||||
Evidence Library`; darauf sitzen Advisor · Runtime · Auditor · Ticket-System · CE-/CRA-/NIS2-/AI-Act-
|
||||
Assistent — **alle auf derselben Wissensbasis**. Die **Capability Library/Registry ist der Dreh- und
|
||||
Angelpunkt** zwischen Product- und Compliance-Graph → muss ein **stabiler, versionierter API-Vertrag**
|
||||
sein (stabile `cap.*`-IDs, nie umbenennen; produktneutral). Das ist #59.
|
||||
|
||||
## Update (2026-06-26): Domäne 3 = FEATURE Knowledge Graph + Sequenz-Entscheidung
|
||||
|
||||
**Rename Domäne 3 → „Feature Knowledge Graph".** Kunden kaufen keine Capabilities/Obligations — sie
|
||||
kaufen Maschinen mit **Fernwartung, Cloud, OTA, SPS, HMI, KI**. Der Advisor MUSS dort beginnen, wo der
|
||||
Kunde steht (`Fernwartung`), nicht bei `cap.transport_encryption`. Domäne 3 besitzt zusätzlich die
|
||||
**Feature Library** (alle bekannten ~200–400 Features: Fernwartung/Cloud/OTA/VPN/WLAN/Bluetooth/USB/
|
||||
Ethernet/OPC-UA/MQTT/CAN/Profinet/EtherCAT/SPS/Safety-SPS/HMI/Vision/Kamera/RFID/NFC/Mobile-App/REST-API/
|
||||
Webserver/SSH/Benutzerverwaltung/Rollenmodell/Logging/KI/…). **Feature Library ≠ Product Profile:**
|
||||
Library = alle bekannten Features; Profile = die Features EINES konkreten Produkts.
|
||||
|
||||
**Volle Pipeline (der eigentliche Advisor):**
|
||||
```
|
||||
Feature Library → Product Profile → Capabilities → Legal Obligations → Procedures → Controls → Evidence
|
||||
```
|
||||
„Fernwartung + Cloud + VPN + OTA + Benutzerverwaltung" → N Capabilities → M Obligations → K
|
||||
Regulierungen → Procedures → Controls → Evidence. Das beginnt das Gespräch in Kundensprache.
|
||||
|
||||
**Sequenz-Entscheidung (User 2026-06-26):**
|
||||
1. **JETZT:** `cap.*`-Vertrag (capability_registry_v1) an Domäne 3 übergeben = der Multiplikator.
|
||||
2. **Domäne 3 Vollgas:** Feature Library + Komponenten + **`Feature → cap.*`** + Product Profile +
|
||||
Missing-Facts. Zuerst OHNE Regulierungen — reine Kundensprache.
|
||||
3. **Domäne 2 STOPP bei #59:** Capability Registry bleibt STABIL (nur Bugfixes, KEINE neuen Capabilities/
|
||||
Procedures), bis Domäne 3 zeigt, WELCHE Capabilities real gebraucht werden (sonst modelliert man 30,
|
||||
von denen 12 genutzt werden).
|
||||
4. **Domäne 1:** Re-Ingest abschließen, Span-Anker, Citation-API stabilisieren.
|
||||
|
||||
### Domäne 2 — Wake-up-Trigger (statt vagem „pausiert")
|
||||
|
||||
Domäne 2 ruht NICHT unbestimmt — sie wird wieder aktiv, sobald EINER dieser Trigger feuert:
|
||||
```
|
||||
Feature Graph (Domäne 3) >= 200 Features → Feature Coverage Report (erster Auftrag, s.u.)
|
||||
ODER Span-Anker verfügbar (Domäne 1) → pending_span_anchor auflösen (citation_pending → echte Spans)
|
||||
ODER neue Regulierung ingestiert → Discovery-Cut + Reuse-Metrik
|
||||
ODER Runtime (Branch B) kennt neue Evidence-Typen → required_procedures/evidence_patterns endgültig füllen
|
||||
```
|
||||
Bis dahin steht überall `citation_pending` / `required_procedures: []` — bewusst, kein Defekt.
|
||||
|
||||
### Erster Folgeauftrag von Domäne 2 (sobald Feature Library v1 steht): FEATURE COVERAGE REPORT
|
||||
|
||||
NICHT „neue CRA-Domäne". Sondern die **Wissenslücken-Analyse**, die diese Architektur erstmals ermöglicht:
|
||||
pro Feature die Kette `Feature → cap.* → realizes_obligations → Procedures → Evidence` traversieren und
|
||||
**Coverage % je Feature** berechnen — wie vollständig ist die Modellierungskette?
|
||||
```
|
||||
Fernwartung → 100 % · USB → 94 % · Bluetooth → 83 % · Cloud → 71 %
|
||||
```
|
||||
Output: je Feature die Lücken — fehlende Capability · fehlende Procedure · fehlender Evidence-Typ.
|
||||
Zeigt sofort, was schon vollständig modelliert ist und wo Domäne 2 als Nächstes nacharbeiten muss.
|
||||
Traversal-Logik gehört Domäne 2 (cap.*→Obligation→Procedure→Evidence); der Feature→cap.*-Input kommt
|
||||
read-only von Domäne 3. Gated auf Feature Library v1.
|
||||
@@ -0,0 +1,289 @@
|
||||
{
|
||||
"schema_version": "capability_registry_v1",
|
||||
"contract_version": "1.0",
|
||||
"status": "stable_api_contract",
|
||||
"note": "PRODUKTNEUTRALER Vertrag zwischen Product Knowledge Graph (Domaene 3, Feature->Capability) und Compliance Execution Graph (Domaene 2). Stabile cap.*-IDs NIE umbenennen. KEINE Business-Features hier (die besitzt die Product-Session). Siehe docs-src/development/session_ownership_model_v1.md + compliance_meta_model_v1.md (Freeze v1.0).",
|
||||
"id_namespace": "cap.",
|
||||
"contract_fields": [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"guidance_basis",
|
||||
"realizes_obligations",
|
||||
"required_procedures",
|
||||
"evidence_patterns",
|
||||
"domains"
|
||||
],
|
||||
"dropped": {
|
||||
"access_control": "OVERLAP (credential_confidentiality <-> sbom_confidentiality), nicht materialisiert"
|
||||
},
|
||||
"candidate_capabilities_followup": [
|
||||
"automatic_update_delivery",
|
||||
"update_rollback",
|
||||
"trusted_update_source",
|
||||
"hash_verification",
|
||||
"secure_boot",
|
||||
"least_functionality",
|
||||
"credential_storage"
|
||||
],
|
||||
"capabilities": [
|
||||
{
|
||||
"id": "cap.multi_factor_authentication",
|
||||
"slug": "multi_factor_authentication",
|
||||
"name": "Multi-Factor Authentication",
|
||||
"description": "Mehrfaktor-Authentisierung als technische Faehigkeit (Besitz/Wissen/Inhaerenz).",
|
||||
"guidance_basis": [
|
||||
{
|
||||
"source": "NIST",
|
||||
"anchor": "SP 800-63B",
|
||||
"role": "best_practice"
|
||||
},
|
||||
{
|
||||
"source": "Out-of-Band-Authentifizierung",
|
||||
"anchor": "",
|
||||
"role": "implementation_guidance",
|
||||
"merged_from": "out_of_band_authentication"
|
||||
},
|
||||
{
|
||||
"source": "Hardware-basierte Authentifizierung (AAL3)",
|
||||
"anchor": "",
|
||||
"role": "implementation_guidance",
|
||||
"merged_from": "hardware_authenticators"
|
||||
},
|
||||
{
|
||||
"source": "E-Mail-Authentifizierungsmechanismen (SPF/DKIM/DMARC)",
|
||||
"anchor": "",
|
||||
"role": "implementation_guidance",
|
||||
"merged_from": "email_authentication"
|
||||
},
|
||||
{
|
||||
"source": "NIST",
|
||||
"anchor": "IA-02",
|
||||
"role": "best_practice"
|
||||
},
|
||||
{
|
||||
"source": "NIST",
|
||||
"anchor": "IA-02(1)",
|
||||
"role": "best_practice"
|
||||
},
|
||||
{
|
||||
"source": "NIST",
|
||||
"anchor": "AC-17",
|
||||
"role": "best_practice"
|
||||
},
|
||||
{
|
||||
"source": "NIST",
|
||||
"anchor": "SP 800-53 IA-2",
|
||||
"role": "best_practice"
|
||||
},
|
||||
{
|
||||
"source": "BSI",
|
||||
"anchor": "ICS Security Kompendium",
|
||||
"role": "best_practice"
|
||||
},
|
||||
{
|
||||
"source": "ISO",
|
||||
"anchor": "ISO 27001 A.5.19",
|
||||
"role": "best_practice"
|
||||
}
|
||||
],
|
||||
"realizes_obligations": [
|
||||
"mfa_required",
|
||||
"privileged_op_reauth",
|
||||
"remote_access_authentication",
|
||||
"remote_access_mfa",
|
||||
"remote_access_user_validation_ot",
|
||||
"supplier_access_auth"
|
||||
],
|
||||
"required_procedures": [],
|
||||
"evidence_patterns": [
|
||||
"iam_config_export",
|
||||
"mfa_policy_export",
|
||||
"auth_audit_log"
|
||||
],
|
||||
"domains": [
|
||||
"authentication",
|
||||
"remote_access"
|
||||
],
|
||||
"provenance": {
|
||||
"source": "cross_domain_relationships.json SHARED_CAPABILITY"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cap.session_management",
|
||||
"slug": "session_management",
|
||||
"name": "Session Management",
|
||||
"description": "Sichere Sitzungsverwaltung: Timeouts, Bindung, Re-Auth, Beendigung.",
|
||||
"guidance_basis": [
|
||||
{
|
||||
"source": "NIST",
|
||||
"anchor": "SP 800-63B 4.3",
|
||||
"role": "best_practice"
|
||||
},
|
||||
{
|
||||
"source": "NIST",
|
||||
"anchor": "SP 800-53 AC-12",
|
||||
"role": "best_practice"
|
||||
},
|
||||
{
|
||||
"source": "OWASP",
|
||||
"anchor": "ASVS V3",
|
||||
"role": "best_practice"
|
||||
},
|
||||
{
|
||||
"source": "NIST",
|
||||
"anchor": "AC-2(5)",
|
||||
"role": "best_practice"
|
||||
}
|
||||
],
|
||||
"realizes_obligations": [
|
||||
"reauth_after_inactivity",
|
||||
"remote_session_management",
|
||||
"session_binding_management",
|
||||
"temporary_remote_access_mgmt"
|
||||
],
|
||||
"required_procedures": [],
|
||||
"evidence_patterns": [
|
||||
"session_config_export",
|
||||
"timeout_policy_export"
|
||||
],
|
||||
"domains": [
|
||||
"authentication",
|
||||
"remote_access"
|
||||
],
|
||||
"provenance": {
|
||||
"source": "cross_domain_relationships.json SHARED_CAPABILITY"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cap.transport_encryption",
|
||||
"slug": "transport_encryption",
|
||||
"name": "Transport Encryption",
|
||||
"description": "Verschluesselter Transport (TLS, mutual-TLS, Zertifikats-Auth, VPN/Tunnel).",
|
||||
"guidance_basis": [
|
||||
{
|
||||
"source": "BSI",
|
||||
"anchor": "TR-02102-2",
|
||||
"role": "best_practice"
|
||||
},
|
||||
{
|
||||
"source": "NIST",
|
||||
"anchor": "IA-03",
|
||||
"role": "best_practice"
|
||||
},
|
||||
{
|
||||
"source": "NIST",
|
||||
"anchor": "SC-8",
|
||||
"role": "best_practice"
|
||||
},
|
||||
{
|
||||
"source": "BSI",
|
||||
"anchor": "IT-Grundschutz NET.3.3",
|
||||
"role": "best_practice"
|
||||
},
|
||||
{
|
||||
"source": "OWASP",
|
||||
"anchor": "API Security Top 10",
|
||||
"role": "best_practice"
|
||||
},
|
||||
{
|
||||
"source": "NIST",
|
||||
"anchor": "IA-05(2)",
|
||||
"role": "best_practice"
|
||||
}
|
||||
],
|
||||
"realizes_obligations": [
|
||||
"encrypted_auth_channel",
|
||||
"mutual_authentication",
|
||||
"reject_insecure_remote_protocols",
|
||||
"remote_access_confidentiality_integrity",
|
||||
"remote_access_encryption",
|
||||
"service_to_service_auth",
|
||||
"tls_certificate_auth"
|
||||
],
|
||||
"required_procedures": [],
|
||||
"evidence_patterns": [
|
||||
"tls_config_export",
|
||||
"cipher_scan",
|
||||
"cert_inventory"
|
||||
],
|
||||
"domains": [
|
||||
"authentication",
|
||||
"remote_access"
|
||||
],
|
||||
"provenance": {
|
||||
"source": "cross_domain_relationships.json SHARED_CAPABILITY"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cap.code_signing",
|
||||
"slug": "code_signing",
|
||||
"name": "Code & Update Signing",
|
||||
"description": "Digitale Signatur + Integritaets-/Authentizitaetspruefung von Firmware/Software/Updates.",
|
||||
"guidance_basis": [
|
||||
{
|
||||
"source": "NIST",
|
||||
"anchor": "SI-07",
|
||||
"role": "best_practice"
|
||||
},
|
||||
{
|
||||
"source": "NIST",
|
||||
"anchor": "SP 800-147 BIOS Protection",
|
||||
"role": "best_practice"
|
||||
}
|
||||
],
|
||||
"realizes_obligations": [
|
||||
"firmware_software_authentication",
|
||||
"signed_update_integrity"
|
||||
],
|
||||
"required_procedures": [],
|
||||
"evidence_patterns": [
|
||||
"signature_verification_log",
|
||||
"sbom",
|
||||
"signing_key_policy"
|
||||
],
|
||||
"domains": [
|
||||
"authentication",
|
||||
"updates"
|
||||
],
|
||||
"provenance": {
|
||||
"source": "cross_domain_relationships.json SHARED_CAPABILITY"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cap.security_monitoring_alerting",
|
||||
"slug": "security_monitoring_alerting",
|
||||
"name": "Security Monitoring & Alerting",
|
||||
"description": "Anomalie-/Bedrohungserkennung und Alarmierung aus Logs/Telemetrie.",
|
||||
"guidance_basis": [
|
||||
{
|
||||
"source": "NIST",
|
||||
"anchor": "AU-6/SI-4",
|
||||
"role": "best_practice"
|
||||
},
|
||||
{
|
||||
"source": "NIST",
|
||||
"anchor": "SP 800-94",
|
||||
"role": "best_practice"
|
||||
}
|
||||
],
|
||||
"realizes_obligations": [
|
||||
"log_monitoring_alerting",
|
||||
"remote_access_threat_detection"
|
||||
],
|
||||
"required_procedures": [],
|
||||
"evidence_patterns": [
|
||||
"siem_config_export",
|
||||
"alert_rule_export",
|
||||
"monitoring_audit_log"
|
||||
],
|
||||
"domains": [
|
||||
"logging",
|
||||
"remote_access"
|
||||
],
|
||||
"provenance": {
|
||||
"source": "cross_domain_relationships.json SHARED_CAPABILITY"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"schema_version": "controls_for_obligation_mapping_v1",
|
||||
"purpose": "Accepted CRA->OWASP controls (Compliance Execution Graph) for the Obligation Registry to propose the SEMANTIC control->obligation_id, replacing the coarse citation_unit interim join. Fill proposed_obligation_id per control, then we adopt it into control_mapping.obligation_id.",
|
||||
"source": "ai-compliance-sdk control_mappings, mapping_status=accepted, reviewed_by=benjamin 2026-06-25",
|
||||
"filled_by": "obligation-registry-session 2026-06-25 (alle 7/7: 4 auth/crypto + 3 logging via cra_logging.json)",
|
||||
"purpose": "Accepted CRA->Framework controls (Compliance Execution Graph) for the Obligation Registry to propose the SEMANTIC control->obligation_id, replacing the coarse citation_unit interim join. Fill proposed_obligation_id per control, then we adopt it into control_mapping.obligation_id.",
|
||||
"source": "ai-compliance-sdk control_mappings, mapping_status=accepted, reviewed_by=benjamin 2026-06-25. OWASP ASVS (7, gefuellt) + NIST SP 800-53 (3, pending).",
|
||||
"filled_by": "obligation-registry-session 2026-06-25. OWASP 7/7 (4 auth/crypto + 3 logging). NIST 3/3 GEFUELLT (Obligation-Session): SI-2->provide_security_updates (stark, (2)(c)/Art.13) · SI-7->signed_update_integrity (update-scoped; SI-7 breiter) · CM-7->remote_access_attack_surface_min (remote-scoped; CM-7 breiter). GAP-BEFUND (Cross-Domain-Review): generische Parent-Obligations software_integrity_protection + attack_surface_minimization FEHLEN — SI-7/CM-7 sind breiter als die domaenen-scoped Treffer. Kandidaten fuer neue generische Obligations (User-Entscheidung). Damit 10/10 proposed_obligation_id gefuellt.",
|
||||
"join_principle": "SEMANTISCH via obligation_id, NICHT via citation_unit/legal_basis-Anker. Die CRA-Anker sind im Registry teils approximativ (siehe anchor_quality_note) — daher ist obligation_id der stabile Primaerschluessel, nicht der Anker.",
|
||||
"anchor_quality_note": "Registry-legal_basis-Anker sind teils CRA-Part-I-fehlzugeordnet (Opus-Synthese): user_authentication_required steht auf (2)(d) statt (2)(c); Crypto-Obligations auf (2)(e) statt (2)(d). CRA Annex I Part I: (2)(c)=Zugriffsschutz, (2)(d)=Vertraulichkeit, (2)(e)=Integritaet. Korrektur kommt mit dem zitierfaehigen Re-Ingest (span-genau). Deshalb: NICHT auf Anker joinen. ABER: der Logging-Cut (V16.*) ist korrekt auf (2)(k) verankert (echte Logging-Subsektion, kein Fehl-Anker).",
|
||||
"count": 7,
|
||||
"mapping_type_note": "NEU: mapping_type=primary_implementation = die kanonische Primaer-Control einer Anforderung (genau eine), staerker als implements/supports. related-Controls (SC-3(3), RA-5, AC-6, SI-16, SA-10, ...) folgen separat als supports. Eine Obligation kann mehrere Controls haben, aber genau einen primary_implementation-Einstieg.",
|
||||
"count": 10,
|
||||
"controls": [
|
||||
{
|
||||
"framework": "OWASP ASVS", "control": "V6.3.1",
|
||||
@@ -62,6 +63,30 @@
|
||||
"proposed_obligation_id": "event_logging_security_events",
|
||||
"mapping_method": "semantic",
|
||||
"mapping_note": "V16.1 = allgemeine Logging-Anforderung -> Umbrella-LM event_logging_security_events. Hohe Konfidenz."
|
||||
},
|
||||
{
|
||||
"framework": "NIST SP 800-53", "control": "SI-7",
|
||||
"source_norm": "CRA Annex I Part I (2)(e) — Integritaet",
|
||||
"citation_unit": "Annex I (2)(e)", "family": "integrity", "mapping_type": "primary_implementation",
|
||||
"proposed_obligation_id": "software_integrity_protection",
|
||||
"mapping_method": "semantic",
|
||||
"mapping_note": "NIST SI-7 = Software/Firmware/Information Integrity (gesamte Produkt-Integritaet). #6 ADOPTIERT (2026-06-26) auf CORE software_integrity_protection (Annex I (2)(f)) — die in #5b materialisierte generische Integritaets-Obligation. Die domaenen-scoped signed_update_integrity (Update-Signatur, (1)(3)(f)) bleibt gueltig als DOMAIN, specializes->CORE. NICHT log_integrity_immutability (Audit-Log-Schutz, andere Ebene)."
|
||||
},
|
||||
{
|
||||
"framework": "NIST SP 800-53", "control": "SI-2",
|
||||
"source_norm": "CRA Annex I Part I (2)(l) — Sichere Updates",
|
||||
"citation_unit": "Annex I (2)(l)", "family": "update", "mapping_type": "primary_implementation",
|
||||
"proposed_obligation_id": "provide_security_updates",
|
||||
"mapping_method": "semantic",
|
||||
"mapping_note": "NIST SI-2 = Flaw Remediation. STARKER Treffer in eurer NEUEN updates-Familie (93-Stand): provide_security_updates (LEGAL_MINIMUM, Annex I (2)(c) + Art. 13) = DAS sichere-Update-LM. -> SI-2 primary_implementation = provide_security_updates. Verwandt (supports): vuln_remediation_patching (Part II Remediations-PROZESS), support_period_maintenance, update_testing_validation, update_rollback. Mein source_norm-Anker (2)(l) ist approximativ -> bitte (2)(c)/Art.13 via provide_security_updates nutzen."
|
||||
},
|
||||
{
|
||||
"framework": "NIST SP 800-53", "control": "CM-7",
|
||||
"source_norm": "CRA Annex I Part I (2)(i) — Angriffsflaeche minimieren",
|
||||
"citation_unit": "Annex I (2)(i)", "family": "attack_surface", "mapping_type": "primary_implementation",
|
||||
"proposed_obligation_id": "attack_surface_minimization",
|
||||
"mapping_method": "semantic",
|
||||
"mapping_note": "NIST CM-7 = Least Functionality (deaktivierte Ports/Dienste/Funktionen, GESAMTE Angriffsflaeche). #6 ADOPTIERT (2026-06-26) auf CORE attack_surface_minimization (Annex I (2)(j)) — die in #5b materialisierte generische Obligation. Die domaenen-scoped remote_access_attack_surface_min (nur Remote-Access-Flaeche) bleibt gueltig als DOMAIN, specializes->CORE. related (supports): SC-3(3)/AC-6/SI-16."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -966,7 +966,10 @@
|
||||
"relationships": [],
|
||||
"citation_anchor_ids": [],
|
||||
"citation_status": "pending_span_anchor",
|
||||
"review_status": "draft"
|
||||
"review_status": "draft",
|
||||
"merged_into": "provide_security_updates",
|
||||
"status": "deprecated_alias",
|
||||
"merge_note": "SAME_OBLIGATION (Cross-Domain-Review). Kanonisch: provide_security_updates ((2)(c)/Art.13). ID bleibt als Alias aufloesbar; downstream provide_security_updates nutzen."
|
||||
},
|
||||
{
|
||||
"id": "vuln_handling_process",
|
||||
|
||||
@@ -10281,7 +10281,11 @@
|
||||
"cluster_size": 4,
|
||||
"llm_model": "claude-opus-4-8",
|
||||
"synthesis_version": "v1"
|
||||
}
|
||||
},
|
||||
"specializes": "software_integrity_protection",
|
||||
"objective_tags": [
|
||||
"integrity"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relationships": [
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"schema_version": "obligation_registry_v1",
|
||||
"regulation": "CRA",
|
||||
"regulation_code": "CRA",
|
||||
"family": "core",
|
||||
"theme": "CORE Security Objectives (CRA Annex I als regulierungs-agnostische Sicherheitsziele)",
|
||||
"generated_by": "materialize_capabilities.py (#5b, Modell C)",
|
||||
"note": "CORE Legal Obligations = Sicherheitsziele (Modell C: KEINE eigene SecurityObjective-Klasse). DOMAIN-Obligations specializes-en hierauf. objective_tags = Vorwaerts-Kompat zu Modell B.",
|
||||
"citation_status": "pending_span_anchor",
|
||||
"obligations": [
|
||||
{
|
||||
"id": "attack_surface_minimization",
|
||||
"name": "Minimierung der Angriffsflaeche",
|
||||
"family": "core",
|
||||
"description": "Das Produkt minimiert seine Angriffsflaeche: unnoetige Funktionen/Ports/Dienste/Schnittstellen sind deaktiviert (Least Functionality).",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"source_role": "LEGAL_BASIS",
|
||||
"applicability": "universal",
|
||||
"objective_tags": [
|
||||
"attack_surface"
|
||||
],
|
||||
"legal_basis": [
|
||||
{
|
||||
"source": "CRA",
|
||||
"anchor": "Annex I Part I (2)(j)",
|
||||
"citation": "limit attack surfaces, including external interfaces"
|
||||
}
|
||||
],
|
||||
"guidance_basis": [
|
||||
{
|
||||
"source": "NIST",
|
||||
"anchor": "CM-7 Least Functionality",
|
||||
"role": "best_practice"
|
||||
}
|
||||
],
|
||||
"specialized_by": [
|
||||
"remote_access_attack_surface_min",
|
||||
"component_remote_interface_security"
|
||||
],
|
||||
"primary_implementation": "NIST CM-7",
|
||||
"citation_status": "pending_span_anchor",
|
||||
"review_status": "core_from_5b"
|
||||
},
|
||||
{
|
||||
"id": "software_integrity_protection",
|
||||
"name": "Schutz der Software-/Firmware-Integritaet",
|
||||
"family": "core",
|
||||
"description": "Das Produkt schuetzt Integritaet und Authentizitaet von Software/Firmware (Manipulationserkennung, Secure Boot, Signaturpruefung, Runtime-Integritaet).",
|
||||
"tier": "LEGAL_MINIMUM",
|
||||
"source_role": "LEGAL_BASIS",
|
||||
"applicability": "universal",
|
||||
"objective_tags": [
|
||||
"integrity"
|
||||
],
|
||||
"legal_basis": [
|
||||
{
|
||||
"source": "CRA",
|
||||
"anchor": "Annex I Part I (2)(f)",
|
||||
"citation": "protect the integrity of stored, transmitted or processed data, software and configuration"
|
||||
}
|
||||
],
|
||||
"guidance_basis": [
|
||||
{
|
||||
"source": "NIST",
|
||||
"anchor": "SI-7 Software, Firmware, and Information Integrity",
|
||||
"role": "best_practice"
|
||||
}
|
||||
],
|
||||
"specialized_by": [
|
||||
"signed_update_integrity",
|
||||
"firmware_software_authentication"
|
||||
],
|
||||
"realized_by_capabilities": [
|
||||
"code_signing"
|
||||
],
|
||||
"primary_implementation": "NIST SI-7",
|
||||
"citation_status": "pending_span_anchor",
|
||||
"review_status": "core_from_5b"
|
||||
}
|
||||
],
|
||||
"relationships": []
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user