"""Neutral CRA applicability verdict for the 'Eingangstür' readiness check. Separates LEGAL obligation from MARKET pull — the distinction SME manufacturers actually care about: a product may not yet be legally in CRA scope, but its B2B customers (machine/plant builders) will demand CRA evidence anyway. Key rule: not the development date but the PLACING ON THE MARKET (Inverkehrbringen) decides. A legacy product (e.g. designed 2019) still placed on the market after the CRA cutoff must be conformant. Pure + deterministic: no DB, no LLM, no network. Reuses the existing Annex III/IV classifier (passed in as `cra_class`) — this is the verdict layer on top, not a fourth classifier. """ CRA_CUTOFF = "2027-12-11" # CRA main obligations apply to products placed on the market from here # Verdict tiers ZWINGEND = "zwingend" RATSAM = "ratsam" NICHT_BETROFFEN = "nicht_betroffen" _TIER_LABEL = { ZWINGEND: "CRA zwingend (Rechtspflicht)", RATSAM: "CRA nicht zwingend, aber dringend ratsam", NICHT_BETROFFEN: "CRA nicht betroffen", } # Producer archetypes — drive default assumptions + verdict emphasis. COMPONENT = "component" # Zulieferteil/Komponente (B2B), z. B. OWIS PS90+ END_DEVICE = "end_device" # Endgerät MACHINE_INTEGRATOR = "machine_integrator" # Anlagen-/Maschinenbauer — Internet/OTA meist gegeben SOFTWARE_APP = "software_app" # App/Frontend/Cloud, keine Hardware PRODUCER_TYPES = (COMPONENT, END_DEVICE, MACHINE_INTEGRATOR, SOFTWARE_APP) # Standard CRA evidence the cyber technical file needs (Annex I/II + Art. 13/14). EVIDENCE_ITEMS = [ {"key": "sbom", "label": "Software-Stückliste (SBOM)"}, {"key": "vdp", "label": "Vulnerability-Disclosure-Policy / Security-Kontakt"}, {"key": "patch_process", "label": "Patch-/Update-Prozess (signiert)"}, {"key": "support_lifecycle", "label": "Support-Zeitraum / Lifecycle-Zusage"}, {"key": "threat_model", "label": "Threat Model / Cyber-Risikobeurteilung"}, {"key": "security_logging", "label": "Security-Logging"}, {"key": "auth_concept", "label": "Authentisierung / Passwortkonzept (Netzwerk)"}, {"key": "incident_process", "label": "Incident-/Meldeprozess (Art. 14, 24/72h)"}, ] _EVIDENCE_KEYS = {e["key"] for e in EVIDENCE_ITEMS} def in_scope(cra_class: str) -> bool: """A product is in CRA scope once it has any digital element / Annex match.""" return (cra_class or "").upper() not in ("", "NOT_IN_SCOPE") def compute_verdict( cra_class: str, placed_on_market_after_cutoff, producer_type: str = "", customers_request: bool = False, ) -> dict: """Neutral 3-tier verdict. `placed_on_market_after_cutoff`: True/False/None (None = unknown → assumed True, conservative: most products keep being sold).""" scope = in_scope(cra_class) after_cutoff = True if placed_on_market_after_cutoff is None else bool(placed_on_market_after_cutoff) # Components are inherently subject to downstream demand even absent an explicit signal. market_pull = bool(customers_request) or producer_type == COMPONENT reasons: list = [] if not scope: tier = NICHT_BETROFFEN reasons.append("Keine digitalen Elemente / keine CRA-Kategorie erkannt.") if market_pull: reasons.append("Hinweis: Kunden könnten dennoch CRA-Nachweise anfragen.") elif after_cutoff: tier = ZWINGEND reasons.append( f"Produkt mit digitalen Elementen und Inverkehrbringen ab {CRA_CUTOFF} → Rechtspflicht." ) reasons.append("Maßgeblich ist das Inverkehrbringen, nicht der Entwicklungszeitpunkt.") else: tier = RATSAM reasons.append("Produkt mit digitalen Elementen, aber kein Inverkehrbringen im CRA-Geltungszeitraum.") reasons.append(f"Sobald ab dem {CRA_CUTOFF} (weiter) in Verkehr gebracht wird, wird CRA zwingend.") if market_pull and tier != NICHT_BETROFFEN: reasons.append("Markt-Druck: B2B-Kunden (Maschinen-/Anlagenbauer) fordern CRA-Nachweise zur Komponente.") return { "tier": tier, "label": _TIER_LABEL[tier], "in_scope": scope, "market_pull": market_pull, "cra_class": (cra_class or "").upper(), "cutoff": CRA_CUTOFF, "placed_on_market_after_cutoff": after_cutoff, "reasons": reasons, } def maturity(provided_evidence_keys) -> dict: """Reifegrad over the standard CRA evidence checklist.""" have = {k for k in (provided_evidence_keys or []) if k in _EVIDENCE_KEYS} present = [e for e in EVIDENCE_ITEMS if e["key"] in have] missing = [e for e in EVIDENCE_ITEMS if e["key"] not in have] total = len(EVIDENCE_ITEMS) pct = round(100.0 * len(present) / total) if total else 0 return {"pct": pct, "present": present, "missing": missing, "total": total} # --- Machinery Regulation (2023/1230) applicability — person-safety axis --- # A bare control component is not "machinery" itself, but if its function can # endanger persons (movement, laser, stored energy …) it is safety-relevant: the # duty hits the machine builder, the component maker supplies evidence, and a # cyber compromise can defeat a safety function (the CRA × MaschinenVO bridge). HAZARD_TYPES = [ {"key": "movement_crush", "label": "Bewegung / Quetschen"}, {"key": "laser_radiation", "label": "Laser / Strahlung (Auge)"}, {"key": "force_energy", "label": "Kraft / gespeicherte Energie"}, {"key": "temperature", "label": "Temperatur / Hitze"}, {"key": "electrical", "label": "Elektrische Gefährdung"}, ] _HAZARD_KEYS = {h["key"]: h["label"] for h in HAZARD_TYPES} MV_DIREKT = "direkt" MV_SICHERHEITSRELEVANT = "sicherheitsrelevant" MV_NICHT = "nicht_relevant" _MV_LABEL = { MV_DIREKT: "Maschinenverordnung direkt betroffen", MV_SICHERHEITSRELEVANT: "Sicherheitsrelevante Komponente (indirekt)", MV_NICHT: "Maschinenverordnung nicht relevant", } def compute_machinery_verdict( producer_type: str = "", is_machinery: bool = False, safety_relevant: bool = False, hazard_types=None, is_safety_component: bool = False, ) -> dict: """3-tier Machinery-Regulation verdict + cyber→safety bridge flag. `safety_relevant`: can the function endanger persons on fault OR manipulation?""" hazards = [{"key": k, "label": _HAZARD_KEYS[k]} for k in (hazard_types or []) if k in _HAZARD_KEYS] direct = bool(is_machinery) or producer_type == MACHINE_INTEGRATOR or bool(is_safety_component) reasons: list = [] if direct: tier = MV_DIREKT reasons.append("Maschine/Anlage bzw. Sicherheitsbauteil → MaschinenVO-Pflichten direkt.") elif safety_relevant or hazards: tier = MV_SICHERHEITSRELEVANT reasons.append("Komponente, deren Funktion bei Fehler oder Manipulation Personen gefährden kann.") reasons.append("MaschinenVO-Pflicht trifft den Maschinenbauer; als Zulieferer liefern Sie Sicherheits-/Cyber-Nachweise zu.") else: tier = MV_NICHT reasons.append("Keine Personengefährdung erkennbar.") bridge = tier in (MV_DIREKT, MV_SICHERHEITSRELEVANT) if bridge: reasons.append( "Cyber-trifft-Safety: Ein Angriff auf die Steuerung kann eine Sicherheitsfunktion aushebeln " "(Geschwindigkeit/Position/Verriegelung) → Personenschaden." ) return { "tier": tier, "label": _MV_LABEL[tier], "hazards": hazards, "cyber_safety_bridge": bridge, "reasons": reasons, }