diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index b4b3b98..fdad54d 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -3437,6 +3437,17 @@ Status: - [ ] Stufe 2: BSI-Korpus, manuelle Zuordnung, Migration (separate Freigabe) - [x] `Update from BSI` mit Sicherheitsmodell-Aktualisierung (Data-Hub-Kachel, in Produktversion 2.22.0 enthalten, 2026-10-08) +#### Client-Info-Tab `Compliance`: Uebersichtszeile (Nutzerentscheidung 2026-10-08, umgesetzt) + +Nach Freigabe eines lokalen, nicht versionierten Mockups mit synthetischen Daten steht ueber der unveraenderten Frameworkkachelzeile eine Uebersichtszeile in denselben sechs Spuren. Reiner Engine-Code (`src/compliance/ui/ComplianceOverview.tsx`, `compliance.css`, `server/compliance/regulatory-mapping-service.ts`); kein Erweiterungspunkt und keine Core-Datei geaendert. + +- `Mapping coverage` (quadratisch, zwei Spuren): gemeinsamer Client-Info-Donut ueber die zuordenbaren Abweichungen des Timeslots (Scannerbefunde und GPO-Einstellungen, eine GPO-Einstellung einmal). Jede Abweichung zaehlt genau einmal im staerksten Zustand ihrer Regel: akzeptierte Kontrolle, sonst erfolgreiche Zuordnung ohne akzeptierte Kontrolle, sonst fehlgeschlagene juengste Zuordnung, sonst noch nicht zugeordnet (auch wartend oder laufend). `accepted` ist identisch mit `Findings` der ISO-Kachel. Die Antwort `coverage` enthaelt nur Zahlen; die Work-Filterzuordnungen tragen sie nicht. +- `Annex A footprint` (vier Spuren): alle 93 Kontrollen nach Thema (Organizational | People ueber Physical | Technological), Kontroll-ID je Zelle, Schattierung in einem neutralen Diagrammton in vier Stufen (1-2, 3-9, 10-24, 25+), keine Status- oder Severityfarbe. Nur Kontrollen und Themen mit Treffern oeffnen Work (`Deviation` plus `ISO control` beziehungsweise `ISO theme`), wie die Facetten. Eine Detailzeile nennt bei Hover oder Fokus ID, Thema, Titel und Befundzahl. Nutzerauftrag derselben Sitzung: auch Kontrollen ohne Befund tragen ihren Titel; er stammt aus den ISO/IEC-27001-Referenzdokumenten des aktiven lizenzierten Korpus (`annexATitles`, nur Titel, nie Klauseltext, je Korpusversion zwischengespeichert, ohne Index keine Titel), der Titel eines Zuordnungslaufs hat Vorrang. ISO-Texte werden weiterhin nicht im Repository gebuendelt. Die Legende sagt ausdruecklich, dass eine leere Zelle keine Konformitaetsaussage ist. +- Nutzerauftrag 2026-10-08, spaeter am Tag: Zwei Schaltflaechen neben der Ueberschrift (`ISO 27001/27002`, `BSI Grundschutz++`, Client-Info-Tabknoepfe mit `aria-pressed`) schalten nur die Uebersichtszeile um; die Frameworkkacheln bleiben unveraendert, Standard ist ISO. Die BSI-Ansicht (Nutzerwahl) zeigt `Mapping coverage` mit fuenf Zustaenden (mit BSI-Anforderung, ISO-Kontrolle ohne BSI-Anforderung, zugeordnet ohne akzeptierte Kontrolle, noch nicht zugeordnet, fehlgeschlagen; die ersten beiden teilen `accepted`) und `Kernel footprint` mit allen 901 Kernel-Anforderungen als unbeschriftete Zellen je Praktik in Katalogreihenfolge. Die 629 Anforderungen, die die BSI-Zuordnung von keiner ISO-Kontrolle erreicht, tragen nur eine kleine neutrale Markierung, damit eine leere Zelle nicht als Luecke dieses Clients gelesen wird. Hover oder Fokus nennen ID, Praktik, Titel und ISO-Weg abgeleiteter Anforderungen und die Befundzahl; nur Anforderungen und Praktiken mit Treffern oeffnen Work (`BSI requirement`, `BSI practice`). Nutzerkorrekturen derselben Sitzung: Jede Anforderung ist ein Quadrat mit nur ihrer Nummer innerhalb der Praktik (zum Beispiel `10.5`; das Praefix nennt die Praktikueberschrift), auch ohne Befund oder ohne ISO-Erreichbarkeit (gestrichelter Rand). Die Kachel zeigt nur die zwei Praktiken mit den meisten Befunden (absteigend, Gleichstand in Katalogreihenfolge), damit die Quadrate lesbar bleiben; `Show all` oeffnet alle Praktiken mit groesseren Quadraten (14px-Datenrolle) im gemeinsamen Results-Fenster, ein Work-Sprung dort schliesst das Fenster. Ohne abgeleitete Anforderung nennt die Kachel das ausdruecklich. Im `Show all`-Fenster bleiben Detailzeile und Legende unten angeheftet, waehrend das Raster scrollt; die Detailzeile nennt fuer jede Anforderung den veroeffentlichten Titel, auch ohne Zuordnung oder ISO-Erreichbarkeit. Die Kontrolluebersicht liefert dafuer `bsiFootprint` (IDs, Titel unter CC BY-SA 4.0 und Erreichbarkeit aus dem aktiven Katalog, keine Anforderungstexte); die Work-Filterzuordnungen tragen ihn nicht. +- Nutzerauftrag 2026-10-08 (Core-Freigabe Presentation): Die Legendenzeile `Mapping failed` der `Mapping coverage` (ISO- und BSI-Ansicht) ist bei mindestens einem Befund ueber die ganze Zeile klickbar und oeffnet Work mit dem Status `Deviation` und der neuen Compliance-Engine-Facette `ISO mapping` = `Mapping failed`; dort laesst sich jeder Befund ueber `Map ISO controls` im Finding Reader neu zuordnen. Die Facette steht nach `ISO theme`, erscheint nur, solange eine Zuordnung fehlgeschlagen ist, und matcht wie die Kontrollfacetten (Scannerbefunde nach Vorkommen, GPO-Einstellungen nach Scope und technischer Identitaet). Grundlage ist `failedFindings` der Work-Zuordnungen: zuordenbare Befunde, deren Regel keine akzeptierte Kontrolle hat und deren juengster Lauf fehlschlug, also dieselbe Zustandsregel wie die Abdeckungszahl. Core-Aenderung nur der neue Drilldown in `DashboardPanel.tsx` neben den bestehenden ISO-/BSI-Drilldowns. +- Nutzerauftrag 2026-10-08, spaeter (Core-Freigabe Presentation): Auch `ISO control, no BSI requirement` der BSI-Abdeckung oeffnet Work, ueber den zweiten Wert gleichen Namens der Facette `ISO mapping` (Befunde mit akzeptierter ISO-Kontrolle, von denen keine eine BSI-Anforderung erreicht; im Browser aus den Work-Zuordnungen berechnet). Der Drilldown in `DashboardPanel.tsx` ist dafuer allgemein `onOpenMappingState(state)`. Der Tab `Compliance` zeigt unter der Tableiste dieselbe Kontextzeile wie die Overview mit `Client` und `Assessment` (Timeslot-Wechsel), ohne `Source`, weil der Tab immer den ganzen Timeslot abdeckt (Nutzerzustimmung zum Vorschlag). +- Waehrend des Ladens Gedankenstrich, nichts klickbar; bei ausgeschalteter Engine nichts. Verifiziert durch synthetische UI- und Diensttests, `npm test` und beide Builds; Sichtpruefung beider Themes in der laufenden App mit gewaehltem Client steht aus (der laufende Server muss fuer `coverage` neu gestartet werden). + ## 8. Aktuell empfohlene Umsetzungsetappe 2026-09-26 - Phase 13D / Phase 13C Darstellung (2.8.3, umgesetzt und verifiziert): Catalog references startet bei aktuellem Client eingeklappt; gruen vor Ueberschrift und aktuellem/erfolgreichem Status, orange und automatisch offen bei Handlungsbedarf oder Fehler. Pruefung, Verarbeitung und leere Clients bleiben neutral. Katalogversionen bleiben eingeklappt sichtbar, Erfolg nach Update bleibt offen, manuelles Umschalten funktioniert per Maus und Tastatur. Timeslots-Zeile mit gleichem Abstand oben/unten und gemeinsam zentrierten Controls. Purple-Knight-Timeslots benennen Active Directory, Entra ID oder kombinierten Scope aus bestehenden kanonischen Namen bzw. exakt lauf-/dateigebundenen gespeicherten Metadaten/Umgebungsbeobachtungen. Ohne Scopebeleg bleibt der Originalname; keine Vermutung aus Dateinamen oder fremden Scans, keine Persistenz-/Importer-/Scoringaenderung und keine neue Reimportpflicht. 36 gezielte Interaktions-/Layout-/Versionsvertragstests, beide Produktionsbuilds, Local-only-/Designcheck und synthetische Dark-/Light-Sicht- und Tastaturpruefung bestanden. App und Backend gesund auf 2.8.3 mit kanonischer PostgreSQL. Patchversion fuer zusammengehoerige UI-Korrekturen; Phase 13D bleibt in Arbeit. Die bekannte Gesamtsuite-Baseline mit sechs fachfremden Fehlern wurde nicht erneut ausgefuehrt. @@ -4234,6 +4245,10 @@ Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projek ## 12. Änderungsprotokoll 2026-10-05 - Phase 7 / Management Report (Version 2.16.6): Zahlen an Client Info angeglichen (Report-Engine-Arbeit mit Core-Freigabe Evaluation, Core Tests und Governance; per Test und am Dummy-Client verifiziert). Nutzerentscheidung: Datengrundlage der Reports ist Raw Data der Work-Eintraege, die in Client Info angezeigten Zahlen sind verbindlich (neuer Abschnitt `Report numbers` in AGENTS.md). Behoben: Die Tabelle `Technische Domain-Auswertung` zaehlte Ergebniszeilen (Dummy: 998 bewertet/579 abweichend) statt Work-Eintraege; sie zaehlt jetzt mit demselben Klassifikator und denselben Ergebniszustaenden wie Client Info auf dem vollstaendigen Raw-Data-Blatt. Der Report-Datensatz liest dafuer dieselben aktuellen GPO-Bewertungen wie Client Info (`listCurrentPolicyEvaluationsForMetrics`). Dummy: 759/477 und je Domain 112/54/19/245/7/40 Abweichungen, identisch mit Client Info. Die vier Themen stehen in Summary, Uebersicht, Diagramm und Themenblaettern einheitlich nach Anzahl absteigend. Heatmap an der Basis behoben (Context Engine 1.11.1, Engine-Arbeit): GPO-Einstellungen mit katalogisierter Wertsemantik (Dummy: MinimumPasswordLength, PasswordComplexity, LockoutBadCount) waren ihrem Problemfeld zugeordnet, ihr ASSESSOR-Urteil `Fail` wurde aber nicht gelesen; die Heatmap zaehlte deshalb 474 statt 477. Die Engine fuehrt diese Urteile jetzt unveraendert als `declaredDeviations`; Heatmap, Client Info und Report zeigen 477 (Kennwortrichtlinie 22), `Ohne Problemkategorie` entfaellt. Synthetischer Regressionstest in `tests/assessment-context/context-engine.test.ts`. Zweite Pruefung am Dummy-Client (gleicher Release-Stand): Zuordnung in ASSESSOR vollstaendig (477 Abweichungen je genau ein Problemfeld und ein Thema, Domains gleich Client Info). Im Report korrigiert: nicht ausgefuehrt/Fehler/nicht anwendbar/unbekannt nach Work-Zustaenden (171/2/3/19 statt 170/2/3/0), Erhebungen nur Scan- und GPO-Erhebungslaeufe mit Timeslot-Datum der Quelldateien (5 im Timeslot 22.7.2026 statt 38 Verarbeitungsschritte), Risikoregister wie Client Info (1 erfasst, 0 aktuell gueltig statt 'dokumentiert'). Offen fuer Core: CIS-CAT `Manual` ist normalisiert `informational`, in Work aber `not run`. Versionsentscheidung: Patch 2.16.5 -> 2.16.6 (rueckwaertskompatible Korrekturen an Report- und Heatmap-Zahlen); Versionstests und beide Produktions-Builds bestanden. +2026-10-08 - Phase 13F / Fehlerbehebung: fruehere ISO-Entscheidung nach Neuzuordnung ablehnbar (Nutzermeldung; nur Engine-Code): Eine Neuzuordnung mit unveraenderten Pipelineversionen ueberschreibt den einzigen Lauf der Regel (deterministische Mapping-ID). Schlug das Modell eine frueher akzeptierte Kontrolle nicht mehr vor, lag sie in keinem gespeicherten Lauf mehr, und Ablehnen scheiterte mit `Decision control was not a grounded candidate.` Beide Engine-Stores (PostgreSQL, Speicher) erlauben eine Entscheidung jetzt auch, wenn fuer Regel und Kontrolle bereits eine Entscheidung besteht; die Kette `previousDecisionId` fuehrt zum begruendenden Lauf zurueck. Eine nie vorgeschlagene Kontrolle bleibt abgelehnt. Keine Migration. Regressionstest der genauen Interaktion (zuordnen, neu zuordnen, frueher akzeptierte Kontrolle ablehnen) gegen den echten Speicher-Store; alle Testdateien bestanden, beide Builds erfolgreich. + +2026-10-08 - Phase 13F / Client-Info-Tab `Compliance`: Uebersichtszeile `Mapping coverage` und `Annex A footprint` (Nutzerauftrag nach Mockup-Freigabe; nur Engine-Code, keine Core-Freigabe): Neue Zeile ueber den Frameworkkacheln mit Umschaltung ISO/BSI (BSI: Kernel footprint, in der Kachel die zwei Praktiken mit den meisten Befunden, `Show all` mit allen 901 Anforderungen), Details in Abschnitt 7, Phase 13F. Die Kontrolluebersicht liefert zusaetzlich `coverage` und `bsiFootprint` (Zahlen sowie IDs, Titel und Erreichbarkeit der BSI-Anforderungen). Alle Testdateien bestanden (373, 7 uebersprungen); Local-only-, Designpruefung und beide Builds erfolgreich. Produktversion vorerst unveraendert, weil `package.json` zum Core-Bereich Platform gehoert; Anhebung auf 2.23.0 (neue Faehigkeit) nach Core-Freigabe Platform. + 2026-10-08 - Phase 13F / Client-Info-Tab `Compliance` mit `Last mapping` (Nutzerauftrag; Core-Freigabe Presentation, Governance, Platform, Core Tests; Produktversion 2.21.0): Die Compliance-Zeile ist aus der Overview in einen eigenen Client-Info-Tab `Compliance` gewandert (nach Overview und Microsoft Defender for IoT, vor `AI Overview`; sichtbar bei aktiver Engine, verbundenem Backend und gewaehltem Timeslot). Die Rahmenwerkskachel und ihr `Results`-Fenster bleiben auf Nutzerwunsch unveraendert, damit weitere Regelwerke als Kacheln folgen koennen. Neu ist der Button `Last mapping: · Automatic|Manual` (ohne Lauf `No mapping yet`), der in einem Fenster die letzten zehn Batches des Timeslots mit Start, Ausloeser, Status und den inhaltsfreien Zaehlern Findings, Sent to model, Reused, Succeeded und Failed zeigt. Die Engine liefert diese Zusammenfassungen nur in der Client-Info-Uebersicht (`mappingBatches`), ohne Scope, Filter oder Schluessel; Automatic erkennt sie an den gemeinsamen Schluesselpraefixen `auto:`/`auto-gpo:` des Watchers. Waehrend ein Batch laeuft, aktualisiert sich der Tab alle 5 s ohne Gedankenstrich-Rueckfall. Core-Aenderung (Presentation): `DashboardPanel.tsx` fuehrt den Tab und zieht `jump` vor die Tab-Rueckgaben. `AGENTS.md` haelt die Entscheidung fest. Synthetische Tests: Tab-Platzierung und Drilldowns, Last-mapping-Button und Fenster, leerer Zustand, Aktualisierung waehrend eines Laufs, Batch-Zusammenfassung im Service. `npm test` mit 2.143 bestandenen und 18 uebersprungenen Tests, beide Produktionsbuilds erfolgreich. Versionsentscheidung: neue rueckwaertskompatible Faehigkeit, Minor 2.20.0 -> 2.21.0. Darstellung in beiden Themes nicht live geprueft, weil die laufende Anwendung reale Daten enthaelt. Kein Reimport. Nachtrag desselben Tages (Nutzerrueckmeldung "laedt jedes Mal neu", nur Engine-Code): Der Tab wird beim Wechsel ausgehaengt und begann deshalb jedes Mal mit Gedankenstrichen. Die Engine merkt sich die letzte Uebersicht je Kunde und Timeslot im Speicher (hoechstens acht Timeslots, nur Laufzeit), zeigt sie beim Zurueckkehren sofort und fordert bei jedem Besuch, jeder Entscheidung und waehrend eines Laufs trotzdem eine frische Uebersicht an, die sie ersetzt; ein fehlgeschlagenes Auffrischen laesst die gezeigten Werte stehen. Synthetischer Test fuer Verlassen und Zurueckkehren (sofort Werte, Hintergrundabfrage, Ersetzung). `npm test` 2.144 bestanden; ein lastbedingter Zeitfehler in `sheet-selection-cost` besteht einzeln; beide Produktionsbuilds erfolgreich; Version bleibt 2.21.0. Zweiter Nachtrag (Nutzerrueckmeldung "zeigt nur Batches, nicht die Einzelzuordnungen", nur Engine-Code): Jeder Zuordnungslauf speichert jetzt seinen Ausloeser (`trigger: single | batch`, im JSON des Engine-Laufs, keine Migration). Das Fenster `ISO mapping history` zeigt neben den Batches die Einzelzuordnungen (`Single finding`) der Regeln mit Abweichungen im Timeslot, gemeinsam nach Startzeit sortiert, hoechstens zehn Eintraege; Zaehler je Einzelzuordnung: Findings des Timeslots zu dieser Regel, 1 an das Modell, Erfolg oder Fehler. Einzelzuordnungen sind global je Regel und koennen deshalb aus einem anderen Kunden stammen. Laeufe vor 2.21.0 tragen keinen Ausloeser und erscheinen nicht als Einzelzuordnung, weil sie nicht von Batch-Laeufen zu unterscheiden sind. Behoben: Die Ausloeserspalte erschien in 16 px, weil die gemeinsame Tabelle ihre zweite Spalte als Wert formatiert; die Verlaufstabelle haelt 14 px. `npm test` 2.144 bestanden, beide Produktionsbuilds erfolgreich. Offen: `AGENTS.md` nennt noch "die letzten zehn Batches" und schliesst Einzelzuordnungen aus (Governance nicht freigegeben). Dritter Nachtrag (Nutzerentscheidung 2026-10-08 "Zuordnungen sind global, also zeigt der Verlauf alles"; Auswahl "Everything, all clients" und 50 Eintraege): Der Verlauf listet jetzt jeden Batch aller Kunden und jede Einzelzuordnung, gemeinsam nach Startzeit, hoechstens 50. Eine neue Spalte `Scope` sagt `This timeslot`, `Other timeslot` (Batch eines anderen Kunden oder Timeslots) oder `All clients` (Einzelzuordnung); der Button `Last mapping` zeigt den neuesten Eintrag global und nennt den Scope, wenn es nicht dieser Timeslot ist. Einzelzuordnungen haben keine eigene Findingzahl (Gedankenstrich); Batches anderer Kunden zeigen ihre eigenen Zaehler. Eintraege bleiben inhaltsfrei (Zeit, Ausloeser, Scope-Markierung, Zaehler; kein Kunde, keine Regel, keine Scope-IDs). `npm test` 2.145 bestanden, beide Produktionsbuilds erfolgreich. Offen wie zuvor: `AGENTS.md`-Wortlaut (Governance). Vierter Nachtrag (Core-Freigabe Governance): `AGENTS.md` haelt den globalen Verlauf (alle Batches aller Kunden und alle Einzelzuordnungen, hoechstens 50, Spalte Scope, Ausloeser je Lauf ab 2.21.0) und den begrenzten Speicher beim Zurueckkehren zum Tab als spaetere Nutzerentscheidung desselben Tages fest; die offenen Punkte zum Wortlaut sind erledigt. Fuenfter Nachtrag (Core-Freigabe Core Tests): Der in den vorigen Laeufen lastbedingt fehlschlagende Test `tests/features/sheet-selection-cost.test.tsx` erzeugte 2.537 synthetische Arbeitsblaetter und damit zweimal Tausende jsdom-Optionen (allein rund 2,4 s, unter Parallellast ueber dem 5-s-Limit). Weil der Sheet-Selektor seine Optionen unabhaengig von der Anzahl zurueckhaelt, prueft er jetzt dasselbe Verhalten mit 300 Blaettern (162 ms); alle Zusicherungen bleiben (eine Option bis Fokus, alle nach Fokus und Pointer, Auswahl erhalten). Drei aufeinanderfolgende vollstaendige `npm test`-Laeufe mit je 2.145 bestandenen und 18 uebersprungenen Tests ohne Fehler. Sechster Nachtrag (Nutzerkorrektur "Scope soll sagen, woher die Zuordnung kam", nur Engine-Code): Die Spalte `Scope` nennt jetzt die Herkunft relativ zum gezeigten Timeslot: `This timeslot`, `Other timeslot` (derselbe Kunde), `Other client` oder `Unknown`. Batches tragen Kunde und Timeslot bereits. Eine Einzelzuordnung sendet aus dem Finding Reader den gezeigten Timeslot mit; der Server prueft ihn gegen den Kunden und speichert nur SHA-256-Merkmale von Kunde, Assessment und Scanlauf (`origin`), weil Zuordnungslaeufe global sind und mit jedem Kundenexport ausgeliefert werden; ein ungueltiger Timeslot hinterlaesst nur den Kunden. Einzelzuordnungen ohne gespeicherte Herkunft (vor dieser Aenderung) erscheinen als `Unknown`. Synthetische Tests: alle vier Herkunftsfaelle fuer Batches und Einzelzuordnungen, keine Klar-IDs im gespeicherten Lauf, ungueltiger Timeslot, Finding Reader sendet den Timeslot. `npm test` 2.147 bestanden, beide Produktionsbuilds erfolgreich. Offen: `AGENTS.md` nennt noch `Other timeslot` fuer jeden anderen Batch und `All clients` fuer Einzelzuordnungen (Governance nicht freigegeben). Siebter Nachtrag (Core-Freigabe Governance): `AGENTS.md` haelt die Herkunftsspalte (`This timeslot`, `Other timeslot`, `Other client`, `Unknown`) und die gehashte Herkunft der Einzelzuordnungen als Nutzerkorrektur fest; der offene Punkt ist erledigt. 2026-10-08 - Phase 13F / Automatische ISO-Zuordnung neuer Uploads (Nutzerauftrag; Engine-Code, dazu Core-Freigabe Platform, Core Tests; Produktversion 2.20.0): Die Compliance Engine ordnet neu hochgeladene Scans und GPO-Auswertungen automatisch ISO-Kontrollen zu, solange der neue Schalter `Map new scans automatically` in der Data-Hub-Kachel `ISO Control Corpus` aktiv ist (Standard aus). `server/compliance/regulatory-auto-mapping.ts` liest den Core ausschliesslich ueber `ComplianceReadPort`: alle 10 s die Projekt-, Timeslot- und Scanlauflisten sowie je Timeslot die GPO-Abweichungszeilen als gefilterte Result-DB-Seiten (`audience: result-db`, `sourceKind: policy-evaluation`, `deviation: true`), weil GPO-Backups keinen Scanlauf erzeugen. Je neuem, nicht abgeloesten Scanlauf startet der bestehende Batch ueber dessen Abweichungen; je Timeslot mit neuen GPO-Abweichungszeilen ein Batch ueber dessen GPO-Abweichungen. Bereits zugeordnete Regeln werden wiederverwendet, sodass nur neue Regeln das Modell erreichen; die vorlaeufige automatische Annahme der ersten zwei Vorschlaege gilt unveraendert. Was beim Start oder Einschalten existiert, ist die Basislinie und wird nicht nachtraeglich zugeordnet; ohne aktiven Korpus warten neue Uploads, bis die Zuordnung verfuegbar ist. Idempotenzschluessel je Scanlauf bzw. je Menge neuer GPO-Zeilen verhindern doppelte Batches. Der Schalter liegt in der Engine-eigenen Datei `regulatory-corpora/auto-mapping.json` (keine Tabelle, keine Migration) und wird im Corpus-Status als `autoMapping` geliefert. Core-Aenderungen (Platform): Route `PUT /reference-data/iso-corpus/auto-mapping` und Methode `setIsoAutoMapping` im API-Client. Synthetische Tests: Watcher (Standard aus ohne Core-Lesezugriff, Basislinie, neuer Scan, neue GPO-Zeilen mit getrennten Batches, abgeloeste Laeufe, Warten auf Verfuegbarkeit, Persistenz und Ausschalten), HTTP-Route und Klickinteraktion der Kachel. `npm test` mit 2.138 bestandenen und 18 uebersprungenen Tests, beide Produktionsbuilds erfolgreich. Versionsentscheidung: rueckwaertskompatible neue Faehigkeit, Minor 2.19.1 -> 2.20.0. Offen: Der Engine-Abschnitt in `AGENTS.md` nennt den Schalter noch nicht (Governance nicht freigegeben); Darstellung in beiden Themes nicht live geprueft, weil die laufende Anwendung reale Daten enthaelt. Kein Phasenstatus geaendert, kein Reimport. Nachtrag desselben Tages (Nutzerauftrag mit Core-Freigabe Presentation): Das Abschalten der Compliance Engine im Engines-Popup schaltet die automatische Zuordnung serverseitig aus. `src/app/engine-preferences.ts` exportiert dafuer `subscribeEnginePreferences`; das Engine-Modul `src/compliance/ui/auto-mapping-engine-link.ts` wird mit der Data-Hub-Kachel beim App-Start geladen und sendet beim Wechsel von an nach aus einmal `setIsoAutoMapping(false)`. Erneutes Einschalten laesst die Zuordnung aus; laufende Batches werden nicht abgebrochen. Die lokale Engine-Wahl wirkt damit auf eine serverweite Einstellung (bei gemeinsamem Server fuer alle Nutzer). Dauerhafter Interaktionstest ueber das Engines-Popup (aus sendet einmal, an sendet nichts, andere Engine sendet nichts); `npm test` mit 2.140 bestandenen und 18 uebersprungenen Tests, beide Produktionsbuilds erfolgreich; Version bleibt 2.20.0 (derselbe noch nicht festgeschriebene Stand). Zweiter Nachtrag (Core-Freigabe Governance): `AGENTS.md` haelt Schalter, Watcher, Basislinie, Wiederverwendung und die Kopplung an das Abschalten der Compliance Engine als Nutzerentscheidung vom 2026-10-08 fest; der offene Punkt dazu ist erledigt. diff --git a/server/compliance/postgres-compliance-store.ts b/server/compliance/postgres-compliance-store.ts index c32ad31..2c2dae7 100644 --- a/server/compliance/postgres-compliance-store.ts +++ b/server/compliance/postgres-compliance-store.ts @@ -182,14 +182,16 @@ export class PostgresComplianceStore implements ComplianceStore { if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded') { throw new RepositoryNotFoundError('Completed regulatory mapping does not exist for this rule.'); } - if (!run.candidates.some(candidate => candidate.controlId === decision.controlId)) { - throw new RepositoryValidationError('Decision control was not a grounded candidate.'); - } const latest = dataRow(await client.query>(` SELECT data FROM assessor_regulatory_mapping_decisions WHERE rule_key = $1 AND control_id = $2 ORDER BY created_at DESC, id DESC LIMIT 1 `, [decision.ruleKey, decision.controlId])); + // A control with an earlier decision stays decidable after a remap no longer retrieves it; the chain of + // previous decisions leads back to the run that grounded it. + if (!latest && !run.candidates.some(candidate => candidate.controlId === decision.controlId)) { + throw new RepositoryValidationError('Decision control was not a grounded candidate.'); + } if ((expectedLatestDecisionId ?? undefined) !== (latest?.id ?? undefined)) { throw new RepositoryConflictError('Regulatory mapping decision changed before this update.'); } diff --git a/server/compliance/regulatory-mapping-service.ts b/server/compliance/regulatory-mapping-service.ts index 67db18c..cd0ce44 100644 --- a/server/compliance/regulatory-mapping-service.ts +++ b/server/compliance/regulatory-mapping-service.ts @@ -39,7 +39,7 @@ import type { UnifiedResultProjection } from '../../src/domain/unified-result'; import { privacySafeFinding } from '../../src/domain/privacy-safe-finding'; import type { RegulatoryFindingRetrievalService } from './regulatory-finding-retrieval'; import { bundledBsiCatalogs, type BsiCatalogs } from './bsi-snapshots'; -import { bsiOverview, type BsiOverview } from '../../src/compliance/bsi-grundschutz'; +import { bsiFootprint, bsiOverview, type BsiOverview } from '../../src/compliance/bsi-grundschutz'; import type { RegulatoryChunkIndex } from './qdrant-regulatory-index'; import { buildRegulatoryFindingQuery, isRegulatoryMappableResult } from '../../src/compliance/regulatory-query'; import { canonicalJson } from '../../src/shared/canonical-json'; @@ -77,6 +77,7 @@ export class RegulatoryMappingService { }> = []; private activeWorkerCount = 0; private activationPending = false; + private titleCache?: { key: string; titles: Record }; constructor( private readonly read: ComplianceReadPort, @@ -271,10 +272,34 @@ export class RegulatoryMappingService { /** BSI Kernel requirements derived from accepted ISO controls through the active BSI mapping (Phase 13F, stage 1). */ private async bsiOverview(acceptedIsoControlIds: readonly string[]): Promise { - const { catalog, crosswalk } = this.options.bsiCatalogs ? await this.options.bsiCatalogs() : bundledBsiCatalogs; + const { catalog, crosswalk } = await this.bsiCatalogs(); return bsiOverview(acceptedIsoControlIds, crosswalk, catalog); } + /** + * Annex A control titles of the active licensed corpus (one ISO/IEC 27001 reference document per control), for the + * Client Info footprint; only titles, never clause text. Cached per corpus version; an unavailable index yields none. + */ + private async annexATitles(): Promise> { + const { index, corpusId, corpusVersion } = this.options; + if (!index || !corpusId || !corpusVersion) return {}; + const key = `${corpusId}\u0000${corpusVersion}`; + if (this.titleCache?.key === key) return this.titleCache.titles; + try { + const chunks = await index.list({ corpusId, corpusVersion, sourceStandard: 'ISO/IEC 27001', retrievalRole: 'reference' }, 100); + const titles: Record = {}; + for (const chunk of chunks) if (chunk.controlId && chunk.title?.trim() && !titles[chunk.controlId]) titles[chunk.controlId] = chunk.title.trim(); + if (Object.keys(titles).length) this.titleCache = { key, titles }; + return titles; + } catch { + return {}; + } + } + + private async bsiCatalogs(): Promise { + return this.options.bsiCatalogs ? this.options.bsiCatalogs() : bundledBsiCatalogs; + } + /** Accepted controls of every mappable finding in a client timeslot, for the Work `ISO control` and `ISO theme` filters. */ async controlAssignments(projectId: string, requested: Pick): Promise { return this.controlOverview(projectId, requested, { deviationsOnly: false }); @@ -302,16 +327,25 @@ export class RegulatoryMappingService { } // Control titles come from the newest succeeded run of each rule that retrieved the control. const titles = new Map>(); + const mapped = new Set(); + const latestRun = new Map(); for (const run of await this.store.listRegulatoryMappingRuns()) { - if (run.status !== 'succeeded' || !byRule.has(run.ruleKey)) continue; + if (!byRule.has(run.ruleKey)) continue; + const latest = latestRun.get(run.ruleKey); + if (!latest || run.createdAt > latest.createdAt || run.createdAt === latest.createdAt && run.id > latest.id) latestRun.set(run.ruleKey, run); + if (run.status !== 'succeeded') continue; + mapped.add(run.ruleKey); const known = titles.get(run.ruleKey) ?? new Map(); for (const candidate of run.candidates) if (!known.has(candidate.controlId)) known.set(candidate.controlId, candidate); titles.set(run.ruleKey, known); } const controls = new Map }>(); const covered = new Set(); + const unaccepted: Array<[string, Map]> = []; for (const [ruleKey, findings] of byRule) { - for (const controlId of acceptedControlIds(await this.store.listRegulatoryMappingDecisions(ruleKey))) { + const accepted = acceptedControlIds(await this.store.listRegulatoryMappingDecisions(ruleKey)); + if (!accepted.length) unaccepted.push([ruleKey, findings]); + for (const controlId of accepted) { const candidate = titles.get(ruleKey)?.get(controlId); const entry = controls.get(controlId) ?? { findings: new Map(), item: { controlId, annexAControlId: candidate?.annexAControlId ?? `A.${controlId}`, findings: [], @@ -323,12 +357,30 @@ export class RegulatoryMappingService { } } const bsi = await this.bsiOverview([...controls.keys()]); + // A finding counts once, in the strongest state of its rule: accepted, mapped, failed, else not mapped. + const mappedNoneAccepted = new Set(); + const failed = new Set(); + const notMapped = new Set(); + const failedFindings: RegulatoryControlFinding[] = []; + for (const [ruleKey, findings] of unaccepted) { + const state = mapped.has(ruleKey) ? mappedNoneAccepted : latestRun.get(ruleKey)?.status === 'failed' ? failed : notMapped; + for (const [identity, finding] of findings) { + if (covered.has(identity)) continue; + state.add(identity); + if (state === failed) failedFindings.push(finding); + } + } + const coverage = { deviations: covered.size + mappedNoneAccepted.size + failed.size + notMapped.size, accepted: covered.size, + mappedNoneAccepted: mappedNoneAccepted.size, failed: failed.size, notMapped: notMapped.size }; return { version: 1, projectId, scope: { assessmentIds: scope.assessmentIds, scanRunIds: scope.scanRunIds }, findingCount: covered.size, controls: [...controls.values()].map(({ item, findings }) => ({ ...item, findings: [...findings.values()] })) .sort((left, right) => right.findings.length - left.findings.length || left.controlId.localeCompare(right.controlId, 'en', { numeric: true })), - ...(deviationsOnly ? { mappingHistory: await this.mappingHistory(projectId, scope) } : {}), + ...(deviationsOnly ? { mappingHistory: await this.mappingHistory(projectId, scope), coverage, annexATitles: await this.annexATitles(), + bsiFootprint: await this.bsiCatalogs().then(({ catalog, crosswalk }) => bsiFootprint(crosswalk, catalog)) } + // The Work `ISO mapping` filter: findings whose rule's latest mapping failed, matched like the control filters. + : { failedFindings }), ...(bsi ? { bsi } : {}) }; } diff --git a/src/compliance/bsi-grundschutz.ts b/src/compliance/bsi-grundschutz.ts index 4dd6c65..a1b8096 100644 --- a/src/compliance/bsi-grundschutz.ts +++ b/src/compliance/bsi-grundschutz.ts @@ -243,6 +243,25 @@ export function bsiOverview(acceptedIsoControlIds: readonly string[], crosswalk: }; } +/** Every Kernel requirement by practice in catalog order with its title (CC BY-SA 4.0), and whether the BSI mapping can reach it from any ISO control. */ +export interface BsiFootprint { + catalogVersion: string; + crosswalkVersion: string; + practices: Array<{ id: string; title: string; requirements: Array<{ id: string; title: string; reachable: boolean }> }>; +} + +/** The Client Info footprint grid; independent of decisions, so it exists before anything is derived. */ +export function bsiFootprint(crosswalk: BsiIsoCrosswalk, catalog: BsiKernelCatalog): BsiFootprint { + const reachable = new Set(crosswalk.entries.flatMap(entry => entry.targets)); + return { + catalogVersion: catalog.version, + crosswalkVersion: crosswalk.version, + practices: catalog.practices.map(practice => ({ ...practice, requirements: catalog.requirements + .filter(requirement => requirement.practice === practice.id) + .map(requirement => ({ id: requirement.id, title: requirement.title, reachable: reachable.has(requirement.id) })) })) + }; +} + /** * The findings of each derived requirement: every finding of an accepted ISO control the requirement was * reached through, once per Result DB row, like the ISO theme counts. diff --git a/src/compliance/local-compliance-store.ts b/src/compliance/local-compliance-store.ts index 2479e42..6b72774 100644 --- a/src/compliance/local-compliance-store.ts +++ b/src/compliance/local-compliance-store.ts @@ -93,8 +93,10 @@ export class LocalComplianceStore implements ComplianceStore { if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded') { throw new RepositoryNotFoundError('Completed regulatory mapping does not exist for this rule.'); } - if (!run.candidates.some(candidate => candidate.controlId === decision.controlId)) throw new RepositoryValidationError('Decision control was not a grounded candidate.'); const latest = this.decisions.filter(item => item.ruleKey === decision.ruleKey && item.controlId === decision.controlId).sort(newestFirst)[0]; + // A control with an earlier decision stays decidable after a remap no longer retrieves it; the chain of + // previous decisions leads back to the run that grounded it. + if (!latest && !run.candidates.some(candidate => candidate.controlId === decision.controlId)) throw new RepositoryValidationError('Decision control was not a grounded candidate.'); if ((expectedLatestDecisionId ?? undefined) !== (latest?.id ?? undefined)) throw new RepositoryConflictError('Regulatory mapping decision changed before this update.'); const stored = clone({ ...decision, ...(latest ? { previousDecisionId: latest.id } : {}) }); this.decisions.push(stored); diff --git a/src/compliance/regulatory-mapping.ts b/src/compliance/regulatory-mapping.ts index 2f530b2..9fcbe48 100644 --- a/src/compliance/regulatory-mapping.ts +++ b/src/compliance/regulatory-mapping.ts @@ -1,5 +1,5 @@ import type { AiOverviewTokenUsage } from './ai-overview'; -import type { BsiOverview } from './bsi-grundschutz'; +import type { BsiFootprint, BsiOverview } from './bsi-grundschutz'; import type { RegulatoryFindingQuery, RegulatorySectionKind } from './regulatory-rag'; import type { ResultDbFilters } from '../domain/result-db-query'; import type { UnifiedResultProjection } from '../domain/unified-result'; @@ -231,8 +231,33 @@ export interface RegulatoryControlOverviewResponse { mappingHistory?: RegulatoryMappingHistoryEntry[]; /** BSI Kernel requirements derived from the accepted controls above; absent when none is derived. */ bsi?: BsiOverview; + /** How far the mappable deviations of the timeslot are mapped; only on the Client Info overview. */ + coverage?: RegulatoryMappingCoverage; + /** Mappable findings whose rule has no accepted control and whose latest mapping failed; only on the Work assignments. */ + failedFindings?: RegulatoryControlFinding[]; + /** Annex A control title by control id from the active licensed corpus, for the footprint; only on the Client Info overview. */ + annexATitles?: Record; + /** Every BSI Kernel requirement for the BSI footprint; only on the Client Info overview. */ + bsiFootprint?: BsiFootprint; } +/** + * Content-free counts of the mappable deviations of one timeslot, each finding in exactly one state by its rule: + * an accepted control, else a succeeded mapping without one, else a failed latest mapping, else not mapped yet + * (including queued and running). `accepted` equals the overview's `findingCount`. + */ +export interface RegulatoryMappingCoverage { + deviations: number; + accepted: number; + mappedNoneAccepted: number; + failed: number; + notMapped: number; +} + +/** The fixed number of controls per ISO/IEC 27002:2022 theme (clauses 5 to 8, 93 controls). */ +export const ANNEX_A_CONTROL_COUNTS: Readonly> = { Organizational: 37, People: 8, Physical: 14, Technological: 34 }; +export const ANNEX_A_THEME_CLAUSES: Readonly> = { Organizational: 5, People: 6, Physical: 7, Technological: 8 }; + export interface RegulatoryMappingResponse { version: 1; projectId: string; diff --git a/src/compliance/ui/ClientIsoControls.tsx b/src/compliance/ui/ClientIsoControls.tsx index 5ca893c..61f2a29 100644 --- a/src/compliance/ui/ClientIsoControls.tsx +++ b/src/compliance/ui/ClientIsoControls.tsx @@ -7,6 +7,8 @@ import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentRe import type { RegulatoryControlOverviewItem, RegulatoryControlOverviewResponse, RegulatoryControlTheme, RegulatoryMappingHistoryEntry } from '../regulatory-mapping'; import { useRegulatoryDecisionRevision } from './decision-revision'; import { bsiRequirementFindings, type BsiOverview } from '../bsi-grundschutz'; +import { ComplianceFrameworkSwitch, ComplianceOverview, type ComplianceFramework } from './ComplianceOverview'; +import type { ComplianceMappingState } from './compliance-work-facets'; import './compliance.css'; const assessorApi = new AssessorApiClient(); @@ -27,6 +29,10 @@ interface Props { onOpenBsiRequirement(requirementId: string): void; /** Opens Work filtered to this BSI practice. */ onOpenBsiPractice(practiceId: string): void; + /** Opens Work with the deviations in this `ISO mapping` facet state. */ + onOpenMappingState(state: ComplianceMappingState): void; + /** The Client Info context row (client and timeslot), shown below the tab bar like on the Overview. */ + context?: ReactNode; /** The Client Info tab bar, shown above this tab like on the AI Overview. */ navigation?: ReactNode; } @@ -53,10 +59,11 @@ function themeGroups(controls: RegulatoryControlOverviewItem[]): ThemeGroup[] { } /** - * Client Info `Compliance` tab: one tile per framework in the environment tile geometry, the complete - * control list in its Results window, and when the timeslot was last mapped. ISO/IEC 27001/27002 is the first framework. + * Client Info `Compliance` tab: an overview row (mapping coverage and Annex A footprint), one tile per framework + * in the environment tile geometry, the complete control list in its Results window, and when the timeslot was + * last mapped. ISO/IEC 27001/27002 is the first framework. */ -export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl, onOpenTheme, onOpenBsiRequirement, onOpenBsiPractice, navigation }: Props) { +export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl, onOpenTheme, onOpenBsiRequirement, onOpenBsiPractice, onOpenMappingState, navigation, context }: Props) { const available = useEnginePreferences().compliance; const revision = useRegulatoryDecisionRevision(); const id = useId(); @@ -69,6 +76,8 @@ export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = }); const [poll, setPoll] = useState(0); + // Which framework the overview row shows; the framework tiles below are unaffected. + const [framework, setFramework] = useState('iso'); useEffect(() => { if (!enabled || !projectId) { setState({ status: 'idle' }); return; } @@ -147,11 +156,19 @@ export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = return
{navigation} + {context}
-

