From 644f1c32878d06eaa9258f34e9e2bd6b9084b279 Mon Sep 17 00:00:00 2001 From: Binh Date: Mon, 12 Oct 2026 10:01:35 +0200 Subject: [PATCH] fix(compliance): fit Compliance tab tiles to content; mapping refresh and hit cap Compliance tab layout (user corrections 2026-10-12): - Overview row is only as tall as its taller tile; Mapping coverage is no longer forced square and both tiles share the row height. - Annex A and BSI Kernel footprint cells grow with the tile up to 64px (5:3, 14px numbers); each Annex A theme column is as wide as its six cells and the columns spread across the tile. BSI requirements fill an auto-fill grid of the same cell size, also in the Show all window. - Kernel footprint tile shows only the practice with the most findings. - BSI Grundschutz++ framework tile shows 4 to 10 practices, as many as its square fits (container queries on the inner width). Fixes from 2026-10-09: - Compliance tab signals changed coverage or mapping history so Work reloads its assignments; `Mapping failed` no longer opens Work without its filter value. - Hybrid ISO retrieval counts hits in rank order against the 8,000 character cap per control and always keeps the best hit, so large mappings no longer fail with regulatory_mapping_prompt_too_large. Governance (Core-Freigabe: Governance): AGENTS.md BSI tile rule updated to the content-fitted practice count. Engine code only otherwise. --- AGENTS.md | 2 +- docs/IMPLEMENTATION_PLAN.md | 3 ++ .../compliance/regulatory-mapping-service.ts | 13 +++-- src/compliance/ui/ClientIsoControls.tsx | 24 ++++++++-- src/compliance/ui/ComplianceOverview.tsx | 10 ++-- src/compliance/ui/compliance-work-facets.ts | 18 ++++--- src/compliance/ui/compliance.css | 40 ++++++++++++---- src/compliance/ui/decision-revision.ts | 20 ++++++++ tests/compliance/client-iso-controls.test.tsx | 47 +++++++++++++++---- .../compliance/iso-full-control-text.test.ts | 14 ++++++ tests/compliance/iso-work-facets.test.tsx | 34 +++++++++++++- 11 files changed, 187 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 883b12b..2533592 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ - Explicit user decision 2026-10-01: ASSESSOR with all its existing functions is the **Core Engine**. The context enrichment (Phase 13E, "context engine"/"Kontext-Engine" in earlier text) is the **Context Engine**. The read-only Report Engine (Phase 7, `src/reporting/`, `server/reporting/`) is an engine in the same sense. Future features arrive as further engines that provide a capability without changing the Core. The UI labels `Context`/`Kontext` stay unchanged. - Explicit user decision 2026-10-05: the regulatory control mapping (Phase 13F, ISO mapping) is the **Compliance Engine** (`src/compliance/`, `server/compliance/`, `tests/compliance/`). It is framework-neutral by intent: ISO/IEC 27001/27002 is its first corpus, later frameworks such as BSI or DORA arrive inside this engine without a Core change. It reads the Core only through `ComplianceReadPort` (single Result DB row by project, filtered Result DB pages, project, assessment and scan-run lists; explicit user decision 2026-10-05: additionally the internal-analysis `policy-evaluation` rows of the same project and timeslot, used solely to read the GPO technical setting ID that the Result DB withholds, so a GPO setting counts once like its rolled-up Work record and the Work ISO facets match it; that ID is never displayed or exported and no other internal field is read) and stores mapping runs, accept/reject decisions, batches and AI overview runs only through its own `ComplianceStore`. Explicit user decision 2026-10-05: all AI integration belongs to this engine. The AI overview (service, model, Client Info panel) moved from the Core into it, and the Result DB `Export JSON` is the engine's 1:1 copy of what its AI features receive: the exact AI overview input built by the same function the overview sends to the model, and every distinct ISO mapping query with the Result DB rows that produce it (the retrieved candidate controls depend on the index and are not exported). The shared model client `server/ai/litellm-client.ts` stays Core platform code. The existing tables of migrations 025 (AI overview runs) and 026 keep their names and data and are owned by the engine; its writes never advance the Core repository revision. Because the Core deletes scan runs and assessments without knowing the engine, the engine removes AI overview runs of a deleted timeslot from its own table on start and on read. The Core project export receives the engine data, including succeeded AI overview runs, beside the Core export at the HTTP route. Its extension points are the Finding Reader `Map ISO controls` action and its window, the Finding Reader `ISO controls` section, the Result DB `Compliance mapping` batch action (formerly `Map all ISO controls`; ISO and BSI) and `Export JSON` action, the Data Hub ISO corpus tile, the Client Info `Compliance` tab (formerly the `Compliance` row and before that the `ISO controls` section) and `AI Overview` tab, and the Work `ISO control` and `ISO theme` facets, and for BSI the Finding Reader `BSI requirements` section, the Client Info `BSI Grundschutz++` tile, the Work `BSI requirement` and `BSI practice` facets and the Data Hub BSI catalog tile. Switched off, the engine also removes the AI overview tab and its requests and the `Export JSON` action. Explicit user decision 2026-10-05: `Map ISO controls` opens a window like `Accepted Risk` with Accept and Reject per control (no third decision value). The Finding Reader lists the controls whose latest decision is accepted in an `ISO controls` section read like `Description`, after the summary fields. Client Info shows the accepted controls of the selected timeslot directly below the Context heatmap in the same tile layout without severity colour, each with its ISO 27002 theme and the number of deviation findings (a GPO setting counts once); a tile opens Work with the `Deviation` status and that control selected in the `ISO control` facet, like a Context heatmap cell. Explicit user decision 2026-10-06 supersedes only the tile layout of that Client Info section, after feedback that it grows too large: the accepted controls are grouped by ISO 27002 theme in ISO order (Organizational, People, Physical, Technological; a theme without hits is omitted). Each full-width theme heading states its number of controls and of distinct findings (a finding counts once per theme) and opens Work with the `Deviation` status and that theme selected in the `ISO theme` facet. Below it, its controls flow in columns of twice the compact tile width, one compact row per control sorted by finding count, showing the Annex A ID, the complete title (wrapped, never truncated) and the authoritative count, without an amount bar (removed the same day because it cost title width); a row opens Work exactly like the former tile. A theme shows its top six controls; a keyboard-accessible `Show all N controls` button below expands that theme and `Show top 6` collapses it again, and every theme starts collapsed for a new timeslot. The theme heading still opens all of its controls in Work. Explicit user decision 2026-10-06, later the same day, supersedes the in-page list and its top-six cap: Client Info shows a `Compliance` row below the Context heatmap with one tile per framework, in the exact environment tile geometry and fact grid (its own `client-compliance-grid` with the same tile rules; the environment row keeps exactly its six tiles), so later frameworks such as BSI or DORA add a tile instead of a section. The `ISO 27001/27002` tile shows `Controls` (accepted controls), `Findings` (distinct findings) and the two themes with the most findings, each with its finding count (fewer when fewer themes have hits; an em dash while loading, never zero). Its `Results` button opens the shared Results window with the complete grouped list described above, without a cap; a theme heading or row there closes the window and opens Work as described. Explicit user decision 2026-10-06, again later the same day, supersedes the two-theme selection of the tile: after `Controls` and `Findings` the tile shows all four themes in ISO order (Organizational, People, Physical, Technological), each with its finding count (`0 findings` without hits, an em dash while loading). A theme with hits is one click target over its whole fact cell and opens Work with the `Deviation` status and that theme selected in the `ISO theme` facet; a theme without hits is not clickable, because the facet offers only values with hits. Hover changes only the cell surface; keyboard focus shows the blue focus ring. All six facts stay visible in this tile at every width, unlike the environment tiles, which show two facts below their measured four-fact width. Explicit user decision 2026-10-05: Work offers the Compliance Engine facets `ISO control` (one value per accepted control, `A.8.20 · Networks security`) and `ISO theme` (Organizational, People, Physical, Technological) in mixed Work and in both GPO entry paths, directly before the permanent final `Source: Scan`; this extends the GPO-only facet list. They read the accepted controls of every mappable finding of the timeslot (scanner findings by occurrence, GPO settings by scope and technical identity), offer only values with hits, count against the other active filters, keep an active selection while loading, and disappear with their filter cleared when the engine is off. Decisions stay global per scanner rule. Provisional explicit user decision 2026-10-05 (may change later), narrowed by explicit user decision 2026-10-09 for ISO and BSI: after a mapping succeeds, single or batch, the engine accepts at most the first two strong proposals in the model's order automatically, a strong proposal being `primary` with `high` or `medium` confidence; weaker proposals wait for a person and without a strong proposal nothing is accepted. The comment is `Accepted automatically: one of the first two strong proposals (primary, high or medium confidence) of the mapping.`; a control that already has a decision keeps it, so a manual reject is never overridden, and a failure of this step never fails the mapping. Explicit user decision 2026-10-08: the Data Hub ISO corpus tile carries the switch `Map new scans automatically` (default off), stored only in the engine's own settings file `regulatory-corpora/auto-mapping.json`, never in a Core table. While it is on, an engine-side backend watcher (`server/compliance/regulatory-auto-mapping.ts`) checks every 10 seconds through `ComplianceReadPort` only: the active (not superseded) scan runs and, because GPO backups create no scan run, the GPO deviation rows of every timeslot as filtered Result DB pages. For each new scan run it starts the existing ISO batch over that run's deviations, and for each timeslot with new GPO deviation rows a batch over that timeslot's GPO deviations, each with a stable idempotency key. Rules with a succeeded or active mapping of the current pipeline versions are reused, so only new rules reach the model; failed mappings are retried, and the provisional auto-acceptance applies unchanged. What exists when the watcher starts or is switched on is the baseline and is not mapped retroactively; new imports wait while mapping is unavailable. Switching the Compliance Engine off in the Engines popup also switches this server-wide setting off (once, from any view); switching the engine on again leaves it off. Already started batches are never cancelled. Explicit user decision 2026-10-08 supersedes only the placement of the 2026-10-06 Client Info `Compliance` row: it is its own Client Info tab `Compliance`, after Overview (and Microsoft Defender for IoT) and before `AI Overview`, shown while the engine is on and the backend can deliver the timeslot; the Overview no longer contains it. The tab keeps the framework tile row exactly as decided, including each tile's `Results` button and window, so later frameworks add a tile there. Explicit user decision 2026-10-08, later the same day (supersedes the timeslot-only, batch-only history first agreed): because mappings and decisions are global per rule, the tab heading carries a `Last mapping` button showing the newest mapping of any client (`Last mapping: · Automatic|Manual|Single finding`, followed by `Other timeslot`, `Other client` or `Unknown` when it is not this timeslot and by the status while not completed, or plain `No mapping yet`). It opens the window `Mapping history` (formerly `ISO mapping history`) with at most 50 entries, newest first: every mapping batch of every client and every single finding mapping (`Map ISO controls` and `Map BSI requirements` in the Finding Reader); explicit user decision 2026-10-09 adds the direct BSI mappings, so the button names a BSI mapping with `· BSI` after the date and an ISO mapping keeps its label. Columns: Started, Framework (`ISO 27001/27002` or `BSI Grundschutz++`), Trigger, Scope, Status and the content-free counts Findings (a batch's own eligible findings; an em dash for a single mapping), Sent to model, Reused, Succeeded and Failed. Entries carry no client, rule, scope ID, filter or key. A batch is Automatic when the watcher started it (idempotency key prefixes `auto:`/`auto-gpo:`), otherwise Manual. Explicit user correction 2026-10-08: Scope names where an entry came from relative to the shown timeslot: `This timeslot` (a batch whose scope shares an assessment or scan run with it, or a single mapping started there), `Other timeslot` (same client, another timeslot), `Other client`, or `Unknown` (a single mapping without a recorded origin). `Map ISO controls` sends the timeslot the Finding Reader shows; the server validates it against the client and stores only SHA-256 origin tokens of the client and of each assessment and scan run, never their ids, because mapping runs are global and exported with every client; an invalid timeslot leaves only the client as origin. Every mapping run records its trigger (`single` or `batch`) in the engine's own run data; runs from before 2.21.0 record none and are not listed as single mappings, because they cannot be told apart from batch runs. While a listed entry runs, the tab refreshes every five seconds without falling back to em dashes. Returning to the tab shows the last overview of that client and timeslot at once from a bounded in-memory cache of at most eight timeslots and always requests a fresh one, which replaces it; a failed refresh keeps the shown values. -- Explicit user decision 2026-10-08: BSI Grundschutz++ is the second framework of the Compliance Engine, limited to the 901 Kernel requirements (class `BSI-Stand-der-Technik-Kernel-G0`) of the BSI Stand-der-Technik-Bibliothek; Methodik and risk-management requirements stay out. Like the MITRE catalogs, the catalog and the official BSI mapping ISO/IEC 27001:2022 Annex A -> Grundschutz++ are compact, commit-pinned snapshots in `server/compliance/data/` with source URL, source SHA-256 and the CC BY-SA 4.0 attribution, generated by `server/compliance/update-bsi-assets.mjs`. Explicit user decision 2026-10-08, later the same day: like the MITRE matrix tile, a Data Hub tile `BSI GRUNDSCHUTZ++ KERNEL` (below the ISO corpus tile, shown while the engine is on) names the active catalog date, mapping version and origin (`Bundled` or `Updated`) and offers `Update from BSI`, which fetches only the two fixed official files from the BSI repository `main` branch in the backend, validates them fail-closed, never activates a state older than the active or bundled one, and stores the compact result with source URL and SHA-256 only in the engine folder `regulatory-corpora/bsi/` (see `SECURITY.md`); `Use bundled catalog`, offered only after an update, returns to the pinned snapshots. Derived requirements follow the active catalog at once. Stage 1 derives BSI requirements from the accepted ISO controls of a rule through that mapping, without a model call or migration: each derived requirement names its ISO control and the mapping relationship, is labelled as derived, is never stored as a decision and never changes an ISO decision, and a mapping target missing from the catalog is reported, never reinterpreted. The Finding Reader `BSI requirements` section follows `ISO controls`; the Client Info `Compliance` tab shows a `BSI Grundschutz++` tile beside the ISO tile (`Requirements`, `Findings` and the four practices with most findings; its `Results` window lists every requirement by practice in catalog order), whose rows and practices open Work with the `Deviation` status and the `BSI requirement` or `BSI practice` facet; Work offers these two facets after the ISO facets and before `Source: Scan`, only while a requirement is derived. BSI texts may be shown with attribution. Explicit user decision 2026-10-09 (stage 2, migration 029 after a verified backup): the model also maps a rule directly to Kernel requirements from its own BSI corpus (`corpusId` `bsi-grundschutz-kernel` in the shared Qdrant/BGE-M3 path, built from the active catalog from the Data Hub BSI tile via `Build search index`/`Rebuild search index` or on the first BSI mapping), started from `Map BSI requirements` in the Finding Reader `BSI requirements` section or from the BSI part of the Result DB `Compliance mapping` window. Mappings and decisions carry a framework (`iso27001`, `bsi-kernel`; ISO objects keep their earlier shape) and the same auto-acceptance rule applies. The Client Info `Compliance` tab tile `ISO ↔ BSI review` compares per rule the directly accepted BSI requirements with those derived from the accepted ISO controls: `Consistent` (one direct requirement in the derived set), `Inconsistent` (reachable but none derived) or `No ISO reference` (none reachable through the BSI mapping); its Results window lists the rules by a Result DB row, offers Accept/Reject through the normal decision path and `Mark reviewed` for one fingerprint of the compared sets (engine table `assessor_regulatory_consistency_reviews`). The comparison is derived only and never changes, rejects or blocks a decision. Explicit user decision 2026-10-09: ISO and BSI retrieval are hybrid: the model restates the public query once in German, and dense English, dense German and German keyword (BM25) rankings are fused by reciprocal rank; BSI leaves out the BES, DLS, GEB and PERS practices and sends 16 candidates, and each ISO candidate carries its full ISO/IEC 27002 control text (never sent to the browser). The automatic mapping of new scans does not cover BSI. +- Explicit user decision 2026-10-08: BSI Grundschutz++ is the second framework of the Compliance Engine, limited to the 901 Kernel requirements (class `BSI-Stand-der-Technik-Kernel-G0`) of the BSI Stand-der-Technik-Bibliothek; Methodik and risk-management requirements stay out. Like the MITRE catalogs, the catalog and the official BSI mapping ISO/IEC 27001:2022 Annex A -> Grundschutz++ are compact, commit-pinned snapshots in `server/compliance/data/` with source URL, source SHA-256 and the CC BY-SA 4.0 attribution, generated by `server/compliance/update-bsi-assets.mjs`. Explicit user decision 2026-10-08, later the same day: like the MITRE matrix tile, a Data Hub tile `BSI GRUNDSCHUTZ++ KERNEL` (below the ISO corpus tile, shown while the engine is on) names the active catalog date, mapping version and origin (`Bundled` or `Updated`) and offers `Update from BSI`, which fetches only the two fixed official files from the BSI repository `main` branch in the backend, validates them fail-closed, never activates a state older than the active or bundled one, and stores the compact result with source URL and SHA-256 only in the engine folder `regulatory-corpora/bsi/` (see `SECURITY.md`); `Use bundled catalog`, offered only after an update, returns to the pinned snapshots. Derived requirements follow the active catalog at once. Stage 1 derives BSI requirements from the accepted ISO controls of a rule through that mapping, without a model call or migration: each derived requirement names its ISO control and the mapping relationship, is labelled as derived, is never stored as a decision and never changes an ISO decision, and a mapping target missing from the catalog is reported, never reinterpreted. The Finding Reader `BSI requirements` section follows `ISO controls`; the Client Info `Compliance` tab shows a `BSI Grundschutz++` tile beside the ISO tile (`Requirements`, `Findings` and the practices with most findings, as many as the square tile fits, at least four and at most ten (explicit user decision 2026-10-12); its `Results` window lists every requirement by practice in catalog order), whose rows and practices open Work with the `Deviation` status and the `BSI requirement` or `BSI practice` facet; Work offers these two facets after the ISO facets and before `Source: Scan`, only while a requirement is derived. BSI texts may be shown with attribution. Explicit user decision 2026-10-09 (stage 2, migration 029 after a verified backup): the model also maps a rule directly to Kernel requirements from its own BSI corpus (`corpusId` `bsi-grundschutz-kernel` in the shared Qdrant/BGE-M3 path, built from the active catalog from the Data Hub BSI tile via `Build search index`/`Rebuild search index` or on the first BSI mapping), started from `Map BSI requirements` in the Finding Reader `BSI requirements` section or from the BSI part of the Result DB `Compliance mapping` window. Mappings and decisions carry a framework (`iso27001`, `bsi-kernel`; ISO objects keep their earlier shape) and the same auto-acceptance rule applies. The Client Info `Compliance` tab tile `ISO ↔ BSI review` compares per rule the directly accepted BSI requirements with those derived from the accepted ISO controls: `Consistent` (one direct requirement in the derived set), `Inconsistent` (reachable but none derived) or `No ISO reference` (none reachable through the BSI mapping); its Results window lists the rules by a Result DB row, offers Accept/Reject through the normal decision path and `Mark reviewed` for one fingerprint of the compared sets (engine table `assessor_regulatory_consistency_reviews`). The comparison is derived only and never changes, rejects or blocks a decision. Explicit user decision 2026-10-09: ISO and BSI retrieval are hybrid: the model restates the public query once in German, and dense English, dense German and German keyword (BM25) rankings are fused by reciprocal rank; BSI leaves out the BES, DLS, GEB and PERS practices and sends 16 candidates, and each ISO candidate carries its full ISO/IEC 27002 control text (never sent to the browser). The automatic mapping of new scans does not cover BSI. - Engines may read the Core without restriction but never write to, change or override it. They read only through a declared read-only port and the read-only database access, never through Core tables or internal modules directly. What an engine shows or exports still obeys the Core's privacy and Result DB disclosure rules. - Dependencies point one way: an engine depends on the Core, the Core never depends on an engine. The Core works unchanged with every engine switched off; each engine keeps a permanent on/off invariance test like the Context layer's. Engines do not build on each other except through another engine's published results. - An engine that needs storage gets its own schema or tables after separate approval with backup and migration, never columns in Core tables. An engine cannot create findings: new scanner profiles, importers and parsers write Core data and are always Core changes. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index d765a35..8025cff 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -3490,6 +3490,8 @@ Nach Freigabe eines lokalen, nicht versionierten Mockups mit synthetischen Daten - 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). +- Nutzerkorrektur 2026-10-12 (ersetzt nur die quadratische `Mapping coverage` und die zwei Praktiken der Kernel-Kachel): Die Uebersichtszeile ist nur so hoch, wie der hoehere Inhalt braucht, beide Kacheln sind gleich hoch. Die Zellen beider Footprints wachsen mit der Kachelbreite von der bisherigen Groesse bis hoechstens 64px (Seitenverhaeltnis 5:3, Nummern in der 14px-Datenrolle); jede Annex-A-Themenspalte ist nur so breit wie ihre sechs Zellen, die Spalten verteilen sich ueber die Kachel. Die BSI-Anforderungen fuellen ein Raster mit so vielen Spalten dieser Groesse, wie passen. Die Kernel-Kachel zeigt nur noch die eine Praktik mit den meisten Befunden (Gleichstand in Katalogreihenfolge); `Show all` bleibt unveraendert. Nur Engine-Code; synthetische UI-Tests angepasst. +- Nutzerkorrektur 2026-10-12, spaeter (ersetzt nur die feste Zahl von vier Praktiken der Frameworkkachel `BSI Grundschutz++`): Die Kachel zeigt nach `Requirements` und `Findings` mindestens vier und hoechstens zehn Praktiken mit den meisten Befunden, so viele, wie ihr Quadrat fasst (Containerabfragen auf die Innenbreite: sechs ab 17rem, acht ab 20.5rem, zehn ab 23.5rem); `Results` listet weiterhin alle. Die Formulierung in `AGENTS.md` ist mit `Core-Freigabe: Governance` entsprechend angepasst. - 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 @@ -4578,6 +4580,7 @@ Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client | 2026-10-06 | Phase 7: Data Hub Deviation/Passed now use the same assessed-percentage helper as Client Info; all six donuts state their population basis. 29 focused tests and synthetic Chrome Dark/Light checks passed; absolute counts, other population shares and assessment semantics retained. Client build, design and UI/API version checks passed. Nine out-of-scope report test failures, a report Local-only failure and report server type errors prevent full release verification; attempted 2.16.9 reverted to 2.16.8 pending successful production builds. | | 2026-10-06 | Phase 7: Client Info Deviations percentage now divides by Passed + Deviations and labels Of assessed checks; zero assessed shows a dash while preserving absolute zero. Permanent UI/click invariance tests in both themes and synthetic Chrome presentation verified; unchanged counts, verdicts, scores and other donuts. Patch 2.16.8: both builds and version contracts passed; full run 1,936 passed/15 skipped with one stale API-version assertion corrected and passed in the 43-test focused rerun; Local-only/design checks passed. | | 2026-10-06 | Phase 13B: GPO folder read diagnostics retain only safe error names, stages and counts, distinguish empty/denied/unreadable selections, preserve cancellation and wrap complete timeslot messages. 27 focused GPO UI tests and synthetic Chrome Dark/Light checks passed; both builds and version contracts verified for patch 2.16.7. Full run: 1,932 passed, 15 skipped, one general import timeout passed on separate rerun; Local-only/design checks passed. Customer-specific read failure remains unconfirmed; no customer path/data used, Phase 13B remains in progress. | +| 2026-10-09 | Phase 13F, Korrekturen nach 2.23.0 (nur Engine-Code, Version unveraendert, da unveroeffentlicht): (1) `Mapping failed` im Tab `Compliance` oeffnete Work ohne Filter, wenn eine Zuordnung nach dem Laden der Work-Zuordnungen fehlschlug, weil ein Fehlschlag keine Entscheidung ist und Work den alten Stand behielt. Der Tab meldet jetzt jede Aenderung von Abdeckung oder Zuordnungshistorie; Work laedt dann neu und bietet bis dahin keinen veralteten Stand an, sodass der gewaehlte Filter erhalten bleibt (Regressionstests fuer Tab und Work). (2) Die Hybridsuche lieferte viele Treffer je Kontrolle, die alle ueber die Obergrenze hinaus behalten wurden; eine Purple-Knight-Zuordnung scheiterte mit `regulatory_mapping_prompt_too_large`. Treffer zaehlen jetzt in Rangfolge zur Obergrenze von 8.000 Zeichen je Kontrolle, der beste bleibt immer (Regressionstest). | | 2026-10-09 | Governance (Core-Freigabe Governance): AGENTS.md uebernimmt die Entscheidungen des Tages, BSI Stufe 2 mit Migration 029, Konsistenzpruefung und Kachel `ISO ↔ BSI review`, Hybridsuche, volle ISO-27002-Kontrolltexte, `Compliance mapping` statt `Map all ISO controls`, `Mapping history` mit ISO und BSI, BSI-Suchindex in der Data-Hub-Kachel und die automatische Annahme nur starker Vorschlaege. | | 2026-10-09 | Phase 13F, in Produktversion 2.23.0 enthalten (unveroeffentlicht; Core-Freigabe Presentation, Platform), Nutzerauftrag: Die Result-DB-Aktion `Map all ISO controls` heisst `Compliance mapping` (Fenster `Compliance mapping`, Aktivitaeten `Starting/Loading compliance mapping batch`), weil sie ISO und BSI startet. Die Data-Hub-Kachel `BSI GRUNDSCHUTZ++ KERNEL` zeigt den Suchindex des aktiven Katalogs (`Built`, `Building` mit Fortschritt, `Not built`, `Outdated`, `Build failed`, `Unavailable`) und bietet `Build search index` beziehungsweise `Rebuild search index`; neue Routen `GET /reference-data/bsi-grundschutz/search-index` und `POST .../search-index/build` (202, `force` fuer den Neuaufbau). Eine BSI-Zuordnung baut einen fehlenden Index weiterhin selbst. Mit Datenbankbereinigung: auf Nutzerwunsch wurden alle Zuordnungsdaten (242 Laeufe, 355 Entscheidungen, 5 Batches) nach verifizierter Sicherung `backups/assessor-pre-clear-mappings-20261009.dump` geloescht; neu zugeordnet wird durch den Nutzer. | | 2026-10-09 | Phase 13F, in Produktversion 2.23.0 enthalten (unveroeffentlicht, nur Engine-Code), Nutzerentscheidung (ersetzt die vorlaeufige Annahme der ersten zwei Vorschlaege vom 2026-10-05 fuer ISO und BSI): automatisch angenommen werden hoechstens die ersten zwei starken Vorschlaege in Modellreihenfolge, also `primary` mit Konfidenz `high` oder `medium`; schwaechere Vorschlaege warten auf eine Person, ohne starken Vorschlag wird nichts angenommen. Bestehende Entscheidungen bleiben unveraendert. Auf den 20 Purple-Knight-Validierungslaeufen: ISO 21 von 25 automatischen Annahmen in der Referenz (vorher 29 von 39), BSI 16 von 26 (vorher 18 von 37). AGENTS.md beschreibt noch die alte Regel; die Anpassung braucht eine Governance-Freigabe. | diff --git a/server/compliance/regulatory-mapping-service.ts b/server/compliance/regulatory-mapping-service.ts index d9309ba..f01679c 100644 --- a/server/compliance/regulatory-mapping-service.ts +++ b/server/compliance/regulatory-mapping-service.ts @@ -995,9 +995,16 @@ export class RegulatoryMappingService { const byId = new Map([...listed, ...matched].filter(usable).map(chunk => [chunk.chunkId, chunk])); const ordered = [...byId.values()].sort((left, right) => SECTION_ORDER.indexOf(left.sectionKind!) - SECTION_ORDER.indexOf(right.sectionKind!) || left.documentId.localeCompare(right.documentId) || left.chunkIndex - right.chunkIndex); - const kept = new Set(matched.filter(usable).map(chunk => chunk.chunkId)); - let length = [...kept].reduce((total, id) => total + byId.get(id)!.text.length, 0); - // Core sections first, other information only with the space left. + // Matched passages first in rank order, the best one always; the hybrid search can match many, so they count + // toward the cap like every other passage. + const kept = new Set(); + let length = 0; + for (const chunk of matched.filter(usable)) { + if (kept.has(chunk.chunkId) || kept.size > 0 && length + chunk.text.length > MAXIMUM_CONTROL_TEXT_CHARACTERS) continue; + kept.add(chunk.chunkId); + length += chunk.text.length; + } + // Core sections next, other information only with the space left. for (const pass of [CORE_SECTIONS, ['other-information']] as const) { for (const chunk of ordered) { if (kept.has(chunk.chunkId) || !pass.includes(chunk.sectionKind as never)) continue; diff --git a/src/compliance/ui/ClientIsoControls.tsx b/src/compliance/ui/ClientIsoControls.tsx index b5ce698..0386d88 100644 --- a/src/compliance/ui/ClientIsoControls.tsx +++ b/src/compliance/ui/ClientIsoControls.tsx @@ -5,7 +5,7 @@ import { useEnginePreferences } from '../../app/engine-preferences'; import { DashboardGlyph, type DashboardFact } from '../../features/dashboard/ClientTileParts'; import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentResultsButton'; import type { RegulatoryControlOverviewItem, RegulatoryControlOverviewResponse, RegulatoryControlTheme, RegulatoryMappingHistoryEntry } from '../regulatory-mapping'; -import { useRegulatoryDecisionRevision } from './decision-revision'; +import { markRegulatoryMappingsChanged, 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'; @@ -95,6 +95,16 @@ export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = return () => { active = false; }; }, [enabled, revision, scopeKey, poll]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { setPoll(0); }, [enabled, revision, scopeKey]); + // A mapping that finished or failed since the last overview changes what Work can filter, so Work reloads its + // assignments; otherwise `Mapping failed` would open Work without its value. + useEffect(() => { + if (state.status !== 'ready') return; + const signature = mappingSignature(state.overview); + const previous = mappingSignatures.get(scopeKey); + mappingSignatures.set(scopeKey, signature); + while (mappingSignatures.size > OVERVIEW_CACHE_LIMIT) mappingSignatures.delete(mappingSignatures.keys().next().value!); + if (previous !== undefined && previous !== signature) markRegulatoryMappingsChanged(); + }, [state, scopeKey]); const history = state.status === 'ready' ? state.overview.mappingHistory ?? [] : []; const mappingActive = history.some(entry => entry.status === 'queued' || entry.status === 'running'); @@ -216,8 +226,13 @@ function rememberOverview(scopeKey: string, overview: RegulatoryControlOverviewR overviewCache.set(scopeKey, overview); while (overviewCache.size > OVERVIEW_CACHE_LIMIT) overviewCache.delete(overviewCache.keys().next().value!); } +// The mapping state last seen per timeslot: coverage and the state of every listed mapping. +const mappingSignatures = new Map(); +function mappingSignature(overview: RegulatoryControlOverviewResponse): string { + return JSON.stringify([overview.coverage ?? null, (overview.mappingHistory ?? []).map(entry => [entry.id, entry.status, entry.counts.succeeded, entry.counts.failed])]); +} /** Test support. */ -export function resetClientComplianceCacheForTests(): void { overviewCache.clear(); overviewRequests.clear(); } +export function resetClientComplianceCacheForTests(): void { overviewCache.clear(); overviewRequests.clear(); mappingSignatures.clear(); } const MAPPING_POLL_MS = 5_000; const triggerLabel = (entry: RegulatoryMappingHistoryEntry) => @@ -260,7 +275,8 @@ function LastMapping({ history }: { history: RegulatoryMappingHistoryEntry[] }) ; } -const BSI_TILE_PRACTICES = 4; +/** At most this many practices; the tile CSS shows as many as its square fits, at least four. */ +const BSI_TILE_PRACTICES = 10; interface BsiPracticeGroup { id: string; title: string; requirements: Array; findings: number } @@ -284,7 +300,7 @@ function bsiPracticeGroups(overview: RegulatoryControlOverviewResponse): { group /** * BSI Grundschutz++ tile (Phase 13F, stage 1): Kernel requirements derived from the accepted ISO controls through - * the BSI mapping. The four practices with the most findings follow the totals; the Results window lists all. + * the BSI mapping. The practices with the most findings follow the totals, as many as the tile fits; the Results window lists all. */ function BsiTile({ state, onOpenRequirement, onOpenPractice }: { state: OverviewState; onOpenRequirement(id: string): void; onOpenPractice(id: string): void }) { const ready = state.status === 'ready'; diff --git a/src/compliance/ui/ComplianceOverview.tsx b/src/compliance/ui/ComplianceOverview.tsx index 90d1e0d..efc31bf 100644 --- a/src/compliance/ui/ComplianceOverview.tsx +++ b/src/compliance/ui/ComplianceOverview.tsx @@ -16,8 +16,8 @@ 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; +/** Practices the BSI footprint tile shows (the one with the most findings); `Show all` shows every practice. */ +const BSI_TILE_PRACTICES = 1; const FRAMEWORKS: Array<[ComplianceFramework, string]> = [['iso', 'ISO 27001/27002'], ['bsi', 'BSI Grundschutz++']]; const number = (value: number) => value.toLocaleString('en-US'); @@ -216,7 +216,7 @@ interface BsiGridProps { /** * BSI Kernel requirements by practice in catalog order, each a square labelled with its number inside the practice. - * `hits` (the tile) shows the two practices with the most findings; `all` (the Show all window) shows every practice, larger. A + * `hits` (the tile) shows the practice with the most findings; `all` (the Show all window) shows every practice, larger. A * requirement the BSI mapping cannot reach from any ISO control is marked, so an empty square never reads as a gap of this client. */ function BsiRequirementGrid({ mode, practices, findingsOf, derived, onOpenRequirement, onOpenPractice }: BsiGridProps & { mode: 'hits' | 'all' }) { @@ -230,7 +230,7 @@ function BsiRequirementGrid({ mode, practices, findingsOf, derived, onOpenRequir }); return { practice, findings: findings.size, cells: practice.requirements, hits: hits.length }; }); - // The tile keeps the practices with the most findings, like the BSI tile; ties stay in catalog order. + // The tile keeps the practice with the most findings; a tie keeps catalog order. if (mode === 'hits') blocks.splice(0, blocks.length, ...blocks.filter(block => block.hits > 0) .sort((left, right) => right.findings - left.findings).slice(0, BSI_TILE_PRACTICES)); if (!blocks.length) return

No BSI requirements derived from accepted ISO controls in this timeslot.

; @@ -269,7 +269,7 @@ function BsiRequirementGrid({ mode, practices, findingsOf, derived, onOpenRequir ; } -/** The tile shows the two practices with the most findings; `Show all` opens every practice in a window. */ +/** The tile shows the practice with the most findings; `Show all` opens every practice in a window. */ function BsiFootprintTile({ controls, bsi, bsiFootprint, onOpenBsiRequirement, onOpenBsiPractice }: Props) { const findingsOf = controls ? bsiRequirementFindings(controls, bsi) : new Map(); const derived = new Map((bsi?.requirements ?? []).map(requirement => [requirement.requirementId, requirement])); diff --git a/src/compliance/ui/compliance-work-facets.ts b/src/compliance/ui/compliance-work-facets.ts index d206aad..e245adb 100644 --- a/src/compliance/ui/compliance-work-facets.ts +++ b/src/compliance/ui/compliance-work-facets.ts @@ -6,7 +6,7 @@ import type { FindingRecord } from '../../domain/finding'; import { gpoWorkSettingIdentity } from '../../features/results-browser/work-record-lookup'; import type { DynamicFacet } from '../../features/search-filter/dynamic-facets'; import type { RegulatoryControlFinding, RegulatoryControlOverviewResponse, RegulatoryControlTheme } from '../regulatory-mapping'; -import { useRegulatoryDecisionRevision } from './decision-revision'; +import { useRegulatoryDecisionRevision, useRegulatoryMappingRevision } from './decision-revision'; /** Work facets of the Compliance Engine; present only while the engine is on and its assignments are loaded. */ export const ISO_CONTROL_FACET_ID = 'compliance:iso-control'; @@ -133,21 +133,22 @@ export function withComplianceFacets(facets: DynamicFacet[], index: ComplianceWo } const assessorApi = new AssessorApiClient(); -// One request per timeslot and decision revision, shared by every Work consumer. +// One request per timeslot, decision revision and mapping revision, shared by every Work consumer. const requests = new Map>(); const ready = new Map(); -const lastReadyByScope = new Map(); +const lastReadyByScope = new Map(); /** The ISO facet index for Work, or undefined while the engine is off or nothing is loaded yet. */ export function useComplianceWorkFacets(): ComplianceWorkFacetIndex | undefined { const app = useApp(); const available = useEnginePreferences().compliance; const revision = useRegulatoryDecisionRevision(); + const mappingRevision = useRegulatoryMappingRevision(); const assessmentIds = [...(app.activeAssessmentIds ?? [])].sort(); const scanRunIds = [...(app.activeScanRunIds ?? [])].sort(); const projectId = app.targetProjectId; const scope = JSON.stringify([projectId, assessmentIds, scanRunIds]); - const key = `${scope}\u0000${revision}`; + const key = `${scope}\u0000${revision}\u0000${mappingRevision}`; const enabled = available && app.backendStatus === 'connected' && Boolean(projectId) && (assessmentIds.length > 0 || scanRunIds.length > 0); const [, setLoaded] = useState(0); @@ -160,7 +161,7 @@ export function useComplianceWorkFacets(): ComplianceWorkFacetIndex | undefined requests.set(key, request); request.then(response => { ready.set(key, response); - lastReadyByScope.set(scope, response); + lastReadyByScope.set(scope, { response, mappingRevision }); while (ready.size > 8) ready.delete(ready.keys().next().value!); while (lastReadyByScope.size > 4) lastReadyByScope.delete(lastReadyByScope.keys().next().value!); }, () => undefined).finally(() => requests.delete(key)); @@ -170,8 +171,11 @@ export function useComplianceWorkFacets(): ComplianceWorkFacetIndex | undefined }, [enabled, key]); // eslint-disable-line react-hooks/exhaustive-deps if (!enabled) return undefined; - // While a new decision revision of the same timeslot loads, the last loaded state keeps an active filter valid. - const response = ready.get(key) ?? lastReadyByScope.get(scope); + // While a new decision revision of the same timeslot loads, the last loaded state keeps an active filter valid. After a + // mapping change it may lack the value just selected (a mapping that failed meanwhile), so nothing is offered until the + // fresh state arrives; Work keeps a selected ISO filter while no assignments are loaded. + const fallback = lastReadyByScope.get(scope); + const response = ready.get(key) ?? (fallback?.mappingRevision === mappingRevision ? fallback.response : undefined); return response ? complianceWorkFacetIndex(response) : undefined; } diff --git a/src/compliance/ui/compliance.css b/src/compliance/ui/compliance.css index ddca5f9..0ecdc0a 100644 --- a/src/compliance/ui/compliance.css +++ b/src/compliance/ui/compliance.css @@ -34,6 +34,19 @@ div.finding-bsi-requirements__item strong { font-weight: var(--fw-semibold); fon .iso-controls__error { margin: var(--space-2) 0 0; font-size: var(--type-table-body-size); } /* The theme facts are this tile's purpose, so they stay visible where narrow environment tiles show two facts. */ .client-compliance-grid .client-env-facts > div:nth-child(n + 3) { display: grid; } +/* The BSI tile always shows its totals and four practices, and one more row of two practices for each further + fact row its square fits. A fact row is about 50px; the thresholds measure the inner width (tile minus padding) + with room to spare, so a wrapped practice name scrolls inside the tile instead of being cut off. */ +.client-compliance-grid [data-framework="bsi-grundschutz-plus-plus"] .client-env-facts > div:nth-child(n + 7) { display: none; } +@container environment-tile (min-width: 17rem) { + .client-compliance-grid [data-framework="bsi-grundschutz-plus-plus"] .client-env-facts > div:nth-child(-n + 8) { display: grid; } +} +@container environment-tile (min-width: 20.5rem) { + .client-compliance-grid [data-framework="bsi-grundschutz-plus-plus"] .client-env-facts > div:nth-child(-n + 10) { display: grid; } +} +@container environment-tile (min-width: 23.5rem) { + .client-compliance-grid [data-framework="bsi-grundschutz-plus-plus"] .client-env-facts > div:nth-child(-n + 12) { display: grid; } +} /* A theme fact with hits is one click target over its whole cell; hover changes the surface only. */ .client-compliance-grid .iso-tile-theme.is-actionable { position: relative; cursor: pointer; } .client-compliance-grid .iso-tile-theme.is-actionable:hover { background: var(--tile-surface-hover); } @@ -85,11 +98,12 @@ button.iso-controls__theme:hover .iso-controls__theme-name { text-decoration: un .client-information-table.client-compliance-history td:nth-child(2) { font-size: var(--type-table-body-size); } /* Client Info `Compliance` overview row: same six tracks as the framework row below, so column starts align. - Mapping coverage is a square over two tracks; the Annex A footprint takes the other four at the same height. */ + Mapping coverage takes two tracks and the footprint the other four; the row is as tall as the taller content + needs, and both tiles stretch to that same height. */ .compliance-overview { display: grid; grid-template-columns: repeat(6, minmax(var(--tile-environment-min-width), 1fr)); gap: var(--segment-gap); min-width: 0; margin-top: var(--segment-gap); overflow-x: auto; } .compliance-overview > .client-env-group { display: flex; flex-direction: column; min-width: 0; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); } .compliance-overview .client-env-group-title { font-size: var(--type-h4-size); font-weight: var(--fw-semibold); } -.compliance-coverage { grid-column: span 2; aspect-ratio: 1; } +.compliance-coverage { grid-column: span 2; } .compliance-footprint { grid-column: span 4; } /* Coverage: the shared Client Info donut; accepted, mapped-only and failed are arcs, not mapped is the track. */ @@ -111,7 +125,15 @@ button.iso-controls__theme:hover .iso-controls__theme-name { text-decoration: un .compliance-coverage__note { margin: var(--space-3) 0 0; color: var(--text-secondary); font-size: var(--type-table-body-size); } /* Footprint: all 93 Annex A controls; one neutral chart tone in four steps, never a status colour. */ -.compliance-footprint__themes { display: flex; flex-wrap: wrap; align-items: flex-start; gap: var(--space-6); margin-block: var(--space-4); } +/* Footprint cells grow with the tile up to a cap, so a wide tile is filled without oversized squares; + each Annex A theme column is only as wide as its six cells, the columns spread across the tile and wrap below + six minimum cells. */ +.compliance-footprint { + --footprint-cell-min: calc(var(--space-8) + var(--space-1) + var(--bw-emphasis)); + --footprint-cell-max: calc(var(--space-8) * 2); + --footprint-cell-gap: calc(var(--space-1) - var(--bw-hairline)); +} +.compliance-footprint__themes { display: grid; grid-template-columns: repeat(auto-fit, minmax(calc(var(--footprint-cell-min) * 6 + var(--footprint-cell-gap) * 5), calc(var(--footprint-cell-max) * 6 + var(--footprint-cell-gap) * 5))); justify-content: space-between; align-items: start; gap: var(--space-6); margin-block: var(--space-4); } .compliance-footprint__column { display: grid; gap: var(--space-4); } .compliance-footprint__theme { display: grid; align-content: start; gap: var(--space-2); } .compliance-footprint__theme-head { display: grid; justify-items: start; padding: 0 0 var(--space-1); border: 0; border-bottom: var(--bw-hairline) solid var(--tile-divider); background: transparent; color: var(--text-primary); font-family: inherit; text-align: left; } @@ -120,7 +142,8 @@ button.compliance-footprint__theme-head:hover .compliance-footprint__theme-name button.compliance-footprint__theme-head:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-emphasis); } .compliance-footprint__theme-name { font-size: var(--type-body-size); font-weight: var(--fw-semibold); } .compliance-footprint__theme-meta { color: var(--text-secondary); font-size: var(--type-table-body-size); font-variant-numeric: tabular-nums; } -.compliance-footprint__cells { display: grid; grid-template-columns: repeat(6, calc(var(--space-8) + var(--space-1) + var(--bw-emphasis))); gap: calc(var(--space-1) - var(--bw-hairline)); } +.compliance-footprint__cells { display: grid; grid-template-columns: repeat(6, minmax(var(--footprint-cell-min), var(--footprint-cell-max))); gap: var(--footprint-cell-gap); } +.compliance-footprint__cells > .compliance-footprint__cell, .compliance-footprint__requirements > .compliance-footprint__cell { height: auto; min-height: calc(var(--space-6) + var(--space-1) + var(--bw-emphasis)); aspect-ratio: 5 / 3; font-size: var(--type-table-body-size); } .compliance-footprint__cell { display: grid; place-items: center; height: calc(var(--space-6) + var(--space-1) + var(--bw-emphasis)); padding: 0; border: var(--bw-hairline) solid var(--tile-divider); background: transparent; color: var(--text-secondary); font-family: inherit; font-size: var(--type-meta-size); font-variant-numeric: tabular-nums; } .compliance-footprint__cell[data-step] { border-color: transparent; color: var(--text-primary); } button.compliance-footprint__cell { cursor: pointer; } @@ -142,14 +165,15 @@ button.compliance-footprint__cell:focus-visible { outline: var(--bw-emphasis) so the practices stack at full width so their squares wrap across the tile. A requirement the ISO mapping cannot reach has a dashed outline, a reachable one without findings the solid outline of an empty Annex A cell. */ .compliance-footprint__practices { display: grid; gap: var(--space-4); margin-block: var(--space-4); } -.compliance-footprint__requirements { display: flex; flex-wrap: wrap; gap: calc(var(--space-1) - var(--bw-hairline)); } -.compliance-footprint__requirements > .compliance-footprint__cell { min-width: calc(var(--space-8) + var(--space-1) + var(--bw-emphasis)); height: calc(var(--space-6) + var(--space-1) + var(--bw-emphasis)); padding: 0 var(--space-1); white-space: nowrap; } +/* As many capped columns as fit, so the squares fill each row at the size of the Annex A cells. */ +.compliance-footprint__requirements { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--footprint-cell-min), var(--footprint-cell-max))); gap: var(--footprint-cell-gap); } +.compliance-footprint__requirements > .compliance-footprint__cell { padding: 0 var(--space-1); white-space: nowrap; } .compliance-footprint__cell.is-unreachable { border-style: dashed; } .compliance-footprint__pending { margin: var(--space-4) 0; color: var(--text-secondary); font-size: var(--type-body-size); } /* Show all window: every practice, with larger squares and the 14px data role for the numbers. */ .compliance-footprint--window { min-width: 0; } -.compliance-footprint__practices.is-window .compliance-footprint__requirements { gap: var(--space-1); } -.compliance-footprint__practices.is-window .compliance-footprint__requirements > .compliance-footprint__cell { min-width: calc(var(--space-8) + var(--space-4)); height: calc(var(--space-8) + var(--space-1)); padding: 0 var(--space-2); font-size: var(--type-table-body-size); } +.compliance-footprint__practices.is-window { --footprint-cell-min: calc(var(--space-8) + var(--space-4)); --footprint-cell-gap: var(--space-1); } +.compliance-footprint__practices.is-window .compliance-footprint__requirements > .compliance-footprint__cell { min-height: calc(var(--space-8) + var(--space-1)); padding: 0 var(--space-2); } .compliance-footprint__footer { margin-top: auto; } /* Pinned to the bottom of the window body (its 20px inset included), on the window surface, while the grid scrolls. */ .compliance-footprint--window .compliance-footprint__footer { position: sticky; bottom: calc(var(--panel-inset) * -1); padding-bottom: var(--panel-inset); background: var(--bg); } diff --git a/src/compliance/ui/decision-revision.ts b/src/compliance/ui/decision-revision.ts index ab40f9d..a3ae7ed 100644 --- a/src/compliance/ui/decision-revision.ts +++ b/src/compliance/ui/decision-revision.ts @@ -19,3 +19,23 @@ function subscribe(listener: () => void) { export function useRegulatoryDecisionRevision(): number { return useSyncExternalStore(subscribe, () => revision, () => 0); } + +// Counts changes of the mapping state another view noticed, such as a mapping that finished or failed while the +// Client Info Compliance tab watched a running batch. Work reloads its ISO assignments on it, because a failed +// mapping is no decision and would otherwise stay invisible to the `ISO mapping` filter. +let mappingRevision = 0; +const mappingListeners = new Set<() => void>(); + +export function markRegulatoryMappingsChanged(): void { + mappingRevision += 1; + mappingListeners.forEach(listener => listener()); +} + +function subscribeMappings(listener: () => void) { + mappingListeners.add(listener); + return () => { mappingListeners.delete(listener); }; +} + +export function useRegulatoryMappingRevision(): number { + return useSyncExternalStore(subscribeMappings, () => mappingRevision, () => 0); +} diff --git a/tests/compliance/client-iso-controls.test.tsx b/tests/compliance/client-iso-controls.test.tsx index 6561ebd..b0e8507 100644 --- a/tests/compliance/client-iso-controls.test.tsx +++ b/tests/compliance/client-iso-controls.test.tsx @@ -9,7 +9,7 @@ import { requestActivity } from '../../src/api/request-activity'; import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences'; import type { RegulatoryControlOverviewResponse } from '../../src/compliance/regulatory-mapping'; import { ClientIsoControls, preloadClientComplianceOverview, resetClientComplianceCacheForTests } from '../../src/compliance/ui/ClientIsoControls'; -import { markRegulatoryDecisionSaved } from '../../src/compliance/ui/decision-revision'; +import { markRegulatoryDecisionSaved, useRegulatoryMappingRevision } from '../../src/compliance/ui/decision-revision'; import { workRecordIdsForResult } from '../../src/features/results-browser/work-record-lookup'; import { makeSheet } from '../../src/importers/sheet-factory'; @@ -233,6 +233,34 @@ describe('Client Info ISO controls', () => { expect(lastMapping()?.textContent).toMatch(/· Single finding · Other client · Running$/); }); + it('tells Work to reload its assignments when a refreshed overview shows another mapping state, and only then', async () => { + const revisions: number[] = []; + function Probe() { revisions.push(useRegulatoryMappingRevision()); return null; } + const running = { id: 'dummy-batch', kind: 'batch' as const, scope: 'timeslot' as const, trigger: 'automatic' as const, status: 'running' as const, createdAt: '2026-10-09T09:12:00Z', counts }; + const coverage = { deviations: 4, accepted: 3, mappedNoneAccepted: 0, failed: 0, notMapped: 1 }; + const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview') + .mockResolvedValueOnce({ ...overview, coverage, mappingHistory: [running] }); + vi.useFakeTimers(); + try { + await act(async () => { + root.render(<>); + await vi.advanceTimersByTimeAsync(0); + }); + const first = revisions.at(-1)!; + // Same state on the next poll: nothing to reload. + request.mockResolvedValueOnce({ ...overview, coverage, mappingHistory: [running] }); + await act(async () => { await vi.advanceTimersByTimeAsync(5_000); }); + expect(revisions.at(-1)).toBe(first); + // The mapping failed meanwhile: Work reloads. + request.mockResolvedValue({ ...overview, coverage: { ...coverage, failed: 1, notMapped: 0 }, + mappingHistory: [{ ...running, status: 'completed', counts: { ...counts, failed: 1 } }] }); + await act(async () => { await vi.advanceTimersByTimeAsync(5_000); }); + expect(revisions.at(-1)).toBe(first + 1); + } finally { vi.useRealTimers(); } + }); + it('says when the timeslot was never mapped, and refreshes while a mapping runs', async () => { const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValueOnce({ ...overview, mappingHistory: [] }); await render(); @@ -373,20 +401,21 @@ describe('Client Info Compliance overview', () => { await act(async () => isoOnly.click()); expect(onOpenMappingState).toHaveBeenLastCalledWith('no-bsi'); expect(host.querySelectorAll('.compliance-coverage__legend button')).toHaveLength(2); - // The tile shows the practices with the most findings first, each with all of its squares labelled by number. + // The tile shows only the practice with the most findings, with all of its squares labelled by number. expect([...host.querySelectorAll('.compliance-footprint__requirements .compliance-footprint__cell')].map(cell => cell.textContent)) - .toEqual(['1.1', '2.1', '3.1', '1.1', '9.9']); + .toEqual(['1.1', '2.1', '3.1']); expect(practiceHead('DET')).toBeNull(); expect(practiceHead('KONF')?.textContent).toBe('KONF · Konfiguration2 of 3 requirements · 3 findings'); - // BER has the fewest findings and stays out of the tile; Show all lists it below. + // ARCH and BER have fewer findings and stay out of the tile; Show all lists them. + expect(practiceHead('ARCH')).toBeNull(); expect(practiceHead('BER')).toBeNull(); expect(requirement('KONF.1.1')?.tagName).toBe('BUTTON'); expect(requirement('KONF.1.1')?.dataset.step).toBe('2'); - expect(requirement('ARCH.9.9')?.classList.contains('is-unreachable')).toBe(true); + expect(requirement('KONF.3.1')?.classList.contains('is-unreachable')).toBe(true); await act(async () => requirement('KONF.1.1')!.click()); expect(onOpenBsiRequirement).toHaveBeenCalledWith('KONF.1.1'); - await act(async () => practiceHead('ARCH')!.click()); - expect(onOpenBsiPractice).toHaveBeenCalledWith('ARCH'); + await act(async () => practiceHead('KONF')!.click()); + expect(onOpenBsiPractice).toHaveBeenCalledWith('KONF'); const detail = () => host.querySelector('.compliance-footprint__detail')?.textContent; await act(async () => requirement('KONF.1.1')!.focus()); expect(detail()).toBe('KONF.1.1 · KONF Konfiguration · via A.8.20, A.8.9Synthetische Konfiguration3 findings'); @@ -420,7 +449,7 @@ describe('Client Info Compliance overview', () => { expect(host.querySelectorAll('.compliance-footprint__themes .compliance-footprint__cell')).toHaveLength(93); }); - it('shows only the two BSI practices with the most findings in the tile, and every practice in Show all', async () => { + it('shows only the BSI practice with the most findings in the tile, and every practice in Show all', async () => { // Two more synthetic practices with hits: DET via 8.9 (3 findings) and NOT via 5.15 (1 finding). const wider = { ...bsi, practices: [...bsi.practices, { id: 'NOT', title: 'Notfallplanung' }], requirements: [...bsi.requirements, { requirementId: 'DET.1.1', title: 'Synthetische Detektion', practice: 'DET', statement: 'Synthetisch.', via: [{ isoControlId: '8.9', relationship: 'subset-of' as const }] }, @@ -433,7 +462,7 @@ describe('Client Info Compliance overview', () => { await act(async () => switchButton('bsi')!.click()); // KONF and DET have 3, ARCH 2, BER and NOT 1 each; the tie between KONF and DET keeps catalog order. const shown = () => [...host.querySelectorAll('.compliance-overview [data-bsi-practice]')].map(block => block.dataset.bsiPractice); - expect(shown()).toEqual(['KONF', 'DET']); + expect(shown()).toEqual(['KONF']); await act(async () => host.querySelector('.compliance-footprint .client-environment-results-button')!.click()); expect([...document.querySelectorAll('.compliance-footprint--window [data-bsi-practice]')].map(block => block.dataset.bsiPractice)) .toEqual(['BER', 'ARCH', 'KONF', 'DET', 'NOT']); diff --git a/tests/compliance/iso-full-control-text.test.ts b/tests/compliance/iso-full-control-text.test.ts index e45a2db..0c9f182 100644 --- a/tests/compliance/iso-full-control-text.test.ts +++ b/tests/compliance/iso-full-control-text.test.ts @@ -82,4 +82,18 @@ describe('ISO full control text (prompt 1.3.0)', () => { expect(ids.slice(0, 2)).toEqual(['27002-8.2-control:0', '27002-8.2-guidance:0']); expect(ids.at(-1)).toBe('27002-8.2-guidance:11'); }); + + // Regression 2026-10-09: the hybrid search matched many long passages of one control, which were all kept beyond the + // cap, and a Purple Knight mapping failed with regulatory_mapping_prompt_too_large. + it('counts matched passages toward the cap, keeping the best one first, so many matches cannot overflow the prompt', async () => { + const guidance = Array.from({ length: 12 }, (_, index) => chunk('ISO/IEC 27002', 'guidance', index, 1_500)); + const corpus = [chunk('ISO/IEC 27001', 'control', 0), chunk('ISO/IEC 27002', 'control', 0), ...guidance]; + const { prompt, response } = await promptFor(corpus, [...guidance].reverse()); + const evidence = (prompt.candidates[0].evidence as Array<{ chunkId: string; sourceStandard: string; text: string }>) + .filter(item => item.sourceStandard === 'ISO/IEC 27002'); + expect(response.run?.status).toBe('succeeded'); + expect(evidence.reduce((total, item) => total + item.text.length, 0)).toBeLessThanOrEqual(8_000); + // The best-ranked match (guidance 11, first in the hybrid ranking) is always kept. + expect(evidence.map(item => item.chunkId)).toContain('27002-8.2-guidance:11'); + }); }); diff --git a/tests/compliance/iso-work-facets.test.tsx b/tests/compliance/iso-work-facets.test.tsx index 69dad4c..52cf27a 100644 --- a/tests/compliance/iso-work-facets.test.tsx +++ b/tests/compliance/iso-work-facets.test.tsx @@ -18,7 +18,7 @@ import { resetComplianceWorkFacetsForTests, useComplianceWorkFacets } from '../../src/compliance/ui/compliance-work-facets'; -import { markRegulatoryDecisionSaved } from '../../src/compliance/ui/decision-revision'; +import { markRegulatoryDecisionSaved, markRegulatoryMappingsChanged } from '../../src/compliance/ui/decision-revision'; import { filterAndSortRecords } from '../../src/features/search-filter/filter-records'; import { selectWorkFacets } from '../../src/features/search-filter/work-facets'; import { makeSheet } from '../../src/importers/sheet-factory'; @@ -222,6 +222,38 @@ describe('Work ISO facet loading and the engine switch', () => { expect(request).toHaveBeenCalledTimes(2); }); + // Regression 2026-10-09: `Mapping failed` in the Compliance tab opened Work without filtering when the mapping failed + // after Work had loaded its assignments, because a failed mapping is no decision and Work kept the outdated state. + it('reloads the assignments after a mapping change and offers no outdated state meanwhile, so Mapping failed stays selected', async () => { + const { loadCanonicalProjectWorkspace } = await import('../../src/features/project-import/load-canonical-project-workspace'); + vi.mocked(loadCanonicalProjectWorkspace).mockResolvedValue({ assessmentIds: ['dummy-assessment'], models: [], + dataset: { fileName: 'Dummy', scanTool: 'Generic', sheets: [workSheet()] } } as never); + vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue({} as never); + let resolveFresh!: (value: RegulatoryControlOverviewResponse) => void; + const withFailure: RegulatoryControlOverviewResponse = { ...assignments, failedFindings: [ + { resultProjectionId: 'scanner-finding:dummy-a', sourceKind: 'scanner-finding', sourceResultId: 'dummy-a' }] }; + const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlAssignments') + .mockResolvedValueOnce(assignments) + .mockImplementationOnce(() => new Promise(resolve => { resolveFresh = resolve; })); + await act(async () => root.render()); + await act(async () => { host.querySelector('button')!.click(); await new Promise(resolve => setTimeout(resolve, 0)); }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + expect(host.querySelector('[data-iso-facets]')?.textContent).toBe('2,2,1'); + // The Compliance tab saw a mapping fail: Work drops the outdated state while it loads the fresh one ... + await act(async () => { markRegulatoryMappingsChanged(); await new Promise(resolve => setTimeout(resolve, 0)); }); + expect(request).toHaveBeenCalledTimes(2); + expect(host.querySelector('[data-iso-facets]')?.textContent).toBe('none'); + // ... and then offers ISO mapping with Mapping failed, matching exactly that finding. + await act(async () => { resolveFresh(withFailure); await new Promise(resolve => setTimeout(resolve, 0)); }); + expect(host.querySelector('[data-iso-facets]')?.textContent).toBe('2,2,2'); + const index = complianceWorkFacetIndex(withFailure); + const mapping = index.facets.find(facet => facet.id === ISO_MAPPING_FACET_ID)!; + expect(mapping.values.map(value => value.value)).toEqual([ISO_MAPPING_FAILED, ISO_MAPPING_NO_BSI]); + expect(filterAndSortRecords(workSheet(), { ...options('all', { [ISO_MAPPING_FACET_ID]: { values: [ISO_MAPPING_FAILED] } }), complianceFacets: index, + sortColumn: '', sortDirection: 'asc' }) + .map(record => record.findingOccurrenceId)).toEqual(['dummy-a']); + }); + it('loads nothing and clears an active ISO filter when the Compliance Engine is switched off', async () => { const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlAssignments').mockResolvedValue(assignments); await act(async () => root.render());