Compare commits

..

103 Commits

Author SHA1 Message Date
pilotadmin 7eb7f61483 Merge pull request 'feat: company capability profile foundation' (#4) from feat/company-intelligence-2a into main
CI / detect-changes (push) Successful in 14s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 10s
CI / validate-canonical-controls (push) Successful in 5s
CI / loc-budget (push) Successful in 20s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 23s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-26 15:13:21 +02:00
Benjamin Admin 8c893ca783 feat(company): Company Intelligence 2A — Company Capability Profile foundation
HEAD of the spine Company->Capability->Product->Regulation->Obligation->Procedure
->Evidence. New compliance/company/ package: CompanyContext container + a four-state
trust model (declared/inferred/confirmed/unknown).

Hard rule (structural): a certification yields at most an INFERRED candidate and is
never auto-treated as CONFIRMED/"erfuellt". A certification produces evidence-of-
capability; only real ExistingEvidence promotes a capability to CONFIRMED.

Ownership: Reasoning owns the container + trust-state; the Certification->Capability
mapping is Execution's domain, consumed via an injected contract. No mapping data in
product code (tests inject mocks). No endpoint/UI/RAG/new regs/controls; no meta-model
classes (freeze v1.0 untouched). 8 tests; mypy --strict clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 14:59:42 +02:00
pilotadmin d1383227b2 Merge pull request 'feat: regulatory change intelligence foundation' (#3) from feat/regulatory-change-intelligence into main
CI / detect-changes (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 8s
CI / validate-canonical-controls (push) Successful in 5s
CI / loc-budget (push) Successful in 21s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 24s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-26 14:01:48 +02:00
Benjamin Admin a5687bbc65 feat(rci): Regulatory Change Intelligence foundation (delta over the stored map)
RCI/Delta as a read-/reasoning layer ON TOP of the product-first pipeline. Answers
"what changes relative to my existing Regulatory Map?" — NOT "what does the new
law say in general". No UI, no ingestion (newsletter/mailbox), no RAG, no new
regulations/controls, no legal evaluation outside the stored map.

- 4 core objects (compliance/rci/schemas.py): ComplianceBaseline (snapshot of
  profile + map + registry obligations + required/present evidence), RegulatoryChange
  (simulated/provided INPUT), ObligationDelta (delta_type NEW|CHANGED|REMOVED|
  ALREADY_COVERED|NEEDS_REVIEW|NOT_APPLICABLE), ChangeImpactSummary. delta_type is a
  THIRD vocabulary, disjoint from ClaimCoverage (Welt 1) and ComplianceStatus (Welt 2).
- create_baseline() snapshots the existing pipeline once; assess_change() computes
  deltas deterministically against the snapshot (no re-evaluation).
- 12 tests = the 5 acceptance questions (affects product? new/changed? already
  covered by evidence? needs human review? not relevant?) + repeal/uncertain-reg/
  missing-evidence/boundary. Existing pipeline tests stay green; mypy clean; LOC ok.
- App/reasoning types only — no compliance-meta-model classes (freeze v1.0 untouched).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 13:45:23 +02:00
pilotadmin da466b3821 Merge pull request 'feat(ai-sdk): IACE hazard-engine quality + offline proposer (Session 4)' (#2) from feat/iace-gt-warewashing into main
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 7s
CI / validate-canonical-controls (push) Successful in 8s
CI / loc-budget (push) Successful in 21s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 1m1s
CI / iace-gt-coverage (push) Successful in 19s
CI / test-python-backend (push) Successful in 24s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-26 11:48:09 +02:00
pilotadmin eca8ec43c5 Merge pull request 'feat(reasoning): product-first regulatory pipeline — Profile → Navigator → Scope → Map → Interpretation' (#1) from feat/regulatory-reasoning-engine into main 2026-06-26 11:47:18 +02:00
Benjamin Admin 37c9b8e773 docs: Domaene-2 Wake-up-Trigger + erster Folgeauftrag Feature Coverage Report
User-Praezisierung: Domaene 2 ruht NICHT unbestimmt. Wake-up-Trigger (EINER reicht):
Feature Graph>=200 Features · Span-Anker verfuegbar · neue Regulierung ingestiert · Runtime
kennt neue Evidence-Typen. Erster Folgeauftrag (gated auf Feature Library v1):
FEATURE COVERAGE REPORT = Wissenslueckenanalyse pro Feature (Feature->cap.*->Obligation->
Procedure->Evidence -> Coverage %; zeigt fehlende Capability/Procedure/Evidence je Feature).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 11:24:06 +02:00
Benjamin Admin 50ae9e94d1 feat(interpretation-in-map): judge a customer interpretation within the map (step 5)
Thin adapter — it judges the customer's reading WITHIN the already-built
RegulatoryMap, it does not assess abstract legal questions and it is not RCI.

- Reuses the existing assess_interpretation (no new legal reasoning); the 6
  verdicts (plausible/too_narrow/too_broad/partially_correct/unsupported/uncertain)
  pass through unchanged.
- Restricts affected_regulations/affected_obligations to those present in the map
  (intersection); links to the map's uncertain regulations.
- Touched unsupported domains (wastewater/chemicals/...) are reported as
  future_corpus_domains (future_corpus_needed) — never pseudo-evaluated.
- Customer-readable explanation ("Ihre Interpretation ist wahrscheinlich zu eng. …
  Betroffen in Ihrer Map: CRA.").
- POST /reasoning/interpretation-in-map (renders the map, then interprets).
- 7 tests; 63 green (existing reasoning MVP stays green), mypy clean, LOC ok.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:58:00 +02:00
Benjamin Admin 429ac957c1 docs: Feature Knowledge Graph + Sequenz (Domaene 3 Rename + Feature Library; Domaene 2 STOPP #59)
User-Entscheidung: Domaene 3 = „Feature Knowledge Graph" (Kunden kaufen Features, nicht
Capabilities — Advisor beginnt bei „Fernwartung", nicht „cap.transport_encryption"). Besitzt
zusaetzlich Feature Library (~200-400 Features) != Product Profile. Volle Pipeline
Feature Library -> Product Profile -> Capabilities -> Obligations -> Procedures -> Controls -> Evidence.
SEQUENZ: (1) cap.*-Vertrag JETZT an Domaene 3 uebergeben (Multiplikator); (2) Domaene 3 Vollgas
(Feature->cap.*); (3) Domaene 2 STOPP bei #59 (Capability Registry STABIL, nur Bugfixes, bis
Domaene 3 den realen Bedarf zeigt); (4) Domaene 1 Re-Ingest/Spans/Citation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:45:46 +02:00
Benjamin Admin 9312ad18ef feat(regulatory-map): customer-readable read-model over the scope (step 4)
The Map Renderer explains the engine's state, it does not extend it. Pure
composition of resolve_product_scope (scope verdict) + derive_obligations
(registry-linked obligations + overlaps) into one RegulatoryMap.

- product_summary, trigger_facts, applicable/uncertain/excluded regulations,
  unsupported_domains, overlaps (shared_obligations), shared_evidence, and a
  customer-readable executive_summary.
- No own legal decisions: applicable/uncertain mirror the scope verdict exactly.
- Obligations shown ONLY when registry-linkable (registry_anchor) — MaschinenVO/
  EMV obligations are proposed, so they render empty + a note, never as linked.
  Overlaps/shared_evidence likewise filtered to registry-linked members.
- Uncertain regulations link to the navigator question that would resolve them
  (RED -> has_radio_module, DataAct -> generates_usage_data).
- Environmental appears only as unsupported_domain; executive_summary has NO
  percentage (counts + "no further regulations identified" instead).
- POST /reasoning/regulatory-map (thin handler). Response types are presentation-
  level, not meta-model classes (freeze v1.0 untouched).
- 9 tests; 56 green (existing reasoning MVP stays green), mypy clean, LOC ok.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:36:06 +02:00
Benjamin Admin 2063615d37 feat: Capability Registry v1 API-Vertrag (#59) + Ownership-Modell finalisiert
#59 (geschaerft, User): capabilities.json -> capability_registry_v1 (contract_version 1.0):
stabile `cap.*`-IDs (NIE umbenennen) + 5 Vertragsfelder (description/guidance_basis/
realizes_obligations/required_procedures/evidence_patterns), PRODUKTNEUTRAL (keine Features).
= stabiler API-Vertrag fuer die Product->Compliance-Schnittstelle (Feature->Capability,
Session 3 mappt read-only dagegen).
session_ownership_model_v1.md RESOLVED: Legal-Owner = Re-Ingest-Session (vergibt KEINE
obligation_id, nur citation_span->legal_basis) · 4. Session -> Quality & Validation (nur
Tests, KEINE Daten) · Compliance 2 Branches DAUERHAFT (A=Build, B=Runtime). 4-Bibliotheken-
Zielbild (Legal/Product/Capability/Evidence).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:35:49 +02:00
Benjamin Admin 4d225f73a8 feat(ai-sdk): coverage blind-spot proposer (P2 slice 6, type 4)
Completes the proposer's four types.

- FindCoverageGaps (proposer_coverage.go): deterministic — which EN ISO 12100
  hazard groups A-G did the engine leave with zero hazards for this machine? An
  empty group is a structural blind-spot signal (the machine may truly lack it,
  or a pattern/GT case is missing). Useful with no model at all.
- ProposeMissingHazards + BuildCoveragePrompt: optional LLM expansion of each gap
  into specific expected-but-missing hazards a safety assessor would name
  (propose-only, reuses LLMCompleter, degrades to nil on any error).
- Wired into iace-audit propose -> audit-reports/coverage.{md,json}.

On the dishwasher: D. Pneumatik (truly absent — nothing invented), E. Laerm
(borderline), F. Ergonomie (a genuine gap: manual loading the engine did not
produce). P3 (pin an accepted proposal into a GT case) remains as a human-in-the-
loop follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:27:01 +02:00
Benjamin Admin c13aa9183a feat(ai-sdk): vocab->tag proposer (P2 slice 5, type 3)
Extends Method C: for each unknown narrative token that pattern text names, suggest
the keyword_dictionary tag = the RequiredComponentTags shared by the naming
patterns (ranked by frequency, kept only when shared by >=40% of them, top 3).
Surfaces real dictionary gaps like "zwischenkreis" -> stored_energy and
"updates" -> has_software, which close coverage without hand-editing the dict.

Two precision fixes to Method C while here:
- patternsMentioning now matches WHOLE WORDS, not substrings — substring matching
  flagged fragments like "stehen" inside "entstehen" and produced nonsensical
  tag suggestions.
- a token is only proposed with a tag if one is shared by >=40% of its naming
  patterns, so diffuse common verbs (spread across categories) drop out.

Wired into iace-audit propose -> audit-reports/vocab.{md,json}. Residual
common-verb noise is left to the human/LLM filter rather than a hand-grown
stopword list. Type 4 (coverage blind spots) + P3 (pin accepted proposals into a
GT case) remain for slice 6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:27:01 +02:00
Benjamin Admin 662aec209a feat(ai-sdk): foreign-framing proposer (P2 slice 4, type 2)
Surfaces fired patterns whose zone names terms the machine's narrative never
mentions — foreign framing that leaks through terms not yet in domainGateTerms
(once a term is a gate term, the ghost-pattern invariant already fences it out).

- FindFramingCandidates (proposer_framing.go): per fired pattern, zone terms with
  no narrative echo (minus a generic hazard-location stoplist). Echo matching is
  bidirectional to survive German compounding (narrative "Steuerung" echoes zone
  "Steuerungssystem"). Heuristic verdict foreign (fully orphan) / plausible
  (partial). Over-surfaces by design — human/LLM is the precision filter.
- Wired into iace-audit propose -> audit-reports/framing.{md,json}, threshold via
  IACE_FRAMING_MIN_ORPHAN (default 0.6).

Honest finding: genuine wrong-MACHINE framing (Walzen, Transportbaender) no longer
fires thanks to the machine-type gate; the residual is mostly cyber/control
patterns with generic-industrial zone vocabulary, candidates for re-framing.
Proposal types 3-4 (vocab->tag, coverage blind spots) remain for slice 5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:27:01 +02:00
Benjamin Admin 8440ddfecb feat(ai-sdk): runnable iace-audit propose CLI + live LLM wiring (P2 slice 3)
Makes the offline proposer runnable end-to-end.

- BuildProposerInput (proposer_input.go): non-test engine->hazards path. The
  PatternMatch->Hazard converter is lifted out of the GT test files into
  production scope so both the tests and the CLI share one pipeline.
- iace-audit propose <narrative.json> [<ground-truth.json>]: detect candidates ->
  GT-screen survivors (when a ground truth is given) -> judge (HeuristicJudge by
  default, LLMJudge over ollama when IACE_PROPOSE_LLM=1) -> write the human-review
  queue to audit-reports/proposals.{md,json}. Propose-only.

Smoke run on a dishwasher narrative: 32 fired -> 3 candidates -> queue with a
confident duplicate, a confident distinct, and one punted to the LLM judge; GT
wall recall-safe. Live qwen is opt-in via env; the heuristic default keeps the
tool runnable (and CI deterministic) without a model. Proposal types 2-4
(foreign-framing gates, vocab->tag, coverage blind spots) remain for slice 4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:27:01 +02:00
Benjamin Admin 0ce4794767 feat(ai-sdk): pluggable LLM judgment over recall-safe dedup candidates (P2 slice 2)
Adds the semantic judgement layer on top of the slice-1 detector + GT wall.
DEV-TIME, propose-only — nothing mutates the library or runtime.

- CandidateJudge interface with two implementations: HeuristicJudge
  (deterministic default/fallback, used in tests) and LLMJudge (offline, over the
  shared llm.ProviderRegistry via the LLMCompleter adapter). LLMJudge degrades to
  "uncertain" on any transport/parse error — it can never break a run.
- BuildJudgePrompt: the ISO 12100 same-vs-distinct prompt, unit-tested
  deterministically even though the call is not.
- RenderProposalQueue: markdown human-review queue with a suggested action per
  candidate (supersede / keep both / needs review).

On real warewashing output the heuristic punts to "uncertain — needs the LLM
judge" for exactly the two recall-safe near-dupes (HP807/HP033 update,
HP101/HP096 winding-vs-friction), making the LLM's role explicit. All 3 GTs
unaffected (read-only). Live qwen wiring + a CLI/file queue are slice 3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:27:01 +02:00
Benjamin Admin 8674b2cd9a feat(ai-sdk): offline dedup-candidate proposer + deterministic GT wall (P2 slice 1)
First thin slice of the offline library-improvement proposer. DEV-TIME ONLY,
propose-only — it never mutates the pattern library or the runtime.

- FindDedupCandidates (proposer_dedup.go): structural near-duplicate detection
  over the fired patterns (category + measure/zone/scenario overlap). Bakes in
  the P1 lesson: only same-category pairs compare, and pairs with different
  operational states are never proposed (normal-operation vs maintenance are
  legitimately distinct, e.g. HP011 vs HP077).
- ScreenSupersession (proposer_screen.go): the wall. A proposal is safe only if
  (1) dropping the hazard does not reduce GT recall AND (2) keep/drop do not
  credit DIFFERENT GT entries. Check 2 catches distinct hazards that merely share
  measures (HP2201 hot surface GT 1.3 vs HP2202 hot ware GT 1.4) which recall
  alone would wave through.

On real warewashing output: 3 candidates -> 1 BLOCKED (distinct GT), 2
RECALL-SAFE for human/LLM review (the update + winding/friction near-dupes).
Nothing auto-applied. All 3 GTs unaffected (read-only). The LLM judgement and a
CLI/file queue are slice 2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:27:01 +02:00
Benjamin Admin 80862e7073 fix(ai-sdk): supersede foreign-framed stored-energy duplicate for warewashing
HP013 (stored electrical energy) fires for dishwashers via the broad stored_energy
tag but its zone is framed for Batteriefaecher/USV-Anlagen, which a dishwasher does
not have. The precise residual-voltage pattern HP144 (Frequenzumrichter/Zwischenkreis,
Priority 90) already fires and covers the same hazard. Add HP013 to the
warewashing-scoped supersession set so the duplicate is dropped only when
dom_warewashing is present.

Warewashing recall stays 100% (25/25), precision 92.6% -> 96.2%. Kistenhub/Bremse
keep HP013 (no dom_warewashing); 26 Bremse pins + benchmark unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:27:01 +02:00
Benjamin Admin a8c61eb320 fix(ai-sdk): warewashing-scoped supersession of generic thermal duplicates
The generic hot-surface patterns HP016 (high_temperature) and HP018 (actuator
burn) fire for dishwashers via broad tags and duplicate the precise warewashing
pattern HP2201 (Boiler/Tank/Spuelkammer). Suppress HP016/HP018 only when
dom_warewashing is present, so the specific pattern wins and the duplicate is
dropped. Scoped to the domain tag -> Kistenhub/Bremse and every non-warewashing
machine keep the generic patterns unchanged.

Warewashing recall stays 100% (25/25), precision 90% -> 92.6% (2 dupes removed).
Bremse 26 pins and Kistenhub benchmark unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:27:01 +02:00
Benjamin Admin 8f89fbf8a7 feat(ai-sdk): order the hazard log by ISO 12100 hazard group
ListHazards returned hazards in pattern-firing order, which reads as a jumble.
Sort by EN ISO 12100 hazard group (A. Mechanisch, B. Elektrisch, C. Thermisch,
D. Pneumatik/Hydraulik, E. Laerm, F. Ergonomie, G. Stoffe, H. Software/Steuerung,
I. Cyber, J. KI), stable within a group. Matches the frontend CATEGORY_LABELS.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:27:01 +02:00
Benjamin Admin 33790bb5e7 fix(ai-sdk): pneumatic restenergy hazard requires actual pneumatics
HP1717 was gated on the generic stored_energy tag (carried by a frequency
converter's DC link) + pneumatic_pressure (emitted by "Boiler unter Druck"),
so it leaked into the dishwasher despite the absence of any pneumatics. Require
pneumatic_part instead. The Bremse pin is a static pattern->measure check
(unaffected); full suite incl. Bremse coverage and Kistenhub 97.1% unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:27:01 +02:00
Benjamin Admin 7287e989a6 fix(ai-sdk): battery hazards require a battery, not generic stored_energy
HP753 (lithium thermal runaway), HP754 (battery off-gassing) and HP755 (HV
battery shock) were gated on stored_energy, which a frequency converter (C034,
DC-link capacitors) legitimately carries — so they leaked into any machine with
a VFD (surfaced by the dishwasher after the Frequenzumrichter narrative). Now
require the "battery" tag; add lithium/batteriespeicher synonyms so real
battery-storage machines still emit it.

GT #3 100% recall unchanged, battery themes gone from the dishwasher log;
Kistenhub 97.1% and Bremse pinned mappings unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:27:01 +02:00
Benjamin Admin 63fe2d496e docs: session_ownership_model_v1.md — Arbeitsteilung nach Modell-Besitz + 3 Vertraege
User-Antwort auf „wie verteilen wir die Arbeit": nach BESITZ der Datenmodelle, NICHT nach
Regulierung. 3 Domaenen (Legal Knowledge / Compliance Execution / Product Knowledge), jede
besitzt EIN Modell (andere read-only). 3 Vertraege: Legal->Compliance citation_span->legal_basis ·
Product->Compliance Feature->Capability (WICHTIGSTE Schnittstelle) · Compliance->Legal
obligation_id->legal_basis. Product Knowledge Graph = naechster Meilenstein (Reasoning-Session
umfokussieren, besitzt schon CanonicalProductRegulatoryProfile+Navigator). NIS2 verschoben.
Offene Fragen: Legal-KG-Owner, IACE-4.-Session, Compliance-2-Branch-Split.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:23:07 +02:00
Benjamin Admin 4e8eb2dc0e feat(product-scope): gate Navigator facts, then reuse discover_scope (step 3)
Connects the Navigator's fact-gate to the existing reasoning discover_scope —
the Scope Engine decides only once the minimum (P0) facts are released.

- resolve_product_scope(canonical): if not ready_for_scope -> NEEDS_FACTS
  (missing_facts + suggested_questions, discover_scope NOT run); else project
  canonical->reasoning profile and run the EXISTING discover_scope exactly once
  -> RESOLVED with applicable/excluded/uncertain regulations.
- Environmental triggers surface ONLY as unsupported_domains (future_corpus_needed),
  never as a legal evaluation — transparency, no false completeness.
- POST /reasoning/product-scope (thin handler) returns case NEEDS_FACTS or RESOLVED.
- No new scope rules, no new regulations, no environmental-law evaluation, no UI,
  no Go, no RAG, no percent-compliance. Response types are application-level, not
  meta-model classes (freeze v1.0 untouched).
- 6 tests incl. discover_scope spy (0 calls when gated, exactly 1 when ready),
  category separation, environmental-as-unsupported-only. 47 tests green (existing
  reasoning MVP tests stay green), mypy clean, LOC ok.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:21:27 +02:00
Benjamin Admin 78aeedafae feat(navigator): Product Regulatory Navigator as a thin missing-facts layer
Step 2 of the convergence sequence. The Navigator sits over 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, NEVER what applies — that stays with the
Scope Engine (step 3). No regulation logic, no UI, no Go, no RAG.

- NavigatorQuestion (interaction type, NOT a compliance-meta-model class — freeze
  v1.0 untouched): question_id, target_field, label, why_needed,
  regulatory_domains_unblocked (static metadata), answer_type, options, priority.
- QUESTION_CATALOG: 12 questions over canonical gaps — P0 (markets, role,
  lifecycle, machine/component), P1 (radio, usage-data, security-function,
  environmental wastewater/air/chemicals triggers), P2 (structured BOM).
- engine: navigate() -> missing_facts + suggested_questions (priority-sorted) +
  completeness_summary (ready_for_scope = no P0 missing); apply_answers() ->
  updated profile. Pure field-presence; no scope import.
- 8 tests: <=10 questions for a filled company-profile, known facts not re-asked,
  environmental = trigger questions only (no law evaluation), apply round-trip,
  P0 ordering, ready_for_scope. 41 tests green, mypy clean, LOC ok.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:05:27 +02:00
Benjamin Admin 2e6eee6ba1 Merge origin/main (8609b696) in machinery-multi-reg-run 2026-06-26 10:05:24 +02:00
Benjamin Admin f23ae32077 feat: MaschVO als erster Multi-Regulation-Run + Reuse-Metrik (Freeze haelt: 0 neue Klassen)
User-Reframe: nicht „naechste Regulierung", sondern erster MULTI-REGULATION-Reuse-Test.
- obligations/cra_machinery.json: 31 MaschVO-Obligations (25 LM = Anhang-III-Essential-Reqs
  rechtlich legit + 6 BP). Pipeline 2229->1096 micro->120 review-units->Opus. out_of_scope
  41 RU (AI-Act/DSGVO/Common-Criteria/Banking/...).
- obligations/machinery_reuse_metrics.json: ERSTE Reuse-KPI. **NEUE OBJEKTKLASSEN = 0**
  (Architektur-Freeze haelt gegen physische-Safety-Regulierung — empirisch). 39% Reuse / 61%
  net-new; Capability-Reuse 2 (Cyber-Safety-Bruecke: access_control_safety_functions->access,
  protection_against_corruption->integrity/tamper), Procedure-Reuse 6, Evidence-Reuse 2,
  CORE-Spezialisierung 2 (risk_assessment->update_risk_assessment, conformity->sbom_tech_doc).
- join_keys 95->126 (machinery 31). precluster.py: machinery-Scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 10:05:00 +02:00
Benjamin Admin 739a477d3f feat(profile): CanonicalProductRegulatoryProfile convergence layer (types + mappers + tests)
ONE canonical product profile so the Go gap engine and the Python reasoning
engine stop diverging ("SPS mit Remote Access" means the same everywhere).
gap.ProductProfile LEADS; the reasoning ProductProfile becomes an adapter/DTO.
Types + mappers only — no regulation logic, no Go changes, no UI, no new questions.

- CanonicalProductRegulatoryProfile mirrors gap.ProductProfile + the Navigator
  gaps the audit found: economic-operator role, radio_module, generates_usage_data,
  lifecycle_phase, structured BOM (ProductComponent), safety-vs-security split,
  machine-vs-component + a forward-looking EnvironmentalImpact domain (wastewater/
  air/chemicals triggers — fields only, no rules yet).
- Mappers: from_product_wizard (lossless), from_company_profile (prefill incl.
  the machineBuilder block), to_gap_profile (emits the unchanged gap JSON shape),
  to_reasoning_profile (projects into the reasoning ProductProfile; AI stays
  delegated to ai-act/ucca). Only profile->reasoning is coupled; reasoning stays
  hermetic.
- 10 tests = the 10 acceptance criteria incl. ProductWizard round-trip lossless,
  markets no longer forced ['EU'], and canonical->reasoning->discover_scope
  proving one semantic profile drives the engine. 33 tests green, mypy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 09:52:46 +02:00
Benjamin Admin 8609b696c9 fix(ucca): CM-7 repo_scan is required evidence for attack_surface_minimization
CI / detect-changes (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 7s
CI / validate-canonical-controls (push) Successful in 5s
CI / loc-budget (push) Successful in 18s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 59s
CI / iace-gt-coverage (push) Successful in 19s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
evidence_required lists only required:true rows; repo_scan was required:false so
attack_surface_minimization surfaced config_export alone. An attack-surface scan
IS required to evidence a minimized attack surface. Adds a test pinning the curated
evidence_required set per NIST obligation (the table test only checked control count).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 09:42:12 +02:00
Benjamin Admin 207fc9cb56 Merge remote-tracking branch 'origin/main' into feat/advisor-status 2026-06-26 09:35:46 +02:00
Benjamin Admin fdaf547b06 feat(ucca): re-point NIST primary_implementation to CORE obligations (#6)
Registry materialized the generic CORE security objectives (#5b, Modell C), so
the two broad NIST controls now point at their canonical parents instead of the
domain-scoped matches:
  SI-7 -> software_integrity_protection  (CORE, Annex I (2)(f))
  CM-7 -> attack_surface_minimization    (CORE, Annex I (2)(j))
Non-breaking: the domain-scoped obligations stay valid and specialize the CORE.

SI-7 evidence = sbom + config_export (SBOM evidences component/supply-chain
integrity; config = signing/secure-boot). Export proposed_obligation_id + handler
test (2 CORE cases) updated. go test green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 09:35:46 +02:00
Benjamin Admin fa536f9714 docs: compliance_meta_model_v1.md — FROZEN v1.0 + Architektur-Freeze
User-Entscheidung: Metamodell als v1.0 einfrieren (nur META-SEMANTIK: 6 Klassen + Kanten-
Vokabular + Attribute; NICHT Registry/Capabilities/Procedures). Architektur-Freeze in Kraft:
neue Regulierung = DATEN nicht Architektur; 0 neue Objektklassen erwartet; reopen nur bei
nachgewiesenem Scheitern (Hazard/Threat = einzige bekannte künftige Öffnungs-Ursache, nur fuer
FMEA). Reuse-Metrik-KPI definiert (Wissens-Akkumulations-Beweis). Validiert gegen 5
Regulierungsarten (DSGVO/CRA/MaschVO/Data-Act/NIS2). Erster Live-Durchlauf: MaschVO.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 09:29:44 +02:00
Benjamin Admin cba066f49b Merge origin/main (f85fff43) in meta-model-validation 2026-06-26 01:09:16 +02:00
Benjamin Admin 75f7bd8de4 docs: meta_model_validation_v1.md (Phase 6) — Modell ist regulierungsunabhaengig
User-Stresstest VOR der naechsten Regulierung: passt MaschVO/Data-Act/AI-Act/NIS2 ins
6-Klassen-Modell (Obligation/Capability/Procedure/Control/Evidence + Guidance) OHNE neue
Objektklasse? Ergebnis 4x NEIN -> Compliance Meta Model steht. 2 Verfeinerungen
(realized_by Capability OPTIONAL; Risiko-Niveau/Frist/Hazard-Schwere/Risiko-Tier = Attribute,
keine Klassen). 1 Watch-Point: Hazard/Threat (erst noetig bei quantitativem FMEA-Risiko als
First-Class-Knoten, nicht fuer Compliance-Abbildung). Kein Code, keine Regulierung ingestiert.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 01:08:57 +02:00
Benjamin Admin f85fff4398 chore(ucca): re-sync data/obligations join-keys copy (93 -> 95)
CI / detect-changes (push) Successful in 4s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 4s
CI / validate-canonical-controls (push) Successful in 4s
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 59s
CI / iace-gt-coverage (push) Successful in 17s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Registry grew to 95 (Capability materialization #5b added CORE obligations).
Keep the ai-sdk build-context copy current so obligation-status reflects the
live registry contract.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 01:02:02 +02:00
Benjamin Admin 3bcffaf52c Merge remote-tracking branch 'origin/main' into feat/advisor-status 2026-06-26 01:01:16 +02:00
Benjamin Admin 3a19affb67 ci(compliance): re-trigger scoped ai-sdk build + doc synced join-keys copy
Prior gitea push's build-ai-sdk failed on a transient registry push (arm64 built
clean on macmini; amd64 cross-compile is green) and last-build/main got poisoned
to that SHA, so a plain re-run scopes to nothing. A real touch in ai-compliance-sdk/
re-scopes the build. Also documents the synced-copy contract for
data/obligations/obligation_join_keys.json.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 01:00:53 +02:00
Benjamin Admin 2b985ad526 Merge origin/main (9aef5ecf) in capability-materialization 2026-06-26 00:54:43 +02:00
Benjamin Admin 4e761c1363 feat: #5b materialize capability layer (Modell C) — capabilities.json + cra_core.json
User-Entscheidung Modell C + objective_tags-Safeguard (Tags, keine Klasse). Deterministisch
via materialize_capabilities.py:
- obligations/capabilities.json: 5 Capabilities (multi_factor_authentication/session_management/
  transport_encryption/code_signing/security_monitoring_alerting), realized_by (n:m) +
  guidance_basis KANONISCH hochgezogen. access_control gedroppt (OVERLAP).
- obligations/cra_core.json: 2 CORE-Sicherheitsziele (attack_surface_minimization (2)(j)/CM-7 +
  software_integrity_protection (2)(f)/SI-7) -> fuellt den #4-NIST-Gap.
- DOMAIN specializes->CORE (remote_access_attack_surface_min, component_remote_interface_security,
  signed_update_integrity, firmware_software_authentication) + objective_tags.
- Merge: vuln_remediation_patching -> deprecated_alias von provide_security_updates.
- remote_access_data_export_protection bleibt BEST_PRACTICE (pending Data-Act-Scope).
- join_keys 93->95 (core 2). Bidirektional validiert.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 00:54:23 +02:00
Benjamin Admin 6673c8052b fix(reasoning): drop "vollständig" from ClaimCoverage wording [F1 final]
"vollständig" still implied fulfillment. potentially_addresses now reads
"… adressiert N Pflichten direkt und M teilweise; K werden durch die Aussage
nicht berührt. … Dies ist keine Konformitätsaussage." Enum value kept
(potentially_addresses chosen over addresses_claimed for product clarity).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 00:49:20 +02:00
Benjamin Admin 5e5002c883 refactor(reasoning): enforce ClaimCoverage (Welt 1) vs ComplianceStatus (Welt 2) boundary [F1]
Architecture-validation finding: the implementation mode produced compliance-
flavored output ("teilweise erfüllt", "covered") from a mere customer claim,
blurring the line to the Execution layer. This is a design decision, not a text
fix — the reasoning layer judges only the customer's STATEMENT, never conformity.

- CoverageStatus -> ClaimCoverage; values are claim-relative + carry "potential":
  potentially_addresses / partially_addresses / does_not_address /
  insufficient_information.
- ImplementationAssessment -> ClaimObligationMapping (coverage_status ->
  claim_coverage); ImplementationResponse -> ImplementationReasoningResponse
  (assessments -> mappings, + explicit `disclaimer`); request renamed; engine
  entry assess_implementation -> reason_implementation_claim.
- Endpoint /reasoning/implementation-assessment -> /reasoning/implementation-reasoning.
- Summary/explanations reworded: "adressiert wahrscheinlich N Pflichten … für
  eine Bewertung der tatsächlichen Umsetzung sind Nachweise erforderlich (keine
  Konformitätsaussage)". No "erfüllt"/"abgedeckt" leaks.
- New guard test asserts no compliance verdict leaks (no "erfüllt"; disclaimer
  separates ClaimCoverage from ComplianceStatus). 23 tests green, mypy clean.

Discovery (scope/obligations) was already structurally claim-free and unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 00:37:57 +02:00
Benjamin Admin 9aef5ecf6c Merge remote-tracking branch 'origin/main' into feat/advisor-status
CI / detect-changes (push) Successful in 6s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 7s
CI / validate-canonical-controls (push) Successful in 5s
CI / loc-budget (push) Successful in 18s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go (push) Successful in 58s
CI / iace-gt-coverage (push) Successful in 18s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-26 00:26:56 +02:00
Benjamin Admin f6c5f4e0a9 fix(ucca): SI-2 evidence = config_export + test_report
Aligns provide_security_updates -> SI-2 evidence to the curated acceptance set:
config_export (secure-update mechanism config) + test_report (patch verification).
For "provide updates" the patch-verification test is more on-point than a vuln
scan; repo_scan stays on CM-7 for attack-surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 00:26:29 +02:00
Benjamin Admin c72fd3eb5a Merge origin/main (Compliance endpoint+graph-loader 2341bda6) in capability-model 2026-06-26 00:24:37 +02:00
Benjamin Admin b0435f9885 docs: capability_model_v1.md (#5a) — Objektarten + Beziehungstypen, NICHT materialisiert
Schema-Papier statt capabilities.json (User-Entscheidung). Befund: die 8 SHARED_CAPABILITY-
Cluster zerfallen in Typ-1 (technische Capabilities: mfa/tls/code_signing/session/anomaly)
und Typ-2 (Sicherheitsziele: attack_surface_min/software_integrity = die #4-Gaps). Empfehlung
Modell C: Capability = EINZIGE neue Klasse; Sicherheitsziele = CORE Legal Obligations
(CORE/DOMAIN existiert bereits). Kanten-Graph (realized_by/specializes/...). guidance_basis
gehört konzeptionell an die Capability. 4 Entscheidungen offen (User). #5b Materialisierung
GEGATED auf Modell-Annahme — keine Daten verschoben.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 00:24:09 +02:00
Benjamin Admin 2341bda621 feat(ucca): adopt NIST obligation_ids (Registry Handoff #4, 10/10)
Registry filled proposed_obligation_id for the 3 NIST primary_implementation
controls: SI-7->signed_update_integrity, SI-2->provide_security_updates,
CM-7->remote_access_attack_surface_min. Adopted onto cra_nist.jsonl so the join
is now EXACT (obligation_id) instead of the coarse citation_unit fallback.
obligation-status now surfaces SI-2 under provide_security_updates; test extended.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 19:37:14 +02:00
Benjamin Admin 4634cc09d0 Merge remote-tracking branch 'origin/main' into feat/advisor-status 2026-06-25 19:31:20 +02:00
Benjamin Admin 1607c89459 feat(reasoning): Regulatory Reasoning Engine MVP (scope/obligations/implementation/interpretation)
Deterministic reasoning layer ON TOP of the Legal Knowledge Graph (obligation
registry) and the Compliance Execution Graph (control mapping/evidence). Answers
which regulations apply to a concrete product, which obligations follow, whether
the customer's implementation covers them, and whether a customer interpretation
is too narrow/broad/plausible.

- ProductProfile with tri-state facts (Optional[bool]=None => uncertain, never
  false security); safe predicate evaluator (no eval).
- 6 regulation triggers (CRA/MaschinenVO/RED/EMV/DataAct/NIS2) with missing-fact
  prompts; 24 obligation scope rules.
- CRA obligation_ids RE-USED verbatim from the registry (93 ids) — never re-minted
  (control_uuid trap); Machine/Data-Act flagged proposed=True.
- required_evidence constrained to the framework-agnostic shared evidence catalog;
  capabilities echo the planned Obligation->Capability layer.
- Overlap groups (CRA<->MaschinenVO cyber-safety) + evidence-for-multiple (USP).
- 4 endpoints POST /reasoning/{scope,obligations,implementation-assessment,
  interpretation-assessment}; thin handlers, registered in api/__init__.py.
- 22 tests (5 machine-builder scenarios + 10 acceptance questions). No DB
  migration, no RAG, no new controls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 19:30:53 +02:00
Benjamin Admin d4df1e01df feat(compliance): GET /sdk/v1/compliance/obligation-status (file-backed graph)
Vertical slice over the Compliance Execution Graph: obligation_id -> accepted
controls -> required evidence -> status. NEVER auto-asserts fulfillment - with
no evidence collection wired (MVP), a mapped obligation is "not_assessed" and
every required evidence is "missing". Fail-closed: no id -> 400; unknown id ->
unknown_obligation; mapped-but-no-control -> unmapped; graph not loaded -> 503.

- ComplianceGraphHandlers (separate from the DB-backed ObligationsHandlers):
  loads Registry join keys + accepted control mappings + evidence once at start.
- LoadComplianceGraph: candidate-path resolution across dev/container/test.
- Data plumbing: Dockerfile now COPYs data/{control_mappings,evidence_requirements,
  obligations}; data/obligations/obligation_join_keys.json is a SYNCED COPY of the
  repo-root Registry contract (re-sync on Registry growth).
- Table-driven handler test (mapped/unmapped/unknown/400 + no-fulfillment-claim).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 19:29:37 +02:00
Benjamin Admin ed31fdc0df fill: NIST primary_implementation -> obligation_id (Handoff #4, jetzt 10/10)
SI-2 -> provide_security_updates (stark, (2)(c)/Art.13) · SI-7 -> signed_update_integrity
(update-scoped) · CM-7 -> remote_access_attack_surface_min (remote-scoped). Validiert gegen
Registries (join_keys 93). 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 Obligations (User-Entscheidung).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 19:15:49 +02:00
Benjamin Admin 5412bf0ba3 Merge origin/main (NIST-Export e46e74dd) in cross-domain-discovery 2026-06-25 19:13:21 +02:00
Benjamin Admin 8a9d5e7c4d Merge remote-tracking branch 'origin/main' into feat/advisor-status 2026-06-25 19:12:41 +02:00
Benjamin Admin 01956ee690 feat: cross-domain relationship discovery — Capability-Schicht-Entwurf (CRA P1)
Stufe 1+2 der Ontologie-Entdeckung (User-Schaerfung #54): nicht Aehnlichkeit sondern
STRUKTURELLE Beziehung. 93 Obligations -> BGE-M3 -> 101 cross-family Paare -> Opus
klassifiziert in 8 Kategorien (genau eine je Paar).
- scripts/obligation_discovery/cross_domain_pairs.py (Stufe 1, key-frei)
- scripts/obligation_discovery/classify_relationships.py (Stufe 2, Opus)
- obligations/cross_domain_relationships.json: 16 SHARED_CAPABILITY -> 8 Capabilities
  (mfa/session/transport-tls/code_signing/anomaly_detection), 23 SUPPORTED_BY
  (Hubs: vuln_identification_inventory<-SBOM-Familie 5x, vuln_remediation_patching 5x),
  1 SAME_OBLIGATION (vuln_remediation_patching == provide_security_updates, MERGE-Kandidat),
  42 OVERLAP_ONLY sauber verworfen. Erstentwurf der Capability-Schicht (Phase 4).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 19:12:17 +02:00
Benjamin Admin e46e74ddbb feat(bridge): export 3 CRA->NIST controls (primary_implementation) for obligation_id
Adds SI-7/SI-2/CM-7 to controls_for_obligation_mapping.json (7 OWASP -> 10),
mapping_type=primary_implementation (the single canonical control per obligation).
proposed_obligation_id left empty for the Registry to assign. Notes aligned to the
updates family (join_keys 93): SI-2 -> provide_security_updates (strong),
SI-7 -> signed_update_integrity (partial; SI-7 broader), CM-7 ->
remote_access_attack_surface_min (partial; CM-7 broader).

Origin-only (data/tooling; backend does not load obligations/* at runtime) -> no Orca.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 18:57:17 +02:00
Benjamin Admin 63d65af41b feat(ucca): persist 3 CRA->NIST mappings (primary_implementation) + evidence
CRA Annex I Part I (2)(e)/(2)(l)/(2)(i) had no clean OWASP target (rejected:
"Mapping ueber NIST/BSI erforderlich"). Their NIST home, curated + accepted:
  (2)(e) Integritaet     -> SI-7 (Software/Firmware/Information Integrity)
  (2)(l) Sichere Updates -> SI-2 (Flaw Remediation)
  (2)(i) Angriffsflaeche -> CM-7 (Least Functionality)

New mapping_type=primary_implementation = the single canonical control per
obligation (stronger than implements/supports); related controls (SC-3(3),
RA-5, AC-6, SI-16, ...) follow later as supports.

Evidence is framework-AGNOSTIC: SI-7/SI-2/CM-7 reuse the shared evidence_type
catalog (config_export/test_report/repo_scan) - same types carry CRA, NIST,
ISO 27001, IEC 62443, BSI. (framework,control) is only the link, not the type.

obligation_id left empty: the Obligation Registry assigns it (exported via
controls_for_obligation_mapping.json), then we adopt. go test ./internal/ucca green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 18:52:35 +02:00
Benjamin Admin 8937f105ea feat(bridge): security-updates obligation cut (CRA Annex I (2)(c)/Art 13) — 9 obligations
- obligations/cra_updates.json: 9 (6 LEGAL_MINIMUM + 3 BEST_PRACTICE), Beziehungen.
  Pipeline 670->318 micro->15 review-units -> Opus-Synthese. Synthese gut kalibriert ->
  light review (KEINE Hart-Re-Tier, vs Auth/Remote-Access). out_of_scope M4/M7.
  5 capability_candidate-Marker (signed/trusted/automatic/rollback/testing) fuer
  Phase-4-Capability-Pruefung. Anker approximativ (curation.anchor_quality).
- obligation_join_keys.json: 84 -> 93 (updates 9). Alle 6 CRA-P1-Domaenen abgedeckt.
- precluster.py: updates-Scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 18:51:09 +02:00
Benjamin Admin 1584b8fb2f feat(bridge): remote-access obligation cut (CRA Annex I) — 18 obligations
- obligations/cra_remote_access.json: 18 (5 LEGAL_MINIMUM outcomes + 13 BEST_PRACTICE),
  15 Beziehungen. Two-stage clustering 445->209 micro->27 review-units -> Opus-Synthese.
  Synthese vergab 14 LM -> key-free re-tier nach Auth-Regel (Mechanismen MFA/Session/VPN/
  insecure-protocol/OT/Wartungs-Governance/temp/data-export/component -> BEST_PRACTICE +
  supports-Kante zur Eltern-LM). out_of_scope M5/M11 = physische Maschinen-Fernsteuerung
  (MaschinenVO 2023/1230). Anker approximativ (siehe curation.anchor_quality).
- obligation_join_keys.json: 66 -> 84 (remote_access 18).
- precluster.py: remote_access-Scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 18:37:10 +02:00
Benjamin Admin 2301fb2122 feat(ucca): adopt obligation_id + harden join to semantic (step 3 core)
The Obligation Registry filled proposed_obligation_id (7/7) + cut the logging
family (obligations 47->66). Adopted obligation_id onto our 7 accepted CRA->OWASP
mappings; the join now prefers the EXACT obligation_id over the coarse
citation_unit (which stays as fallback for not-yet-adopted rows).

Effect: semantic coverage 2->4 (user_authentication_required,
credential_confidentiality_protection, auth_key_management,
event_logging_security_events). Befund 1 resolved: V11.2.1 crypto now sits under
credential_confidentiality_protection, not user_authentication_required.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 12:18:34 +02:00
Benjamin Admin 4aa6aa9812 Merge remote-tracking branch 'origin/main' into feat/advisor-status 2026-06-25 12:04:47 +02:00
Benjamin Admin a53d67a35a feat(bridge): logging/audit obligation cut (CRA Annex I (2)(k)) + 7/7 control mapping
- obligations/cra_logging.json: 19 obligations (6 LEGAL_MINIMUM auf (2)(k) korrekt
  verankert, 13 BEST_PRACTICE), 13 Beziehungen; out_of_scope M8/M5/M81 (AI-Act/FRT/PIN).
  Two-stage clustering (2601->1361 micro->100 review-units) -> Opus-Synthese -> Kuration.
- controls_for_obligation_mapping.json: V16.1.1/V16.3.3/V16.3.4 -> event_logging_security_events
  (Umbrella-LM; spezifische Alternativen via ASVS-Control-Text). Jetzt 7/7 gefuellt.
- obligation_join_keys.json: 47->66 obligation_ids (logging family).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 11:57:16 +02:00
Benjamin Admin 3259984d1c Fill semantic control->obligation_id (4/7; V16 pending logging cut)
V6.x->user_authentication_required, V11.2.1->credential_confidentiality_protection,
V11.7.1->auth_key_management; semantisch (NICHT CRA-Anker, die sind approximativ).
V16.x pending bis Logging-Cut. anchor_quality_note dokumentiert.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 11:44:56 +02:00
Benjamin Admin 5e3ed4071b Merge remote-tracking branch 'origin/main' into feat/obligation-aggregation 2026-06-25 11:41:00 +02:00
Benjamin Admin c090617afd Add logging scope to precluster (logging cut)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 11:40:59 +02:00
Benjamin Admin c5ecfa8f6c feat(bridge): export 7 accepted CRA->OWASP controls for obligation_id proposal
CI / detect-changes (push) Successful in 5s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 9s
CI / validate-canonical-controls (push) Successful in 5s
CI / loc-budget (push) Successful in 23s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
obligations/controls_for_obligation_mapping.json — the Compliance Execution
Graph's accepted controls (V6 auth / V11 crypto / V16 logging) handed to the
Obligation Registry to propose the SEMANTIC control->obligation_id, replacing
the coarse citation_unit interim join (Befund 1). Registry fills
proposed_obligation_id; we then adopt it into control_mapping.obligation_id.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 11:36:57 +02:00
Benjamin Admin 417bcda68c feat(ucca): Advisor obligation-status Durchstich (step 3 complete)
AssessObligationStatus traverses obligation_id -> (citation_unit) -> accepted
controls -> required evidence -> status (erfuellt|offen|unklar). Evidence
presence is a callback; MVP passes nil (nothing collected yet) -> offen.
citation_spans = "pending" until the Legal-Knowledge-Graph session attaches
them. This is the vertical slice that makes the graph a product feature:
"CRA obligation fulfilled because evidence X/Y/Z is present", not "a doc exists".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 11:15:57 +02:00
Benjamin Admin 86d1473a6a feat(ucca): obligation-join loader + citation_unit bridge + coverage report
Consumes the cross-session contract obligations/obligation_join_keys.json (47
obligation_ids). Interim bridge = citation_unit (our source_norm <-> registry
citation_units), to be hardened to the stable obligation_id (field now optional
on ControlMapping).

ComputeObligationCoverage joins the 47 registry obligations to our accepted
control mappings: covered=2 (user_authentication_required, firmware_software_
authentication), mapped_rejected=3 ((2)(e) -> our OWASP mappings rejected,
route via NIST/BSI), uncovered=42. This coverage signal is the feedback to the
Obligation session for what to cut/refine next.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 11:10:53 +02:00
Benjamin Admin 9e0a9ccef4 Add obligation_id join-key contract (cross-session bridge)
CI / detect-changes (push) Successful in 5s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 8s
CI / validate-canonical-controls (push) Successful in 7s
CI / loc-budget (push) Successful in 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Macht meine Seite des Cross-Session-Vertrags konkret: obligation_id ist der stabile Join-Key
zwischen Legal Knowledge Graph (citation_spans -> obligation_id) und Compliance Execution Graph
(control_mapping.source_norm -> obligation_id). Export aller 47 obligation_ids (CRA: 11 sbom +
7 vuln + 29 auth) mit citation_units als Interim-Brücke. Disziplin: obligation_id nie neu
vergeben (re-link, Pendant zu span_id/control_uuid).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 10:29:29 +02:00
Benjamin Admin 7e1c3668bf Merge remote-tracking branch 'origin/main' into feat/obligation-aggregation
CI / detect-changes (push) Successful in 8s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 5s
CI / validate-canonical-controls (push) Successful in 4s
CI / loc-budget (push) Successful in 14s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-25 10:15:25 +02:00
Benjamin Admin ab3cb86b1c feat(ucca): Evidence-Requirement model (step A)
CI / detect-changes (push) Successful in 5s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 7s
CI / validate-canonical-controls (push) Successful in 5s
CI / loc-budget (push) Successful in 20s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 1m5s
CI / iace-gt-coverage (push) Successful in 17s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
The last edge of the compliance graph: what concrete, fresh evidence proves a
framework control is met (config_export/test_report/sbom/audit_log/pentest/...
from github/ci/scanner/manual_upload, with a freshness requirement).

Seeded for all 7 accepted CRA->OWASP controls (Auth/Crypto/Logging). A graph
test enforces connectivity: every accepted control must carry >=1 required
evidence — no dangling node in Obligation -> Control -> Evidence.

This is what will let the Advisor state "the CRA requirement is fulfilled" from
present evidence, not from the mere existence of a document.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 10:06:09 +02:00
Benjamin Admin 0db0e9a129 feat(ucca): curate CRA Annex I -> OWASP mappings (review B)
7 accepted, 13 rejected (reviewed_by=benjamin, 2026-06-25). The accepted set is
the first audited ground truth of the compliance graph:
  (2c) Zugriff   -> V6.3.1, V6.1.1   (Auth)
  (2d) Crypto    -> V11.2.1, V11.7.1 (corrected from the retriever's wrong V14)
  (2k) Logging   -> V16.3.3, V16.3.4, V16.1.1

Rejected stay as audit trail. (2e) integrity, (2l) updates, (2i) attack surface
rejected with reason "OWASP ASVS not the right target standard, map via NIST/BSI"
— architectural proof for the multi-framework framework_* layer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 10:01:06 +02:00
Benjamin Admin 53ea388ea0 refactor(ucca): control-mapping model per review feedback
- DROP confidence from the persisted mapping: a curated mapping is a
  professional statement, not an AI guess (retriever score -> rationale only).
- ADD mapping_status (candidate|accepted|rejected|superseded) — the review state.
- ADD audit trail (reviewed_by/review_date/review_reason); accepted/rejected
  fail-closed without it.
- EXTEND mapping_type: + implements, + contradicts.
- Advisor truth = mapping_status=accepted (acceptedOnly filter).
- migrate the 18 CRA->OWASP rows to mapping_status=candidate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 09:50:37 +02:00
Benjamin Admin e5cce9caff Extend advisor proof with procedure→evidence chain
Vollständige Begründungskette aus der Registry: Rechtsgrundlage → Obligation → Procedure
→ Controls → Evidence → Antwort. Join cra.json × cra_procedures.json, deterministisch, kein LLM.

SBOM-Beweis: 7 Pflichten je mit CRA-Rechtsgrundlage + Procedure (wie umgesetzt) + Controls
(Prüfung) + aggregierte Required Evidence; 4 Best-Practice (Guidance OWASP/NIST/ENISA);
Beziehung sbom_*→supports→vuln_identification; citation 7/7 pending_span_anchor.

Der Unterschied zu RAG sichtbar: RAG beantwortet — BreakPilot begründet UND operationalisiert.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 09:44:27 +02:00
Benjamin Admin 2f3c98fbe0 feat(ucca): first CRA Annex I -> OWASP retriever candidates (step 3)
18 retriever_candidate mappings generated via the sdk-dev control-intent
retriever. All marked retriever_candidate (NOT curated truth) — the review
step turns the good ones into human_curated.

Empirical validation of the A-decision: the retriever proposes, but produces
wrong candidates (e.g. encryption -> V14 Config instead of V11 Crypto;
V14.2.4 over-appears) that only human review catches. Review notes inline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 09:36:53 +02:00
Benjamin Admin d987e4fde6 feat(ucca): persisted Control-Mapping data model (Obligation -> framework control)
Versioned JSONL store + Go model for Regulation->Control mappings, per the
A-decision: the retriever only PROPOSES candidates; the curated mapping is the
audited truth the Advisor uses at runtime, never re-invented per query.

- ControlMapping struct (source_norm/source_role/target_framework/target_control/
  mapping_type/confidence/provenance/rationale/version)
- enum validation (rule layer), fail-closed loader, forward+reverse index,
  curated-only filter (IsCurated)
- seed: 2 retriever_candidate rows CRA Annex I -> OWASP ASVS (not yet curated)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 09:32:15 +02:00
Benjamin Admin 67dba5f641 Add CRA procedure model (SBOM + Vuln)
Schließt die Lücke Obligation→Procedure→Control→Evidence (Schritt 3, Compliance-OS-Ebene).
Procedure = Umsetzungs-/Nachweisebene EINER Obligation, KEINE neue Pflicht (LEGAL_MINIMUM
bleibt an der Obligation; Procedure beschreibt Umsetzung; Evidence belegt sie).

- 11 Procedures (5 SBOM + 6 Vuln), 2 Worked Examples; source_role=procedural_requirement
  (Konvergenz mit der Legal-Knowledge-Engine der anderen Session)
- fulfills_obligations[] referenziert die cra.json-Obligations (alle gültig, volle Abdeckung)
- steps/controls/evidence je Procedure; KEINE tier/legal_basis-Felder (kein Pflicht-Duplikat)
- citation_spans: [] / pending_span_anchor (Join folgt mit dem zitierfähigen Re-Ingest)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 09:28:40 +02:00
Benjamin Admin a3053c3c86 docs(architecture): RAG retrieval engine architecture set (01-09)
CI / detect-changes (push) Successful in 14s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 9s
CI / validate-canonical-controls (push) Successful in 19s
CI / loc-budget (push) Successful in 23s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
9 docs + index in docs-src/architecture/ documenting the deterministic
retrieval engine: retrieval pipeline, authority rerank, source_class,
source_role, control-intent + diversity, assessment, confidence,
explainability + supersede, framework_* layer. Each doc carries the exact
constants, the rationale behind them, code refs, and the failure class
it addresses. Audit/onboarding reference.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 09:25:22 +02:00
Benjamin Admin db2fd9d8e9 Add obligation advisor proof (P3)
Demonstriert den Produktnutzen der Registry: obligation-basierte Antwort statt RAG-Text.
Frage → Pflicht (LEGAL_MINIMUM + Rechtsgrundlage + Applicability) ⊥ Best Practice
(guidance_basis) ⊥ Nachweise (evidence_facets + member controls) + Beziehungen, deterministisch
aus obligations/cra.json (kein LLM, zitierfähig).

Beleg (SBOM, Maschinenbauer): JA — 7 CRA-Mindestpflichten + 4 Best-Practice (OWASP/NIST/ENISA);
sbom_* supports vuln_identification_inventory.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 09:06:34 +02:00
Benjamin Admin d21e1247c9 Merge remote-tracking branch 'origin/main' into feat/obligation-aggregation
CI / detect-changes (push) Successful in 5s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 4s
CI / validate-canonical-controls (push) Successful in 3s
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 25s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-25 07:49:16 +02:00
Benjamin Admin e1b270c36e Add obligation discovery pipeline tooling
Sichert die validierte Obligation Discovery Pipeline aus /tmp als dauerhaftes,
committetes Tooling (scripts/obligation_discovery/) — der eigentliche Vermögenswert.

Stufen: precluster (Embedding-Cache + Mikro-Cluster) → meta_cluster (Review Units,
Skalierungs-Fix) → synthesize_obligations (Opus, Key aus ENV, Streaming, harte Tier-Regel,
Provenance) → validate_registry → merge_review_diff. Reine Helfer in _core.py, 16 Unit-Tests.

Doku docs-src/development/obligation_discovery_pipeline_v1.md mit Meilensteinen
(SBOM/Vuln reproduziert, Auth 4408→170 Review Units→54→kuriert 29) und der Architekturregel:
Runtime deterministisch, Discovery LLM-gestützt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 07:41:45 +02:00
Benjamin Admin 48e39423e6 Add curated CRA authentication obligations (scaling test)
Erster großer Skalierungstest der Registry-Pipeline mit Zwei-Stufen-Clustering:
4408 Controls → 2134 Mikro → 170 Review Units → Opus-Synthese 54 → Kuration 29.

- Zwei-Stufen-Clustering (Mikro→Meta/Review-Unit) ist der Skalierungs-Fix für große Domänen
- harte Tier-Regel generalisiert: nur 6 LEGAL_MINIMUM (CRA fordert nur High-Level-Auth),
  23 BEST_PRACTICE; MFA/Passwort/Session/Krypto = guidance_basis, kein CRA-Primärrecht
- Kuration (key-frei, regelbasiert): Krypto-Mikro→guidance · Prüf/Nachweis→evidence-Facette ·
  Mechanismus-Familien behalten · eID/PSD2→out_of_scope; 6 LM unangetastet
- Provenance pro Obligation (source_meta_cluster/confidence/model/version)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 07:30:55 +02:00
Benjamin Admin 31222885b3 feat(ai-sdk): control-intent result diversity + standard-name classifier override
CI / detect-changes (push) Successful in 7s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 5s
CI / validate-canonical-controls (push) Successful in 8s
CI / loc-budget (push) Successful in 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 58s
CI / iace-gt-coverage (push) Successful in 17s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
On an implementation question impl_guidance (ENISA) keeps its earned semantic
Top-1, but the top-K now surfaces the best operational_requirement and
control_standard from the pool (ensureControlDiversity) — so different source
roles are visible instead of one role flooding the list, without forcing the
binding sources to Top-1.

A recognised standard NAME (NIST/OWASP/ISO 27001/CIS/CSA CCM/Grundschutz) now
overrides a mis-applied supervisory_guidance source_class in classifyAuthority,
so those standards classify and rank as technical_standard (control_standard
role). The corpus tags many standards as guidance (weight 70); the name wins.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 01:54:36 +02:00
Benjamin Admin 188bb787d2 Add proposed CRA obligation relationships
11 human-reasoned Beziehungskanten in cra.json gemerged (dedupliziert gegen die
Pipeline-Kanten), getaggt review_status=proposed / source=human_reasoned_preview /
confidence=high. Nur die kleine Sprache depends_on / supports / produces_evidence_for;
gerichtet. Cross-Family SBOM→Vuln-Kanten erlauben dem Advisor Ursachen-/Wirkungsketten.

Damit ist der CRA-v1-Baustein vollständig: Obligations · legal_basis · guidance_basis ·
out_of_scope · relationships · pending citation anchors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 00:08:47 +02:00
Benjamin Admin d9d04deb00 feat(ai-sdk): close the 4 GT #3 recall gaps — backflow, cut, restart, spray-arm
CI / detect-changes (push) Successful in 6s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 5s
CI / validate-canonical-controls (push) Successful in 4s
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 58s
CI / iace-gt-coverage (push) Successful in 14s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Phase 1 complete. GT #3 recall 84% -> 100% (25/25 matched), no regression:
- HP2207 backflow / potable-water contamination (EN 1717) + measure M2209
  (Rueckflussverhinderer / Systemtrenner) — the only genuinely new hazard.
- HP2208 cut on sharp edges/screens (new sharp_edge tag from scharfe-Kante/Sieb).
- HP2209 unexpected restart during maintenance (dedicated dom_warewashing pattern;
  avoids flooding the log via the broad moving_part tag).
- Spray-arm contact now covered by the enclosure-re-scoped contact patterns.

Kistenhub 97.1% and Bremse pinned mappings unchanged; 0/28 hazards without a
measure. Completes the commercial-dishwasher (white-goods Phase 1) coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-25 00:05:30 +02:00
Benjamin Admin 2645b5b043 Add draft CRA obligation registry
Erstes belastbares Registry-Artefakt (obligation_registry_v1) aus den validierten
SBOM+Vuln-Candidates der Obligation Discovery Pipeline.

- 18 Obligations (11 SBOM + 7 Vuln)
- 14 LEGAL_MINIMUM, alle mit legal_basis (harte Tier-Regel)
- 4 BEST_PRACTICE korrekt herabgestuft (source_role GUIDANCE/IMPLEMENTATION)
- 70 OUT_OF_SCOPE-Cluster getrennt; member_controls vollständig
- legal_basis (CRA-Primärrecht) ⊥ guidance_basis (BSI/ENISA/NIST/...)
- citation_status=pending_span_anchor (span_id folgt mit Asset 2), review_status=draft

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 23:52:20 +02:00
Benjamin Admin fe5dc59152 test(ai-sdk): GT #3 completeness — 8 shared white-goods hazards + CNC gate
CI / detect-changes (push) Successful in 7s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 6s
CI / validate-canonical-controls (push) Successful in 4s
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 58s
CI / iace-gt-coverage (push) Successful in 15s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Phase 1 of the commercial white-goods expansion (EN ISO 10472 family). Extend
GT #3 with 8 completeness hazards a Fachmann expects but that were neither in
the GT nor previously questioned: dry-run boiler overheating, residual/stored
electrical energy, sharp-edge cut, tipping, interlock-failure, unexpected
restart, backflow (EN 1717), microbial/legionella. Enrich the UC-M narrative
with the real features so existing library patterns can fire.

Result: 4/8 auto-covered by existing patterns (dry-run, residual voltage,
tipping, interlock-failure) — recall 84% (21/25). Remaining gaps documented:
spray-arm contact (4.3), sharp-edge cut (4.6), backflow (2.3), restart (6.4).

Gate the re-surfaced CNC leak ("spanende Bearbeitung", high_temperature-only)
via dom_cnc. Kistenhub 97.1% and Bremse pinned mappings unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 23:46:19 +02:00
Benjamin Admin 6b7950f428 Freeze Obligation Registry v1 spec (citability + two-graph)
Schreibt das Zielmodell fest: Legal Obligation = gemeinsame Sprache zwischen
Legal Knowledge Graph (Chat) und Compliance Execution Graph (Engine).

- Registry-Schema v1 (id/tier/legal_basis/guidance_basis/facets/citation_anchor_ids/
  relationships/decision_method)
- Zitierfähigkeit hängt an der OBLIGATION, nicht an Controls (Regulierungsänderung =
  Anchor tauschen, Controls unverändert)
- legal_basis (Primärrecht) vs guidance_basis (NIST/OWASP/...) + source_role
  (LEGAL_BASIS/GUIDANCE/EVIDENCE/IMPLEMENTATION/OUT_OF_SCOPE)
- HARTE Regel: LEGAL_MINIMUM nur mit Primärrechts-Anker
- Beziehungsgraph: requires/implements/supports/produces_evidence_for/depends_on/derived_from
- Citation-Anchor-Pipeline Document→Obligation (KEIN Re-Ingest zum Control-Neubau)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 23:33:29 +02:00
Benjamin Admin 8563798c4f fix(ai-sdk): one hazard per pattern in init — drop cross-category duplicates
Class E1. A multi-category pattern (e.g. "Motorueberlast" [electrical, thermal],
"Lagerschaden" [mechanical, thermal]) created one hazard per category, so the
same scenario+zone appeared twice in the CE hazard log under different labels.
InitializeProject now breaks after the primary (first eligible) category — one
hazard per pattern.

This aligns production with the GT benchmark, which already scores one hazard per
matched pattern. Cyber-skip, per-category cap and cross-pattern measure-merge
still use continue (unchanged). Handlers + iace suites green; Kistenhub/Bremse
unchanged.

Note (E2, not fixed): some scenarios exist as TWO separate patterns (e.g.
"Sicherheitssoftware manipuliert" in hazard_patterns_final_c.go and _final_d.go)
— library redundancy that E1's per-pattern break cannot merge. Left for a
separate, GT-guarded library-dedup audit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 23:06:01 +02:00
Benjamin Admin bde6e76a57 fix(ai-sdk): keyword precision — stop adjective/generic ghost components
Class D (generic keyword hygiene, GT-guarded). Two over-broad keyword->component
mappings produced ghost components:
- "kuehl"/"cool" -> Kuehlaggregat (C095) matched product variants
  ("Cool-Ausfuehrung") and outputs ("kuehle Glaeser"). Narrowed to cooling-UNIT
  terms (kuehlaggregat, kuehlanlage, kuehler, kaeltemaschine, chiller, rueckkuehl).
- "filter" -> Absauganlage/Oelnebelabscheider (C124) matched any filter
  (Laugen-/Wasser-/Oelfilter). Keep "filteranlage" only.

No pattern or GT test depends on these mappings (Kistenhub/Bremse use hand-crafted
inputs). UC-M now parses 6 plausible components (was 8 incl. the two ghosts).
Warewashing GT recall 82.4% and Kistenhub/Bremse pins unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 23:01:19 +02:00
Benjamin Admin 5318a70f9e feat(ai-sdk): interlocked-enclosure model — guard-open re-scoping of contact hazards
Class C (phase-aware, generic EN ISO 14120). A contact/entanglement hazard from
a moving part is removed during NORMAL operation when the part is behind an
interlocked guard; it remains only when the guard is open (maintenance/cleaning).

- New HazardPattern.GuardableByEnclosure flag; set on HP096 (friction at
  rotating surfaces) and HP101 (entanglement of hair/clothing).
- Narrative emits interlocked_enclosure for an interlocked door/hood.
- pattern_enclosure.go: suppressedByEnclosure (drop in normal-op-only contexts)
  + guardedLifecycles (re-scope to maintenance/cleaning).
- GT #3 gains the maintenance-phase entanglement/friction rows.

Generic + regression-safe: machines that do not emit interlocked_enclosure are
unaffected. GT #3 recall 80% -> 82.4%, one false positive removed (Aufwickeln).
Kistenhub 97.1% and all 26 Bremse pinned mappings unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 22:13:34 +02:00
Benjamin Admin cf86dc241b test(ai-sdk): GT #3 (commercial dishwasher) + fix Drehtisch keyword mislabel
Add ground_truth_warewashing.json + TestWarewashing_GTCoverage. The test runs
the UC-M narrative through the SAME chain as production (ParseNarrative ->
engine -> relevance + cyber filter), so keyword/gating fixes are measured on
the real hazard set, and false positives show up as "extra".

Class A (generic keyword hygiene): spuelarm/spuelfeld no longer map to library
component C004 ("Drehtisch" / rotary table) — that mislabelled the spray arm.
Keep the rotating_part tag. Removes the bogus "Drehtisch" hazard.

GT #3 baseline -> after Class A: recall 80% (unchanged), one false positive
(Drehtisch) removed. Kistenhub 97.1% and Bremse pinned mappings unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 21:51:26 +02:00
Benjamin Admin ba6f1bd1f6 Document obligation aggregation validation results
Hält den bewiesenen Shadow-Stand fest: vier Schichten (Obligation Aggregation,
Applicability, Recall-limited Segregation, Targeted LLM Fix) + Zahlen.

- 7-Firmen-Shadow: 136 legacy control-findings → 29 obligation findings = 4,7×
  (23 echte Lücken, 6 recall_limited in nur 2/7 Firmen, 46 MET, 2 N/A)
- LLM-Fix validiert: teamviewer 5→0, safetykon 7→4 (echte Portability-Lücke bleibt,
  legitimate_interest→NA); recall_limited 3→0 bei beiden
- Modell: Haiku 4.5 (fest verdrahteter Sufficiency-Judge), NICHT OVH-Kaskade/Opus
  → Deploy-Gate ist ein gültiger Anthropic-Key auf dev, nicht der OVH-Pfad

Kein Deploy, kein Live-Schalten.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 21:39:28 +02:00
Benjamin Admin 79ad95e244 feat(ai-sdk): keep cyber/AI hazards out of the traditional CE hazard log
CI / detect-changes (push) Successful in 5s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 5s
CI / validate-canonical-controls (push) Successful in 2s
CI / loc-budget (push) Successful in 16s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 57s
CI / iace-gt-coverage (push) Successful in 18s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
InitializeProject created hazards for every matched pattern, so native
cybersecurity/AI topics (unauthorized access, firmware manipulation, missing
SBOM, ...) mixed into the ISO 12100 hazard log. Route the security categories
(frontend groups I. Cyber/Netzwerk + J. KI) to the CRA module instead —
generically for EVERY project, enforced centrally in InitializeProject.

The split is by the nature of the hazard, not the component: functional-safety
control faults stay in CE (software faults, lost safety functions, config
errors, bus failures, botched updates) — they are random/systematic faults,
not attacks, and feed the CRA safety-function bridge. This holds whether the
controller is a bought-in CE-marked PLC or the manufacturer's own control.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 20:20:15 +02:00
Benjamin Admin a6f1020b2c feat(ai-sdk): IACE warewashing hazard patterns + cross-domain gating
Add commercial-dishwasher hazard patterns (HP2200-HP2206): hot-water/steam
scald on door opening, hot surfaces, hot ware, corrosive detergent/rinse-aid
burn, respiratory irritation, door pinch and wet-floor slip — each gated by
dom_warewashing so they never leak into other machine classes. Add the
matching warewashing protective measures (M2200-M2208).

Tighten capability-domain gating: emit dom_flame/dom_glue and add welding
surface-form gate terms (schweissarbeitsplatz, schweissfunke, lichtbogenzone,
...) so the welding/flame/glue burn patterns stop leaking into thermal-capable
machines such as a dishwasher.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 20:20:15 +02:00
Benjamin Admin c1ea9458a7 Add met_count and recall_limited_obligations to shadow telemetry
Reichert die Obligation-Shadow-Telemetrie um zwei Felder an für die Cross-Firmen-
Auswertung: met_count (abgedeckte Obligations) + recall_limited_obligations (welche
Obligations recall-limitiert sind) — erlaubt die Konzentrations-Analyse über Firmen.

7-Firmen-Shadow: 136 Control-Findings → 29 Obligation-Findings (4,7×); recall_limited
nur 6/29, konzentriert auf third_country/safeguards in 2/7 Firmen → LLM-Fix bounded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 20:15:45 +02:00
Benjamin_Boenisch e50892a2aa feat(ai-sdk): searchControls — recall control sources on implementation questions (#39)
CI / detect-changes (push) Successful in 5s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 6s
CI / validate-canonical-controls (push) Successful in 3s
CI / loc-budget (push) Successful in 18s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 58s
CI / iace-gt-coverage (push) Successful in 15s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-24 12:08:29 +00:00
Benjamin Admin 0631a98bdd Mark recall-limited obligations in DSE shadow telemetry
Trennt im Shadow drei Kategorien statt eines pauschalen FAILED:
  - echte Lücke (failed_by_current_checker)
  - redundanter Control-FP (kollabiert per OR zu MET)
  - Prüfer-Reichweitenproblem (recall_limited)

obligation_taxonomy.py: decision_method_required=LLM für recipients_disclosed,
third_country_transfer_disclosed, safeguards_disclosed, safeguards_accessible
(versioniertes Registry-Artefakt bis DB-Tabelle, v1-Spec). Empirisch: TeamViewer
0/22 kw+emb trotz erfüllter Pflicht (cos 0.49-0.57) → CONTENT/LLM-Klasse, kein Schwellen-Fix.

compute_obligation_shadow segregiert FAILED/PARTIAL über requires_llm(): teamviewer
5 Findings → 2 echte + 3 recall_limited. 9 neue Unit-Tests (41 gesamt grün).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 13:46:21 +02:00
Benjamin_Boenisch 9cfe6f83b1 feat(ai-sdk): source_role control-pool (controls != only technical_standard) (#38)
CI / detect-changes (push) Successful in 4s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 6s
CI / validate-canonical-controls (push) Successful in 3s
CI / loc-budget (push) Successful in 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 57s
CI / iace-gt-coverage (push) Successful in 15s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-24 11:12:22 +00:00
Benjamin Admin c3542f7dfe feat(dse): obligation shadow telemetry
Verdrahtet die Obligation Aggregation Engine als Layer 4 (SHADOW) in v3_engine:
erzeugt aus den results zusätzlich Obligation-Ergebnisse AUSSCHLIESSLICH für die
Telemetrie. Greift NICHT in results ein — nutzer-sichtbare Findings unverändert.

- _obligation_shadow.py: fetch_obligation_markers (legal_obligations + applicability)
  + compute_obligation_shadow (pure): legacy_control_findings, obligation_shadow_results,
  collapse_factor, na_count, met_failed_delta, top_collapsed_obligations
- met-Signal = Legacy-passed (kein zusätzlicher Prüfer-Call/Key)

E2E (3 Firmen, echte Engine): 57 Control-Findings → 14 Obligation-Findings (4,1×);
Redundanz kollabiert wo Evidenz existiert, echte Lücken bleiben FAILED. 6 Unit-Tests grün.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 12:59:52 +02:00
Benjamin Admin 7ec29999a2 feat(obligation): obligation applicability predicates
Minimaler Applicability-Hook für die Obligation Aggregation Engine: entscheidet
aus dem Dokumenttext, ob eine bedingte Obligation anwendbar ist (True/False/None).

- has_third_country_transfer · uses_legitimate_interest · direct_marketing
  (+ Alias legitimate_interest_or_public_task)
- unbekanntes Prädikat → None → Aufrufer behält Default=anwendbar (fail-safe, nie stille NA)
- profiling/employment/telecom/health/data_act folgen als nächste Charge

Re-Benchmark (Opus-GT, 3 Firmen): Prädikate erkennen Transfer/berecht.Interesse/
Direktwerbung korrekt → keine falsche NA; NA-Flip-Probe bestätigt FEHLT→NA ohne Transfer.
14 Unit-Tests grün.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 12:43:42 +02:00
Benjamin Admin 402a42d30d feat(obligation): obligation-level aggregation engine
Erste Ausführung des Legal Obligation Layer v1: aggregiert Bewertungen auf
Kriterium-/Control-Ebene zu Findings auf Obligation-Ebene
(Regulation → Legal Obligation → Control → Criterion).

- regulierungs-agnostisch (obligation_id/tier/met/legal_basis/conditional)
- fail-safe: LM applicable=false→NA · keine erfüllt→FAILED · alle→MET · Teil→PARTIAL;
  BP/OPT covered→MET sonst OPEN (nie FAILED); LM unbewertbar→UNDETERMINED (Legacy behalten)
- Redundanz-Kollaps per OR pro legal_basis-Anforderung → kein künstliches PARTIAL
- Applicability als Hook (Prädikat-Engine folgt separat)

Shadow-Benchmark (Opus-GT, 3 Firmen): 38 Control-Findings → 13 Obligation-Findings
(2,9×); ~23 redundante Falsch-Positive strukturell korrigiert, echte Lücken erhalten,
PARTIAL=0. 16/16 Unit-Tests grün.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-24 12:28:03 +02:00
Benjamin_Boenisch df7966656a feat(ai-sdk): classify NIST/OWASP/Grundschutz as technical_standard (#37)
CI / detect-changes (push) Successful in 4s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 6s
CI / validate-canonical-controls (push) Successful in 3s
CI / loc-budget (push) Successful in 18s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 1m0s
CI / iace-gt-coverage (push) Successful in 14s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-24 10:15:17 +00:00
Benjamin_Boenisch 05d75e8039 feat(ai-sdk): control-intent — technical_standard may win implementation questions (#36)
CI / detect-changes (push) Successful in 5s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 4s
CI / validate-canonical-controls (push) Successful in 4s
CI / loc-budget (push) Successful in 15s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 54s
CI / iace-gt-coverage (push) Successful in 14s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-24 09:58:35 +00:00
Benjamin_Boenisch e24a551ee4 fix(ai-sdk): make interpretation-intent override reliably win (#35)
CI / detect-changes (push) Successful in 8s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 4s
CI / validate-canonical-controls (push) Successful in 2s
CI / loc-budget (push) Successful in 18s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 57s
CI / iace-gt-coverage (push) Successful in 15s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-24 09:31:58 +00:00
171 changed files with 43148 additions and 112 deletions
+8
View File
@@ -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
+3 -1
View File
@@ -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) {
+188
View File
@@ -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,24 @@
// Control-Mapping: CRA Annex I -> OWASP ASVS 5.0. Eine Zeile = ein Mapping (Schema: ControlMapping).
// Reviewt 2026-06-25 (benjamin): 7 accepted, 13 rejected. accepted = Audit-Wahrheit (Advisor nutzt acceptedOnly).
// rejected bleiben als Audit-Spur ("warum verworfen"). KEIN confidence — kuratiert = fachliche Feststellung.
// Architekturbeweis: CRA -> OWASP fuer AppSec/Auth/Crypto/Logging; Ops/Update/Attack-Surface/Integritaet -> NIST/BSI.
{"source_norm": "CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V6.3.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V6 = Authentication.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V6 = Authentication, sauberer Treffer fuer Zugriffsschutz/Authentisierung.", "version": "2026-06-25", "obligation_id": "user_authentication_required"}
{"source_norm": "CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V6.1.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V6 = Authentication.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V6 = Authentication, sauberer Treffer fuer Zugriffsschutz/Authentisierung.", "version": "2026-06-25", "obligation_id": "user_authentication_required"}
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V11.2.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V11 = Cryptography.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "Korrektur von V14: V11 = Cryptography, richtiger Bereich fuer Verschluesselung.", "version": "2026-06-25", "obligation_id": "credential_confidentiality_protection"}
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V11.7.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V11.7 = Key Management.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "Korrektur von V14: V11.7 = Key Management fuer Verschluesselung/Schluesselverwaltung.", "version": "2026-06-25", "obligation_id": "auth_key_management"}
{"source_norm": "CRA Annex I Part I (2)(k) — Sicherheitsrelevante Ereignisse / Logging", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V16.3.3", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V16 = Security Logging.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V16 = Logging, sauberer Treffer fuer sicherheitsrelevante Ereignisse.", "version": "2026-06-25", "obligation_id": "event_logging_security_events"}
{"source_norm": "CRA Annex I Part I (2)(k) — Sicherheitsrelevante Ereignisse / Logging", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V16.3.4", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V16 = Security Logging.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V16 = Logging, sauberer Treffer fuer sicherheitsrelevante Ereignisse.", "version": "2026-06-25", "obligation_id": "event_logging_security_events"}
{"source_norm": "CRA Annex I Part I (2)(k) — Sicherheitsrelevante Ereignisse / Logging", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V16.1.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V16 = Security Logging.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V16 = Logging, sauberer Treffer fuer sicherheitsrelevante Ereignisse.", "version": "2026-06-25", "obligation_id": "event_logging_security_events"}
{"source_norm": "CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V14 = Config, kein Auth — verworfen.", "version": "2026-06-25"}
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V14 = Config, Crypto gehoert zu V11 — verworfen.", "version": "2026-06-25"}
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.3.2", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V14 = Config, Crypto gehoert zu V11 — verworfen.", "version": "2026-06-25"}
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.2.3", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V14 = Config, Crypto gehoert zu V11 — verworfen.", "version": "2026-06-25"}
{"source_norm": "CRA Annex I Part I (2)(e) — Integritaet", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
{"source_norm": "CRA Annex I Part I (2)(e) — Integritaet", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V1.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
{"source_norm": "CRA Annex I Part I (2)(e) — Integritaet", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V6.1.1", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
{"source_norm": "CRA Annex I Part I (2)(l) — Sichere Updates", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
{"source_norm": "CRA Annex I Part I (2)(l) — Sichere Updates", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V2.4.1", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
{"source_norm": "CRA Annex I Part I (2)(l) — Sichere Updates", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V6.1.1", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
{"source_norm": "CRA Annex I Part I (2)(i) — Angriffsflaeche minimieren", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V6.1.1", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
{"source_norm": "CRA Annex I Part I (2)(i) — Angriffsflaeche minimieren", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V15.3.3", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
{"source_norm": "CRA Annex I Part I (2)(i) — Angriffsflaeche minimieren", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V8.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
@@ -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,16 @@
// Evidence-Requirements je OWASP-ASVS-Control (Schema: EvidenceRequirement). Eine Zeile = eine geforderte Evidenz.
// Autoriert/kuratiert (nicht Retriever). Der Advisor kann eine CRA-Anforderung erst dann als erfuellt melden,
// wenn die required Evidenzen der gemappten, accepted Controls vorliegen + frisch genug sind.
// Stand 2026-06-25, Basis: die 7 accepted CRA->OWASP-Mappings (Auth V6, Crypto V11, Logging V16).
{"framework": "OWASP ASVS", "control": "V6.3.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "IAM-/Zugriffskonfiguration als Nachweis der Authentisierungs-Anforderung.", "version": "2026-06-25"}
{"framework": "OWASP ASVS", "control": "V6.3.1", "evidence_type": "test_report", "evidence_source": "ci", "freshness_requirement": "per_release", "required": true, "rationale": "Automatisierter Zugriffstest (CI) belegt funktionierende Zugriffskontrolle.", "version": "2026-06-25"}
{"framework": "OWASP ASVS", "control": "V6.3.1", "evidence_type": "pentest", "evidence_source": "manual_upload", "freshness_requirement": "annually", "required": false, "rationale": "Jaehrlicher PenTest der Authentisierung — vertieft, aber nicht Pflicht je Release.", "version": "2026-06-25"}
{"framework": "OWASP ASVS", "control": "V6.1.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Rollenmodell/Auth-Architektur als Nachweis.", "version": "2026-06-25"}
{"framework": "OWASP ASVS", "control": "V11.2.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Krypto-Konfiguration (zugelassene Algorithmen) als Nachweis der Verschluesselung.", "version": "2026-06-25"}
{"framework": "OWASP ASVS", "control": "V11.2.1", "evidence_type": "sbom", "evidence_source": "ci", "freshness_requirement": "per_release", "required": true, "rationale": "SBOM weist die eingesetzten Krypto-Bibliotheken/-Versionen nach.", "version": "2026-06-25"}
{"framework": "OWASP ASVS", "control": "V11.7.1", "evidence_type": "policy", "evidence_source": "manual_upload", "freshness_requirement": "annually", "required": true, "rationale": "Key-Management-Policy (Rotation, Aufbewahrung) als organisatorischer Nachweis.", "version": "2026-06-25"}
{"framework": "OWASP ASVS", "control": "V11.7.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Konfiguration der Schluesselverwaltung als technischer Nachweis.", "version": "2026-06-25"}
{"framework": "OWASP ASVS", "control": "V16.3.3", "evidence_type": "audit_log", "evidence_source": "ci", "freshness_requirement": "continuous", "required": true, "rationale": "Security-Audit-Logs belegen, dass sicherheitsrelevante Ereignisse protokolliert werden.", "version": "2026-06-25"}
{"framework": "OWASP ASVS", "control": "V16.3.3", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Logging-Konfiguration als Nachweis der erfassten Ereignisarten.", "version": "2026-06-25"}
{"framework": "OWASP ASVS", "control": "V16.3.4", "evidence_type": "audit_log", "evidence_source": "ci", "freshness_requirement": "continuous", "required": true, "rationale": "Security-Audit-Logs.", "version": "2026-06-25"}
{"framework": "OWASP ASVS", "control": "V16.1.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Logging-Architektur-Konfiguration als Nachweis.", "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
}
@@ -211,6 +211,13 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
}
for _, cat := range mp.HazardCats {
// Native cyber/AI categories (frontend groups I+J) belong to the
// CRA module, not the traditional CE (ISO 12100) hazard log.
// Enforced centrally here so it holds for EVERY project.
if isCyberSecurityCategory(cat) {
fmt.Printf("CYBER-SKIP: cat=%s pattern=%s — routed to CRA module\n", cat, mp.PatternID)
continue
}
maxForCat := categoryHazardCap(cat, len(comps))
if catCount[cat] >= maxForCat {
continue
@@ -291,6 +298,10 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
if len(mp.SuggestedMeasureIDs) > 0 {
hazardPatternMeasures[hz.ID] = mp.SuggestedMeasureIDs
}
// E1: one hazard per pattern — keep only the primary (first
// eligible) category; a secondary category would be the same
// scenario+zone under a different label (cross-category duplicate).
break
}
}
}
@@ -0,0 +1,45 @@
package handlers
// Safety/Security separation for the IACE hazard log.
//
// The traditional CE risk assessment (Maschinenrichtlinie / EN ISO 12100) and
// the cybersecurity assessment (Cyber Resilience Act) are two distinct steps.
// IACE owns the traditional, physical + functional-safety hazards; the CRA
// module (/sdk/iace/{id}/cra) owns the native cyber/AI topics and re-examines
// which safety functions a cyber attack can re-open (see iace-safety-bridge).
//
// The split is by the NATURE of the hazard, not by the component: a control
// fault, bus failure or botched update is FUNCTIONAL safety (random/systematic
// fault) and stays in CE — independent of whether the controller is a bought-in
// CE-marked PLC or the manufacturer's own embedded control. Only the security
// PROPERTIES against malicious actors (access control, firmware/update
// integrity, SBOM, vulnerability handling, default passwords) are CRA.
//
// Functional-safety control categories (software_control, software_fault,
// safety_function_failure, configuration_error, communication_failure,
// update_failure, sensor_fault, …) therefore intentionally STAY in IACE — they
// are the safety functions whose loss the CRA bridge re-examines.
//
// Enforced centrally in InitializeProject so it holds for EVERY project.
var nativeCyberSecurityCategories = map[string]bool{
// I. Cyber / Netzwerk — security against malicious actors
"unauthorized_access": true,
"firmware_corruption": true,
"cyber_resilience": true,
"logging_audit_failure": true,
"cyber_network": true,
"sensor_spoofing": true,
// J. KI-spezifisch
"ai_specific": true,
"ai_misclassification": true,
"false_classification": true,
"model_drift": true,
"data_poisoning": true,
"unintended_bias": true,
}
// isCyberSecurityCategory reports whether a hazard category is a native cyber/AI
// topic that belongs to the CRA module rather than the traditional CE hazard log.
func isCyberSecurityCategory(category string) bool {
return nativeCyberSecurityCategories[category]
}
@@ -0,0 +1,37 @@
package handlers
import "testing"
func TestIsCyberSecurityCategory_RoutedToCRA(t *testing.T) {
cyber := []string{
"unauthorized_access", "firmware_corruption", "cyber_resilience",
"logging_audit_failure", "cyber_network", "sensor_spoofing",
"ai_specific", "ai_misclassification", "false_classification",
"model_drift", "data_poisoning", "unintended_bias",
}
for _, c := range cyber {
if !isCyberSecurityCategory(c) {
t.Errorf("category %q must be routed to the CRA module, not the traditional IACE log", c)
}
}
}
func TestIsCyberSecurityCategory_StaysInIACE(t *testing.T) {
// Physical + functional-safety categories must remain in the traditional CE
// hazard log. communication_failure (bus failure -> loss of control) and
// update_failure (botched update -> lost safety function) are FUNCTIONAL
// faults, not attacks, so they stay too.
keep := []string{
"mechanical_hazard", "electrical_hazard", "thermal_hazard",
"pneumatic_hydraulic", "noise_vibration", "ergonomic_hazard",
"material_environmental", "chemical_risk", "fire_explosion",
"software_control", "software_fault", "safety_function_failure",
"configuration_error", "sensor_fault", "hmi_error",
"communication_failure", "update_failure",
}
for _, c := range keep {
if isCyberSecurityCategory(c) {
t.Errorf("category %q must stay in the traditional IACE log, not be routed to CRA", c)
}
}
}
+8 -1
View File
@@ -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
}
+2
View File
@@ -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
@@ -0,0 +1,237 @@
package iace
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sort"
"testing"
)
// GT #3 — commercial UNDERCOUNTER dishwasher (Winterhalter UC-M). Self-assessed
// ground truth: we can judge what a dishwasher is. The test runs the narrative
// through the SAME chain as production (ParseNarrative -> engine -> relevance
// filter + cyber-skip), so keyword/gating fixes are measured on the hazard set
// the user actually sees — not the raw pattern flood.
// Condensed UC-M limits_form narrative. Deliberately includes "Cool-Ausfuehrung"
// and "Filter" so the known false components (Kuehlaggregat, Absauganlage) are
// reproduced and visible in the baseline.
const warewashingNarrative = `Gewerbliche Untertisch-Geschirrspuelmaschine fuer Gastronomie-Kueche, ` +
`vernetzt ueber LAN und WLAN (Connected Wash Internetportal). Heisswasser-Boiler mit ` +
`Nachspueltemperatur ca. 85 Grad C, Tank mit Hygiene-Tankheizkoerper. Spuelpumpe 150-200 l/min ` +
`mit rotierenden Spuelfeldern und Spuelarmen, Ablaufpumpe. Eingebautes Dosiergeraet fuer Reiniger ` +
`und Klarspueler (aetzende Konzentrate). 4-fach-Laugenfiltration mit Filter. Doppelwandige Tuer ` +
`mit Sicherheitsschalter und Rastposition (Thermostopp). Elektromotor (Drehstrom) 400 V. ` +
`Touch-Steuerung (SPS) mit Bedienfeld und HMI, USB-Schnittstelle fuer Softwareupdates, ` +
`PIN-geschuetzter Servicetechniker-Fernzugriff. Cool-Ausfuehrung mit kalter Nachspuelung. ` +
`Untertischmontage. Eingreifen in die Spuelkammer moeglich. Aerosole und Daempfe der ` +
`Reinigungschemie gelangen in die Atemzone. Manuelles Be- und Entladen der Spuelkoerbe von Hand. ` +
`Reinigung und Wartung durch Servicetechniker. Branche Lebensmittel und Getraenke. ` +
`Siebe und scharfe Blechkanten in der Spuelkammer. Boiler kann bei Wassermangel trockenlaufen. ` +
`Frequenzumrichter und Elektronik mit Restspannung nach dem Abschalten. Wartung nur im ` +
`freigeschalteten Zustand; Gefahr des unerwarteten Wiederanlaufs. Frischwasseranschluss mit ` +
`Rueckflussverhinderer gegen Ruecksaugen in das Trinkwassernetz. Stehwasser im Boiler ` +
`(Hygiene/Legionellen). Standsicherheit bei Untertischmontage.`
// warewashingCyberCategories mirrors handlers.nativeCyberSecurityCategories —
// native cyber/AI hazards are routed to the CRA module, not the CE hazard log.
var warewashingCyberCategories = map[string]bool{
"unauthorized_access": true, "firmware_corruption": true, "cyber_resilience": true,
"logging_audit_failure": true, "cyber_network": true, "sensor_spoofing": true,
"ai_specific": true, "ai_misclassification": true, "false_classification": true,
"model_drift": true, "data_poisoning": true, "unintended_bias": true,
}
// warewashingEngineOutput runs the production chain and returns the filtered
// hazards/mitigations the user would see for the UC-M.
func warewashingEngineOutput() ([]Hazard, []Mitigation, []PatternMatch) {
res := ParseNarrative(warewashingNarrative, "Gewerbliche Untertisch-Geschirrspuelmaschine (vernetzt)")
var compIDs, compNames []string
for _, c := range res.Components {
if c.Negated {
continue
}
compIDs = append(compIDs, c.LibraryID)
compNames = append(compNames, c.NameDE)
}
var energyIDs []string
for _, e := range res.EnergySources {
energyIDs = append(energyIDs, e.SourceID)
}
lifecycles := append([]string{}, res.LifecyclePhases...)
lifecycles = append(lifecycles, "normal_operation", "maintenance", "cleaning", "setup", "fault_clearing")
input := MatchInput{
ComponentLibraryIDs: compIDs,
EnergySourceIDs: energyIDs,
LifecyclePhases: lifecycles,
CustomTags: res.CustomTags,
OperationalStates: append(res.OperationalStates, "normal_operation", "cleaning", "maintenance"),
HumanRoles: res.Roles,
MachineTypes: []string{"food_processing", "Gewerbliche Untertisch-Geschirrspuelmaschine (vernetzt)"},
}
out := NewPatternEngine().Match(input)
var kept []PatternMatch
for _, pm := range out.MatchedPatterns {
if !IsPatternRelevant(pm, warewashingNarrative, compNames) {
continue
}
allCyber := len(pm.HazardCats) > 0
for _, c := range pm.HazardCats {
if !warewashingCyberCategories[c] {
allCyber = false
}
}
if allCyber {
continue
}
kept = append(kept, pm)
}
filtered := *out
filtered.MatchedPatterns = kept
hazards, mitigations := patternsToHazardsAndMitigations(&filtered)
return hazards, mitigations, kept
}
func TestWarewashing_GTCoverage(t *testing.T) {
gtPath := filepath.Join("testdata", "ground_truth_warewashing.json")
raw, err := os.ReadFile(gtPath)
if err != nil {
t.Fatalf("read GT: %v", err)
}
var gt GroundTruth
if err := json.Unmarshal(raw, &gt); err != nil {
t.Fatalf("parse GT: %v", err)
}
{
res := ParseNarrative(warewashingNarrative, "Gewerbliche Untertisch-Geschirrspuelmaschine (vernetzt)")
var cn []string
for _, c := range res.Components {
if !c.Negated {
cn = append(cn, c.NameDE)
}
}
t.Logf("Parsed components: %v", cn)
}
hazards, mitigations, keptPatterns := warewashingEngineOutput()
t.Logf("Engine: %d patterns kept (relevance+cyber filter) -> %d hazards", len(keptPatterns), len(hazards))
result := CompareBenchmark(&gt, hazards, mitigations)
precision := 0.0
if result.TotalEngine > 0 {
precision = float64(len(result.MatchedPairs)) / float64(result.TotalEngine)
}
t.Logf("=== Warewashing-GT (GT #3) Baseline ===")
t.Logf("Recall (Coverage): %.1f%% (%d/%d matched, %d missing)",
result.CoverageScore*100, len(result.MatchedPairs), result.TotalGT, len(result.MissingFromEngine))
t.Logf("Precision: %.1f%% (%d engine hazards, %d extra)",
precision*100, result.TotalEngine, len(result.ExtraInEngine))
if len(result.MissingFromEngine) > 0 {
t.Logf("--- MISSING (recall gaps) ---")
for _, m := range result.MissingFromEngine {
t.Logf(" MISS %s: %s", m.Nr, abbrev(m.HazardType, 60))
}
}
// Measure completeness: which generated hazards have NO protective measure?
t.Logf("--- Measure completeness ---")
t.Logf("Measure coverage (GT-matched): %.0f%%", result.MeasureCoverage*100)
withMeas := make(map[string]bool)
for _, m := range mitigations {
withMeas[m.HazardID.String()] = true
}
noMeasure := 0
for _, h := range hazards {
if !withMeas[h.ID.String()] {
noMeasure++
n := h.Name
if n == "" {
n = h.Scenario
}
t.Logf(" NO-MEASURE: [%s] %s", h.Category, abbrev(n, 60))
}
}
t.Logf("Hazards without any measure: %d/%d", noMeasure, len(hazards))
if len(result.ExtraInEngine) > 0 {
t.Logf("--- EXTRA (false positives / precision loss) ---")
names := make([]string, 0, len(result.ExtraInEngine))
for _, e := range result.ExtraInEngine {
n := e.Name
if n == "" {
n = e.Scenario
}
names = append(names, "["+e.Category+"] "+n)
}
sort.Strings(names)
for _, n := range names {
t.Logf(" EXTRA %s", abbrev(n, 85))
}
}
// Loose smoke floor for the baseline — fixes should push recall up, not down.
if result.CoverageScore < 0.4 {
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, &gt); 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(&gt, 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)
})
}
@@ -62,6 +62,13 @@ type HazardPattern struct {
// "hazard" = source only, "hazardous_situation" = person exposed, "harm" = injury.
// Empty = default (hazardous_situation).
GeneratedHazardType string `json:"generated_hazard_type,omitempty"`
// GuardableByEnclosure marks a contact/entanglement hazard that an interlocked
// enclosure removes during normal operation. When the project emits the
// "interlocked_enclosure" tag, such a pattern is re-scoped to maintenance/
// cleaning (guard open) and does NOT fire as a normal-operation hazard.
// Generic EN ISO 14120 logic — surfaced by the warewashing GT (the spray
// arm rotates behind the interlocked door).
GuardableByEnclosure bool `json:"guardable_by_enclosure,omitempty"`
// RequiredFailureModes restricts this pattern to fire only when at least one
// of the listed failure modes is relevant (by ComponentType match against project components).
// Empty/nil = fires regardless of failure modes (backwards compatible).
@@ -37,6 +37,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
},
{
ID: "HP096", NameDE: "Reibung/Abrieb durch rotierende Oberflaechen", NameEN: "Friction/abrasion by rotating surfaces",
GuardableByEnclosure: true,
RequiredComponentTags: []string{"rotating_part"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"mechanical_hazard"},
@@ -88,6 +89,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
},
{
ID: "HP101", NameDE: "Aufwickeln von Kleidung/Haaren", NameEN: "Winding up of clothing/hair",
GuardableByEnclosure: true,
RequiredComponentTags: []string{"rotating_part"},
RequiredEnergyTags: []string{"rotational"},
GeneratedHazardCats: []string{"mechanical_hazard"},
@@ -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"},
@@ -0,0 +1,178 @@
package iace
// GetWarewashingPatterns returns hazard patterns for commercial warewashing
// machines (gewerbliche Geschirrspuelmaschinen / Untertisch-, Hauben-, Korb-
// und Bandspuelmaschinen). These capture the machine-specific hazards a
// Fachmann immediately expects but that the generic library did not cover:
// hot-water/steam scalding on door opening, hot surfaces, hot ware, corrosive
// detergent/rinse-aid contact, door pinch and wet-floor slipping.
//
// Every pattern is gated by the capability tag "dom_warewashing" (emitted only
// by warewashing narrative keywords in keyword_dictionary.go), so none of these
// leak into unrelated machine classes.
//
// HP range: HP2200-HP2206. ISO 12100 Annex B section identifiers only (facts);
// product standard EN 60335-2-58 (commercial dishwashing machines).
func GetWarewashingPatterns() []HazardPattern {
return []HazardPattern{
{
ID: "HP2200", NameDE: "Verbruehung durch Heisswasser/Dampf beim Oeffnen der Tuer", NameEN: "Scalding by hot water/steam when opening the door",
RequiredComponentTags: []string{"dom_warewashing", "steam_emission"},
GeneratedHazardCats: []string{"thermal_hazard"},
SuggestedMeasureIDs: []string{"M2200", "M2201", "M2202", "M2208"},
Priority: 94,
ApplicableLifecycles: []string{"normal_operation", "cleaning"},
ScenarioDE: "Beim Oeffnen der Tuer waehrend oder unmittelbar nach dem Spuelgang tritt ein Schwall aus heissem Wasser und Wrasen (Dampf) aus der Spuelkammer aus und trifft Gesicht, Haende und Arme des Bedieners.",
TriggerDE: "Tuer wird vor Programmende oder bei noch vorhandenem Restdampf geoeffnet; Tuerverriegelung fehlt oder ist ueberbrueckt; Nachspueltemperatur ca. 85 Grad C.",
HarmDE: "Verbruehung 1.-2. Grades an Gesicht, Haenden und Unterarmen; Augenreizung durch heissen Dampf.",
AffectedDE: "Bedienpersonal (Spuelkraft)",
ZoneDE: "Tuer- und Beschickungsoeffnung der Spuelkammer",
ISO12100Section: "6.2.4",
DefaultSeverity: 3, DefaultExposure: 4,
},
{
ID: "HP2201", NameDE: "Verbrennung an heissen Oberflaechen (Boiler/Tank/Spuelkammer)", NameEN: "Burn on hot surfaces (boiler/tank/wash chamber)",
RequiredComponentTags: []string{"dom_warewashing", "high_temperature"},
GeneratedHazardCats: []string{"thermal_hazard"},
SuggestedMeasureIDs: []string{"M2202", "M055", "M2208"},
Priority: 90,
ApplicableLifecycles: []string{"cleaning", "maintenance"},
ScenarioDE: "Beruehrung heisser Oberflaechen von Boiler, Tankheizkoerper oder Spuelkammerwaenden bei Reinigung, Entkalkung oder Wartung fuehrt zu Kontaktverbrennungen.",
TriggerDE: "Reinigung/Entkalkung ohne Abkuehlzeit; Eingriff in die Spuelkammer bei betriebswarmem Geraet.",
HarmDE: "Kontaktverbrennung an Haenden und Unterarmen.",
AffectedDE: "Reinigungspersonal, Wartungspersonal",
ZoneDE: "Boiler, Tankheizkoerper, Spuelkammerwaende",
ISO12100Section: "6.2.4",
DefaultSeverity: 2, DefaultExposure: 3,
},
{
ID: "HP2202", NameDE: "Verbrennung an heissem Spuelgut beim Entladen", NameEN: "Burn on hot ware when unloading",
RequiredComponentTags: []string{"dom_warewashing", "hot_water"},
GeneratedHazardCats: []string{"thermal_hazard"},
SuggestedMeasureIDs: []string{"M2202", "M055", "M2208"},
Priority: 86,
ApplicableLifecycles: []string{"normal_operation"},
ScenarioDE: "Geschirr, Glaeser und Bestecke sind nach dem Spuelgang durch die Heisswasser-Nachspuelung sehr heiss; beim Entladen kommt es zu Verbrennungen.",
TriggerDE: "Sofortiges Entnehmen des Spuelguts nach Programmende ohne Abkuehl-/Trocknungszeit.",
HarmDE: "Verbrennung an Haenden/Fingern beim Greifen heisser Teile.",
AffectedDE: "Bedienpersonal (Spuelkraft)",
ZoneDE: "Spuelkammer, Entnahmebereich/Korb",
ISO12100Section: "6.2.4",
DefaultSeverity: 2, DefaultExposure: 3,
},
{
ID: "HP2203", NameDE: "Chemische Veraetzung (Haut/Augen) durch Reiniger-/Klarspueler-Konzentrat", NameEN: "Chemical burn (skin/eyes) from detergent/rinse-aid concentrate",
RequiredComponentTags: []string{"dom_warewashing", "corrosive_chemical"},
GeneratedHazardCats: []string{"chemical_risk"},
SuggestedMeasureIDs: []string{"M2203", "M2204", "M2208"},
Priority: 92,
ApplicableLifecycles: []string{"normal_operation", "maintenance"},
ScenarioDE: "Direkter Kontakt mit dem aetzenden (alkalischen) Reiniger- bzw. Klarspueler-Konzentrat beim Nachfuellen, Sauglanzenwechsel oder bei Leckage fuehrt zu Veraetzungen von Haut und Augen.",
TriggerDE: "Gebinde-/Sauglanzenwechsel ohne Schutzausruestung; Umfuellen von Konzentrat; undichte Dosierleitung.",
HarmDE: "Veraetzung von Haut und Augen (alkalische Verletzung), bleibende Augenschaeden moeglich.",
AffectedDE: "Bedienpersonal, Reinigungspersonal beim Chemikalien-Handling",
ZoneDE: "Dosiergeraet, Reiniger-/Klarspueler-Gebinde, Sauglanzen",
ISO12100Section: "6.2.4",
DefaultSeverity: 3, DefaultExposure: 3,
ClarificationQuestionsDE: []string{
"Liegt fuer alle eingesetzten Reiniger/Klarspueler/Entkalker ein aktuelles Sicherheitsdatenblatt (SDB) am Geraet vor?",
"Ist ein geschlossenes Dosiersystem mit Sauglanzen vorhanden, sodass kein Umfuellen noetig ist?",
},
},
{
ID: "HP2204", NameDE: "Reizung/Veraetzung der Atemwege durch Reinigungs-Aerosole/Daempfe", NameEN: "Respiratory irritation from cleaning aerosols/vapours",
RequiredComponentTags: []string{"dom_warewashing", "corrosive_chemical"},
GeneratedHazardCats: []string{"chemical_risk"},
SuggestedMeasureIDs: []string{"M2205", "M2203", "M2204"},
Priority: 82,
ApplicableLifecycles: []string{"normal_operation", "maintenance"},
ScenarioDE: "Aerosole und Daempfe der Reinigungschemie (insbesondere beim Oeffnen kurz nach dem Spuelgang oder bei der Entkalkung mit Saeure) gelangen in die Atemzone und reizen Atemwege und Schleimhaeute.",
TriggerDE: "Oeffnen bei laufender/heisser Chemie; Entkalkung mit Saeure; unzureichende Lueftung des Aufstellbereichs.",
HarmDE: "Reizung von Atemwegen, Augen und Schleimhaeuten; bei Saeure-/Laugen-Vermischung gefaehrliche Gase.",
AffectedDE: "Bedienpersonal, Reinigungspersonal",
ZoneDE: "Atemzone vor der Spuelkammer, Aufstellbereich",
ISO12100Section: "6.2.4",
DefaultSeverity: 2, DefaultExposure: 2,
ClarificationQuestionsDE: []string{
"Ist der Aufstellbereich ausreichend be-/entlueftet (Kuechenlueftung)?",
"Wird in der BA vor dem Vermischen von Reiniger und Entkalker/Saeure gewarnt?",
},
},
{
ID: "HP2205", NameDE: "Quetschen der Finger an der Tuer/Haube", NameEN: "Finger crushing at the door/hood",
RequiredComponentTags: []string{"dom_warewashing", "access_door"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M2206", "M003", "M2208"},
Priority: 78,
ApplicableLifecycles: []string{"normal_operation"},
ScenarioDE: "Beim Schliessen der Tuer bzw. Absenken der Haube werden Finger zwischen Tuer/Haube und Gehaeuse gequetscht.",
TriggerDE: "Greifen in den Schliessbereich beim Schliessen; hohe Schliesskraft der Haube; scharfe Kanten.",
HarmDE: "Quetschung und Prellung der Finger.",
AffectedDE: "Bedienpersonal (Spuelkraft)",
ZoneDE: "Tuer-/Haubenkante, Schliessbereich",
ISO12100Section: "6.2.3",
DefaultSeverity: 1, DefaultExposure: 3,
},
{
ID: "HP2206", NameDE: "Ausrutschen auf nassem Boden (Wasseraustritt/Leckage)", NameEN: "Slipping on wet floor (water leakage)",
RequiredComponentTags: []string{"dom_warewashing"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M2207", "M538", "M2208"},
Priority: 76,
ApplicableLifecycles: []string{"normal_operation", "cleaning", "maintenance"},
ScenarioDE: "Aus der Spuelmaschine austretendes Wasser (Beschickung, Tuer oeffnen, Leckage, Tankwasserwechsel) macht den Boden im Aufstellbereich rutschig; der Bediener rutscht aus.",
TriggerDE: "Wasseraustritt beim Oeffnen/Beschicken; undichter Ablauf; fehlender Bodenablauf.",
HarmDE: "Sturz mit Prellungen, Knochenbruechen oder Kopfaufprall.",
AffectedDE: "Bedienpersonal, Reinigungspersonal",
ZoneDE: "Aufstell- und Bedienbereich der Spuelmaschine",
ISO12100Section: "6.3.5.6",
DefaultSeverity: 2, DefaultExposure: 3,
},
{
ID: "HP2207", NameDE: "Rueckfluss / Kontamination des Trinkwassers", NameEN: "Backflow / potable-water contamination",
RequiredComponentTags: []string{"dom_warewashing", "backflow_risk"},
GeneratedHazardCats: []string{"material_environmental"},
SuggestedMeasureIDs: []string{"M2209"},
Priority: 84,
ApplicableLifecycles: []string{"normal_operation"},
ScenarioDE: "Verschmutztes Spuel- oder Chemiewasser wird ueber den Frischwasseranschluss in das Trinkwassernetz zurueckgesaugt und kontaminiert es (Ruecksaugen bei Unterdruck im Netz).",
TriggerDE: "Fehlender oder defekter Rueckflussverhinderer/Systemtrenner; Unterdruck im Trinkwassernetz; kein freier Auslauf.",
HarmDE: "Gesundheitsgefaehrdung Dritter durch kontaminiertes Trinkwasser (Chemie, Keime).",
AffectedDE: "Verbraucher am selben Trinkwassernetz, Betreiber",
ZoneDE: "Frischwasseranschluss, Wasserzulauf",
ISO12100Section: "6.2.4",
DefaultSeverity: 3, DefaultExposure: 2,
},
{
ID: "HP2208", NameDE: "Schnittverletzung an scharfen Kanten/Sieben", NameEN: "Cut injury on sharp edges/screens",
RequiredComponentTags: []string{"dom_warewashing", "sharp_edge"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M003"},
Priority: 74,
ApplicableLifecycles: []string{"cleaning", "maintenance"},
ScenarioDE: "Schneiden an scharfen Blechkanten, Sieben oder dem Ablaufpumpen-Laufrad beim Reinigen oder Eingreifen in die Spuelkammer.",
TriggerDE: "Entnehmen/Reinigen der Siebe; Eingreifen an scharfen Kanten ohne Schutzhandschuhe.",
HarmDE: "Schnittwunden an Haenden und Fingern.",
AffectedDE: "Reinigungspersonal, Bedienpersonal",
ZoneDE: "Zugaengliche Kanten, Siebe, Spuelkammer, Ablaufpumpe",
ISO12100Section: "6.2.2.1",
DefaultSeverity: 1, DefaultExposure: 3,
},
{
ID: "HP2209", NameDE: "Unerwarteter Wiederanlauf bei Wartung/Reinigung", NameEN: "Unexpected restart during maintenance/cleaning",
RequiredComponentTags: []string{"dom_warewashing", "programmable"},
RequiredLifecycles: []string{"maintenance", "cleaning", "fault_clearing"},
GeneratedHazardCats: []string{"safety_function_failure"},
SuggestedMeasureIDs: []string{"M042"},
Priority: 80,
ApplicableLifecycles: []string{"maintenance", "cleaning"},
ScenarioDE: "Waehrend Wartung oder Reinigung laeuft die Maschine durch fehlende Freischaltung (LOTO) oder automatischen Wiederanlauf unerwartet an (Pumpe, Spuelgang).",
TriggerDE: "Kein Freischalten/Sichern gegen Wiedereinschalten; automatischer Wiederanlauf nach Netzunterbrechung.",
HarmDE: "Verbruehung, Quetschen oder elektrischer Schlag durch unerwartet anlaufende Maschine.",
AffectedDE: "Wartungspersonal, Reinigungspersonal",
ZoneDE: "Gesamte Maschine, Pumpe, Antriebe",
ISO12100Section: "6.2.11.4",
DefaultSeverity: 3, DefaultExposure: 2,
},
}
}
@@ -0,0 +1,112 @@
package iace
import "testing"
// firedSet runs the engine for the given custom tags and returns the set of
// fired pattern IDs.
func firedSet(customTags []string) map[string]bool {
engine := NewPatternEngine()
out := engine.Match(MatchInput{CustomTags: customTags})
fired := make(map[string]bool, len(out.MatchedPatterns))
for _, m := range out.MatchedPatterns {
fired[m.PatternID] = true
}
return fired
}
// A warewashing narrative emits these capability + functional tags.
var warewashingTags = []string{
"dom_warewashing", "steam_emission", "hot_water", "high_temperature",
"corrosive_chemical", "access_door", "rotating_part",
}
func TestWarewashing_PatternsFireForDishwasher(t *testing.T) {
fired := firedSet(warewashingTags)
want := []string{"HP2200", "HP2201", "HP2202", "HP2203", "HP2204", "HP2205", "HP2206"}
for _, id := range want {
if !fired[id] {
t.Errorf("expected warewashing pattern %s to fire for a dishwasher, but it did not", id)
}
}
}
func TestWarewashing_PatternsDoNotLeakIntoOtherMachines(t *testing.T) {
// A machine with thermal + electrical + chemical capability but NOT a
// dishwasher must never produce warewashing hazards (dom_warewashing gate).
fired := firedSet([]string{"high_temperature", "electrical_part", "chemical_risk", "rotating_part", "moving_part"})
for _, id := range []string{"HP2200", "HP2201", "HP2202", "HP2203", "HP2204", "HP2205", "HP2206"} {
if fired[id] {
t.Errorf("warewashing pattern %s leaked into a non-dishwasher machine", id)
}
}
}
func TestWarewashing_WeldingAndGlueDoNotLeakIntoDishwasher(t *testing.T) {
// The gate-term additions must stop the welding/flame/glue burn patterns
// from firing for a dishwasher (they previously leaked via high_temperature
// / electrical_part). dom_welding/dom_flame/dom_glue are absent here.
fired := firedSet(warewashingTags)
leak := map[string]string{
"HP530": "Lichtbogen-Verbrennung (Schweissen)",
"HP532": "Schweissrauch",
"HP533": "Brand durch Schweissfunken (Schweissen)",
}
for id, name := range leak {
if fired[id] {
t.Errorf("cross-domain pattern %s (%s) leaked into a dishwasher", id, name)
}
}
}
func TestWarewashing_MeasureIDsExist(t *testing.T) {
lib := GetProtectiveMeasureLibrary()
have := make(map[string]bool, len(lib))
for _, m := range lib {
have[m.ID] = true
}
for _, p := range GetWarewashingPatterns() {
for _, mid := range p.SuggestedMeasureIDs {
if !have[mid] {
t.Errorf("pattern %s references measure %s which is not in the library", p.ID, mid)
}
}
}
}
func TestWarewashing_NarrativeEmitsTags(t *testing.T) {
// Closes the loop: a realistic dishwasher description must emit the tags
// the warewashing patterns gate on (otherwise the patterns are dead).
narrative := "Gewerbliche Untertisch-Geschirrspuelmaschine mit Heisswasser-Boiler " +
"und Nachspuelung ca. 85 Grad C, Spuelpumpe mit rotierenden Spuelfeldern, " +
"Dampf-/Wrasenabgabe beim Oeffnen, Reiniger und Klarspueler ueber Dosiergeraet, " +
"Tuer mit Sicherheitsschalter, Eingreifen in die Spuelkammer."
res := ParseNarrative(narrative, "Gewerbliche Geschirrspuelmaschine")
got := make(map[string]bool, len(res.CustomTags))
for _, tag := range res.CustomTags {
got[tag] = true
}
for _, want := range []string{"dom_warewashing", "steam_emission", "hot_water", "corrosive_chemical", "access_door", "rotating_part"} {
if !got[want] {
t.Errorf("narrative did not emit expected tag %q (got %v)", want, res.CustomTags)
}
}
// And it must NOT emit any welding/flame/glue domain that would re-open leaks.
for _, bad := range []string{"dom_welding", "dom_flame", "dom_glue"} {
if got[bad] {
t.Errorf("dishwasher narrative unexpectedly emitted cross-domain tag %q", bad)
}
}
}
func TestWarewashing_NewMeasuresPresent(t *testing.T) {
lib := GetProtectiveMeasureLibrary()
have := make(map[string]bool, len(lib))
for _, m := range lib {
have[m.ID] = true
}
for _, mid := range []string{"M2200", "M2201", "M2202", "M2203", "M2204", "M2205", "M2206", "M2207", "M2208"} {
if !have[mid] {
t.Errorf("expected warewashing measure %s to be registered in the library", mid)
}
}
}
@@ -88,6 +88,28 @@ func GetKeywordDictionary() []KeywordEntry {
{Keywords: []string{"folienwickler", "wickelmaschine", "konfektioniermaschine", "folienverpackung", "wellpappe"}, ExtraTags: []string{"dom_converting"}},
{Keywords: []string{"bergbau", "untertage", "tunnelbau", "off-grid"}, ExtraTags: []string{"dom_remote"}},
{Keywords: []string{"asbest", "asbestsanierung", "asbestexposition"}, ExtraTags: []string{"dom_asbestos"}},
{Keywords: []string{"gasbrenner", "brennerbetrieb", "offene flamme", "flammhaert", "abflammen", "flammrichten"}, ExtraTags: []string{"dom_flame"}},
{Keywords: []string{"heissleim", "heissleimanlage", "schmelzkleber", "schmelzklebstoff", "klebstoffschmelzer", "leimwerk"}, ExtraTags: []string{"dom_glue"}},
// ── Gewerbliche Spuelmaschine / Warewashing ──────────────────────
// dom_warewashing gates the warewashing-specific patterns
// (hazard_patterns_warewashing.go) so they never leak into other
// machine classes. The functional tags (hot_water, steam_emission,
// corrosive_chemical, access_door) are the within-domain triggers.
{Keywords: []string{"spuelmaschine", "geschirrspuelmaschine", "geschirrspueler", "haubenspuelmaschine", "untertischspuelmaschine", "korbspuelmaschine", "bandspuelmaschine", "glaeserspuelmaschine", "bistrospuelmaschine", "warewashing", "dishwasher"}, ExtraTags: []string{"dom_warewashing"}},
{Keywords: []string{"heisswasser", "nachspuelung", "nachspueltemperatur", "spuelgang", "spuelzyklus", "thermostopp", "thermostop"}, ExtraTags: []string{"hot_water", "high_temperature"}},
{Keywords: []string{"dampf", "wrasen", "schwaden", "brueden"}, ExtraTags: []string{"steam_emission", "high_temperature"}},
{Keywords: []string{"boiler", "spuelboiler", "nachspuelboiler", "tankheiz", "boilerheiz"}, ComponentIDs: []string{"C094"}, ExtraTags: []string{"heating_element", "high_temperature"}},
{Keywords: []string{"reiniger", "klarspueler", "spuelmittel", "reinigungsmittel", "reinigerkonzentrat", "spuelchemie", "dosiergeraet", "dosierpumpe", "sauglanze", "entkalker"}, ExtraTags: []string{"corrosive_chemical"}},
// Spuelarm/Spuelfeld emit only the rotating_part capability tag. They are
// NOT mapped to a library component — C004 is a "Drehtisch" (rotary table)
// and that mislabels the spray arm. Keyword->component must be semantically
// honest (generic hygiene; surfaced by the warewashing GT).
{Keywords: []string{"spuelarm", "spuelfeld", "wascharm", "spruehfeld"}, ExtraTags: []string{"rotating_part"}},
{Keywords: []string{"spuelkammer", "spueltuer", "geraetetuer", "haubentuer", "klapptuer"}, ExtraTags: []string{"access_door"}},
// Frischwasseranschluss an das Trinkwassernetz -> Rueckfluss/Ruecksaug-Risiko (EN 1717).
{Keywords: []string{"rueckfluss", "rueckflussverhinderer", "ruecksaug", "trinkwasser", "frischwasseranschluss", "systemtrenner"}, ExtraTags: []string{"backflow_risk"}},
{Keywords: []string{"scharfe kante", "scharfkant", "blechkante", "scharfe blechkante", "sieb", "siebe"}, ExtraTags: []string{"sharp_edge"}},
// Ghost-Closure (Emit-Seite): macht die 34 toten Required-Tags
// emittierbar, jeweils NUR via domaenenspezifische Keywords -> die 120
// Ghost-Patterns feuern wieder, aber nur fuer ihre echte Maschine (kein
@@ -115,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"}},
@@ -182,6 +204,12 @@ func GetKeywordDictionary() []KeywordEntry {
{Keywords: []string{"lichtgitter", "lichtvorhang", "light curtain", "light grid"}, ComponentIDs: []string{"C102"}, ExtraTags: []string{"safety_device"}},
{Keywords: []string{"sicherheitsschalter", "safety switch"}, ComponentIDs: []string{"C104"}, ExtraTags: []string{"safety_device", "interlocked"}},
{Keywords: []string{"zuhaltung", "guard locking", "interlock"}, ComponentIDs: []string{"C105"}, ExtraTags: []string{"safety_device", "interlocked"}},
// interlocked_enclosure signals that moving parts are inaccessible behind a
// guard that is monitored/locked — feeds the GuardableByEnclosure re-scoping
// (contact/entanglement becomes a maintenance/guard-open hazard, not a
// normal-operation one). Emitted only by explicit "interlocked door/guard"
// vocabulary so it does not trigger for machines with exposed motion.
{Keywords: []string{"tuer mit sicherheitsschalter", "verriegelte tuer", "verriegelte haube", "verriegelte einhausung", "sicherheitstuer", "tuerverriegelung", "haube mit sicherheitsschalter"}, ExtraTags: []string{"interlocked_enclosure"}},
{Keywords: []string{"zweihand", "two-hand", "zweihandschaltung"}, ComponentIDs: []string{"C106"}, ExtraTags: []string{"safety_device", "two_hand_control_required"}},
{Keywords: []string{"schaltmatte", "safety mat"}, ComponentIDs: []string{"C108"}, ExtraTags: []string{"safety_device"}},
{Keywords: []string{"seilzug", "pull wire"}, ComponentIDs: []string{"C109"}, ExtraTags: []string{"safety_device"}},
@@ -194,7 +222,9 @@ func GetKeywordDictionary() []KeywordEntry {
// ── Absaugung / Umwelt ──────────────────────────────────────────
{Keywords: []string{"absaug", "extraction", "abscheider"}, ComponentIDs: []string{"C124"}, ExtraTags: []string{"noise_source"}},
{Keywords: []string{"filter", "filteranlage"}, ComponentIDs: []string{"C124"}, ExtraTags: []string{}},
// "filteranlage" only — bare "filter" falsely mapped any filter (Laugen-,
// Wasser-, Oel-, Netzfilter) to the oil-mist extractor C124.
{Keywords: []string{"filteranlage"}, ComponentIDs: []string{"C124"}, ExtraTags: []string{}},
// ── IT / Netzwerk ───────────────────────────────────────────────
{Keywords: []string{"switch", "netzwerk"}, ComponentIDs: []string{"C111"}, ExtraTags: []string{"networked"}},
@@ -223,7 +253,10 @@ func GetKeywordDictionary() []KeywordEntry {
{Keywords: []string{"biege", "bend"}, ComponentIDs: []string{"C019"}, ExtraTags: []string{"high_force"}},
{Keywords: []string{"stanz", "stamp", "punch"}, ComponentIDs: []string{"C018"}, ExtraTags: []string{"high_force", "crush_point"}},
{Keywords: []string{"heiz", "heater", "heating"}, ComponentIDs: []string{"C094"}, EnergyIDs: []string{"EN06"}, ExtraTags: []string{"high_temperature"}},
{Keywords: []string{"kuehl", "cool"}, ComponentIDs: []string{"C095"}, ExtraTags: []string{}},
// Cooling UNIT only — not the bare adjectives "kuehl"/"cool", which falsely
// matched product-variant names ("Cool-Ausfuehrung") and outputs ("kuehle
// Glaeser"). Keyword->component must name an actual component.
{Keywords: []string{"kuehlaggregat", "kuehlanlage", "kuehler", "kaeltemaschine", "chiller", "rueckkuehl"}, ComponentIDs: []string{"C095"}, ExtraTags: []string{}},
{Keywords: []string{"luefter", "fan", "geblaese"}, ComponentIDs: []string{"C096"}, ExtraTags: []string{"rotating_part", "noise_source"}},
{Keywords: []string{"spannvorrichtung", "fixture", "clamp"}, ComponentIDs: []string{"C100"}, ExtraTags: []string{"clamping_part"}},
@@ -22,6 +22,7 @@ func GetProtectiveMeasureLibrary() []ProtectiveMeasureEntry {
all = append(all, getGTBremseMeasures()...) // GT-Bremse-Coverage-Gaps (M483-M522)
all = append(all, GetCRAMeasures()...) // CRA / DIN EN 40000-1-2 cyber-resilience (M540-M548)
all = append(all, getLiftEndstopMeasures()...) // Lift/hoist endstop (M600-M604) — bridges OSHA MD library
all = append(all, getWarewashingMeasures()...) // Commercial dishwasher (M2200-M2208) — scald/chemical/door/slip
return all
}
@@ -0,0 +1,75 @@
package iace
// getWarewashingMeasures returns protective measures for commercial warewashing
// machines (gewerbliche Geschirrspuelmaschinen): hot-water/steam scalding,
// hot surfaces, corrosive cleaning chemicals, door pinch and wet-floor slip.
// They complement the generic thermal/mechanical/material measures with the
// machine-specific controls a Fachmann expects for this product class.
//
// M-ID range: M2200-M2208. Norm identifiers only (facts) — no norm text is
// reproduced (DIN/Beuth license). Lead standard: EN 60335-2-58 (safety of
// commercial electric dishwashing machines).
func getWarewashingMeasures() []ProtectiveMeasureEntry {
return []ProtectiveMeasureEntry{
{ID: "M2200", ReductionType: "design", SubType: "interlock",
Name: "Tuer-/Haubenverriegelung beendet Spuelgang vor dem Oeffnen",
Description: "Die Tuer bzw. Haube ist so mit der Steuerung verriegelt, dass beim Oeffnen Spuelpumpe und Nachspuelung sofort abschalten und ein Oeffnen erst nach Programmende (bzw. nach Abbau des Restdampfs) freigegeben wird. Verhindert den Schwall aus Heisswasser/Wrasen und den Kontakt mit noch rotierenden Spuelfeldern.",
HazardCategory: "thermal",
Examples: []string{"Tuerkontaktschalter schaltet Pumpe + Heizung beim Oeffnen ab", "Rastposition mit Restdampf-Verzoegerung vor Freigabe"},
NormReferences: []string{"EN 60335-2-58", "EN ISO 12100 — Inhaerent sichere Konstruktion"}},
{ID: "M2201", ReductionType: "design", SubType: "thermal",
Name: "Wrasen-/Dampfreduzierung (Kondensations- / Waermerueckgewinnungssystem)",
Description: "Der beim Oeffnen austretende Wrasen wird durch ein Kondensations- bzw. Waermerueckgewinnungssystem reduziert, sodass beim Entnehmen kein gefaehrlicher Dampfschwall entsteht. Senkt zugleich die Restwaerme- und Feuchtebelastung am Arbeitsplatz.",
HazardCategory: "thermal",
Examples: []string{"Umluft-Waermerueckgewinnung reduziert austretenden Wrasen", "Kondensationshaube ueber der Spuelkammer"},
NormReferences: []string{"EN 60335-2-58"}},
{ID: "M2202", ReductionType: "protection", SubType: "monitoring",
Name: "Thermostop / Temperaturueberwachung von Boiler und Tank",
Description: "Boiler- und Tanktemperatur werden ueberwacht; ein Thermostop gibt den naechsten Schritt erst frei, wenn die Solltemperatur erreicht ist, und begrenzt die maximale Nachspueltemperatur. Schuetzt vor Verbruehung durch unkontrolliert heisses Nachspuelwasser.",
HazardCategory: "thermal",
Examples: []string{"Temperatursensor in Boiler und Tank mit Abschaltgrenze", "Thermostop-Funktion im Spuelprogramm"},
NormReferences: []string{"EN 60335-2-58", "EN ISO 13732-1"}},
{ID: "M2203", ReductionType: "design", SubType: "containment",
Name: "Geschlossenes Dosiersystem mit Sauglanzen und Niveauueberwachung",
Description: "Reiniger und Klarspueler werden ausschliesslich ueber ein geschlossenes Dosiersystem mit Sauglanzen aus dem Originalgebinde gefoerdert (Niveau-Ueberwachung statt Umfuellen). Direkter Haut-/Augenkontakt mit dem aetzenden Konzentrat beim Nachfuellen wird konstruktiv vermieden.",
HazardCategory: "material_environmental",
Examples: []string{"Sauglanze mit Leermeldung im Reiniger-Kanister", "Kein Umfuellen — Gebindewechsel ohne offenen Chemiekontakt"},
NormReferences: []string{"EN 60335-2-58", "Verordnung (EG) Nr. 1272/2008 (CLP/GHS)"}},
{ID: "M2204", ReductionType: "information", SubType: "ppe",
Name: "PSA (Augen-/Hautschutz) + GHS-Kennzeichnung und Sicherheitsdatenblatt",
Description: "Fuer Handhabung, Gebindewechsel und Entkalkung werden Augen- und Handschutz vorgeschrieben; Reiniger/Klarspueler/Entkalker sind GHS-gekennzeichnet und das Sicherheitsdatenblatt liegt am Geraet vor. Stellt die sichere Handhabung der aetzenden Konzentrate sicher.",
HazardCategory: "material_environmental",
Examples: []string{"Schutzbrille + chemikalienbestaendige Handschuhe bei Gebindewechsel", "GHS-Etikett und SDB im Chemikalienschrank am Geraet"},
NormReferences: []string{"Verordnung (EG) Nr. 1272/2008 (CLP/GHS)", "TRGS 500"}},
{ID: "M2205", ReductionType: "protection", SubType: "ventilation",
Name: "Be-/Entlueftung bzw. geschlossene Haube gegen Chemie-Aerosole und Wrasen",
Description: "Der Aufstellbereich ist ausreichend be- und entlueftet bzw. die Spuelkammer bleibt waehrend des Programms geschlossen, sodass Reinigungs-Aerosole und heisser Wrasen nicht in die Atemzone des Bedieners gelangen.",
HazardCategory: "material_environmental",
Examples: []string{"Kuechenlueftung ueber dem Spuelbereich", "Programmstart nur bei geschlossener Haube"},
NormReferences: []string{"EN 60335-2-58", "TRGS 500"}},
{ID: "M2206", ReductionType: "design", SubType: "geometry",
Name: "Tuerkanten mit geringer Schliesskraft / Einklemmschutz",
Description: "Die Tuer-/Haubenmechanik ist so gestaltet (gefuehrte Bewegung, begrenzte Schliesskraft, abgerundete Kanten), dass beim Schliessen keine Finger gequetscht werden.",
HazardCategory: "mechanical",
Examples: []string{"Gefuehrte Haube mit gedaempfter Schliessbewegung", "Abgerundete Tuerkanten ohne Quetschspalt"},
NormReferences: []string{"EN 60335-2-58", "EN ISO 12100 — Geometrie und Anordnung"}},
{ID: "M2207", ReductionType: "design", SubType: "environment",
Name: "Rutschhemmender Bodenbelag + Ablauf/Leckagewanne im Aufstellbereich",
Description: "Im Aufstell- und Bedienbereich der Spuelmaschine sorgen rutschhemmender Bodenbelag und ein definierter Ablauf bzw. eine Leckagewanne dafuer, dass austretendes Wasser nicht zur Sturzgefahr wird.",
HazardCategory: "mechanical",
Examples: []string{"Rutschhemmender Industrieboden (Bewertungsgruppe R11/R12)", "Bodenablauf bzw. Leckagewanne unter dem Geraet"},
NormReferences: []string{"ASR A1.5/1,2", "DGUV Regel 108-003"}},
{ID: "M2208", ReductionType: "information", SubType: "signage",
Name: "Warnhinweis heisser Dampf/Heisswasser — Tuer erst nach Programmende oeffnen",
Description: "Am Geraet und in der Betriebsanleitung wird vor heissem Dampf und Heisswasser gewarnt und das Oeffnen der Tuer erst nach Programmende mit vorsichtigem Anheben vorgeschrieben. Sprachneutrale Piktogramme ergaenzen den Hinweis.",
HazardCategory: "general",
Examples: []string{"Warnpiktogramm 'Heisser Dampf' an der Tuer", "BA-Hinweis 'Tuer nach Programmende langsam oeffnen'"},
NormReferences: []string{"ISO 7010", "EN 60335-2-58"}},
{ID: "M2209", ReductionType: "design", SubType: "containment",
Name: "Rueckflussverhinderer / Systemtrenner nach EN 1717",
Description: "Der Frischwasseranschluss ist durch einen Rueckflussverhinderer bzw. Systemtrenner der passenden Schutzklasse oder durch einen freien Auslauf gegen Ruecksaugen verschmutzten Wassers in das Trinkwassernetz gesichert.",
HazardCategory: "material_environmental",
Examples: []string{"Systemtrenner Typ BA nach EN 1717", "Freier Auslauf Typ AB ueber dem hoechsten Wasserstand"},
NormReferences: []string{"EN 1717", "EN 60335-2-58"}},
}
}
@@ -46,6 +46,20 @@ var domainGateTerms = map[string]string{
"widerstandsschweiss": "dom_welding", "lichtbogenschweiss": "dom_welding",
"schutzgasschweiss": "dom_welding", "punktschweiss": "dom_welding",
"schweisselektrod": "dom_welding", "elektrodenspalt": "dom_welding",
// Schweissen — Oberflaechenformen die bisher ungegatet leakten (z.B. in
// thermische Hazards einer Spuelmaschine ueber high_temperature/electrical_part)
"schweissarbeitsplatz": "dom_welding", "schweissfunke": "dom_welding",
"schweisshelm": "dom_welding", "schweisserschutz": "dom_welding",
"lichtbogenzone": "dom_welding", "lichtbogen-verbrennung": "dom_welding",
"schweissrauch": "dom_welding", "schweissgeraet": "dom_welding",
"schweisszone": "dom_welding", "schweissbrenner": "dom_welding",
"schweissspritzer": "dom_welding", "schweissstrom": "dom_welding",
// Offene Flamme / Brenner (Gasbrenner, Flammhaerten, Abflammen)
"offene flamme": "dom_flame", "brennerbereich": "dom_flame",
"flammenzone": "dom_flame", "gasbrenner": "dom_flame",
// Heissleim / Schmelzkleber
"heissleimanlage": "dom_glue", "klebstoffschmelzer": "dom_glue",
"heisskleber": "dom_glue", "schmelzkleber": "dom_glue",
// Solar / PV
"pv-modul": "dom_solar", "photovoltaik": "dom_solar", "pv-anlage": "dom_solar",
"dc-steckverbindung": "dom_solar", "solarmodul": "dom_solar",
@@ -53,6 +67,7 @@ var domainGateTerms = map[string]string{
"gondel": "dom_wind", "rotorblatt": "dom_wind", "windenergieanlage": "dom_wind",
// CNC / Zerspanung
"drehmaschine": "dom_cnc", "fraesmaschine": "dom_cnc",
"spanende": "dom_cnc", "spanenden bearbeitung": "dom_cnc",
// Landwirtschaft
"maehdrescher": "dom_agri", "ballenpresse": "dom_agri", "feldhaecksler": "dom_agri",
// Roll-/Fahrtreppe
@@ -0,0 +1,70 @@
package iace
// Interlocked-enclosure model (EN ISO 14120 / EN ISO 12100).
//
// A contact or entanglement hazard from a moving part is removed during NORMAL
// operation when that part is inaccessible behind an interlocked guard. The
// hazard then remains only when the guard is open — maintenance, cleaning or
// fault clearing. Patterns flagged GuardableByEnclosure express this; a project
// emits the "interlocked_enclosure" tag (interlocked door/hood, see
// keyword_dictionary.go) to declare the guard.
//
// This is GENERIC: it applies to every enclosed machine (dishwasher spray arm,
// enclosed mixer, centrifuge ...) and is regression-safe — machines that do not
// emit interlocked_enclosure are unaffected.
const (
phaseMaintenance = "maintenance"
phaseCleaning = "cleaning"
phaseFaultClearing = "fault_clearing"
)
// suppressedByEnclosure reports whether a guardable hazard must be dropped: the
// part is enclosed AND none of the project's lifecycle phases opens the guard.
func suppressedByEnclosure(p HazardPattern, tagSet map[string]bool, lifecycles []string) bool {
if !p.GuardableByEnclosure || !tagSet["interlocked_enclosure"] || len(lifecycles) == 0 {
return false
}
for _, lc := range lifecycles {
if lc == phaseMaintenance || lc == phaseCleaning || lc == phaseFaultClearing {
return false // guard is open in some phase → hazard remains there
}
}
return true
}
// guardedLifecycles re-scopes a guardable hazard to the guard-open phases when
// the project declares an interlocked enclosure, so it is documented as a
// maintenance/cleaning hazard rather than a normal-operation one.
func guardedLifecycles(p HazardPattern, tagSet map[string]bool) []string {
if p.GuardableByEnclosure && tagSet["interlocked_enclosure"] {
return []string{phaseMaintenance, phaseCleaning}
}
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]
}
@@ -223,7 +223,7 @@ func (e *PatternEngine) Match(input MatchInput) *MatchOutput {
HumanRoles: p.HumanRoles,
GeneratedHazardType: p.GeneratedHazardType,
MatchedFailureModes: matchedFMs,
ApplicableLifecycles: p.ApplicableLifecycles,
ApplicableLifecycles: guardedLifecycles(p, tagSet),
SuggestedMeasureIDs: p.SuggestedMeasureIDs,
ClarificationQuestionsDE: p.ClarificationQuestionsDE,
ISO12100Section: p.ISO12100Section,
@@ -411,6 +411,16 @@ func patternMatches(p HazardPattern, tagSet map[string]bool, input MatchInput) b
}
}
// Interlocked-enclosure gate (guardable contact/entanglement). See pattern_enclosure.go.
if suppressedByEnclosure(p, tagSet, input.LifecyclePhases) {
return false
}
// Domain-specific supersession (generic duplicate replaced by a precise one).
if supersededByDomainSpecific(p, tagSet) {
return false
}
return true
}
@@ -44,6 +44,7 @@ func collectAllPatterns() []HazardPattern {
patterns = append(patterns, GetCRAPatterns()...) // HP1910-HP1918 CRA / DIN EN 40000-1-2 cyber-resilience spur
patterns = append(patterns, GetSecondaryHarmDemoPatterns()...) // HP2000-HP2001 secondary harm chain demos (Cola splitter, Pharma)
patterns = append(patterns, GetLiftEndstopPatterns()...) // HP2100-HP2102 lift body-part crush at endstops
patterns = append(patterns, GetWarewashingPatterns()...) // HP2200-HP2206 commercial dishwasher (scald/chemical/door/slip)
patterns = applyMachineTypeOverrides(patterns) // Fill MachineTypes on legacy patterns to prevent drift
patterns = applyDomainGates(patterns) // Capability-domain gate: stop domain-specific patterns leaking cross-machine
return patterns
@@ -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 &registryCompleter{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,383 @@
{
"machine_name": "Gewerbliche Untertisch-Geschirrspuelmaschine (Winterhalter UC-M)",
"machine_description": "Untertisch-Gewerbespuelmaschine, vernetzt (Connected Wash), Heisswasser-Boiler, Spuelpumpe mit rotierenden Spuelfeldern, Tuer mit Sicherheitsschalter, Reiniger-/Klarspueler-Dosierung.",
"source": "Selbstbewertung GT #3 (Fachmann-Erwartung, EN 60335-2-58 + EN ISO 12100)",
"version": "1.0",
"entries": [
{
"nr": "1.1",
"hazard_group": "Thermische Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Verbrühung durch Heißwasser und Dampf",
"hazard_cause": "Beim Öffnen der Tür während oder kurz nach dem Spülgang tritt heißes Wasser und Wrasen (Dampf) aus der Spülkammer aus und trifft Gesicht, Hände und Arme",
"lifecycle_phases": ["Betrieb", "Reinigung"],
"component_zone": "Tür und Beschickungsöffnung der Spülkammer",
"risk_in": {"f": 4, "w": 3, "p": 2, "s": 3, "r": 27},
"measures": ["Türverriegelung beendet Spülgang vor dem Öffnen", "Wrasen-/Dampfreduzierung", "Warnhinweis heißer Dampf"],
"measure_type": "KM",
"risk_out": {"f": 2, "w": 1, "p": 1, "s": 2, "r": 8},
"norm_references": ["EN 60335-2-58"],
"sufficient": true
},
{
"nr": "1.2",
"hazard_group": "Thermische Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Verbrennung an heißen Oberflächen",
"hazard_cause": "Berührung heißer Oberflächen von Boiler, Tankheizkörper oder Spülkammerwänden bei Reinigung, Entkalkung oder Wartung",
"lifecycle_phases": ["Reinigung", "Instandhaltung"],
"component_zone": "Boiler, Tankheizkörper, Spülkammerwände",
"risk_in": {"f": 3, "w": 2, "p": 2, "s": 2, "r": 14},
"measures": ["Temperaturbegrenzung zugänglicher Oberflächen", "Warnhinweis heiße Oberfläche"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 6},
"norm_references": ["EN ISO 13732-1"],
"sufficient": true
},
{
"nr": "1.3",
"hazard_group": "Thermische Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Verbrennung an heißem Spülgut",
"hazard_cause": "Geschirr und Gläser sind nach der Heißwasser-Nachspülung sehr heiß, beim Entladen kommt es zu Verbrennungen an den Händen",
"lifecycle_phases": ["Betrieb"],
"component_zone": "Spülkammer, Entnahmebereich, Korb",
"risk_in": {"f": 3, "w": 3, "p": 2, "s": 2, "r": 16},
"measures": ["Abkühl-/Trocknungszeit", "Warnhinweis heißes Spülgut"],
"measure_type": "BI",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 6},
"norm_references": ["EN 60335-2-58"],
"sufficient": true
},
{
"nr": "2.1",
"hazard_group": "Gefährdungen durch Materialien und Substanzen",
"hazard_group_applicable": true,
"hazard_type": "Verätzung von Haut und Augen durch Reiniger-/Klarspüler-Konzentrat",
"hazard_cause": "Direkter Kontakt mit dem ätzenden Reiniger- bzw. Klarspüler-Konzentrat beim Nachfüllen, Sauglanzenwechsel oder bei Leckage des Dosiergeräts",
"lifecycle_phases": ["Betrieb", "Instandhaltung"],
"component_zone": "Dosiergerät, Reiniger- und Klarspüler-Gebinde, Sauglanzen",
"risk_in": {"f": 3, "w": 3, "p": 2, "s": 3, "r": 24},
"measures": ["Geschlossenes Dosiersystem mit Sauglanzen", "PSA Augen-/Hautschutz", "GHS-Kennzeichnung und Sicherheitsdatenblatt"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9},
"norm_references": ["Verordnung (EG) Nr. 1272/2008", "TRGS 500"],
"sufficient": true
},
{
"nr": "2.2",
"hazard_group": "Gefährdungen durch Materialien und Substanzen",
"hazard_group_applicable": true,
"hazard_type": "Reizung der Atemwege durch Reinigungs-Aerosole und Dämpfe",
"hazard_cause": "Einatmen von Aerosolen und Dämpfen der Reinigungschemie beim Öffnen kurz nach dem Spülgang oder bei der Entkalkung mit Säure",
"lifecycle_phases": ["Betrieb", "Instandhaltung"],
"component_zone": "Atemzone vor der Spülkammer, Aufstellbereich",
"risk_in": {"f": 2, "w": 2, "p": 2, "s": 2, "r": 12},
"measures": ["Be-/Entlüftung", "geschlossene Haube", "Warnung vor Vermischen von Reiniger und Säure"],
"measure_type": "BI",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 6},
"norm_references": ["TRGS 500"],
"sufficient": true
},
{
"nr": "3.1",
"hazard_group": "Elektrische Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Elektrischer Schlag in Nassumgebung",
"hazard_cause": "Berührung spannungsführender Teile bei unzureichendem IP-Schutz, defekten Kabeldurchführungen oder Feuchtigkeit im Steuerungsgehäuse",
"lifecycle_phases": ["Betrieb", "Reinigung", "Instandhaltung"],
"component_zone": "Steuerungsgehäuse, Kabelübergänge, Antriebsgehäuse",
"risk_in": {"f": 3, "w": 2, "p": 3, "s": 4, "r": 32},
"measures": ["IP-Schutz gegen eindringendes Wasser", "Fehlerstrom-Schutzeinrichtung (RCD)"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 4, "r": 12},
"norm_references": ["IEC 60335-1"],
"sufficient": true
},
{
"nr": "3.2",
"hazard_group": "Elektrische Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Kurzschluss und Brand bei Reinigung am Schaltschrank",
"hazard_cause": "Reinigung ohne vorherige Freischaltung oder mit Hochdruckreiniger am elektrisch aktiven Schaltschrank führt zu Kurzschluss und Brand",
"lifecycle_phases": ["Reinigung", "Instandhaltung"],
"component_zone": "Schaltschrank, elektrisch aktive Komponenten",
"risk_in": {"f": 2, "w": 2, "p": 2, "s": 3, "r": 18},
"measures": ["Netztrenneinrichtung", "Warnhinweis Reinigung nur spannungsfrei, kein Hochdruckreiniger"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9},
"norm_references": ["IEC 60204-1"],
"sufficient": true
},
{
"nr": "3.3",
"hazard_group": "Elektrische Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Motorüberlast mit Überhitzung",
"hazard_cause": "Blockierter oder überlasteter Pumpenmotor überhitzt, Wicklungsbrand und Rauchentwicklung",
"lifecycle_phases": ["Betrieb"],
"component_zone": "Motorgehäuse, Umgebung",
"risk_in": {"f": 2, "w": 2, "p": 2, "s": 2, "r": 12},
"measures": ["Überstromschutz", "Motorschutzschalter"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 6},
"norm_references": ["IEC 60204-1"],
"sufficient": true
},
{
"nr": "4.1",
"hazard_group": "Mechanische Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Ausrutschen auf nassem Boden",
"hazard_cause": "Aus der Spülmaschine austretendes Wasser durch Leckage oder beim Öffnen macht den Boden im Aufstellbereich rutschig, Person rutscht aus und stürzt",
"lifecycle_phases": ["Betrieb", "Reinigung", "Instandhaltung"],
"component_zone": "Aufstell- und Bedienbereich der Spülmaschine",
"risk_in": {"f": 3, "w": 3, "p": 2, "s": 2, "r": 16},
"measures": ["Rutschhemmender Bodenbelag", "Bodenablauf bzw. Leckagewanne"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 6},
"norm_references": ["ASR A1.5/1,2"],
"sufficient": true
},
{
"nr": "4.2",
"hazard_group": "Mechanische Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Quetschen der Finger an der Tür/Haube",
"hazard_cause": "Beim Schließen der Tür bzw. Absenken der Haube werden Finger zwischen Tür/Haube und Gehäuse gequetscht",
"lifecycle_phases": ["Betrieb"],
"component_zone": "Tür- und Haubenkante, Schließbereich",
"risk_in": {"f": 3, "w": 2, "p": 2, "s": 1, "r": 7},
"measures": ["Geringe Schließkraft, Einklemmschutz", "Abgerundete Türkanten"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 1, "r": 3},
"norm_references": ["EN ISO 12100"],
"sufficient": true
},
{
"nr": "4.3",
"hazard_group": "Mechanische Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Kontakt mit rotierendem Spülarm bei geöffneter Tür",
"hazard_cause": "Eingreifen in die Spülkammer bei noch nachlaufendem rotierendem Spülarm/Spülfeld nach dem Öffnen der Tür",
"lifecycle_phases": ["Betrieb", "Reinigung"],
"component_zone": "Spülkammer, Spülarm und Spülfeld",
"risk_in": {"f": 2, "w": 2, "p": 2, "s": 1, "r": 6},
"measures": ["Türverriegelung stoppt Spülarm beim Öffnen"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 1, "r": 3},
"norm_references": ["EN ISO 12100"],
"sufficient": true
},
{
"nr": "5.1",
"hazard_group": "Ergonomische Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Belastung des Bewegungsapparats durch wiederholte Be- und Entladung",
"hazard_cause": "Wiederholtes Heben und Bücken beim manuellen Be- und Entladen der Spülkörbe am Untertischgerät",
"lifecycle_phases": ["Betrieb"],
"component_zone": "Be- und Entladestelle, Spülkorb",
"risk_in": {"f": 4, "w": 3, "p": 2, "s": 1, "r": 9},
"measures": ["Ergonomische Arbeitshöhe", "Be-/Entladung auf günstiger Greifhöhe"],
"measure_type": "KM",
"risk_out": {"f": 2, "w": 1, "p": 1, "s": 1, "r": 4},
"norm_references": ["EN 1005-2"],
"sufficient": true
},
{
"nr": "5.2",
"hazard_group": "Ergonomische Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Zwangshaltung durch ungünstige Bedienelement-Position",
"hazard_cause": "Bedienelemente am HMI außerhalb der ergonomisch günstigen Reichweite führen bei dauerhafter Bedienung zu Zwangshaltung",
"lifecycle_phases": ["Betrieb"],
"component_zone": "Bedienstand HMI, Steuerpult",
"risk_in": {"f": 3, "w": 2, "p": 1, "s": 1, "r": 6},
"measures": ["Bedienelemente in ergonomisch günstiger Höhe"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 1, "r": 3},
"norm_references": ["EN 894-3"],
"sufficient": true
},
{
"nr": "6.1",
"hazard_group": "zusätzliche Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Verlust einer Sicherheitsfunktion durch Steuerungs- oder Softwarefehler",
"hazard_cause": "Steuerungs- oder Softwarefehler der eigenen Maschinensteuerung führt zu unkontrolliertem Verhalten oder Verlust einer Sicherheitsfunktion",
"lifecycle_phases": ["Betrieb", "Instandhaltung"],
"component_zone": "Gesamte Maschine, Steuerung",
"risk_in": {"f": 2, "w": 2, "p": 2, "s": 3, "r": 18},
"measures": ["Sichere Fehlerbehandlung", "Sichere Software-Fallbacks", "Watchdog"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9},
"norm_references": ["EN ISO 13849-1"],
"sufficient": true
},
{
"nr": "6.2",
"hazard_group": "zusätzliche Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Verlust der Sicherheitsfunktion nach fehlerhaftem Software-Update",
"hazard_cause": "Korrupte oder inkompatible Firmware nach fehlerhaftem Update über die USB-Schnittstelle lässt die Steuerung undefiniert verhalten oder Sicherheitsfunktion verlieren",
"lifecycle_phases": ["Instandhaltung"],
"component_zone": "Gesamte Maschine, Steuerung, Update-Schnittstelle",
"risk_in": {"f": 2, "w": 2, "p": 2, "s": 3, "r": 18},
"measures": ["Atomares Update mit Rückfall auf lauffähige Version", "Kompatibilitätsprüfung vor Update"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9},
"norm_references": ["EN ISO 13849-1"],
"sufficient": true
},
{
"nr": "4.4",
"hazard_group": "Mechanische Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Erfassen/Aufwickeln an rotierenden Teilen bei geöffneter Schutztür",
"hazard_cause": "Bei geöffneter Tür im Wartungs- oder Reinigungsfall können lose Kleidung oder Haare an noch zugänglichen rotierenden Wellen erfasst und aufgewickelt werden",
"lifecycle_phases": ["Instandhaltung", "Reinigung"],
"component_zone": "Rotierende Wellen, Spülarm bei geöffneter Schutztür",
"risk_in": {"f": 1, "w": 1, "p": 2, "s": 3, "r": 12},
"measures": ["Rotation stoppt bei geöffneter Tür durch Verriegelung", "Warnhinweis"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 6},
"norm_references": ["EN ISO 14120"],
"sufficient": true
},
{
"nr": "4.5",
"hazard_group": "Mechanische Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Reibung/Hautabschürfung an rotierenden Teilen bei geöffneter Schutztür",
"hazard_cause": "Berührung rotierender Wellen oder Oberflächen bei geöffneter Tür im Wartungsfall führt zu Hautabschürfungen durch Reibung",
"lifecycle_phases": ["Instandhaltung"],
"component_zone": "Rotierende Welle bei geöffneter Schutztür",
"risk_in": {"f": 1, "w": 1, "p": 2, "s": 2, "r": 8},
"measures": ["Rotation stoppt bei geöffneter Tür durch Verriegelung"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 4},
"norm_references": ["EN ISO 14120"],
"sufficient": true
},
{
"nr": "1.4",
"hazard_group": "Thermische Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Trockenlauf-Überhitzung von Boiler/Heizung",
"hazard_cause": "Das Heizelement bzw. der Boiler läuft bei Wassermangel trocken, überhitzt und kann einen Brand oder eine Verbrühung durch überhitztes Wasser auslösen",
"lifecycle_phases": ["Betrieb"],
"component_zone": "Boiler, Tankheizkörper, Heizelement",
"risk_in": {"f": 2, "w": 2, "p": 2, "s": 3, "r": 18},
"measures": ["Trockengehschutz / Niveauüberwachung der Heizung", "Temperaturbegrenzer (STB)"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9},
"norm_references": ["EN 60335-2-58", "EN 60335-1"],
"sufficient": true
},
{
"nr": "3.4",
"hazard_group": "Elektrische Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Restspannung / gespeicherte elektrische Energie nach Abschalten",
"hazard_cause": "Nach dem Abschalten der Spannungsversorgung stehen durch Kondensatoren im Frequenzumrichter oder Netzfilter noch gefährliche Berührungsspannungen an",
"lifecycle_phases": ["Instandhaltung", "Fehlersuche und -beseitigung"],
"component_zone": "Frequenzumrichter, Netzfilter, Schaltschrank",
"risk_in": {"f": 1, "w": 2, "p": 3, "s": 4, "r": 24},
"measures": ["Sichere Energieentladung nach Abschalten", "Warnhinweis Restspannung, Entladezeit abwarten"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 4, "r": 12},
"norm_references": ["IEC 60204-1"],
"sufficient": true
},
{
"nr": "4.6",
"hazard_group": "Mechanische Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Schnittverletzung an scharfen Kanten",
"hazard_cause": "Schneiden an scharfen Blechkanten, Sieben oder dem Ablaufpumpen-Laufrad beim Reinigen oder Eingreifen in die Spülkammer",
"lifecycle_phases": ["Reinigung", "Instandhaltung"],
"component_zone": "Zugängliche Kanten, Siebe, Spülkammer, Ablaufpumpe",
"risk_in": {"f": 3, "w": 2, "p": 2, "s": 1, "r": 7},
"measures": ["Brechen oder Runden aller zugänglichen Kanten"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 1, "r": 3},
"norm_references": ["EN ISO 12100"],
"sufficient": true
},
{
"nr": "4.7",
"hazard_group": "Mechanische Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Kippen / mangelnde Standsicherheit",
"hazard_cause": "Unzureichende Standsicherheit bei Untertischmontage, Transport oder Installation führt zum Kippen oder Umstürzen der Maschine",
"lifecycle_phases": ["Transport", "Montage und Installation"],
"component_zone": "Gesamte Maschine, Aufstellbereich",
"risk_in": {"f": 1, "w": 1, "p": 2, "s": 2, "r": 8},
"measures": ["Standsichere Aufstellung / Befestigung", "Kippsichere Konstruktion"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 4},
"norm_references": ["EN ISO 12100"],
"sufficient": true
},
{
"nr": "2.3",
"hazard_group": "Gefährdungen durch Materialien und Substanzen",
"hazard_group_applicable": true,
"hazard_type": "Rückfluss / Kontamination des Trinkwassers",
"hazard_cause": "Verschmutztes Spül- oder Chemiewasser wird ohne Rückflussverhinderer in das Trinkwassernetz zurückgesaugt und kontaminiert es",
"lifecycle_phases": ["Betrieb"],
"component_zone": "Frischwasseranschluss, Wasserzulauf",
"risk_in": {"f": 2, "w": 2, "p": 2, "s": 3, "r": 18},
"measures": ["Rückflussverhinderer / Systemtrenner nach EN 1717", "Freier Auslauf"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9},
"norm_references": ["EN 1717", "EN 60335-2-58"],
"sufficient": true
},
{
"nr": "2.4",
"hazard_group": "Gefährdungen durch Materialien und Substanzen",
"hazard_group_applicable": true,
"hazard_type": "Mikrobielle Belastung / Legionellen im Stehwasser",
"hazard_cause": "Stehwasser im Boiler oder Tank bei niedrigen Temperaturen begünstigt mikrobielles Wachstum und Legionellen, die über Aerosole eingeatmet werden",
"lifecycle_phases": ["Betrieb", "Instandhaltung"],
"component_zone": "Boiler, Tank, Stehwasser",
"risk_in": {"f": 1, "w": 1, "p": 2, "s": 3, "r": 12},
"measures": ["Thermische Desinfektion / ausreichende Wassertemperatur", "Regelmäßiger Wasserwechsel"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9},
"norm_references": ["EN 60335-2-58"],
"sufficient": true
},
{
"nr": "6.3",
"hazard_group": "zusätzliche Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Versagen der Tür-/Schutzeinrichtungs-Verriegelung",
"hazard_cause": "Die Verriegelung des Tür-Sicherheitsschalters versagt oder wird überbrückt, sodass der Zugriff in die Spülkammer bei laufendem Spülgang (Heißwasser, rotierender Spülarm) möglich wird",
"lifecycle_phases": ["Betrieb", "Instandhaltung"],
"component_zone": "Tür-Sicherheitsschalter, Verriegelung, Spülkammer",
"risk_in": {"f": 3, "w": 2, "p": 2, "s": 3, "r": 21},
"measures": ["Sichere Verriegelung mit Fehlerüberwachung (PL nach ISO 13849)", "Zwangsöffnende Kontakte"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9},
"norm_references": ["EN ISO 14119", "EN ISO 13849-1"],
"sufficient": true
},
{
"nr": "6.4",
"hazard_group": "zusätzliche Gefährdungen",
"hazard_group_applicable": true,
"hazard_type": "Unerwarteter Wiederanlauf bei Wartung",
"hazard_cause": "Während Wartung oder Reinigung läuft die Maschine durch fehlende Freischaltung (LOTO) oder automatischen Wiederanlauf unerwartet an",
"lifecycle_phases": ["Instandhaltung", "Reinigung"],
"component_zone": "Gesamte Maschine, Antriebe, Pumpe",
"risk_in": {"f": 2, "w": 2, "p": 2, "s": 3, "r": 18},
"measures": ["Freischalten und gegen Wiedereinschalten sichern (LOTO)", "Kein automatischer Wiederanlauf"],
"measure_type": "KM",
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9},
"norm_references": ["IEC 60204-1", "EN ISO 12100"],
"sufficient": true
}
]
}
+21 -4
View File
@@ -9,8 +9,8 @@ import (
// authorityInfo is the normative classification of a search result, used internally
// for re-ranking only (Phase 1 changes ordering, not the response contract).
type authorityInfo struct {
weight int // 100 binding_law, 70 guidance, 0 foreign_law, 50 unknown
sourceClass string // binding_law | supervisory_guidance | foreign_law | unknown
weight int // 100 binding, 80 technical_standard, 70 guidance, 0 foreign, 50 unknown
sourceClass string // binding_law | technical_standard | supervisory_guidance | foreign_law | unknown
jurisdiction string // DE | EU | CH
}
@@ -18,7 +18,13 @@ var (
guidanceMarkers = []string{
"DSK", "EDPB", "BfDI", "BFDI", "BayLfD", "Baylfb", "ENISA", "BSI", "EUCC",
"Standards Mapping", "Kpnr", "Orientierungshilfe", "Handreichung", "Beschluss",
"Leitlinie", "Guidance", "Empfehlung", "NIST", "OECD", "CISA", "Blue Guide",
"Leitlinie", "Guidance", "Empfehlung", "OECD", "CISA", "Blue Guide",
}
// Technical standards / control frameworks (best-practice controls). Checked BEFORE
// guidanceMarkers so a "BSI Grundschutz" chunk classifies as a standard, not BSI guidance.
standardMarkers = []string{
"NIST", "OWASP", "Grundschutz", "ISO 27001", "ISO/IEC 27001",
"CSA CCM", "Cloud Controls Matrix", "CIS Benchmark", "CIS Control",
}
foreignMarkers = []string{"RevDSG", "fedlex", "(CH)"}
deMarkers = []string{"BDSG", "DSK", "BfDI", "BFDI", "BayLfD", "Baylfb", "BSI"}
@@ -34,6 +40,14 @@ func classifyAuthority(r LegalSearchResult) authorityInfo {
if jur == "" {
jur = inferJurisdiction(r)
}
hay := r.ArticleLabel + " " + r.RegulationShort + " " + r.RegulationName + " " + r.RegulationCode
// A recognised standard NAME (NIST/OWASP/ISO 27001/CIS/CSA CCM/Grundschutz) is authoritative
// even when the corpus mis-tagged the chunk as supervisory_guidance (weight 70) — many
// standards were ingested with a generic guidance source_class. The name wins, so they
// classify (and rank) as technical_standard / control_standard. binding_law is preserved.
if r.SourceClass != "binding_law" && containsAny(hay, standardMarkers) {
return authorityInfo{weight: 80, sourceClass: "technical_standard", jurisdiction: jur}
}
if r.SourceClass != "" {
w := r.AuthorityWeight
if w == 0 && r.SourceClass == "binding_law" {
@@ -44,10 +58,11 @@ func classifyAuthority(r LegalSearchResult) authorityInfo {
if r.AuthorityWeight > 0 {
return authorityInfo{weight: r.AuthorityWeight, sourceClass: sourceClassFromWeight(r.AuthorityWeight), jurisdiction: jur}
}
hay := r.ArticleLabel + " " + r.RegulationShort + " " + r.RegulationName + " " + r.RegulationCode
switch {
case containsAny(hay, foreignMarkers):
return authorityInfo{weight: 0, sourceClass: "foreign_law", jurisdiction: "CH"}
case r.Category == "standard" || containsAny(hay, standardMarkers):
return authorityInfo{weight: 80, sourceClass: "technical_standard", jurisdiction: jur}
case r.Category == "guidance" || containsAny(hay, guidanceMarkers):
return authorityInfo{weight: 70, sourceClass: "supervisory_guidance", jurisdiction: jur}
case r.Category == "regulation" || r.Category == "eu_recht" || normPattern.MatchString(r.ArticleLabel):
@@ -61,6 +76,8 @@ func sourceClassFromWeight(w int) string {
switch {
case w >= 100:
return "binding_law"
case w >= 80:
return "technical_standard"
case w >= 70:
return "supervisory_guidance"
case w <= 0:
@@ -16,8 +16,8 @@ const (
scopePenalty = 0.25 // BDSG Teil 3 (law enforcement) on a general DP question
topicGain = 0.18 // amplifier only
supersededPenalty = 0.50 // superseded Alt-Quelle (pre-eu-v1): demoted, nicht versteckt
guidanceIntentGain = 0.10 // epsilon a qualifying guideline is lifted ABOVE the best binding hit
guidanceIntentMargin = 0.05 // ...only if the guideline is semantically competitive with binding
intentLiftGain = 0.10 // epsilon a qualifying interpretative source is lifted ABOVE the best binding
intentLiftMargin = 0.05 // ...only if that source is semantically competitive with binding
)
// guidanceIntentSignals mark a query that EXPLICITLY asks for an interpretation /
@@ -29,10 +29,19 @@ var guidanceIntentSignals = []string{
"auslegung", "empfiehlt", "empfehlung", "sagt", "laut",
}
// queryWantsGuidance reports whether the query explicitly asks for guidance/interpretation.
func queryWantsGuidance(query string) bool {
// controlIntentSignals mark a query that asks HOW to implement / which controls or
// measures fit — rather than WHAT the binding obligation is. Only then may a
// (semantically competitive) technical_standard outrank the binding norm.
var controlIntentSignals = []string{
"control", "controls", "maßnahme", "massnahme", "schutzmaßnahme",
"best practice", "best-practice", "umsetzen", "implementier", "absicher",
"härt", "haert", "hardening", "nist", "owasp", "grundschutz",
"ccm", "iso 27001", "isms",
}
func queryMatchesAny(query string, signals []string) bool {
q := strings.ToLower(query)
for _, sig := range guidanceIntentSignals {
for _, sig := range signals {
if strings.Contains(q, sig) {
return true
}
@@ -40,16 +49,22 @@ func queryWantsGuidance(query string) bool {
return false
}
// queryWantsGuidance reports whether the query explicitly asks for guidance/interpretation.
func queryWantsGuidance(query string) bool { return queryMatchesAny(query, guidanceIntentSignals) }
// queryWantsControls reports whether the query asks for implementation controls/measures.
func queryWantsControls(query string) bool { return queryMatchesAny(query, controlIntentSignals) }
// bestBindingSemantic returns the highest RAW semantic score among binding-law
// results (0 if none / intent not requested). Used as the guard threshold so an
// off-topic guideline cannot ride the interpretation-intent boost.
func bestBindingSemantic(results []LegalSearchResult, wantsGuidance bool) float64 {
if !wantsGuidance {
// results (0 if none / no intent). Used as the guard threshold so an off-topic
// interpretative source cannot ride the intent boost.
func bestBindingSemantic(results []LegalSearchResult, wantsIntent bool) float64 {
if !wantsIntent {
return 0
}
best := 0.0
for _, r := range results {
if r.SourceClass == "binding_law" && r.Score > best {
if classifyAuthority(r).sourceClass == "binding_law" && r.Score > best {
best = r.Score
}
}
@@ -104,6 +119,7 @@ func rerankByAuthority(query string, results []LegalSearchResult) []LegalSearchR
qDomain := queryDomain(query)
qForeign := queryIsForeign(query)
wantsGuidance := queryWantsGuidance(query)
wantsControls := queryWantsControls(query)
bestBindingSem := bestBindingSemantic(results, wantsGuidance)
out := make([]LegalSearchResult, len(results))
@@ -111,8 +127,15 @@ func rerankByAuthority(query string, results []LegalSearchResult) []LegalSearchR
for i := range out {
out[i].Score = authorityScore(query, out[i], qDomain, qForeign)
}
// Explicit interpretation intent → a competitive guideline may outrank binding (lift
// above the best binding FINAL). Explicit implementation intent → boost the CONTROL-POOL
// (operational/procedural requirement, control standard, implementation guidance) over
// the abstract obligation, soft-ordered by role. Norm questions (neither) stay untouched.
if wantsGuidance {
applyGuidanceIntent(out, results, bestBindingSem)
liftAboveBinding(out, results, bestBindingSem, "supervisory_guidance")
}
if wantsControls {
applyControlRoles(out)
}
sort.SliceStable(out, func(a, b int) bool {
return out[a].Score > out[b].Score
@@ -120,24 +143,27 @@ func rerankByAuthority(query string, results []LegalSearchResult) []LegalSearchR
return out
}
// applyGuidanceIntent lifts semantically-competitive guidance just ABOVE the best
// binding hit (ordered by semantic), so an EXPLICIT interpretation question can
// return guidance Top-1. Obligation questions (no intent → not called) keep
// binding on top. Guidance below the semantic margin is left untouched, so an
// off-topic guideline can never ride the override — and the lift is computed from
// the binding FINAL score, so authority/topic/domain bonuses cannot edge it out.
func applyGuidanceIntent(out, raw []LegalSearchResult, bestBindingSem float64) {
// liftAboveBinding lifts a semantically-competitive interpretative source (the given
// sourceClass — supervisory_guidance or technical_standard) just ABOVE the best binding
// hit, ordered by semantic, so an EXPLICIT guidance/implementation question can return
// that source Top-1. A pure norm question (no intent → not called) keeps binding on top.
// Sources below the semantic margin are left untouched, so an off-topic source can never
// ride the override — and the lift is from the binding FINAL score, so authority/topic/
// domain bonuses cannot edge it out.
func liftAboveBinding(out, raw []LegalSearchResult, bestBindingSem float64, sourceClass string) {
bestBindingFinal := 0.0
for i := range out {
if out[i].SourceClass == "binding_law" && out[i].Score > bestBindingFinal {
if classifyAuthority(out[i]).sourceClass == "binding_law" && out[i].Score > bestBindingFinal {
bestBindingFinal = out[i].Score
}
}
for i := range out {
if out[i].SourceClass != "supervisory_guidance" || raw[i].Score < bestBindingSem-guidanceIntentMargin {
// Classify (not raw payload) so the untagged legacy corpus — e.g. NIST ingested
// before source_class tagging — is still recognized as its interpretative class.
if classifyAuthority(out[i]).sourceClass != sourceClass || raw[i].Score < bestBindingSem-intentLiftMargin {
continue
}
lifted := bestBindingFinal + guidanceIntentGain + (raw[i].Score - bestBindingSem)
lifted := bestBindingFinal + intentLiftGain + (raw[i].Score - bestBindingSem)
if lifted > out[i].Score {
out[i].Score = lifted
}
@@ -14,6 +14,11 @@ func TestClassifyAuthority(t *testing.T) {
{"tagged guidance DE", LegalSearchResult{AuthorityWeight: 70, SourceClass: "supervisory_guidance", Jurisdiction: "DE"}, 70, "supervisory_guidance", "DE"},
{"tagged foreign CH", LegalSearchResult{AuthorityWeight: 0, SourceClass: "foreign_law", Jurisdiction: "CH"}, 0, "foreign_law", "CH"},
{"untagged ENISA guidance", LegalSearchResult{RegulationShort: "ENISA", ArticleLabel: "ENISA CRA Standards Mapping"}, 70, "supervisory_guidance", "EU"},
{"untagged NIST standard", LegalSearchResult{RegulationShort: "NIST SP 800-82r3", ArticleLabel: "AU-8"}, 80, "technical_standard", "EU"},
{"mis-tagged NIST guidance -> standard by name", LegalSearchResult{SourceClass: "supervisory_guidance", AuthorityWeight: 70, RegulationShort: "NIST SP 800-82r3", ArticleLabel: "NIST SP 800-82r3"}, 80, "technical_standard", "EU"},
{"BSI Grundschutz standard beats BSI guidance", LegalSearchResult{RegulationShort: "BSI Grundschutz", ArticleLabel: "BSI Grundschutz Baustein"}, 80, "technical_standard", "DE"},
{"weight-only 85 TRGS standard", LegalSearchResult{AuthorityWeight: 85, RegulationShort: "TRGS 529"}, 85, "technical_standard", "EU"},
{"tagged technical_standard", LegalSearchResult{AuthorityWeight: 80, SourceClass: "technical_standard", Jurisdiction: "EU"}, 80, "technical_standard", "EU"},
{"untagged CRA binding", LegalSearchResult{RegulationShort: "CRA", ArticleLabel: "Art. 13 CRA", Category: "regulation"}, 100, "binding_law", "EU"},
{"untagged BDSG binding DE", LegalSearchResult{RegulationShort: "BDSG", ArticleLabel: "§ 38 BDSG"}, 100, "binding_law", "DE"},
{"untagged RevDSG foreign", LegalSearchResult{RegulationShort: "RevDSG", ArticleLabel: "RevDSG (CH)"}, 0, "foreign_law", "CH"},
@@ -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
}
@@ -0,0 +1,71 @@
package ucca
// ObligationStatus is the Advisor's vertical slice over the compliance graph for ONE legal
// obligation: which accepted controls satisfy it, what evidence they require, what's missing,
// and the resulting status. The point is "the required evidence is (not) present", not "a
// document exists". citation_spans is pending until the Legal-Knowledge-Graph session attaches
// them to the obligation (the upper half of the bridge).
type ObligationStatus struct {
ObligationID string `json:"obligation_id"`
LegalBasis []string `json:"legal_basis"` // the obligation's citation_units
Status string `json:"status"` // erfuellt | offen | unklar
Controls []ObligationControlStatus `json:"controls"`
CitationSpans string `json:"citation_spans"` // "pending" until the registry fills them
}
// ObligationControlStatus is one control under an obligation with its evidence picture.
type ObligationControlStatus struct {
Framework string `json:"framework"`
Control string `json:"control"`
MappingType string `json:"mapping_type"`
RequiredEvidence []EvidenceRequirement `json:"required_evidence"`
MissingEvidence []EvidenceRequirement `json:"missing_evidence"`
}
// AssessObligationStatus traverses obligation_id -> (citation_unit) -> accepted Controls ->
// required Evidence -> Status. hasEvidence reports whether a given (framework, control,
// evidence_type) is already collected; pass nil in the MVP (no collection yet) -> everything
// required is missing and the status is "offen". Unknown or unmapped obligation -> "unklar".
func AssessObligationStatus(joins *ObligationJoinKeys, mappings *ControlMappingSet, evidence *EvidenceRequirementSet, obligationID string, hasEvidence func(framework, control, evidenceType string) bool) ObligationStatus {
ob := joins.FindObligation(obligationID)
if ob == nil {
return ObligationStatus{ObligationID: obligationID, Status: "unklar", CitationSpans: "pending"}
}
st := ObligationStatus{
ObligationID: obligationID,
LegalBasis: ob.CitationUnits,
CitationSpans: "pending",
Controls: []ObligationControlStatus{},
}
ctrls := AcceptedControlsForObligation(*ob, mappings)
if len(ctrls) == 0 {
st.Status = "unklar" // no accepted control reaches it — we cannot assess
return st
}
anyMissing := false
for _, m := range ctrls {
req := evidence.RequiredFor(m.TargetFramework, m.TargetControl)
missing := make([]EvidenceRequirement, 0, len(req))
for _, e := range req {
if hasEvidence == nil || !hasEvidence(e.Framework, e.Control, e.EvidenceType) {
missing = append(missing, e)
}
}
if len(missing) > 0 {
anyMissing = true
}
st.Controls = append(st.Controls, ObligationControlStatus{
Framework: m.TargetFramework,
Control: m.TargetControl,
MappingType: m.MappingType,
RequiredEvidence: req,
MissingEvidence: missing,
})
}
if anyMissing {
st.Status = "offen"
} else {
st.Status = "erfuellt"
}
return st
}
@@ -0,0 +1,59 @@
package ucca
import "testing"
func loadGraph(t *testing.T) (*ObligationJoinKeys, *ControlMappingSet, *EvidenceRequirementSet) {
t.Helper()
joins, err := LoadObligationJoinKeys("../../../obligations/obligation_join_keys.json")
if err != nil {
t.Fatalf("join keys: %v", err)
}
maps, err := LoadControlMappings("../../data/control_mappings")
if err != nil {
t.Fatalf("mappings: %v", err)
}
ev, err := LoadEvidenceRequirements("../../data/evidence_requirements")
if err != nil {
t.Fatalf("evidence: %v", err)
}
return joins, maps, ev
}
func TestAssessObligationStatus(t *testing.T) {
joins, maps, ev := loadGraph(t)
// covered obligation, no evidence collected yet (MVP) -> offen
st := AssessObligationStatus(joins, maps, ev, "user_authentication_required", nil)
if st.Status != "offen" {
t.Errorf("want offen, got %q", st.Status)
}
if len(st.Controls) == 0 {
t.Fatal("expected controls for a covered obligation")
}
for _, c := range st.Controls {
if len(c.MissingEvidence) != len(c.RequiredEvidence) {
t.Error("MVP: all required evidence should be missing")
}
}
t.Logf("DURCHSTICH user_authentication_required: status=%s legal_basis=%v citation_spans=%s",
st.Status, st.LegalBasis, st.CitationSpans)
for _, c := range st.Controls {
t.Logf(" %s %s (%s): %d required evidence, %d missing", c.Framework, c.Control, c.MappingType, len(c.RequiredEvidence), len(c.MissingEvidence))
}
// all evidence present -> erfuellt
st2 := AssessObligationStatus(joins, maps, ev, "user_authentication_required", func(f, c, et string) bool { return true })
if st2.Status != "erfuellt" {
t.Errorf("want erfuellt with all evidence present, got %q", st2.Status)
}
// uncovered obligation (no accepted control reaches it) -> unklar
if st3 := AssessObligationStatus(joins, maps, ev, "sbom_creation", nil); st3.Status != "unklar" {
t.Errorf("uncovered sbom_creation: want unklar, got %q", st3.Status)
}
// unknown obligation_id -> unklar
if st4 := AssessObligationStatus(joins, maps, ev, "does_not_exist", nil); st4.Status != "unklar" {
t.Errorf("unknown obligation: want unklar, got %q", st4.Status)
}
}
@@ -0,0 +1,152 @@
package ucca
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// ControlMapping is one persisted, versioned, REVIEWABLE link from a legal
// obligation/requirement to a concrete framework control — a node in the curated
// compliance graph (Regulation -> Obligation -> Control -> Evidence). The retriever only
// PROPOSES candidates (mapping_status=candidate); a human/rule decision turns the good ones
// into mapping_status=accepted, which is the audited truth the Advisor uses at runtime.
//
// There is intentionally NO probabilistic "confidence" field: once curated, a mapping is a
// professional statement, not an AI guess. The retriever's score lives only in the rationale
// of a candidate, never as structured truth.
type ControlMapping struct {
SourceNorm string `json:"source_norm"` // e.g. "CRA Annex I Part I (2)(c)"
SourceRole string `json:"source_role"` // source_role of the norm (operational_requirement, ...)
TargetFramework string `json:"target_framework"` // e.g. "OWASP ASVS"
TargetControl string `json:"target_control"` // e.g. "V6.3.1"
MappingType string `json:"mapping_type"` // 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
Rationale string `json:"rationale"`
ReviewedBy string `json:"reviewed_by,omitempty"` // who decided (human or rule id)
ReviewDate string `json:"review_date,omitempty"` // YYYY-MM-DD
ReviewReason string `json:"review_reason,omitempty"`
Version string `json:"version"`
}
// Allowed enum values — the deterministic "rule" layer that keeps the curated store clean.
var (
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}
)
// Validate checks required fields + enum membership, and enforces the audit trail: any
// human/rule DECISION (accepted/rejected) must carry who/when/why. Fail-closed at load.
func (m ControlMapping) Validate() error {
switch {
case m.SourceNorm == "":
return fmt.Errorf("control mapping: source_norm required")
case m.TargetFramework == "":
return fmt.Errorf("control mapping: target_framework required")
case m.TargetControl == "":
return fmt.Errorf("control mapping: target_control required")
case !mappingTypeValues[m.MappingType]:
return fmt.Errorf("control mapping: invalid mapping_type %q", m.MappingType)
case !mappingStatusValues[m.MappingStatus]:
return fmt.Errorf("control mapping: invalid mapping_status %q", m.MappingStatus)
case !provenanceValues[m.Provenance]:
return fmt.Errorf("control mapping: invalid provenance %q", m.Provenance)
}
if m.MappingStatus == "accepted" || m.MappingStatus == "rejected" {
if m.ReviewedBy == "" || m.ReviewDate == "" || m.ReviewReason == "" {
return fmt.Errorf("control mapping %s->%s: status %q requires reviewed_by + review_date + review_reason (audit trail)",
m.SourceNorm, m.TargetControl, m.MappingStatus)
}
}
return nil
}
// IsAccepted reports whether this mapping is the active audited truth.
func (m ControlMapping) IsAccepted() bool { return m.MappingStatus == "accepted" }
// ControlMappingSet is the loaded, indexed mapping store (forward + reverse lookup).
type ControlMappingSet struct {
All []ControlMapping
bySourceNorm map[string][]ControlMapping
byControl map[string][]ControlMapping
}
func controlKey(framework, control string) string { return framework + ":" + control }
// ControlsFor returns the controls mapped to a source norm. acceptedOnly restricts to the
// audited truth (what the Advisor may treat as fact).
func (s *ControlMappingSet) ControlsFor(sourceNorm string, acceptedOnly bool) []ControlMapping {
return filterAccepted(s.bySourceNorm[sourceNorm], acceptedOnly)
}
// ObligationsFor returns the norms mapped to a framework control (reverse lookup).
func (s *ControlMappingSet) ObligationsFor(framework, control string, acceptedOnly bool) []ControlMapping {
return filterAccepted(s.byControl[controlKey(framework, control)], acceptedOnly)
}
func filterAccepted(in []ControlMapping, acceptedOnly bool) []ControlMapping {
if !acceptedOnly {
return in
}
out := make([]ControlMapping, 0, len(in))
for _, m := range in {
if m.IsAccepted() {
out = append(out, m)
}
}
return out
}
// LoadControlMappings reads every *.jsonl file under dir (one mapping per line; blank and
// //-prefixed lines ignored), validates each row, and builds the index. An invalid row
// aborts the whole load — fail-closed, because this is the audit truth, not best-effort.
func LoadControlMappings(dir string) (*ControlMappingSet, error) {
files, err := filepath.Glob(filepath.Join(dir, "*.jsonl"))
if err != nil {
return nil, err
}
set := &ControlMappingSet{
bySourceNorm: map[string][]ControlMapping{},
byControl: map[string][]ControlMapping{},
}
for _, f := range files {
fh, err := os.Open(f)
if err != nil {
return nil, err
}
sc := bufio.NewScanner(fh)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
line := 0
for sc.Scan() {
line++
raw := strings.TrimSpace(sc.Text())
if raw == "" || strings.HasPrefix(raw, "//") {
continue
}
var m ControlMapping
if err := json.Unmarshal([]byte(raw), &m); err != nil {
fh.Close()
return nil, fmt.Errorf("%s:%d: %w", f, line, err)
}
if err := m.Validate(); err != nil {
fh.Close()
return nil, fmt.Errorf("%s:%d: %w", f, line, err)
}
set.All = append(set.All, m)
set.bySourceNorm[m.SourceNorm] = append(set.bySourceNorm[m.SourceNorm], m)
k := controlKey(m.TargetFramework, m.TargetControl)
set.byControl[k] = append(set.byControl[k], m)
}
fh.Close()
if err := sc.Err(); err != nil {
return nil, err
}
}
return set, nil
}
@@ -0,0 +1,85 @@
package ucca
import (
"os"
"path/filepath"
"testing"
)
func TestControlMapping_Validate(t *testing.T) {
candidate := ControlMapping{SourceNorm: "CRA Annex I", TargetFramework: "OWASP ASVS", TargetControl: "V6.3.1", MappingType: "supports", MappingStatus: "candidate", Provenance: "retriever_candidate"}
if err := candidate.Validate(); err != nil {
t.Fatalf("valid candidate rejected: %v", err)
}
accepted := ControlMapping{SourceNorm: "A", TargetFramework: "X", TargetControl: "Y", MappingType: "implements", MappingStatus: "accepted", Provenance: "human_curated", ReviewedBy: "benjamin", ReviewDate: "2026-06-25", ReviewReason: "passt"}
if err := accepted.Validate(); err != nil {
t.Fatalf("valid accepted rejected: %v", err)
}
bad := []struct {
name string
m ControlMapping
}{
{"no source_norm", ControlMapping{TargetFramework: "X", TargetControl: "Y", MappingType: "supports", MappingStatus: "candidate", Provenance: "retriever_candidate"}},
{"bad mapping_type", ControlMapping{SourceNorm: "A", TargetFramework: "X", TargetControl: "Y", MappingType: "nope", MappingStatus: "candidate", Provenance: "retriever_candidate"}},
{"bad mapping_status", ControlMapping{SourceNorm: "A", TargetFramework: "X", TargetControl: "Y", MappingType: "supports", MappingStatus: "maybe", Provenance: "retriever_candidate"}},
{"bad provenance", ControlMapping{SourceNorm: "A", TargetFramework: "X", TargetControl: "Y", MappingType: "supports", MappingStatus: "candidate", Provenance: "guessed"}},
{"accepted without audit trail", ControlMapping{SourceNorm: "A", TargetFramework: "X", TargetControl: "Y", MappingType: "supports", MappingStatus: "accepted", Provenance: "human_curated"}},
{"rejected without reason", ControlMapping{SourceNorm: "A", TargetFramework: "X", TargetControl: "Y", MappingType: "supports", MappingStatus: "rejected", Provenance: "human_curated", ReviewedBy: "b", ReviewDate: "2026-06-25"}},
}
for _, tt := range bad {
if err := tt.m.Validate(); err == nil {
t.Errorf("%s: expected rejection", tt.name)
}
}
}
func TestLoadControlMappings(t *testing.T) {
dir := t.TempDir()
content := `// header comment, ignored
{"source_norm":"CRA Annex I","source_role":"operational_requirement","target_framework":"OWASP ASVS","target_control":"V6.3.1","mapping_type":"supports","mapping_status":"accepted","provenance":"human_curated","reviewed_by":"benjamin","review_date":"2026-06-25","review_reason":"V6=Auth passt","rationale":"r","version":"2026-06-25"}
{"source_norm":"CRA Annex I","source_role":"operational_requirement","target_framework":"OWASP ASVS","target_control":"V14.2.4","mapping_type":"related","mapping_status":"candidate","provenance":"retriever_candidate","rationale":"r","version":"2026-06-25"}
`
if err := os.WriteFile(filepath.Join(dir, "m.jsonl"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
set, err := LoadControlMappings(dir)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(set.All) != 2 {
t.Fatalf("want 2 mappings, got %d", len(set.All))
}
if got := set.ControlsFor("CRA Annex I", false); len(got) != 2 {
t.Errorf("ControlsFor(all): want 2, got %d", len(got))
}
if got := set.ControlsFor("CRA Annex I", true); len(got) != 1 {
t.Errorf("ControlsFor(acceptedOnly): want 1 (only accepted), got %d", len(got))
}
if got := set.ObligationsFor("OWASP ASVS", "V6.3.1", true); len(got) != 1 {
t.Errorf("ObligationsFor accepted reverse lookup: want 1, got %d", len(got))
}
}
func TestLoadControlMappings_RejectsInvalid(t *testing.T) {
dir := t.TempDir()
// accepted without the who/when/why audit trail must fail-closed.
if err := os.WriteFile(filepath.Join(dir, "bad.jsonl"), []byte(`{"source_norm":"A","target_framework":"X","target_control":"Y","mapping_type":"supports","mapping_status":"accepted","provenance":"human_curated","rationale":"r","version":"v"}`), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadControlMappings(dir); err == nil {
t.Error("accepted mapping without audit trail must fail the load (fail-closed)")
}
}
func TestControlMappings_SeedFileValid(t *testing.T) {
// The committed seed store must always load + validate.
set, err := LoadControlMappings("../../data/control_mappings")
if err != nil {
t.Fatalf("seed control_mappings failed to load: %v", err)
}
if len(set.All) == 0 {
t.Fatal("seed control_mappings is empty")
}
}
@@ -0,0 +1,174 @@
package ucca
import "strings"
// source_role is the FUNCTIONAL role of a chunk — WHAT must be done (obligation),
// HOW to implement it (operational/procedural requirement, control standard,
// implementation guidance), or how to READ the norm (interpretation/definition).
// It is ORTHOGONAL to source_class (legal authority): source_class decides RANK,
// source_role decides CONTROL-POOL membership for implementation questions.
// Derived deterministically from markers, so the untagged corpus needs no re-tag.
const (
roleObligation = "obligation" // the abstract duty (the WHAT)
roleOperationalReq = "operational_requirement" // concrete binding requirement (CRA Annex I)
roleProceduralReq = "procedural_requirement" // a process: notification/registration/DPIA/incident report
roleControlStandard = "control_standard" // best-practice control catalog (NIST/OWASP/ISO/CIS)
roleImplGuidance = "implementation_guidance" // advisory how-to (ENISA good practices, BSI)
roleInterpretation = "interpretation" // interprets the norm's MEANING (EDPB guideline)
roleDefinition = "definition" // definitions / scope / recitals
)
var (
proceduralMarkers = []string{
"Meldung", "Meldepflicht", "Notification", "Notifizierung", "Registrierung",
"Registration", "Konformitätserklärung", "Declaration of Conformity", "Incident",
"Berichterstattung", "Reporting", "Folgenabschätzung", "DSFA", "DPIA", "Anzeigepflicht",
}
annexMarkers = []string{"Anhang", "Annex", "Appendix", "Anlage"}
operationalMarkers = []string{"Anforderung", "Requirement", "essential", "wesentliche"}
implMarkers = []string{
"Good Practice", "Best Practice", "Standards Mapping", "Umsetzung", "Implementation",
"Handreichung", "Maßnahmenkatalog", "ICS", "SCADA", "Technical Guideline", "TIG",
}
definitionMarkers = []string{"Begriffsbestimmung", "Definition"}
)
// classifyRole derives the functional source_role from chunk metadata + the authority
// class. technical_standard is always a control_standard; guidance splits into
// implementation_guidance (how-to) vs interpretation (meaning); binding splits into
// procedural / operational requirement / definition / plain obligation.
func classifyRole(r LegalSearchResult) string {
cls := classifyAuthority(r).sourceClass
hay := strings.ToLower(r.ArticleLabel + " " + r.RegulationShort + " " + r.RegulationName + " " + r.Article)
switch {
case r.IsRecital:
return roleDefinition
case cls == "technical_standard":
return roleControlStandard
case cls == "supervisory_guidance":
if containsAnyLower(hay, implMarkers) {
return roleImplGuidance
}
return roleInterpretation
case cls == "binding_law":
switch {
case containsAnyLower(hay, definitionMarkers):
return roleDefinition
case containsAnyLower(hay, proceduralMarkers):
return roleProceduralReq
case containsAnyLower(hay, annexMarkers) || containsAnyLower(hay, operationalMarkers):
return roleOperationalReq
default:
return roleObligation
}
default:
return roleObligation
}
}
// controlRoleBonus is the soft intra-pool preference (User 2026-06-24):
// operational_requirement > procedural_requirement > control_standard > implementation_guidance.
var controlRoleBonus = map[string]float64{
roleOperationalReq: 0.100,
roleProceduralReq: 0.075,
roleControlStandard: 0.050,
roleImplGuidance: 0.000,
}
// controlPoolGain lifts EVERY control-pool role over the non-control roles (obligation/
// interpretation/definition) on an implementation question, so the binding abstract
// obligation does not dominate by authority alone. The obligation is not removed — it
// stays visible as "Rechtsgrundlage" context below the recommended measures.
const controlPoolGain = 0.15
// applyControlRoles boosts the control-pool (the four implementation roles) for an
// EXPLICIT implementation question, soft-ordered op_req > procedural > standard > guidance.
// Replaces the earlier "lift technical_standard above binding" — controls are not only
// technical_standard, and the binding operational_requirement (e.g. CRA Annex I) should win.
func applyControlRoles(out []LegalSearchResult) {
for i := range out {
if bonus, ok := controlRoleBonus[classifyRole(out[i])]; ok {
out[i].Score += controlPoolGain + bonus
}
}
}
// isControlPoolRole reports whether a role belongs to the control-pool surfaced on
// implementation questions (the four "how to implement" roles).
func isControlPoolRole(role string) bool {
switch role {
case roleOperationalReq, roleProceduralReq, roleControlStandard, roleImplGuidance:
return true
}
return false
}
// controlRoleOf classifies a raw Qdrant payload into a source_role, so searchControls can
// filter its deep dense pull to the control-pool BEFORE hits are mapped to LegalSearchResult.
func controlRoleOf(payload map[string]interface{}) string {
article := getString(payload, "article")
if article == "" {
article = getString(payload, "section")
}
return classifyRole(LegalSearchResult{
RegulationShort: getString(payload, "regulation_short"),
RegulationName: getString(payload, "regulation_name_de"),
ArticleLabel: getString(payload, "article_label"),
Article: article,
Category: getString(payload, "category"),
SourceClass: getString(payload, "source_class"),
AuthorityWeight: getInt(payload, "authority_weight"),
IsRecital: getBool(payload, "is_recital"),
})
}
// ensureControlDiversity guarantees that the returned top-K of a control question surfaces at
// least one operational_requirement and one control_standard WHEN the pool contains them —
// without forcing them to Top-1. implementation_guidance (e.g. ENISA good practices) keeps its
// earned semantic lead; the rule only promotes the best hit of a missing control role into the
// top-K by overwriting the lowest-ranked redundant guidance slot. So an implementation question
// shows the relevant source ROLES (binding requirement + standard + guidance) side by side
// instead of one role flooding the list. The promoted hit's original (now duplicate) position
// stays in the tail and is dropped by the caller's truncation to topK.
func ensureControlDiversity(results []LegalSearchResult, topK int) []LegalSearchResult {
if topK <= 0 || topK >= len(results) {
return results // everything is already returned — nothing to promote
}
roleAt := make([]string, len(results))
for i := range results {
roleAt[i] = classifyRole(results[i])
}
present := make(map[string]bool, topK)
for i := 0; i < topK; i++ {
present[roleAt[i]] = true
}
for _, want := range []string{roleOperationalReq, roleControlStandard} {
if present[want] {
continue
}
src := -1
for i := topK; i < len(results); i++ {
if roleAt[i] == want {
src = i
break
}
}
if src < 0 {
continue // role absent from the whole pool — nothing to promote
}
dst := -1
for j := topK - 1; j >= 0; j-- {
if roleAt[j] == roleImplGuidance {
dst = j
break
}
}
if dst < 0 {
continue // no redundant guidance to sacrifice — leave the head untouched
}
results[dst] = results[src]
roleAt[dst] = want
present[want] = true
}
return results
}
@@ -0,0 +1,134 @@
package ucca
import "testing"
func TestClassifyRole(t *testing.T) {
tests := []struct {
name string
r LegalSearchResult
want string
}{
{"NIST -> control_standard", LegalSearchResult{RegulationShort: "NIST SP 800-82r3", ArticleLabel: "AU-8"}, roleControlStandard},
{"OWASP -> control_standard", LegalSearchResult{RegulationShort: "OWASP ASVS"}, roleControlStandard},
{"CRA Anhang -> operational_requirement", LegalSearchResult{RegulationShort: "CRA", ArticleLabel: "CRA Anhang I", Category: "regulation"}, roleOperationalReq},
{"CRA Meldepflicht -> procedural_requirement", LegalSearchResult{RegulationShort: "CRA", ArticleLabel: "Art. 14 CRA Meldepflicht", Category: "regulation"}, roleProceduralReq},
{"ENISA Good Practices -> implementation_guidance", LegalSearchResult{RegulationShort: "ENISA Supply Chain Good Practices"}, roleImplGuidance},
{"EDPB Leitlinie -> interpretation", LegalSearchResult{RegulationShort: "EDPB DPO", ArticleLabel: "WP243 Leitlinien Datenschutzbeauftragte"}, roleInterpretation},
{"DORA article -> obligation", LegalSearchResult{RegulationShort: "DORA", ArticleLabel: "Art. 5 DORA", Category: "regulation"}, roleObligation},
{"DSGVO Begriffsbestimmungen -> definition", LegalSearchResult{RegulationShort: "DSGVO", ArticleLabel: "Art. 4 DSGVO Begriffsbestimmungen", Category: "regulation"}, roleDefinition},
{"recital -> definition", LegalSearchResult{RegulationShort: "CRA", IsRecital: true}, roleDefinition},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := classifyRole(tt.r); got != tt.want {
t.Errorf("classifyRole() = %q, want %q", got, tt.want)
}
})
}
}
func TestApplyControlRoles_PoolPreference(t *testing.T) {
// op_req > procedural > control_standard > impl_guidance; non-control roles get no boost.
roles := []struct {
r LegalSearchResult
wantGain float64
}{
{LegalSearchResult{ArticleLabel: "CRA Anhang I", Category: "regulation"}, controlPoolGain + 0.100},
{LegalSearchResult{ArticleLabel: "Art. 14 CRA Meldepflicht", Category: "regulation"}, controlPoolGain + 0.075},
{LegalSearchResult{RegulationShort: "NIST SP 800-53"}, controlPoolGain + 0.050},
{LegalSearchResult{RegulationShort: "ENISA Good Practices"}, controlPoolGain + 0.000},
{LegalSearchResult{ArticleLabel: "Art. 5 DORA", Category: "regulation"}, 0.0}, // obligation: no boost
}
for _, rc := range roles {
out := []LegalSearchResult{rc.r}
out[0].Score = 1.0
applyControlRoles(out)
if got := out[0].Score - 1.0; got < rc.wantGain-1e-9 || got > rc.wantGain+1e-9 {
t.Errorf("role %q: gain %.3f, want %.3f", classifyRole(rc.r), got, rc.wantGain)
}
}
}
func TestIsControlPoolRole(t *testing.T) {
for _, r := range []string{roleOperationalReq, roleProceduralReq, roleControlStandard, roleImplGuidance} {
if !isControlPoolRole(r) {
t.Errorf("%q should be in the control-pool", r)
}
}
for _, r := range []string{roleObligation, roleInterpretation, roleDefinition} {
if isControlPoolRole(r) {
t.Errorf("%q should NOT be in the control-pool", r)
}
}
}
func TestControlRoleOf_Payload(t *testing.T) {
// searchControls filters its deep dense pull by classifying the raw Qdrant payload.
nist := map[string]interface{}{"regulation_short": "NIST SP 800-82r3", "article": "AU-8"}
if got := controlRoleOf(nist); got != roleControlStandard {
t.Errorf("untagged NIST payload role = %q, want control_standard", got)
}
craAnnex := map[string]interface{}{"regulation_short": "CRA", "article": "Anhang-I", "category": "regulation"}
if got := controlRoleOf(craAnnex); got != roleOperationalReq {
t.Errorf("CRA Anhang payload role = %q, want operational_requirement", got)
}
dora := map[string]interface{}{"regulation_short": "DORA", "article_label": "Art. 5 DORA", "category": "regulation"}
if got := controlRoleOf(dora); isControlPoolRole(got) {
t.Errorf("DORA abstract article role = %q must be excluded from the control-pool", got)
}
}
func headHasRole(head []LegalSearchResult, role string) bool {
for _, r := range head {
if classifyRole(r) == role {
return true
}
}
return false
}
func TestEnsureControlDiversity(t *testing.T) {
ig := func(n string) LegalSearchResult {
return LegalSearchResult{RegulationShort: "ENISA " + n + " Good Practices"}
}
opReq := LegalSearchResult{RegulationShort: "CRA", ArticleLabel: "CRA Anhang I", Category: "regulation"}
std := LegalSearchResult{RegulationShort: "NIST SP 800-53"}
t.Run("injects missing op_req + control_standard, guidance keeps Top-1", func(t *testing.T) {
out := ensureControlDiversity([]LegalSearchResult{ig("A"), ig("B"), ig("C"), std, opReq}, 3)
head := out[:3]
if classifyRole(head[0]) != roleImplGuidance {
t.Errorf("Top-1 should stay implementation_guidance, got %q", classifyRole(head[0]))
}
if !headHasRole(head, roleOperationalReq) {
t.Error("top-K must contain an operational_requirement after diversity")
}
if !headHasRole(head, roleControlStandard) {
t.Error("top-K must contain a control_standard after diversity")
}
})
t.Run("no-op when both roles already present", func(t *testing.T) {
out := ensureControlDiversity([]LegalSearchResult{opReq, std, ig("A"), ig("B")}, 3)
if classifyRole(out[0]) != roleOperationalReq || classifyRole(out[1]) != roleControlStandard {
t.Error("already-diverse top-K must be left untouched")
}
})
t.Run("absent role is not forced (no panic)", func(t *testing.T) {
out := ensureControlDiversity([]LegalSearchResult{ig("A"), ig("B"), ig("C"), std}, 3)
if !headHasRole(out[:3], roleControlStandard) {
t.Error("present control_standard should be injected")
}
if headHasRole(out[:3], roleOperationalReq) {
t.Error("operational_requirement absent from the pool must NOT appear")
}
})
t.Run("topK covering the whole pool is unchanged", func(t *testing.T) {
out := ensureControlDiversity([]LegalSearchResult{ig("A"), opReq}, 5)
if len(out) != 2 || classifyRole(out[0]) != roleImplGuidance {
t.Error("topK >= len must return results unchanged")
}
})
}
@@ -0,0 +1,117 @@
package ucca
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// EvidenceRequirement is the last edge of the compliance graph: it says WHAT concrete
// evidence proves a framework control is met, and how fresh that evidence must be. This is
// what lets the Advisor eventually state "the CRA requirement is fulfilled" — not because a
// document exists, but because the required, current evidence is present. Authored/curated,
// not retriever-generated.
type EvidenceRequirement struct {
Framework string `json:"framework"` // e.g. "OWASP ASVS"
Control string `json:"control"` // e.g. "V6.3.1"
EvidenceType string `json:"evidence_type"` // sbom|test_report|config_export|repo_scan|policy|ticket|audit_log|pentest
EvidenceSource string `json:"evidence_source"` // github|ci|scanner|manual_upload
FreshnessRequirement string `json:"freshness_requirement"` // per_release|quarterly|annually|continuous
Required bool `json:"required"`
Rationale string `json:"rationale"`
Version string `json:"version"`
}
// Allowed enum values — the rule layer that keeps the evidence catalog clean.
var (
evidenceTypeValues = map[string]bool{"sbom": true, "test_report": true, "config_export": true, "repo_scan": true, "policy": true, "ticket": true, "audit_log": true, "pentest": true}
evidenceSourceValues = map[string]bool{"github": true, "ci": true, "scanner": true, "manual_upload": true}
freshnessValues = map[string]bool{"per_release": true, "quarterly": true, "annually": true, "continuous": true}
)
// Validate checks required fields + enum membership. Fail-closed at load.
func (e EvidenceRequirement) Validate() error {
switch {
case e.Framework == "":
return fmt.Errorf("evidence requirement: framework required")
case e.Control == "":
return fmt.Errorf("evidence requirement: control required")
case !evidenceTypeValues[e.EvidenceType]:
return fmt.Errorf("evidence requirement: invalid evidence_type %q", e.EvidenceType)
case !evidenceSourceValues[e.EvidenceSource]:
return fmt.Errorf("evidence requirement: invalid evidence_source %q", e.EvidenceSource)
case !freshnessValues[e.FreshnessRequirement]:
return fmt.Errorf("evidence requirement: invalid freshness_requirement %q", e.FreshnessRequirement)
}
return nil
}
// EvidenceRequirementSet is the loaded, indexed evidence catalog.
type EvidenceRequirementSet struct {
All []EvidenceRequirement
byControl map[string][]EvidenceRequirement
}
// For returns all evidence requirements declared for a framework control.
func (s *EvidenceRequirementSet) For(framework, control string) []EvidenceRequirement {
return s.byControl[controlKey(framework, control)]
}
// RequiredFor returns only the required evidence for a control — the minimum that must be
// present before the control may be treated as met.
func (s *EvidenceRequirementSet) RequiredFor(framework, control string) []EvidenceRequirement {
out := make([]EvidenceRequirement, 0)
for _, e := range s.byControl[controlKey(framework, control)] {
if e.Required {
out = append(out, e)
}
}
return out
}
// LoadEvidenceRequirements reads every *.jsonl file under dir (one requirement per line;
// blank and //-prefixed lines ignored), validates each, and builds the per-control index.
// An invalid row aborts the load — fail-closed.
func LoadEvidenceRequirements(dir string) (*EvidenceRequirementSet, error) {
files, err := filepath.Glob(filepath.Join(dir, "*.jsonl"))
if err != nil {
return nil, err
}
set := &EvidenceRequirementSet{byControl: map[string][]EvidenceRequirement{}}
for _, f := range files {
fh, err := os.Open(f)
if err != nil {
return nil, err
}
sc := bufio.NewScanner(fh)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
line := 0
for sc.Scan() {
line++
raw := strings.TrimSpace(sc.Text())
if raw == "" || strings.HasPrefix(raw, "//") {
continue
}
var e EvidenceRequirement
if err := json.Unmarshal([]byte(raw), &e); err != nil {
fh.Close()
return nil, fmt.Errorf("%s:%d: %w", f, line, err)
}
if err := e.Validate(); err != nil {
fh.Close()
return nil, fmt.Errorf("%s:%d: %w", f, line, err)
}
set.All = append(set.All, e)
k := controlKey(e.Framework, e.Control)
set.byControl[k] = append(set.byControl[k], e)
}
fh.Close()
if err := sc.Err(); err != nil {
return nil, err
}
}
return set, nil
}
@@ -0,0 +1,84 @@
package ucca
import (
"os"
"path/filepath"
"testing"
)
func TestEvidenceRequirement_Validate(t *testing.T) {
valid := EvidenceRequirement{Framework: "OWASP ASVS", Control: "V6.3.1", EvidenceType: "config_export", EvidenceSource: "github", FreshnessRequirement: "per_release", Required: true}
if err := valid.Validate(); err != nil {
t.Fatalf("valid rejected: %v", err)
}
bad := []struct {
name string
e EvidenceRequirement
}{
{"no control", EvidenceRequirement{Framework: "X", EvidenceType: "sbom", EvidenceSource: "ci", FreshnessRequirement: "per_release"}},
{"bad evidence_type", EvidenceRequirement{Framework: "X", Control: "Y", EvidenceType: "screenshot", EvidenceSource: "ci", FreshnessRequirement: "per_release"}},
{"bad evidence_source", EvidenceRequirement{Framework: "X", Control: "Y", EvidenceType: "sbom", EvidenceSource: "email", FreshnessRequirement: "per_release"}},
{"bad freshness", EvidenceRequirement{Framework: "X", Control: "Y", EvidenceType: "sbom", EvidenceSource: "ci", FreshnessRequirement: "weekly"}},
}
for _, tt := range bad {
if err := tt.e.Validate(); err == nil {
t.Errorf("%s: expected rejection", tt.name)
}
}
}
func TestLoadEvidenceRequirements(t *testing.T) {
dir := t.TempDir()
content := `// header
{"framework":"OWASP ASVS","control":"V6.3.1","evidence_type":"config_export","evidence_source":"github","freshness_requirement":"per_release","required":true,"version":"2026-06-25"}
{"framework":"OWASP ASVS","control":"V6.3.1","evidence_type":"pentest","evidence_source":"manual_upload","freshness_requirement":"annually","required":false,"version":"2026-06-25"}
`
if err := os.WriteFile(filepath.Join(dir, "e.jsonl"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
set, err := LoadEvidenceRequirements(dir)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(set.All) != 2 {
t.Fatalf("want 2, got %d", len(set.All))
}
if got := set.For("OWASP ASVS", "V6.3.1"); len(got) != 2 {
t.Errorf("For: want 2, got %d", len(got))
}
if got := set.RequiredFor("OWASP ASVS", "V6.3.1"); len(got) != 1 {
t.Errorf("RequiredFor: want 1 (pentest is optional), got %d", len(got))
}
}
func TestEvidenceRequirements_SeedFileValid(t *testing.T) {
set, err := LoadEvidenceRequirements("../../data/evidence_requirements")
if err != nil {
t.Fatalf("seed evidence_requirements failed to load: %v", err)
}
if len(set.All) == 0 {
t.Fatal("seed evidence_requirements is empty")
}
}
// TestGraph_AcceptedControlsHaveEvidence wires the two layers: every control an accepted
// CRA->OWASP mapping points to must have >=1 required evidence — the Obligation -> Control ->
// Evidence chain must be connected, no dangling control nodes.
func TestGraph_AcceptedControlsHaveEvidence(t *testing.T) {
maps, err := LoadControlMappings("../../data/control_mappings")
if err != nil {
t.Fatal(err)
}
ev, err := LoadEvidenceRequirements("../../data/evidence_requirements")
if err != nil {
t.Fatal(err)
}
for _, m := range maps.All {
if !m.IsAccepted() {
continue
}
if len(ev.RequiredFor(m.TargetFramework, m.TargetControl)) == 0 {
t.Errorf("accepted control %s %s has no required evidence (dangling graph node)", m.TargetFramework, m.TargetControl)
}
}
}
@@ -107,6 +107,15 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string,
hits = mergeDedupHits(hits, bindingHits)
}
// Control-Augmentation: bei expliziter Umsetzungsfrage einen tiefen dense-Pool ziehen und
// nur die Control-Pool-Rollen behalten — so werden NIST/CRA-Anhang (dense rank ~8-9, unter
// dem kleinen top-K) Kandidaten. Re-Rank/applyControlRoles ordnen sie danach.
if queryWantsControls(query) {
if controlHits, cErr := c.searchControls(ctx, collection, embedding); cErr == nil {
hits = mergeDedupHits(hits, controlHits)
}
}
// Graph-Augmentation: verbundene Normen (references_out/in) der Top-Hits ueber die
// praezise Zitations-Kante in den Pool ziehen — z.B. Art. 13 CRA zieht Anhang I (die
// eigentliche Pflichtquelle). Pool-Augmentation only; Re-Rank + topK bleiben.
@@ -157,6 +166,15 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string,
// Response-Schema unveraendert. Score traegt den Authority-Score, damit nachgelagerte
// Multi-Collection-Merges (Advisor) die Ordnung bewahren.
results = rerankByAuthority(query, results)
// Control-Diversity: auf einer Umsetzungsfrage darf impl_guidance (ENISA) Top-1 bleiben,
// aber die Top-K soll mindestens eine binding operational_requirement (CRA Anhang I) und
// einen control_standard (NIST/ISO) zeigen, falls im Pool — Quellenarten sichtbar machen
// statt sie kuenstlich auf Top-1 zu heben. Nur Reihenfolge, vor der Truncation.
if queryWantsControls(query) {
results = ensureControlDiversity(results, topK)
}
if topK > 0 && len(results) > topK {
results = results[:topK]
}
@@ -204,6 +204,34 @@ func (c *LegalRAGClient) searchBinding(ctx context.Context, collection string, e
return c.doPointsSearch(ctx, collection, searchReq)
}
// controlPoolDepth is how deep the dense control pull reaches. Measured: for an EU-cyber
// control query the relevant control sources sit at dense rank ~8-9 (NIST, CRA Annex), far
// below the client's small top-K — so a fixed dense depth of 60 reliably surfaces them.
const controlPoolDepth = 60
// searchControls fetches a DEEP dense pool and keeps only the control-pool roles, so control
// sources that the small top-K (hybrid) search misses become candidates on an implementation
// question. Role is derived in code (no source_role tag needed). AUGMENTS the pool — the
// caller gates it on control-intent.
func (c *LegalRAGClient) searchControls(ctx context.Context, collection string, embedding []float64) ([]qdrantSearchHit, error) {
searchReq := qdrantSearchRequest{
Vector: embedding,
Limit: controlPoolDepth,
WithPayload: true,
}
hits, err := c.doPointsSearch(ctx, collection, searchReq)
if err != nil {
return nil, err
}
kept := make([]qdrantSearchHit, 0, len(hits))
for _, h := range hits {
if isControlPoolRole(controlRoleOf(h.Payload)) {
kept = append(kept, h)
}
}
return kept, nil
}
// doPointsSearch issues a POST /points/search and decodes the hits.
func (c *LegalRAGClient) doPointsSearch(ctx context.Context, collection string, searchReq qdrantSearchRequest) ([]qdrantSearchHit, error) {
jsonBody, err := json.Marshal(searchReq)
@@ -70,3 +70,66 @@ func TestRerank_OffTopicGuidance_BlockedByGuard(t *testing.T) {
t.Errorf("off-topic guidance must not win even with intent, got %s", out[0].SourceClass)
}
}
func TestQueryWantsControls(t *testing.T) {
wants := []string{
"Welche Controls passen zu Security Updates?",
"Welche Maßnahmen sollten wir umsetzen?",
"Wie härten wir den Server ab?",
"Gibt es NIST-Controls dafür?",
"OWASP Best Practice für Logging?",
"BSI Grundschutz Bausteine",
}
plain := []string{
"Welche Anforderungen bestehen an Security Updates?",
"Ab wann braucht man einen Datenschutzbeauftragten?",
}
for _, q := range wants {
if !queryWantsControls(q) {
t.Errorf("should detect control/implementation intent: %q", q)
}
}
for _, q := range plain {
if queryWantsControls(q) {
t.Errorf("should NOT detect control intent (norm question): %q", q)
}
}
}
func TestRerank_ControlQuestion_OperationalReqTop(t *testing.T) {
// User priority for implementation questions: operational_requirement (binding concrete,
// CRA Anhang I) > control_standard (NIST). Both are in the control-pool; op_req wins.
results := []LegalSearchResult{
{RegulationShort: "NIST SP 800-82r3", ArticleLabel: "AU-8", SourceClass: "technical_standard", AuthorityWeight: 80, Jurisdiction: "EU", Score: 0.60},
{RegulationShort: "CRA", ArticleLabel: "CRA Anhang I", Category: "regulation", Score: 0.58},
}
out := rerankByAuthority("Welche Controls und Massnahmen passen zu Security Updates?", results)
if out[0].RegulationShort != "CRA" {
t.Errorf("operational_requirement (CRA Anhang I) should be Top-1 over control_standard, got %q", out[0].RegulationShort)
}
}
func TestRerank_NormQuestion_BindingOverStandard(t *testing.T) {
// "Anforderungen" → no control intent → binding obligation stays Top-1 over the standard.
results := []LegalSearchResult{
intentRes("NIST SP 800-82", "technical_standard", 0.62, 80),
intentRes("CRA", "binding_law", 0.58, 100),
}
out := rerankByAuthority("Welche Anforderungen bestehen an Security Updates?", results)
if out[0].SourceClass != "binding_law" {
t.Errorf("norm question: binding must stay Top-1 over standard, got %s", out[0].SourceClass)
}
}
func TestRerank_ControlQuestion_PoolBeatsBareObligation(t *testing.T) {
// A control-pool source (NIST control_standard) outranks an abstract obligation with no
// domain/topic advantage, because the implementation intent boosts the control-pool.
results := []LegalSearchResult{
{RegulationShort: "NIST SP 800-82r3", ArticleLabel: "AU-8", SourceClass: "technical_standard", AuthorityWeight: 80, Jurisdiction: "EU", Score: 0.55},
{RegulationShort: "XYZ", ArticleLabel: "Art. 5 XYZ", Category: "regulation", Score: 0.58},
}
out := rerankByAuthority("Welche Controls und Massnahmen passen zu Security Updates?", results)
if out[0].RegulationShort != "NIST SP 800-82r3" {
t.Errorf("control_standard should beat a bare abstract obligation on a control question, got %q", out[0].RegulationShort)
}
}
@@ -0,0 +1,172 @@
package ucca
import (
"encoding/json"
"os"
"regexp"
"strings"
)
// ObligationKey is one entry of the Obligation Registry's cross-session contract
// (obligations/obligation_join_keys.json). obligation_id is the STABLE join key — assigned
// only by the Registry, never minted here. citation_units are the interim bridge until our
// ControlMapping adopts obligation_id directly.
type ObligationKey struct {
ObligationID string `json:"obligation_id"`
Regulation string `json:"regulation"`
Family string `json:"family"`
Tier string `json:"tier"`
CitationUnits []string `json:"citation_units"`
SourceRole string `json:"source_role"`
}
// ObligationJoinKeys is the loaded contract + a citation-unit index for the interim join.
type ObligationJoinKeys struct {
SchemaVersion string `json:"schema_version"`
Count int `json:"count"`
ObligationIDs []ObligationKey `json:"obligation_ids"`
byCitationKey map[string][]string
}
var citationRefRe = regexp.MustCompile(`\(([0-9a-zA-Z]+)\)`)
// citationUnitKey normalizes a CRA Annex I reference for the INTERIM citation_unit join, so
// our "CRA Annex I Part I (2)(c)" and the Registry's "Annex I (2)(c)" collapse to the same
// key ("i:2.c"). Interim only — superseded by the stable obligation_id once adopted.
func citationUnitKey(cu string) string {
low := strings.ToLower(cu)
part := ""
switch {
case strings.Contains(low, "part ii"):
part = "ii"
case strings.Contains(low, "part i"), strings.Contains(low, "(2)"):
part = "i" // CRA Annex I Part I = the (2)(x) essential requirements
}
var refs []string
for _, m := range citationRefRe.FindAllStringSubmatch(cu, -1) {
refs = append(refs, strings.ToLower(m[1]))
}
return part + ":" + strings.Join(refs, ".")
}
// LoadObligationJoinKeys reads the Registry contract and indexes it by citation-unit key.
func LoadObligationJoinKeys(path string) (*ObligationJoinKeys, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var o ObligationJoinKeys
if err := json.Unmarshal(raw, &o); err != nil {
return nil, err
}
o.byCitationKey = map[string][]string{}
for _, ob := range o.ObligationIDs {
for _, cu := range ob.CitationUnits {
k := citationUnitKey(cu)
o.byCitationKey[k] = append(o.byCitationKey[k], ob.ObligationID)
}
}
return &o, nil
}
// ObligationsForCitation returns the obligation_ids that join (interim) to a citation
// reference such as a control_mapping.source_norm.
func (o *ObligationJoinKeys) ObligationsForCitation(citationRef string) []string {
return o.byCitationKey[citationUnitKey(citationRef)]
}
// FindObligation returns the registry entry for an obligation_id (nil if unknown).
func (o *ObligationJoinKeys) FindObligation(obligationID string) *ObligationKey {
for i := range o.ObligationIDs {
if o.ObligationIDs[i].ObligationID == obligationID {
return &o.ObligationIDs[i]
}
}
return nil
}
// mappingReaches reports whether a control mapping reaches an obligation — EXACT via the
// adopted obligation_id (semantic, preferred), else via the interim citation_unit join (for
// not-yet-adopted rows). Once obligation_id is set, the coarse citation_unit match is ignored:
// that is how the semantic join replaces the structural one (e.g. V11.2.1 crypto no longer
// rides (2)(d) into user_authentication_required — it goes to credential_confidentiality_protection).
func mappingReaches(m ControlMapping, ob ObligationKey, citationKeys map[string]bool) bool {
if m.ObligationID != "" {
return m.ObligationID == ob.ObligationID
}
return citationKeys[citationUnitKey(m.SourceNorm)]
}
// AcceptedControlsForObligation returns our accepted control mappings that reach an obligation
// (deduped by target control), obligation_id-exact where adopted, citation_unit otherwise.
func AcceptedControlsForObligation(ob ObligationKey, mappings *ControlMappingSet) []ControlMapping {
keys := make(map[string]bool, len(ob.CitationUnits))
for _, cu := range ob.CitationUnits {
keys[citationUnitKey(cu)] = true
}
out := []ControlMapping{}
seen := map[string]bool{}
for _, m := range mappings.All {
if !m.IsAccepted() || !mappingReaches(m, ob, keys) {
continue
}
ck := m.TargetFramework + ":" + m.TargetControl
if seen[ck] {
continue
}
seen[ck] = true
out = append(out, m)
}
return out
}
// ObligationCoverage is one row of the cross-session coverage report.
type ObligationCoverage struct {
ObligationID string `json:"obligation_id"`
Family string `json:"family"`
Status string `json:"status"` // covered | mapped_rejected | uncovered
AcceptedControls []string `json:"accepted_controls"`
EvidenceCount int `json:"evidence_count"`
}
// ComputeObligationCoverage joins the Registry obligations to our control mappings — exact via
// obligation_id where adopted, else via the interim citation_unit join — and reports per
// obligation: covered (>=1 accepted control reaches it), mapped_rejected (only rejected
// mappings reach it), or uncovered. The signal back to the Obligation session.
func ComputeObligationCoverage(joins *ObligationJoinKeys, mappings *ControlMappingSet, evidence *EvidenceRequirementSet) []ObligationCoverage {
out := make([]ObligationCoverage, 0, len(joins.ObligationIDs))
for _, ob := range joins.ObligationIDs {
keys := make(map[string]bool, len(ob.CitationUnits))
for _, cu := range ob.CitationUnits {
keys[citationUnitKey(cu)] = true
}
cov := ObligationCoverage{ObligationID: ob.ObligationID, Family: ob.Family}
seen := map[string]bool{}
rejected := false
for _, m := range mappings.All {
if !mappingReaches(m, ob, keys) {
continue
}
if m.IsAccepted() {
ck := m.TargetFramework + ":" + m.TargetControl
if !seen[ck] {
seen[ck] = true
cov.AcceptedControls = append(cov.AcceptedControls, ck)
cov.EvidenceCount += len(evidence.RequiredFor(m.TargetFramework, m.TargetControl))
}
} else if m.MappingStatus == "rejected" {
rejected = true
}
}
switch {
case len(cov.AcceptedControls) > 0:
cov.Status = "covered"
case rejected:
cov.Status = "mapped_rejected"
default:
cov.Status = "uncovered"
}
out = append(out, cov)
}
return out
}
@@ -0,0 +1,61 @@
package ucca
import "testing"
func TestCitationUnitKey_Join(t *testing.T) {
// our source_norm and the registry citation_unit must collapse to the SAME key.
if citationUnitKey("CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff") != citationUnitKey("Annex I (2)(c)") {
t.Errorf("interim join broken: %q vs %q",
citationUnitKey("CRA Annex I Part I (2)(c)"), citationUnitKey("Annex I (2)(c)"))
}
// Part II must NOT collide with Part I.
if citationUnitKey("Annex I Part II (1)") == citationUnitKey("CRA Annex I Part I (2)(c)") {
t.Error("Part II must not join to Part I")
}
}
func TestLoadObligationJoinKeys(t *testing.T) {
o, err := LoadObligationJoinKeys("../../../obligations/obligation_join_keys.json")
if err != nil {
t.Fatalf("load: %v", err)
}
if o.Count != len(o.ObligationIDs) {
t.Errorf("count %d != len %d", o.Count, len(o.ObligationIDs))
}
if len(o.ObligationIDs) == 0 {
t.Fatal("empty contract")
}
if got := o.ObligationsForCitation("CRA Annex I Part I (2)(c)"); len(got) == 0 {
t.Error("expected an obligation joined to (2)(c)")
}
}
func TestObligationCoverage_Report(t *testing.T) {
joins, err := LoadObligationJoinKeys("../../../obligations/obligation_join_keys.json")
if err != nil {
t.Fatalf("join keys: %v", err)
}
maps, err := LoadControlMappings("../../data/control_mappings")
if err != nil {
t.Fatalf("mappings: %v", err)
}
ev, err := LoadEvidenceRequirements("../../data/evidence_requirements")
if err != nil {
t.Fatalf("evidence: %v", err)
}
cov := ComputeObligationCoverage(joins, maps, ev)
if len(cov) == 0 {
t.Fatal("no coverage computed")
}
byStatus := map[string]int{}
for _, c := range cov {
byStatus[c.Status]++
}
t.Logf("COVERAGE: %d Obligations | covered=%d mapped_rejected=%d uncovered=%d",
len(cov), byStatus["covered"], byStatus["mapped_rejected"], byStatus["uncovered"])
for _, c := range cov {
if c.Status != "uncovered" {
t.Logf(" %-15s %-36s controls=%v evidence=%d", c.Status, c.ObligationID, c.AcceptedControls, c.EvidenceCount)
}
}
}
@@ -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,
),
]

Some files were not shown because too many files have changed in this diff Show More