Compliance

+
+

Compliance

+ {state.status !== 'error' && } +
{state.status === 'ready' && }
+ {state.status !== 'error' && } {/* Same tile geometry as the environment row; later frameworks add their tiles here. */}
diff --git a/src/compliance/ui/ComplianceOverview.tsx b/src/compliance/ui/ComplianceOverview.tsx new file mode 100644 index 0000000..90d1e0d --- /dev/null +++ b/src/compliance/ui/ComplianceOverview.tsx @@ -0,0 +1,288 @@ +import { useState, type ReactNode } from 'react'; +import { DonutSmallArcMarker, donutArcPath, donutPercentageLabel } from '../../components/DonutMetricCard/donut-arc'; +import { DashboardGlyph } from '../../features/dashboard/ClientTileParts'; +import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentResultsButton'; +import { ISO_MAPPING_FAILED, ISO_MAPPING_NO_BSI, type ComplianceMappingState } from './compliance-work-facets'; +import { bsiRequirementFindings, type BsiFootprint, type BsiOverview } from '../bsi-grundschutz'; +import { + ANNEX_A_CONTROL_COUNTS, ANNEX_A_THEME_CLAUSES, + type RegulatoryControlOverviewItem, type RegulatoryControlTheme, type RegulatoryMappingCoverage +} from '../regulatory-mapping'; + +export type ComplianceFramework = 'iso' | 'bsi'; +// People and Physical share one column, so the footprint stays three columns wide. +const COLUMNS: RegulatoryControlTheme[][] = [['Organizational'], ['People', 'Physical'], ['Technological']]; +export const COVERAGE_TILE_TITLE = 'Mapping coverage'; +export const FOOTPRINT_TILE_TITLE = 'Annex A footprint'; +export const BSI_FOOTPRINT_TILE_TITLE = 'Kernel footprint'; +export const BSI_FOOTPRINT_WINDOW_TITLE = 'BSI Grundschutz++ Kernel footprint'; +/** Practices the BSI footprint tile shows; `Show all` shows every practice. */ +const BSI_TILE_PRACTICES = 2; +const FRAMEWORKS: Array<[ComplianceFramework, string]> = [['iso', 'ISO 27001/27002'], ['bsi', 'BSI Grundschutz++']]; + +const number = (value: number) => value.toLocaleString('en-US'); +const findingCount = (value: number) => `${number(value)} finding${value === 1 ? '' : 's'}`; +/** Shading steps of the footprint; a control or requirement without findings stays unshaded. */ +export const footprintStep = (findings: number) => findings >= 25 ? 4 : findings >= 10 ? 3 : findings >= 3 ? 2 : findings >= 1 ? 1 : 0; + +/** Switches the overview row between the frameworks; the framework tiles below stay as they are. */ +export function ComplianceFrameworkSwitch({ value, onChange }: { value: ComplianceFramework; onChange(value: ComplianceFramework): void }) { + // The Client Info tab buttons: the approved selection control of this view. + return
+ {FRAMEWORKS.map(([id, label]) => )} +
; +} + +interface Props { + framework: ComplianceFramework; + /** Undefined while the overview loads: values stay an em dash and nothing opens Work. */ + controls?: RegulatoryControlOverviewItem[]; + coverage?: RegulatoryMappingCoverage; + bsi?: BsiOverview; + bsiFootprint?: BsiFootprint; + /** Annex A titles of the active corpus; a control without findings is named by these. */ + annexATitles?: Record; + onOpenControl(controlId: string): void; + onOpenTheme(theme: RegulatoryControlTheme): void; + onOpenBsiRequirement(requirementId: string): void; + onOpenBsiPractice(practiceId: string): void; + /** Opens Work with the deviations in this `ISO mapping` state (failed, to map them again, or without a BSI requirement). */ + onOpenMappingState(state: ComplianceMappingState): void; +} + +/** + * Client Info `Compliance` overview row above the framework tiles, for the framework chosen by the switch: how far + * the timeslot's deviations are mapped, and where they land in the catalog. It states where findings land, + * never whether a control or requirement is met. + */ +export function ComplianceOverview(props: Props) { + const { framework, controls, coverage, bsi, onOpenMappingState } = props; + const ready = controls && coverage ? coverage : undefined; + if (framework === 'iso') { + return
+ onOpenMappingState(ISO_MAPPING_FAILED) }]}/> + +
; + } + // A finding with a derived requirement also has an accepted ISO control; the rest of those keep only ISO. + const withBsi = ready && controls + ? new Set([...bsiRequirementFindings(controls, bsi).values()].flat().map(finding => finding.resultProjectionId)).size : undefined; + return
+ onOpenMappingState(ISO_MAPPING_NO_BSI) }, + { key: 'mappedNoneAccepted', label: 'Mapped, none accepted', value: ready?.mappedNoneAccepted, arc: true }, + { key: 'notMapped', label: 'Not mapped yet', value: ready?.notMapped }, + { key: 'failed', label: 'Mapping failed', value: ready?.failed, arc: true, onOpen: () => onOpenMappingState(ISO_MAPPING_FAILED) }]}/> + +
; +} + +interface CoverageState { key: string; label: string; value?: number; arc?: boolean; onOpen?(): void } + +/** The shared Client Info donut; states without an arc are the uncovered remainder drawn by the neutral track. */ +function CoverageTile({ caption, centre, total, states }: { caption: string; centre?: number; total?: number; states: CoverageState[] }) { + const share = (value: number) => total ? value / total * 100 : 0; + let offset = 0; + const arcs = states.filter(state => state.arc && state.value).map(state => { + const percentage = share(state.value!); + const arcOffset = offset; + offset += percentage; + return + ; + }); + const shown = (value?: number) => value === undefined ? '—' : number(value); + return
+
+

{COVERAGE_TILE_TITLE}

+
+
+
+
+ + {shown(centre)}{centre !== undefined && total ? donutPercentageLabel(share(centre)) : '—'} +
+ {caption} +
+
+ {states.map(state => { + // A state with an action and findings is one click target over its whole row, like an ISO tile theme. + const actionable = Boolean(state.onOpen && state.value); + return
+