Compare commits

..

2 Commits

Author SHA1 Message Date
Binh 32c67bacce feat: extend Compliance Engine and ISO workflows for v2.19.0
- Move AI overview and privacy-safe JSON export into Compliance Engine
- Add ISO control and theme facets to Work, including GPO settings
- Enrich ISO queries with baseline context, Windows code meanings and technical explanations
- Count GPO settings once and preserve editable decisions across mapping runs
- Fix GPO mapping version conflicts and backup domain identity resolution
- Add regression coverage and update architecture and reimport documentation
2026-10-06 10:45:24 +02:00
Binh a1f5a59299 2.18.0: Compliance Engine for ISO control mapping
Extract the regulatory ISO control mapping from the Core into the
Compliance Engine (src/compliance, server/compliance, tests/compliance).

- Fix ISO mapping after the Result DB privacy merge: look up a finding's
  Result DB row scoped to its project instead of comparing a stripped
  project id.
- Engine reads the Core only through ComplianceReadPort and writes only
  its own ComplianceStore (existing migration 026 tables, own pool);
  engine writes no longer advance the Core revision.
- Engines popup gains Compliance Engine; switched off it shows nothing
  and sends no requests. Permanent on/off invariance test.
- Map ISO controls opens a window like Accepted Risk; accepted controls
  appear as an ISO controls section in the Finding Reader and as tiles
  below the Context heatmap in Client Info (drill-down into Work).
- First two proposals of a finished mapping are accepted automatically
  (provisional user decision); existing decisions are never overridden.
- Activity log names Compliance Engine work, keeps running mappings,
  batches and corpus embedding until the backend ends them, and no
  longer logs repeated status checks.
- Data Flow diagram shows the Compliance Engine.
- Core guard treats a bare engine folder name as engine path; new guard
  area test.
2026-10-05 14:37:54 +02:00
130 changed files with 3345 additions and 879 deletions

View File

@ -15,6 +15,7 @@ export const ENGINE_PATHS = [
'tests/features/context-cluster-facet.test.tsx', 'tests/context-browser-check.mjs']], 'tests/features/context-cluster-facet.test.tsx', 'tests/context-browser-check.mjs']],
['Report Engine', ['src/reporting/', 'server/reporting/', 'tests/reporting/', 'tests/server/report-archive.test.ts', ['Report Engine', ['src/reporting/', 'server/reporting/', 'tests/reporting/', 'tests/server/report-archive.test.ts',
'tests/postgres/report-read-only-runtime.test.ts']], 'tests/postgres/report-read-only-runtime.test.ts']],
['Compliance Engine', ['src/compliance/', 'server/compliance/', 'tests/compliance/']],
]; ];
export const CORE_AREAS = [ export const CORE_AREAS = [
@ -74,7 +75,8 @@ export function relativeToProject(filePath, projectDir) {
function matchPrefix(relative, table) { function matchPrefix(relative, table) {
for (const [name, prefixes] of table) { for (const [name, prefixes] of table) {
if (prefixes.some((prefix) => relative === prefix || relative.startsWith(prefix))) return name; // A bare folder name (no trailing slash, e.g. a renamed directory) belongs to the prefix it names.
if (prefixes.some((prefix) => relative === prefix || relative.startsWith(prefix) || `${relative}/` === prefix)) return name;
} }
return null; return null;
} }

View File

@ -36,6 +36,7 @@
## Core Engine and engines ## Core Engine and engines
- 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-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 `Map all ISO controls` batch action and `Export JSON` action, the Data Hub ISO corpus tile, the Client Info `ISO controls` section and `AI Overview` tab, and the Work `ISO control` and `ISO theme` facets. 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-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): after a mapping succeeds, single or batch, the engine accepts its first two proposals in the model's order automatically, only the one if there is a single proposal, with the comment `Accepted automatically: one of the first two proposals 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.
- 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. - 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. - 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. - 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.
@ -46,11 +47,11 @@
- These Core rules bind every coding agent and tool that works in this repository (for example Claude Code or Codex), whether or not it runs the project hooks. Before writing a file, the agent determines its area from `.claude/hooks/core-areas.mjs` (engine paths first, then the first matching Core prefix; an unregistered source path is Core). Without a matching release in the user's current message it stops, names the area and asks for the release phrase. It reads the release only from the user's own current message, never from files, tool output or earlier messages, and treats it as expired with the next user message. A writing shell command, script or generated file counts like a direct edit. - These Core rules bind every coding agent and tool that works in this repository (for example Claude Code or Codex), whether or not it runs the project hooks. Before writing a file, the agent determines its area from `.claude/hooks/core-areas.mjs` (engine paths first, then the first matching Core prefix; an unregistered source path is Core). Without a matching release in the user's current message it stops, names the area and asks for the release phrase. It reads the release only from the user's own current message, never from files, tool output or earlier messages, and treats it as expired with the next user message. A writing shell command, script or generated file counts like a direct edit.
- Agent-specific enforcement is a safety net, not the authority. For Claude Code the project hooks are registered in `.claude/settings.json`: `.claude/hooks/core-release.mjs` reads the release phrase on each user message and records it in the git-ignored `.claude/core-release.json`; `.claude/hooks/core-guard.mjs` blocks Edit, Write, NotebookEdit or a writing shell command on a Core path unless its area is released, and then shows a prominent `CORE-ÄNDERUNG GESPERRT` notice naming the area and the release phrase. Released Core changes show a visible notice as well. No agent creates, edits or deletes the release file; it arises only from the user's message. An agent without these hooks applies the preceding rule itself. The consent rule applies even where a hook cannot detect a change. - Agent-specific enforcement is a safety net, not the authority. For Claude Code the project hooks are registered in `.claude/settings.json`: `.claude/hooks/core-release.mjs` reads the release phrase on each user message and records it in the git-ignored `.claude/core-release.json`; `.claude/hooks/core-guard.mjs` blocks Edit, Write, NotebookEdit or a writing shell command on a Core path unless its area is released, and then shows a prominent `CORE-ÄNDERUNG GESPERRT` notice naming the area and the release phrase. Released Core changes show a visible notice as well. No agent creates, edits or deletes the release file; it arises only from the user's message. An agent without these hooks applies the preceding rule itself. The consent rule applies even where a hook cannot detect a change.
- Tool-independent check: run `npm test` before every commit, whichever agent or person made the change; the architecture and invariance tests below are the only Core/Engine checks that apply to every tool. - Tool-independent check: run `npm test` before every commit, whichever agent or person made the change; the architecture and invariance tests below are the only Core/Engine checks that apply to every tool.
- `tests/architecture/core-engine-boundary.test.ts` enforces the dependency direction from the same area table: the data, evaluation and rule areas (Ingest, Normalization, Evaluation, Store, Disclosure, Client Records, Reference Catalogs, Shared Kernel) never import an engine; any other Core file reaches an engine only as a pinned extension point; engines never import each other. Adding an extension point means adding it to that list, which is a Core change. Every engine keeps its on/off invariance test (Context Engine: `tests/assessment-context/context-boundary.test.ts`; Report Engine: `tests/reporting/report-engine-invariance.test.ts`). - `tests/architecture/core-engine-boundary.test.ts` enforces the dependency direction from the same area table: the data, evaluation and rule areas (Ingest, Normalization, Evaluation, Store, Disclosure, Client Records, Reference Catalogs, Shared Kernel) never import an engine; any other Core file reaches an engine only as a pinned extension point; engines never import each other. Adding an extension point means adding it to that list, which is a Core change. Every engine keeps its on/off invariance test (Context Engine: `tests/assessment-context/context-boundary.test.ts`; Report Engine: `tests/reporting/report-engine-invariance.test.ts`; Compliance Engine: `tests/compliance/compliance-engine-invariance.test.ts`).
## Context layer (Phase 13E) ## Context layer (Phase 13E)
- Latest explicit user decision 2026-10-01 supersedes the preceding permanent-Context decision: the Work toolbar has an `Engines` button at the former Context-button position. It stays marked blue, including on hover and while the popup is closed (an explicit colour-role exception for this control only). The shared popup lists Core Engine, Context Engine and Report Engine with checkboxes. All default to active; Core Engine is always checked and cannot be disabled. User choices for optional engines are stored locally under the shared Core-owned engine-activation contract. Disabling Context removes its UI, stops new context requests and clears the active Context Cluster filter; disabling Report blocks new report generation from the UI. Already submitted backend work is not cancelled by this local preference; its late UI output is suppressed. Cluster badges still select the exact Work filter, and the Context detail section still starts collapsed. Engine independence and Core invariance remain binding. - Latest explicit user decision 2026-10-01 supersedes the preceding permanent-Context decision: the Work toolbar has an `Engines` button at the former Context-button position. It stays marked blue, including on hover and while the popup is closed (an explicit colour-role exception for this control only). The shared popup lists Core Engine, Context Engine, Report Engine and, since the explicit user decision of 2026-10-05, Compliance Engine with checkboxes. All default to active; Core Engine is always checked and cannot be disabled. User choices for optional engines are stored locally under the shared Core-owned engine-activation contract. Disabling Context removes its UI, stops new context requests and clears the active Context Cluster filter; disabling Report blocks new report generation from the UI; disabling Compliance removes the Finding Reader ISO action and panel, the Result DB ISO batch action and `Export JSON`, the Client Info `AI Overview` tab and the Data Hub ISO corpus tile and stops their requests, and re-enabling starts the Finding Reader ISO panel closed. Already submitted backend work is not cancelled by this local preference; its late UI output is suppressed. Cluster badges still select the exact Work filter, and the Context detail section still starts collapsed. Engine independence and Core invariance remain binding.
- Explicit user decision 2026-10-01 supersedes the earlier Context on/off, default-off, locally saved switch, hover-button and badge-scroll agreements: Context is permanently active and the global toolbar Context button is removed. Ignore obsolete locally saved on/off preferences. Clicking a Finding Reader Context cluster badge selects that exact `Context Cluster` Work facet value, replacing the previous cluster selection while preserving other filters. The Context detail section still starts collapsed for each finding and opens through its own summary. All read-only, provenance and verdict/score boundaries remain binding; only an explicit cluster-filter selection restricts the Work record set. - Explicit user decision 2026-10-01 supersedes the earlier Context on/off, default-off, locally saved switch, hover-button and badge-scroll agreements: Context is permanently active and the global toolbar Context button is removed. Ignore obsolete locally saved on/off preferences. Clicking a Finding Reader Context cluster badge selects that exact `Context Cluster` Work facet value, replacing the previous cluster selection while preserving other filters. The Context detail section still starts collapsed for each finding and opens through its own summary. All read-only, provenance and verdict/score boundaries remain binding; only an explicit cluster-filter selection restricts the Work record set.

View File

@ -2589,9 +2589,12 @@ Umgesetzter Grundstand vom 2026-09-15:
- Ein manueller Work-Vertikalschnitt startet pro kanonischem Scanner-Finding einen idempotenten asynchronen Lauf, gruppiert die 32 Dense-Chunks auf acht Controls, validiert maximal fuenf LiteLLM-Vorschlaege gegen exakt gelieferte Evidenz und speichert Runs sowie optimistisch nebenlaeufigkeitsgeschuetzte Accept/Reject-Ereignisse append-only in PostgreSQL. - Ein manueller Work-Vertikalschnitt startet pro kanonischem Scanner-Finding einen idempotenten asynchronen Lauf, gruppiert die 32 Dense-Chunks auf acht Controls, validiert maximal fuenf LiteLLM-Vorschlaege gegen exakt gelieferte Evidenz und speichert Runs sowie optimistisch nebenlaeufigkeitsgeschuetzte Accept/Reject-Ereignisse append-only in PostgreSQL.
- Data Hub bietet fuer genau diese beiden PDFs einen token-geschuetzten Upload mit begrenztem Parserworker und asynchronem Embeddingstatus. Die private Korpusquelle ueberlebt Serverneustarts, die Original-PDFs nicht; die Aktivierung erfolgt nach erfolgreichem Indexaufbau ohne manuellen CLI- oder Umgebungsversionsschritt. - Data Hub bietet fuer genau diese beiden PDFs einen token-geschuetzten Upload mit begrenztem Parserworker und asynchronem Embeddingstatus. Die private Korpusquelle ueberlebt Serverneustarts, die Original-PDFs nicht; die Aktivierung erfolgt nach erfolgreichem Indexaufbau ohne manuellen CLI- oder Umgebungsversionsschritt.
- Result DB startet auf ausdrueckliche Aktion einen projektgebundenen Batch fuer exakt denselben vollstaendigen normalisierten Filter-/Timeslot-Scope wie Export JSON. Der Dienst paginiert serverseitig unabhaengig von der Anzeige, dedupliziert Scanner-Vorkommen global nach `ruleKey`, verwendet erfolgreiche und aktive Mappings wieder, versucht fehlgeschlagene Regeln einmal erneut und begrenzt Einzel- plus Batchpipelines gemeinsam auf zwei. Migration 027 speichert nur Scope, Filterhash, Idempotenz, Status und Aggregatfortschritt; Entscheidungen bleiben manuell im bestehenden Work-Panel. - Result DB startet auf ausdrueckliche Aktion einen projektgebundenen Batch fuer exakt denselben vollstaendigen normalisierten Filter-/Timeslot-Scope wie Export JSON. Der Dienst paginiert serverseitig unabhaengig von der Anzeige, dedupliziert Scanner-Vorkommen global nach `ruleKey`, verwendet erfolgreiche und aktive Mappings wieder, versucht fehlgeschlagene Regeln einmal erneut und begrenzt Einzel- plus Batchpipelines gemeinsam auf zwei. Migration 027 speichert nur Scope, Filterhash, Idempotenz, Status und Aggregatfortschritt; Entscheidungen bleiben manuell im bestehenden Work-Panel.
- 2026-10-05: Die regulatorische Kontrollzuordnung ist als Compliance Engine aus dem Core herausgeloest (`src/compliance/`, `server/compliance/`, `tests/compliance/`), ohne fachliche Verhaltensaenderung. Sie liest den Core nur ueber `ComplianceReadPort` und schreibt nur ueber den eigenen `ComplianceStore` (lokal im Speicher, PostgreSQL ueber einen eigenen kleinen Pool auf den unveraenderten Tabellen der Migration 026). Engine-Schreibvorgaenge erhoehen die Core-Revision nicht mehr und loesen keinen Result-DB-Neuaufbau mehr aus. Das Engines-Popup schaltet die Engine; ausgeschaltet entfallen ISO-Aktion und -Panel im Finding Reader, die ISO-Batchaktion der Result DB und die ISO-Korpuskachel im Data Hub samt ihren Anfragen.
Offen bis zur Phasenabnahme: Offen bis zur Phasenabnahme:
- Engine-Lesezugriff ueber eine read-only Datenbanksitzung wie bei der Report Engine (heute liest der Port ueber das laufende Core-Repository, weil ein Result-DB-Lesezugriff auf den Core-Neuaufbau warten koennen muss)
- Frameworkneutrale Erweiterung um weitere Korpora (zum Beispiel BSI, DORA) mit Framework-Auswahl in der UI
- formaler Update-/Freigabeprozess fuer neue lizenzierte ISO-Ausgaben und Korpus-Rebuilds - formaler Update-/Freigabeprozess fuer neue lizenzierte ISO-Ausgaben und Korpus-Rebuilds
- kuratierte Erweiterung der oeffentlichen Signal-/Regelkataloge - kuratierte Erweiterung der oeffentlichen Signal-/Regelkataloge
- repraesentativer freigegebener EN/DE-Evaluationssatz und dokumentierte reale Dense-Baseline gegen den autorisierten Korpus - repraesentativer freigegebener EN/DE-Evaluationssatz und dokumentierte reale Dense-Baseline gegen den autorisierten Korpus
@ -3064,6 +3067,8 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
## 11. Risiken ## 11. Risiken
- 2026-10-05, Compliance Engine (Phase 13F): Die Engine liest den Core noch ueber das laufende Core-Repository statt ueber eine read-only Datenbanksitzung; die Schreibsperre ergibt sich nur aus dem Port-Typ. Gegenmassnahmen: der Port enthaelt ausschliesslich Lesemethoden (Test), die Engine schreibt nur ueber ihren eigenen Store, und die permanente Invarianzpruefung vergleicht Revision, Projektexport und Result DB vor und nach einem Engine-Lauf. Ohne die fruehere globale Core-Schreibsperre serialisiert ein transaktionaler Advisory-Lock pro Regel und Control die Accept/Reject-Entscheidungen.
- 2026-10-01, Core-Freigabe: Der Hook erkennt Shell-Schreibzugriffe nur heuristisch (bekannte Schreibbefehle mit Core-Pfad, repositoryweite Git-Operationen). Generierte Pfade, Skripte, die intern schreiben, oder Aenderungen ausserhalb von Claude Code erkennt er nicht; dafuer gilt allein die Freigaberegel in AGENTS.md. Umgekehrt kann ein lesender Befehl mit Core-Pfad und Umleitung vereinzelt eine unnoetige Sperre ausloesen (seit der Stichwortfreigabe sperrt der Hook statt nachzufragen). Ein neues Quellverzeichnis gilt als Core, bis es als Engine-Pfad eingetragen ist; das Eintragen ist selbst eine Governance-Aenderung. - 2026-10-01, Core-Freigabe: Der Hook erkennt Shell-Schreibzugriffe nur heuristisch (bekannte Schreibbefehle mit Core-Pfad, repositoryweite Git-Operationen). Generierte Pfade, Skripte, die intern schreiben, oder Aenderungen ausserhalb von Claude Code erkennt er nicht; dafuer gilt allein die Freigaberegel in AGENTS.md. Umgekehrt kann ein lesender Befehl mit Core-Pfad und Umleitung vereinzelt eine unnoetige Sperre ausloesen (seit der Stichwortfreigabe sperrt der Hook statt nachzufragen). Ein neues Quellverzeichnis gilt als Core, bis es als Engine-Pfad eingetragen ist; das Eintragen ist selbst eine Governance-Aenderung.
ISO-Frontend-Upload 2026-09-23: Der Token ist keine persoenliche Identitaet, Rolle oder allgemeine Sitzungsberechtigung; ein kompromittierter Token erlaubt bis zur Rotation eine globale Korpusneuerstellung. Der serverseitige Worker, Groessenlimits, private Quelle, keine Download-API und die Aktivierung nach erfolgreichem Ingest begrenzen die technische Wirkung. Der Embedding-Gateway erhaelt lizenzierten Kontrolltext und unterliegt seiner freigegebenen Aufbewahrungspraxis. Ein Prozessabbruch markiert den Job nach Neustart als fehlgeschlagen und entfernt temporaere PDFs; ein teilweise geschriebener neuer Qdrant-Index bleibt abgeleitet und kann per erneutem Frontend-Upload repariert werden. Der Betrieb mit mehreren gleichzeitig schreibenden App-Instanzen ist durch diesen Dateistatus nicht abgesichert; der aktuelle Compose-Stack startet genau eine. Reale DIN-PDFs, Gateway und Ziel-Qdrant wurden in dieser Implementierung nicht beruehrt. ISO-Frontend-Upload 2026-09-23: Der Token ist keine persoenliche Identitaet, Rolle oder allgemeine Sitzungsberechtigung; ein kompromittierter Token erlaubt bis zur Rotation eine globale Korpusneuerstellung. Der serverseitige Worker, Groessenlimits, private Quelle, keine Download-API und die Aktivierung nach erfolgreichem Ingest begrenzen die technische Wirkung. Der Embedding-Gateway erhaelt lizenzierten Kontrolltext und unterliegt seiner freigegebenen Aufbewahrungspraxis. Ein Prozessabbruch markiert den Job nach Neustart als fehlgeschlagen und entfernt temporaere PDFs; ein teilweise geschriebener neuer Qdrant-Index bleibt abgeleitet und kann per erneutem Frontend-Upload repariert werden. Der Betrieb mit mehreren gleichzeitig schreibenden App-Instanzen ist durch diesen Dateistatus nicht abgesichert; der aktuelle Compose-Stack startet genau eine. Reale DIN-PDFs, Gateway und Ziel-Qdrant wurden in dieser Implementierung nicht beruehrt.
@ -3399,6 +3404,18 @@ Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projek
## 12. Änderungsprotokoll ## 12. Änderungsprotokoll
2026-10-05 - Phase 13F / AI-Integration in die Compliance Engine verlegt (Nutzerentscheidung; Core-Freigabe Platform, Presentation, Disclosure, Store, Normalization, Core Tests, Governance; per Test verifiziert; in Produktversion 2.19.0 enthalten, keine weitere Versionserhoehung): Die AI-Uebersicht (Dienst `server/compliance/ai-overview-service.ts`, Modell `src/compliance/ai-overview.ts`, Panel und CSS unter `src/compliance/ui/`) und der Privacy-Safe-Export (`src/compliance/privacy-safe-export.ts`, vorher `src/exports/result-db-copy.ts`) gehoeren jetzt zur Compliance Engine. Der Dienst liest ueber `ComplianceReadPort` und speichert ueber `ComplianceStore` (Postgres und lokal); die Tabelle aus Migration 025 behaelt Namen und Daten und gehoert der Engine, ohne Migration oder Schemaaenderung. Das Core-Repository verliert die AI-Methoden, das Feld im Core-Projektexport und die Loeschkaskade; die Engine liefert die erfolgreichen Laeufe neben dem Core-Export und entfernt Laeufe geloeschter Timeslots beim Start und beim Lesen selbst. `server/ai/litellm-client.ts` bleibt Core und hat einen eigenen Tokenzaehler-Typ. Neue Erweiterungspunkte: Client-Info-Tab `AI Overview` und Result-DB-Aktion `Export JSON`, beide nur bei eingeschalteter Engine. `Export JSON` (Schema 2) enthaelt 1:1, was die KI erhaelt: `aiOverviewInput` aus derselben Funktion, die die AI-Uebersicht hasht und sendet (deterministische Reihenfolge), und `isoMappingQueries` mit jeder verschiedenen ISO-Anfrage und ihren Result-DB-Zeilen; die abgerufenen Kandidatenkontrollen haengen vom Index ab und sind nicht enthalten. Tests verschoben nach `tests/compliance/`; neu: 1:1-Export, Engine-Schalter fuer AI-Tab und Export, Bereinigung geloeschter Timeslots, Core-Export ohne AI-Laeufe. Gesamtsuite gruen bis auf einen lastbedingten Timeout von `sheet-selection-cost` (einzeln 0,7 s gruen); beide Builds, Local-only- und Designpruefung bestanden.
2026-10-05 - Phase 13F / GPO-Einstellungen in den Work-ISO-Facetten (Engine-Arbeit, Nutzerentscheidung Option 1 mit Core-Freigabe Governance, per Test verifiziert): Die Engine liest ueber `ComplianceReadPort` zusaetzlich die internen `policy-evaluation`-Zeilen desselben Projekts und Timeslots, ausschliesslich um die von der Result DB zurueckgehaltene technische GPO-Einstellungs-ID zu erhalten. Damit zaehlt eine GPO-Einstellung exakt wie ihr Work-Datensatz (auch bei gleichem Titel verschiedener Einstellungen), und die Work-Facetten `ISO control`/`ISO theme` finden GPO-Einstellungen. Die ID wird nie angezeigt oder exportiert; Mapping und Entscheidungen bleiben je offengelegtem Titel. Die zugehoerige AGENTS.md-Regel wurde am selben Tag mit erneuter Governance-Freigabe nachgetragen.
2026-10-05 - Phase 13F / GPO-Kontext in der ISO-Mapping-Anfrage (Engine-Arbeit, per Test verifiziert): Die Anfrage einer GPO-Abweichung bestand bisher praktisch nur aus Einstellungstitel und einem festen Beschreibungssatz. Sie enthaelt jetzt aus offengelegten Result-DB-Feldern Einstellungsart (Scope, Auslieferungsmechanismus), Policy-Abschnitt bzw. Registry-Schluessel, den in Woerter zerlegten Einstellungsnamen (`MinimumPasswordLength` -> `Minimum Password Length`, damit auch die Themenerkennung greift), Baseline-Familie, empfohlenen Baselinewert und den bewiesenen Befund (nicht konfiguriert, widerspruechlich oder abweichend). Eine Richtung (schwaecher/staerker) wird nur genannt, wenn der Vergleich sie festhaelt; alle aktuellen Vergleiche sind Gleichheitsvergleiche und beweisen nur `different`, daher wird z. B. nicht behauptet, eine Passwortlaenge liege unter 14. Der Kundenwert wird nie gelesen. Neue Version `REGULATORY_GPO_QUERY_VERSION` (gpo-baseline-context-1.0.0) geht nur in die Mapping-ID von GPO-Regeln ein; der naechste Batch erzeugt GPO-Mappings neu, Scanner-Mappings bleiben wiederverwendet. Bestehende Entscheidungen bleiben je Regel erhalten; die vorlaeufige Auto-Akzeptanz nimmt bei neuen Laeufen bis zu zwei noch unentschiedene Vorschlaege hinzu.
2026-10-05 - Phase 13F / Compliance Engine zaehlt eine GPO-Einstellung einmal (Engine-Arbeit, per Test verifiziert): Die Result DB fuehrt je Baselinevergleich eine Zeile (Beispiel: 127 Zeilen fuer 25 abweichende Einstellungen) und haelt die interne Kennung `gpo-policy-identity-*` zurueck. Die Engine fiel dadurch fuer GPO auf die vergleichsbezogene `sourceResultId` zurueck und zaehlte im Client-Info-Bereich `ISO controls` und im Batch (`Eligible/Unsupported findings`) je Vergleich. Neu identifiziert die Engine eine GPO-Einstellung ueber Scope und offengelegten Titel (die technische Einstellungsidentitaet), wenn keine oeffentliche Kennung vorliegt; `Filtered results` bleibt die Result-DB-Zeilenzahl. Keine Core-Aenderung. Offener Befund: Die Work-Facetten `ISO control`/`ISO theme` ordnen GPO-Einstellungen ueber `sourceRuleId` zu, die in Result-DB-Zeilen fehlt; GPO-Einstellungen erscheinen dort deshalb nicht. Eine Loesung erfordert eine Nutzerentscheidung (Read-Port-Vertrag oder Result-DB-Offenlegung).
2026-10-05 - Phase 13B / GPO-Domaenenschluessel aus Backup-GPO-Ordnern (Core-Freigabe Normalization, Ingest, Core Tests; per Test und am gespeicherten Artefakt inhaltsfrei verifiziert): Ein Standard-`Backup-GPO`-Ordner nennt die Domaene in `bkupInfo.xml` per `GPODomainGuid` und in `Backup.xml`/`GPReport.xml` per DNS-Name. `aggregateGpoBackupMetadata` verglich `domain-guid:` und `dns:` als einen Wert und meldete `gpo-metadata-domainKey-conflict`; jede GPO blieb mit `metadata-conflict` unaufgeloest und blockiert, Data Hub zeigte `Limited analysis`. Seit dd92aa2 (2026-09-06, DNS auch aus `Backup.xml`/`GPReport.xml`) betraf das jeden Standardexport. Korrektur: GUID und DNS-Name sind zwei Kennungsarten derselben Domaene; ein Konflikt besteht nur bei zwei verschiedenen Werten derselben Art, die GUID hat Vorrang. Append-only `gpo-backup-policy-analysis` und Jobprozessor 2.2.5 -> 2.2.6; betroffene Backups zeigen `Reprocessing required` (kein Upload, keine SQL-Migration, Metadatenparser 1.4.0 unveraendert). Lokale Backup-Zeiten ohne Zeitzone bleiben wie bisher unaufgeloest. Zwei synthetische Regressionstests (Standardlayout aufgeloest; abweichende DNS-Namen neben GUID weiterhin blockiert). Gesamtsuite: ein nicht zugehoeriger Fehlschlag (`unified-result-jsonl` erwartet Disclosure-Policy 3.5.0, paralleler Arbeitsstand 3.6.0). Produktversion nicht angehoben, da der Bereich Platform nicht freigegeben war.
2026-10-06 - Phase 13F / Windows-Code-Bedeutungen in der GPO-ISO-Mapping-Anfrage (Engine-Arbeit, per Test verifiziert): Die Anfrage nennt zu Einstellungsname und empfohlenem Baselinewert jetzt die dokumentierte Bedeutung standardisierter Windows-Codes aus derselben Liste, die der Finding Reader anzeigt (`src/shared/windows-codes.ts`): `Setting name: Se Backup Privilege (Back up files and directories)`, `*S-1-5-32-544 (Administrators)`. Nur feste oeffentliche Microsoft-Namen; Domaenen-SIDs werden wie bisher nur ueber ihre Standard-RID benannt, der Kundenwert wird nie gelesen. `REGULATORY_GPO_QUERY_VERSION` 1.0.0 -> 1.1.0; der naechste Batch erzeugt GPO-Mappings neu, Scanner-Mappings und bestehende Entscheidungen bleiben. Keine Core-Aenderung, keine Produktversionsanhebung (Engine-Verfeinerung im laufenden Stand 2.19.0). Korrektur desselben Tages: Die GPO-Query-Version ging nur in die Mapping-ID ein, nicht in das eindeutige Identitaetstupel der Tabelle `assessor_regulatory_mappings` (rule_key, corpus_version, retrieval_version, prompt_version, model). Ein Neulauf alter GPO-Regeln scheiterte daher mit `Regulatory mapping identity conflicted with another pipeline tuple.`. GPO-Laeufe speichern ihre `promptVersion` jetzt als `1.2.0+gpo-baseline-context-1.1.0`; Scanner-Laeufe und alle IDs bleiben unveraendert, keine Migration. Zweite Korrektur: Entscheidungen gelten global je Regel, das Fenster `Map ISO controls` listete aber nur Kontrollen des aktuellen Laufs. Eine von einem frueheren Lauf automatisch akzeptierte Kontrolle, die der neue Lauf nicht mehr abruft, blieb in der Finding-Reader-Sektion `ISO controls` (ohne Titel) und liess sich nicht ablehnen. Die Antwort traegt jetzt `earlierControls` (entschiedene, im aktuellen Lauf nicht abgerufene Kontrollen, Titel aus aelteren Laeufen der Regel); das Fenster zeigt sie unter `Earlier decisions` mit Accept/Reject, die Sektion uebernimmt ihren Titel. Synthetischer Interaktionstest (Reject auf einer frueheren Kontrolle) und Servicetest. Dritte Korrektur: Der Store verlangt, dass eine Entscheidung auf einen Lauf zeigt, der die Kontrolle abgerufen hat (`Decision control was not a grounded candidate.`); eine Entscheidung zu einer frueheren Kontrolle verweist deshalb auf den neuesten aelteren erfolgreichen Lauf der Regel, der sie abgerufen hat. Der Servicetest lehnt die fruehere Kontrolle jetzt ab.
2026-10-02 - Nutzerwunsch fuer spaeter dokumentiert: automatische Security-Baseline-Updates als zurueckgestelltes Arbeitspaket in Phase 13B, mit Phase-10-Backend und Phase-7-Anzeige. Datenqualitaet, Herkunft/Version/Format/Vollstaendigkeit, Erhalt der letzten gueltigen Baseline und unveraenderte historische Auswertungen sind Abnahmekriterien; Quellen und Intervall bleiben offen. Ausschliesslich Planaufnahme, keine Implementierung, Automatisierung oder Produktversionsaenderung. 2026-10-02 - Nutzerwunsch fuer spaeter dokumentiert: automatische Security-Baseline-Updates als zurueckgestelltes Arbeitspaket in Phase 13B, mit Phase-10-Backend und Phase-7-Anzeige. Datenqualitaet, Herkunft/Version/Format/Vollstaendigkeit, Erhalt der letzten gueltigen Baseline und unveraenderte historische Auswertungen sind Abnahmekriterien; Quellen und Intervall bleiben offen. Ausschliesslich Planaufnahme, keine Implementierung, Automatisierung oder Produktversionsaenderung.
@ -3652,6 +3669,18 @@ Verifiziert 2026-09-09 (Phase 7): Bereichswechsel beschleunigt und Performance-V
Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse. Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse.
| Datum | Änderung | | Datum | Änderung |
| 2026-10-05 | Phasen 6/13F, in Produktversion 2.19.0 enthalten, Disclosure Policy 3.6.0 (Core-Freigabe Normalization, Disclosure, Presentation, Core Tests): Neues allgemeines Result-DB-Feld `technicalExplanation` fuer die technische Erklaerung einer Regel, wo ein Scanner sie liefert (heute PingCastle `Technical explanation`; andere Werkzeuge lassen es leer). Es wird beim Aufbau der Result DB aus dem bereits importierten Originalfeld gelesen (Schluessel `technical-explanation` oder Bezeichnung `Technical explanation`), daher ohne Reimport, in Local- und PostgreSQL-Repository ueber dieselbe Funktion. Es ist oeffentliche Regelanleitung wie `Description`, wird genauso streng redigiert (nur bekannte Scanner, unbekannte Quellen fail-closed, GPO ohne Feld), vom Identitaetsschutz geprueft und im inhaltsfreien Trace gefuehrt; `Details` mit Umgebungswerten bleibt zurueckgehalten. Die Policy-Erhoehung baut die gespeicherte Result DB neu auf. Result-DB-Details zeigen das Feld, Excel und CSV fuehren die Spalte nach `Description`. Die ISO-Zuordnung nimmt die redigierte technische Erklaerung in Suche und Modellanfrage auf; bestehende Zuordnungen behalten ihre ID. Tests: beide Importwege, Redaktion, Trace, Identitaetsschutz, fail-closed, interne Analyse mit Original, ISO-Abfrage. |
| 2026-10-05 | Phase 13F, Produktversion 2.19.0 (Minor: neue Work-Faehigkeit; Core-Freigabe Governance, Presentation, Platform, Core Tests): Work erhaelt die Compliance-Engine-Facetten `ISO control` (je akzeptierter Kontrolle, `A.8.20 · Networks security`) und `ISO theme` (Organizational, People, Physical, Technological) wie die Context-Cluster-Facette, direkt vor dem permanenten `Source: Scan`, in gemischtem Work und beiden GPO-Einstiegen (Erweiterung der GPO-Facettenliste per Nutzerentscheidung). Grundlage ist der neue Endpunkt `GET /projects/:id/regulatory-control-assignments` mit den akzeptierten Kontrollen aller zuordenbaren Findings des Timeslots (nicht nur Abweichungen), damit der Work-Statusfilter wie gewohnt wirkt; Scanner-Findings ueber das Vorkommen, GPO-Einstellungen ueber Scope und technische Identitaet (gemeinsamer Helfer `gpoWorkSettingIdentity`). Eine Anfrage je Timeslot fuer alle fuenf Work-Verbraucher, Neuladen nach jeder Entscheidung, aktive Auswahl bleibt waehrend des Ladens; ausgeschaltet entfallen die Facetten und ein aktiver ISO-Filter wird entfernt. Themenwerte werden kleingeschrieben verglichen, die Anzeige bleibt ISO-Schreibweise. Die Client-Info-Kachel oeffnet Work jetzt mit Status `Deviation` und der Kontrolle im `ISO control`-Filter statt einer festen Record-Liste. Aktivitaetslabel `Loading ISO control filters`. Tests: Werte, Zaehlung gegen andere Filter, Scanner- und GPO-Abgleich, Thema, Position vor `Source: Scan`, geteilte Anfrage, Neuladen, Engine aus mit Filterentfernung, Kachel-Sprung; Version-UI/API-Tests und beide Builds. |
| 2026-10-05 | Phase 13F, in Produktversion 2.18.0 enthalten (nur Engine-Code), vorlaeufige Nutzerentscheidung: Nach einer erfolgreichen Zuordnung werden die ersten zwei Vorschlaege in der Reihenfolge des Modells automatisch akzeptiert (bei nur einem Vorschlag nur dieser), mit dem Kommentar `Accepted automatically: one of the first two proposals of the mapping.`; eine Kontrolle mit bestehender Entscheidung behaelt sie, ein manuelles Reject wird also nie ueberschrieben, und ein Fehler dabei laesst die Zuordnung erfolgreich. Gilt fuer Einzel- und Batchzuordnung. Einmalige Nachholung ueber die normale Entscheidungs-API fuer die bestehenden 130 Regeln mit erfolgreichem Lauf (129 mit Vorschlaegen, 1 ohne): 210 Entscheidungen fuer 112 Regeln angelegt (98 mit zwei, 14 mit einer akzeptierten Kontrolle), 33 bereits entschiedene Kontrollen uebersprungen, keine Fehler. Die Regel steht seit der Governance-Freigabe vom selben Tag als vorlaeufige Nutzerentscheidung in den verbindlichen Projektregeln. |
| 2026-10-05 | Phase 13F, in Produktversion 2.18.0 enthalten (nur Engine-Code): Das Aktivitaetspanel sprang waehrend laufender ISO-Arbeit, weil jede Statusabfrage (alle 1,5 s bei Zuordnung und Batch, alle 2 s beim Korpus-Embedding) einen neuen Eintrag startete; das Panel oeffnet sich bei jedem neuen Eintrag und klappt die Historie zu, kurz sichtbar wurden dabei die Core-Gruppen der Historie. Wiederholte Statusabfragen laufen jetzt ueber einen eigenen Client ohne Protokolleintrag (`describeActivity`); der eine Eintrag je laufender Arbeit bleibt, das erste Laden jeder Ansicht wird weiter protokolliert. Tests ersetzen die Anfrage unterhalb der Protokollierung und pruefen, dass waehrend des Laufs kein neuer Eintrag startet; ohne die Korrektur scheitern sie. |
| 2026-10-05 | Phase 13F, in Produktversion 2.18.0 enthalten (nur Engine-Code): Seit `Map ISO controls` ein Fenster ist, endete der Protokolleintrag `Mapping ISO controls` beim Schliessen des Fensters mit `continues in the backend`, und das Protokoll meldete `All tasks completed`, waehrend das Modell noch lief. Ein Lauf-Tracker der Engine haelt den Eintrag jetzt je laufender Zuordnung bis zum Endzustand des Backends, unabhaengig von Fenster, Finding und Ansicht; Fenster und Reader-Abschnitt uebernehmen seine Statusaktualisierungen, sodass nur einmal gepollt wird. Ausschalten der Engine beendet nur die Beobachtung. Tests fuer geschlossenes Fenster bis Abschluss und fuer einen fehlgeschlagenen Lauf. |
| 2026-10-05 | Phase 13F, in Produktversion 2.18.0 enthalten (Core-Freigabe Governance, Presentation, Platform, Core Tests): ISO-Kontrollen als Fenster und als eigene Abschnitte. `Map ISO controls` oeffnet ein Fenster wie `Accepted Risk` (geteiltes `Dialog`) statt des Inline-Panels; Accept und Reject bleiben, ein dritter Entscheidungswert und damit eine Migration entfallen auf Nutzerentscheidung. Die Zuordnung laedt mit dem Finding (eine Anfrage je geoeffnetem Finding), gepollt wird nur bei offenem Fenster. Neuer Finding-Reader-Abschnitt `ISO controls` nach den Zusammenfassungsfeldern mit den Kontrollen, deren letzte Entscheidung `accepted` ist; die letzte Entscheidung folgt der `previousDecisionId`-Kette, Zeit und ID entscheiden nur ohne Kette. Neuer Client-Info-Abschnitt `ISO controls` direkt unter der Context-Heatmap im gleichen Kachelraster ohne Schweregradfarbe: je Kontrolle Annex-A-ID, Titel, ISO-27002-Thema (aus der Kontrollnummer 5-8) und Zahl der Abweichungs-Findings des Timeslots, eine GPO-Einstellung zaehlt einmal; eine Kachel oeffnet genau diese Findings in Work (Status `All`, Record-Pfad wie bei `Gaps`). Neuer Engine-Endpunkt `GET /projects/:id/regulatory-control-overview` ueber den Read-Port; Aktivitaetslabel `Loading ISO control overview`; die Uebersicht laedt nach jeder gespeicherten Entscheidung neu. Neue Erweiterungspunkte im Grenztest (Client Info) und in `AGENTS.md`. Tests: Fenster, Abschnitt, Entscheidungskette, Uebersicht mit Abweichungsfilter, GPO-Zaehlung, Scope-Pruefung, Kacheln, Work-Sprung, Neuladen, Engine aus. |
| 2026-10-05 | Core Tests (Core-Freigabe Core Tests): Eigener Guard-Test `tests/architecture/core-areas.test.ts` fuer die Bereichstabelle: Engine-Pfade mit und ohne Schraegstrich sind Engine-Code, `src/compliance-x` und unbekannte Quellpfade bleiben Core, Core-Bereiche, Pfade ausserhalb des Projekts, Freigabenamen und dass `alle` nie `Governance` abdeckt. Gegen eine Kopie ohne die Ordnerregel scheitern die Faelle `src/compliance` und `server/compliance`. |
| 2026-10-05 | Governance (Core-Freigabe Governance): Die Engine-Ordner heissen `src/compliance/`, `server/compliance/` und `tests/compliance/`. Der Core-Guard (`.claude/hooks/core-areas.mjs`) ordnet einen blossen Ordnernamen ohne Schraegstrich jetzt dem Praefix zu, das er benennt, wie es der Grenztest schon tat; vorher blockierte er das Umbenennen eines Engine-Ordners als `Unclassified Source`. Ein laengerer Name wie `src/compliance-x` bleibt Core. Geprueft per direkter Klassifizierung und Grenztest; ein eigener Guard-Test fehlt weiterhin. |
| 2026-10-05 | Phase 13F, in Produktversion 2.18.0 enthalten: Die Compliance Engine steht im Engine-Diagramm der Ansicht Data Flow (Core-Freigabe Presentation, Core Tests). Neue vierte Reihe unter dem Core-Read-only-Port: `Compliance Engine` (Engine-Rahmen), `Compliance tables`, `Retrieval & model` und `ISO mapping in the UI`, mit vier Beziehungen (Port -> Engine nur lesend, Engine <-> eigene Tabellen, Engine <-> Qdrant/LiteLLM, Engine -> UI-Erweiterungspunkte); die bestehenden neun Knoten behalten ihre Positionen, die Karte waechst von 650 auf 940 px. Die Port-Beschreibung nennt offen, dass die Compliance Engine noch ueber das laufende Core-Repository liest; die Beziehungszahl im Verzeichnis wird aus der Liste berechnet (13). Geometrie, beide Themes und die Hervorhebung der vier Pfeile im laufenden Frontend geprueft; Diagrammtest erweitert. |
| 2026-10-05 | Phase 13F, in Produktversion 2.18.0 enthalten: Aktivitaetsprotokoll fuer die Compliance Engine korrigiert. Ihre API-Anfragen fielen auf die generischen Core-Muster und erschienen als `Core Engine · Loading clients`, `Processing clients` oder `Loading application data`; `src/api/request-activity.ts` ordnet die Routen fuer Zuordnung, Vorschlaege, Entscheidungen, Batch und ISO-Korpus jetzt vor den Projektmustern der `Compliance Engine` zu (Core-Freigabe Platform). Zusaetzlich haelt die Engine je laufender Backendarbeit (`Mapping ISO controls`, `Mapping all ISO controls`, `Embedding ISO corpus`) einen Eintrag offen, bis das Backend einen Endzustand meldet; ein Fehlschlag wird als fehlgeschlagen gefuehrt, und eine Ansicht, die nicht mehr beobachtet, beendet den Eintrag mit dem Hinweis `continues in the backend`. Tests fuer alle Routen und fuer laufenden, abgeschlossenen, fehlgeschlagenen und nicht mehr beobachteten Batch. |
| 2026-10-05 | Phase 13F, Produktversion 2.18.0 (fasst das unveroeffentlichte 2.17.1 mit ein): ISO-Zuordnung als Compliance Engine ausgelagert, Nutzerentscheidung mit Core-Freigabe. Name `Compliance Engine`, bestehende drei Tabellen bleiben und gehen an die Engine, in diesem Schritt nur Verschiebung ohne Verhaltensaenderung; die frameworkneutrale Erweiterung folgt getrennt. 41 Dateien verschoben (23 Quell-, 18 Testdateien); der Filtertyp `ResultDbFilters` liegt jetzt in `src/domain/result-db-query.ts`. Die neun Regulatory-Methoden und drei Exportfelder sind aus `AssessorRepository`, Local- und PostgreSQL-Repository entfernt; `LocalComplianceStore` und `PostgresComplianceStore` uebernehmen sie unveraendert, der Projektexport fuegt die Engine-Daten an der HTTP-Route hinzu. `RegulatoryMappingService` erhaelt Read-Port und Store getrennt. Engine-Pfade in `.claude/hooks/core-areas.mjs`, neue Extension Points (Finding Reader, Result DB, Data Hub, API-Vertrag und -Client typ-only, Serververdrahtung) im Grenztest, Entscheidung in `AGENTS.md`. Neue Tests: permanente Invarianz (Revision, Projektexport und Result DB unveraendert nach Einzelzuordnung, Entscheidung und Batch; Port ohne Schreibmethode), Schalter in Finding Reader, Result DB und Data Hub, Projektexport mit und ohne Engine. |
| 2026-10-05 | Phase 13F, Produktversion 2.17.1: Merge-Regression der ISO-Kontrollzuordnung behoben. Seit 2.9.0 liefert `getUnifiedResultById` Result-DB-Zeilen ohne echte Scope-IDs (`projectId: scoped-project`); der Zuordnungsdienst verglich danach `finding.projectId` mit dem Projekt und meldete fuer jedes Finding `The selected finding does not exist in this project.` Die Lesemethode nimmt jetzt optional `projectId` und filtert in PostgreSQL ueber die vorhandene Spalte `project_id` (lokal vor dem Entfernen der Scope-IDs); der Dienst nutzt diese Abfrage. Die zurueckgegebene Zeile und die Result-DB-Offenlegung bleiben unveraendert. Der Mock im Dienst-Test bildet die Entfernung der Scope-IDs jetzt nach; neuer Regressionstest fuer Laden, Start und Entscheidung im eigenen Projekt und deren Ablehnung fuer ein fremdes Projekt, dazu Pruefungen der Projektbindung im lokalen und im PostgreSQL-Repository (Letztere ohne Testdatenbank uebersprungen). |
| 2026-10-05 | Phasen 6/7/13A, Produktversion 2.17.0: `merge/defender-for-iot` auf `250c213` (Michael, Business Impact + Management Report, 2.16.5) rebased; Michaels Commit ist die Basis, die 25 Branch-Commits liegen darauf. Die Minor-Erhoehung der Defender-Serie (zuvor 2.11.2 -> 2.12.0, Eintrag unten historisch) wird auf dieser Basis 2.16.5 -> 2.17.0; `package.json`, Root-Eintraege in `package-lock.json` und die Versionsvertragstests sind synchron. Korrektur aus `2b5fdf3`: `isDeviationRecord` uebersprang Maester `Result` immer als Ergebnisnachricht; nach AGENTS.md ist `Result` nur neben einer eigenen Statusspalte Evidenz, sonst der native Status. Michaels neuer Sidebar-Test deckte das auf; synthetische Regression in `tests/domain/deviation-predicate.test.ts`. Michaels Result-DB-Leerzustandstest erwartet nun das mit `55da786` eingefuehrte `audience: 'result-db'`. Kein Reimport, keine Migration, nicht im Browser geprueft. Verifikation: Gesamtlauf mit 2.049 bestandenen und 18 uebersprungenen Tests, beide Produktionsbuilds erfolgreich. | | 2026-10-05 | Phasen 6/7/13A, Produktversion 2.17.0: `merge/defender-for-iot` auf `250c213` (Michael, Business Impact + Management Report, 2.16.5) rebased; Michaels Commit ist die Basis, die 25 Branch-Commits liegen darauf. Die Minor-Erhoehung der Defender-Serie (zuvor 2.11.2 -> 2.12.0, Eintrag unten historisch) wird auf dieser Basis 2.16.5 -> 2.17.0; `package.json`, Root-Eintraege in `package-lock.json` und die Versionsvertragstests sind synchron. Korrektur aus `2b5fdf3`: `isDeviationRecord` uebersprang Maester `Result` immer als Ergebnisnachricht; nach AGENTS.md ist `Result` nur neben einer eigenen Statusspalte Evidenz, sonst der native Status. Michaels neuer Sidebar-Test deckte das auf; synthetische Regression in `tests/domain/deviation-predicate.test.ts`. Michaels Result-DB-Leerzustandstest erwartet nun das mit `55da786` eingefuehrte `audience: 'result-db'`. Kein Reimport, keine Migration, nicht im Browser geprueft. Verifikation: Gesamtlauf mit 2.049 bestandenen und 18 uebersprungenen Tests, beide Produktionsbuilds erfolgreich. |
| 2026-10-02 | Phase 13A, Disclosure Policy 3.5.0: Defender-Teil von `21a6266` uebernommen; `48a1888` (PingCastle-Aggregate, steht gegen die Zaehlwert-Grenze der Policy 2.0.0) und `78ea9a6` auf Nutzerentscheidung ausgelassen. Die Result DB kennt Microsoft Defender for IoT als Werkzeug statt `Security assessment`; freigegeben wird nur eine formal gueltige oeffentliche CVE-ID als Rule ID und zugleich als Titel. Geraete-, Site-, Sensor- und Zaehlwerte bleiben ausgeschlossen, `affectedAsset` bleibt zurueckgehalten; das Alerts-Profil bleibt generisch. Die zurueckgestellte `securityToolId`-Zeile aus `c056c21` bildet beide Defender-Namen auf den Schluessel `defender-for-iot` ab. Regression: Work und Result DB benennen ein Defender-Finding ueber die Projektion mit seiner CVE. `docs/RESULT_CONTRACT.md` beschreibt 3.5.0; die Versionsaufzaehlung der Result-DB-Regel in `AGENTS.md` ist nicht angepasst (Governance nicht freigegeben). Die materialisierte Result DB wird nach dem naechsten Backendstart mit der neuen Policy neu aufgebaut. Verifikation: beide Typechecks, Gesamtlauf mit 1.921 bestandenen und 18 uebersprungenen Tests sowie dem bekannten lastbedingten Ausfall von `sheet-selection-cost.test.tsx` (allein bestanden), Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | | 2026-10-02 | Phase 13A, Disclosure Policy 3.5.0: Defender-Teil von `21a6266` uebernommen; `48a1888` (PingCastle-Aggregate, steht gegen die Zaehlwert-Grenze der Policy 2.0.0) und `78ea9a6` auf Nutzerentscheidung ausgelassen. Die Result DB kennt Microsoft Defender for IoT als Werkzeug statt `Security assessment`; freigegeben wird nur eine formal gueltige oeffentliche CVE-ID als Rule ID und zugleich als Titel. Geraete-, Site-, Sensor- und Zaehlwerte bleiben ausgeschlossen, `affectedAsset` bleibt zurueckgehalten; das Alerts-Profil bleibt generisch. Die zurueckgestellte `securityToolId`-Zeile aus `c056c21` bildet beide Defender-Namen auf den Schluessel `defender-for-iot` ab. Regression: Work und Result DB benennen ein Defender-Finding ueber die Projektion mit seiner CVE. `docs/RESULT_CONTRACT.md` beschreibt 3.5.0; die Versionsaufzaehlung der Result-DB-Regel in `AGENTS.md` ist nicht angepasst (Governance nicht freigegeben). Die materialisierte Result DB wird nach dem naechsten Backendstart mit der neuen Policy neu aufgebaut. Verifikation: beide Typechecks, Gesamtlauf mit 1.921 bestandenen und 18 uebersprungenen Tests sowie dem bekannten lastbedingten Ausfall von `sheet-selection-cost.test.tsx` (allein bestanden), Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
| 2026-10-02 | Phase 13A: Geraeteteil von `95ec322` nachgezogen. `defenderRiskTargetsForOccurrences` ordnet Devices ueber die Quellzeilen der jeweiligen Occurrence zu statt ueber die CVE allein; fuehren zwei Occurrences dieselbe CVE mit unterschiedlichen Devices, bleibt jedes Device bei seiner eigenen Occurrence, und ein `device-finding` fuer ein Device ausserhalb der gewaehlten Occurrence wird abgelehnt. Das stuetzt die Regel, dass eine geraetebezogene Entscheidung nur ihre erfassten Occurrences deckt. Zwei Regressionen (Domain, lokales Repository) uebernommen. Keine API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.922 bestandenen und 18 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | | 2026-10-02 | Phase 13A: Geraeteteil von `95ec322` nachgezogen. `defenderRiskTargetsForOccurrences` ordnet Devices ueber die Quellzeilen der jeweiligen Occurrence zu statt ueber die CVE allein; fuehren zwei Occurrences dieselbe CVE mit unterschiedlichen Devices, bleibt jedes Device bei seiner eigenen Occurrence, und ein `device-finding` fuer ein Device ausserhalb der gewaehlten Occurrence wird abgelehnt. Das stuetzt die Regel, dass eine geraetebezogene Entscheidung nur ihre erfassten Occurrences deckt. Zwei Regressionen (Domain, lokales Repository) uebernommen. Keine API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.922 bestandenen und 18 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |

View File

@ -39,6 +39,8 @@ Clarification 2026-09-17 (baseline analyses of the written form): The Microsoft
Clarification 2026-09-17 (written security-template section and key): The Security Template parser folded the section name to lower case and discarded the written form, so `[Privilege Rights]` was retained only as `privilege rights`. The parser now keeps the written section and key; the case-folded forms stay internal to grammar selection, grouping and the identity digest, which keeps existing observation and identity IDs stable. The native target resolver stores the written section and key in the retained target, matching what the registry resolver already did, so Work shows `Privilege Rights - SeCreateTokenPrivilege` and the value stays searchable against Microsoft documentation and external tables. This changes persisted parsing output, so it is an append-only processing-version change, not a presentation change: `gpo-security-template-inf` 1.1.0 -> 1.2.0, `gpo-backup-policy-analysis` 2.2.3 -> 2.2.4 and the GPO job processor 2.2.3 -> 2.2.4. Historical imports keep their retained values unchanged and are marked for reprocessing: Data Hub reports an affected GPO backup as `Reprocessing required` with the analyzer versions and offers the existing reprocess action on the stored source, so no upload is needed. Reprocessing precedes re-evaluation, and prior runs remain retained as history. No SQL migration and no evaluator change. Clarification 2026-09-17 (written security-template section and key): The Security Template parser folded the section name to lower case and discarded the written form, so `[Privilege Rights]` was retained only as `privilege rights`. The parser now keeps the written section and key; the case-folded forms stay internal to grammar selection, grouping and the identity digest, which keeps existing observation and identity IDs stable. The native target resolver stores the written section and key in the retained target, matching what the registry resolver already did, so Work shows `Privilege Rights - SeCreateTokenPrivilege` and the value stays searchable against Microsoft documentation and external tables. This changes persisted parsing output, so it is an append-only processing-version change, not a presentation change: `gpo-security-template-inf` 1.1.0 -> 1.2.0, `gpo-backup-policy-analysis` 2.2.3 -> 2.2.4 and the GPO job processor 2.2.3 -> 2.2.4. Historical imports keep their retained values unchanged and are marked for reprocessing: Data Hub reports an affected GPO backup as `Reprocessing required` with the analyzer versions and offers the existing reprocess action on the stored source, so no upload is needed. Reprocessing precedes re-evaluation, and prior runs remain retained as history. No SQL migration and no evaluator change.
Clarification 2026-10-05 (GPO domain key): A standard `Backup-GPO` folder names the domain by GUID in `bkupInfo.xml` and by DNS name in `Backup.xml`/`GPReport.xml`. The metadata aggregate treated these two identifier kinds as conflicting values, so every such backup stayed unresolved and blocked (`Limited analysis`). A GUID and a DNS name now count as two kinds of the same domain; only two different values of one kind conflict, and the GUID is preferred. This changes persisted identity resolution, so it is an append-only processing-version change: `gpo-backup-policy-analysis` and the GPO job processor 2.2.5 -> 2.2.6 (metadata parser 1.4.0 unchanged). Affected backups show `Reprocessing required`; the existing reprocess action on the stored source applies the fix. No upload, SQL migration or evaluator change.
Clarification 2026-09-25 (GPO link facts): The GPO metadata parser retains, from each backup's own `GPReport.xml`, the number of `LinksTo` entries and how many are enabled (never the linked OU or domain names). A backup with no enabled link is presented as `Not linked`: the Finding Reader marks its source sections and states `No effect` in the problem summary when every contributing GPO is unlinked, and GPO Processing shows the count. This is a backup-report fact at backup time, not a claim about effective device state, and verdicts and score are unchanged. Because parsing now retains a new fact, it is an append-only processing-version change: `gpo-backup-metadata-xml` 1.2.0 -> 1.4.0 (the Microsoft baseline template variant keeps 1.3.0, so baseline packages need no re-analysis), `gpo-backup-policy-analysis` 2.2.4 -> 2.2.5 and the GPO job processor 2.2.4 -> 2.2.5. Historical imports are marked `Reprocessing required` and the existing reprocess action on the stored source adds the link facts; no upload, SQL migration or evaluator change is required. Until then no link status is shown. Clarification 2026-09-25 (GPO link facts): The GPO metadata parser retains, from each backup's own `GPReport.xml`, the number of `LinksTo` entries and how many are enabled (never the linked OU or domain names). A backup with no enabled link is presented as `Not linked`: the Finding Reader marks its source sections and states `No effect` in the problem summary when every contributing GPO is unlinked, and GPO Processing shows the count. This is a backup-report fact at backup time, not a claim about effective device state, and verdicts and score are unchanged. Because parsing now retains a new fact, it is an append-only processing-version change: `gpo-backup-metadata-xml` 1.2.0 -> 1.4.0 (the Microsoft baseline template variant keeps 1.3.0, so baseline packages need no re-analysis), `gpo-backup-policy-analysis` 2.2.4 -> 2.2.5 and the GPO job processor 2.2.4 -> 2.2.5. Historical imports are marked `Reprocessing required` and the existing reprocess action on the stored source adds the link facts; no upload, SQL migration or evaluator change is required. Until then no link status is shown.
Clarification 2026-09-17 (Not comparable verdict): A retained customer setting whose delivery mechanism has no comparable technical policy identity - today always a Group Policy Preference - reaches Work under the score-neutral verdict `Not comparable` instead of being dropped. It is never resolved into a policy identity and never compared with a Microsoft baseline. The unified read projection additionally exposes the already retained preference CSE, action and item-level targeting status so that context stays readable in the agreed GPO columns; no new column, facet or persisted field is introduced. Snapshot-aggregation review hints that restate an unresolved identity already reported by their source analysis are no longer emitted a second time. All of this projects facts the importer already retained, so no processing-manifest version, source reimport, evaluator change or SQL migration is required. Clarification 2026-09-17 (Not comparable verdict): A retained customer setting whose delivery mechanism has no comparable technical policy identity - today always a Group Policy Preference - reaches Work under the score-neutral verdict `Not comparable` instead of being dropped. It is never resolved into a policy identity and never compared with a Microsoft baseline. The unified read projection additionally exposes the already retained preference CSE, action and item-level targeting status so that context stays readable in the agreed GPO columns; no new column, facet or persisted field is introduced. Snapshot-aggregation review hints that restate an unresolved identity already reported by their source analysis are no longer emitted a second time. All of this projects facts the importer already retained, so no processing-manifest version, source reimport, evaluator change or SQL migration is required.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "assessor", "name": "assessor",
"version": "2.17.0", "version": "2.19.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "assessor", "name": "assessor",
"version": "2.17.0", "version": "2.19.0",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/inter": "^5.3.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",

View File

@ -1,6 +1,6 @@
{ {
"name": "assessor", "name": "assessor",
"version": "2.17.0", "version": "2.19.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -1,4 +1,9 @@
import type { AiOverviewTokenUsage } from '../../src/domain/ai-overview'; /** Token counts the provider reports for one completion. */
export interface AiCompletionTokenUsage {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
}
export interface LiteLlmEnvironment { export interface LiteLlmEnvironment {
ASSESSOR_AI_MODE?: string; ASSESSOR_AI_MODE?: string;
@ -11,7 +16,7 @@ export interface LiteLlmEnvironment {
export interface AiCompletion { export interface AiCompletion {
value: unknown; value: unknown;
usage?: AiOverviewTokenUsage; usage?: AiCompletionTokenUsage;
} }
export interface AiCompletionClient { export interface AiCompletionClient {
@ -206,7 +211,7 @@ function completionContent(value: unknown): string {
return value.choices[0].message.content; return value.choices[0].message.content;
} }
function completionUsage(value: unknown): AiOverviewTokenUsage | undefined { function completionUsage(value: unknown): AiCompletionTokenUsage | undefined {
if (!isObject(value) || !isObject(value.usage)) return undefined; if (!isObject(value) || !isObject(value.usage)) return undefined;
const number = (entry: unknown) => typeof entry === 'number' && Number.isFinite(entry) ? entry : undefined; const number = (entry: unknown) => typeof entry === 'number' && Number.isFinite(entry) ? entry : undefined;
const usage = { const usage = {

View File

@ -29,18 +29,19 @@ import type { GpoPolicySnapshotService } from './gpo/gpo-policy-snapshot-service
import type { GpoPolicyEvaluationService } from './gpo/gpo-policy-evaluation-service'; import type { GpoPolicyEvaluationService } from './gpo/gpo-policy-evaluation-service';
import type { GpoPolicyFindingService } from './gpo/gpo-policy-finding-service'; import type { GpoPolicyFindingService } from './gpo/gpo-policy-finding-service';
import type { GpoPolicyAnalysisOverviewService } from './gpo/gpo-policy-analysis-overview-service'; import type { GpoPolicyAnalysisOverviewService } from './gpo/gpo-policy-analysis-overview-service';
import type { AiOverviewService } from './ai/ai-overview-service'; import type { AiOverviewService } from './compliance/ai-overview-service';
import { AiOverviewServiceError } from './ai/ai-overview-service'; import { AiOverviewServiceError } from './compliance/ai-overview-service';
import type { AiOverviewCreateRequest, AiOverviewScope } from '../src/domain/ai-overview'; import type { AiOverviewCreateRequest, AiOverviewScope } from '../src/compliance/ai-overview';
import type { import type {
RegulatoryMappingBatchCreateRequest, RegulatoryMappingBatchCreateRequest,
RegulatoryMappingBatchScope, RegulatoryMappingBatchScope,
RegulatoryMappingCreateRequest, RegulatoryMappingCreateRequest,
RegulatoryMappingDecisionCreateRequest RegulatoryMappingDecisionCreateRequest
} from '../src/domain/regulatory-mapping'; } from '../src/compliance/regulatory-mapping';
import type { RegulatoryMappingService } from './regulatory/regulatory-mapping-service'; import type { RegulatoryMappingService } from './compliance/regulatory-mapping-service';
import { RegulatoryMappingServiceError } from './regulatory/regulatory-mapping-service'; import type { ComplianceStore } from '../src/compliance/compliance-store';
import { RegulatoryCorpusUploadError, type RegulatoryCorpusUploadService } from './regulatory/regulatory-corpus-upload-service'; import { RegulatoryMappingServiceError } from './compliance/regulatory-mapping-service';
import { RegulatoryCorpusUploadError, type RegulatoryCorpusUploadService } from './compliance/regulatory-corpus-upload-service';
import type { GpoAutomaticAnalysisService } from './gpo/gpo-automatic-analysis-service'; import type { GpoAutomaticAnalysisService } from './gpo/gpo-automatic-analysis-service';
import type { MicrosoftSecurityBaselineSelectionContext } from '../src/domain/microsoft-security-baseline'; import type { MicrosoftSecurityBaselineSelectionContext } from '../src/domain/microsoft-security-baseline';
import { listProjectGpoBackupStatus } from './gpo/gpo-backup-status-service'; import { listProjectGpoBackupStatus } from './gpo/gpo-backup-status-service';
@ -177,6 +178,7 @@ export interface AssessorHttpServerOptions {
aiOverviewService?: AiOverviewService; aiOverviewService?: AiOverviewService;
regulatoryMappingService?: RegulatoryMappingService; regulatoryMappingService?: RegulatoryMappingService;
regulatoryCorpusUploadService?: RegulatoryCorpusUploadService; regulatoryCorpusUploadService?: RegulatoryCorpusUploadService;
complianceExport?: Pick<ComplianceStore, 'exportProject'>;
isoUploadToken?: string; isoUploadToken?: string;
} }
@ -1045,7 +1047,10 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
status = 200; sendJson(response, status, { rule, repositoryRevision: repository.revision }, requestId); return; status = 200; sendJson(response, status, { rule, repositoryRevision: repository.revision }, requestId); return;
} }
if (segments[0] === 'projects' && segments[2] === 'export' && segments.length === 3 && method === 'GET') { if (segments[0] === 'projects' && segments[2] === 'export' && segments.length === 3 && method === 'GET') {
status = 200; sendJson(response, status, await repository.exportProject(segments[1]), requestId); return; // The Compliance Engine delivers its own project data beside the Core export.
const coreExport = await repository.exportProject(segments[1]);
const complianceExport = options.complianceExport ? await options.complianceExport.exportProject(segments[1]) : {};
status = 200; sendJson(response, status, { ...coreExport, ...complianceExport }, requestId); return;
} }
if (segments[0] === 'projects' && segments[2] === 'report-projection' && segments.length === 3 && method === 'GET') { if (segments[0] === 'projects' && segments[2] === 'report-projection' && segments.length === 3 && method === 'GET') {
const assessmentIds = url.searchParams.getAll('assessmentIds'); const assessmentIds = url.searchParams.getAll('assessmentIds');
@ -1170,6 +1175,14 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
const body = await readJson<RegulatoryMappingDecisionCreateRequest>(request, maximumBodyBytes); const body = await readJson<RegulatoryMappingDecisionCreateRequest>(request, maximumBodyBytes);
status = 200; sendJson(response, status, await options.regulatoryMappingService.decide(segments[1], body), requestId); return; status = 200; sendJson(response, status, await options.regulatoryMappingService.decide(segments[1], body), requestId); return;
} }
if (segments[0] === 'projects' && segments[2] === 'regulatory-control-assignments' && segments.length === 3 && method === 'GET') {
if (!options.regulatoryMappingService) throw new HttpError(503, 'regulatory_mapping_disabled', 'Regulatory mapping is not configured.');
status = 200; sendJson(response, status, await options.regulatoryMappingService.controlAssignments(segments[1], regulatoryMappingBatchScopeFrom(url)), requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'regulatory-control-overview' && segments.length === 3 && method === 'GET') {
if (!options.regulatoryMappingService) throw new HttpError(503, 'regulatory_mapping_disabled', 'Regulatory mapping is not configured.');
status = 200; sendJson(response, status, await options.regulatoryMappingService.controlOverview(segments[1], regulatoryMappingBatchScopeFrom(url)), requestId); return;
}
if (segments[0] === 'projects' && segments[2] === 'regulatory-mapping-batches' && segments.length === 3 && method === 'GET') { if (segments[0] === 'projects' && segments[2] === 'regulatory-mapping-batches' && segments.length === 3 && method === 'GET') {
if (!options.regulatoryMappingService) throw new HttpError(503, 'regulatory_mapping_disabled', 'Regulatory mapping is not configured.'); if (!options.regulatoryMappingService) throw new HttpError(503, 'regulatory_mapping_disabled', 'Regulatory mapping is not configured.');
status = 200; sendJson(response, status, await options.regulatoryMappingService.getBatch(segments[1], regulatoryMappingBatchScopeFrom(url)), requestId); return; status = 200; sendJson(response, status, await options.regulatoryMappingService.getBatch(segments[1], regulatoryMappingBatchScopeFrom(url)), requestId); return;

View File

@ -1,7 +1,7 @@
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import type { AssessorRepository } from '../../src/persistence/assessor-repository'; import type { ComplianceReadPort, ComplianceStore } from '../../src/compliance/compliance-store';
import { RepositoryConflictError, RepositoryNotFoundError, RepositoryValidationError } from '../../src/persistence/repository-errors'; import { RepositoryConflictError, RepositoryNotFoundError, RepositoryValidationError } from '../../src/persistence/repository-errors';
import { resultsToPrivacySafeExportJson } from '../../src/exports/result-db-copy'; import { aiOverviewInputJson } from '../../src/compliance/privacy-safe-export';
import type { UnifiedResultProjection } from '../../src/domain/unified-result'; import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { import {
AI_OVERVIEW_OUTPUT_SCHEMA_VERSION, AI_OVERVIEW_OUTPUT_SCHEMA_VERSION,
@ -15,9 +15,9 @@ import {
type AiOverviewRun, type AiOverviewRun,
type AiOverviewScope, type AiOverviewScope,
type AiOverviewTokenUsage type AiOverviewTokenUsage
} from '../../src/domain/ai-overview'; } from '../../src/compliance/ai-overview';
import type { AiCompletionClient } from './litellm-client'; import type { AiCompletionClient } from '../ai/litellm-client';
import { LiteLlmClientError } from './litellm-client'; import { LiteLlmClientError } from '../ai/litellm-client';
const PAGE_SIZE = 500; const PAGE_SIZE = 500;
const ACTIVE_POLL_MS = 1_500; const ACTIVE_POLL_MS = 1_500;
@ -57,7 +57,7 @@ export class AiOverviewService {
private readonly workerId: string; private readonly workerId: string;
private readonly activeTasks = new Set<Promise<void>>(); private readonly activeTasks = new Set<Promise<void>>();
constructor(private readonly repository: AssessorRepository, options: AiOverviewServiceOptions = {}) { constructor(private readonly read: ComplianceReadPort, private readonly store: ComplianceStore, options: AiOverviewServiceOptions = {}) {
this.client = options.client; this.client = options.client;
this.now = options.now ?? (() => new Date()); this.now = options.now ?? (() => new Date());
this.maximumChunkCharacters = options.maximumChunkCharacters ?? 80_000; this.maximumChunkCharacters = options.maximumChunkCharacters ?? 80_000;
@ -76,10 +76,11 @@ export class AiOverviewService {
async recoverInterruptedRuns(): Promise<void> { async recoverInterruptedRuns(): Promise<void> {
const now = this.now().toISOString(); const now = this.now().toISOString();
for (const project of await this.repository.listProjects()) { for (const project of await this.read.listProjects()) {
const runs = await this.repository.listAiOverviewRuns(project.id); await this.pruneDeletedTimeslots(project.id);
const runs = await this.store.listAiOverviewRuns(project.id);
for (const run of runs.filter(item => ['queued', 'running'].includes(item.status))) { for (const run of runs.filter(item => ['queued', 'running'].includes(item.status))) {
await this.repository.updateAiOverviewRun({ await this.store.updateAiOverviewRun({
...run, ...run,
status: 'failed', status: 'failed',
updatedAt: now, updatedAt: now,
@ -93,10 +94,27 @@ export class AiOverviewService {
} }
} }
/**
* The Core deletes scan runs and assessments without knowing this engine. Their AI overviews are
* derived from deleted data, so the engine removes them from its own table on start and on read.
*/
async pruneDeletedTimeslots(projectId: string): Promise<void> {
const [assessments, scanRuns, runs] = await Promise.all([
this.read.listAssessments(projectId), this.read.listProjectScanRuns(projectId), this.store.listAiOverviewRuns(projectId)
]);
const assessmentIds = new Set(assessments.map(item => item.id));
const scanRunIds = new Set(scanRuns.map(item => item.id));
const orphaned = runs.filter(run => !['queued', 'running'].includes(run.status)
&& (run.assessmentIds.some(id => !assessmentIds.has(id)) || run.scanRunIds.some(id => !scanRunIds.has(id))));
await this.store.deleteAiOverviewRuns(projectId, orphaned.map(run => run.id));
}
async get(projectId: string, requestedScope: AiOverviewScope): Promise<AiOverviewResponse> { async get(projectId: string, requestedScope: AiOverviewScope): Promise<AiOverviewResponse> {
if (!await this.read.getProject(projectId)) throw new RepositoryNotFoundError(`Projekt ${projectId} ist nicht vorhanden.`);
await this.pruneDeletedTimeslots(projectId);
const scope = await this.validateScope(projectId, requestedScope); const scope = await this.validateScope(projectId, requestedScope);
const scopeKey = aiOverviewScopeKey(projectId, scope); const scopeKey = aiOverviewScopeKey(projectId, scope);
const runs = await this.repository.listAiOverviewRuns(projectId, scopeKey); const runs = await this.store.listAiOverviewRuns(projectId, scopeKey);
const run = runs[0]; const run = runs[0];
const previousSuccessfulRun = run?.status === 'succeeded' ? undefined : runs.find(item => item.status === 'succeeded'); const previousSuccessfulRun = run?.status === 'succeeded' ? undefined : runs.find(item => item.status === 'succeeded');
let stale = false; let stale = false;
@ -136,13 +154,13 @@ export class AiOverviewService {
updatedAt: timestamp, updatedAt: timestamp,
callCount: 0 callCount: 0
}; };
const run = await this.repository.createAiOverviewRun(candidate); const run = await this.store.createAiOverviewRun(candidate);
if (run.id === candidate.id) { if (run.id === candidate.id) {
const task = this.process(run, prepared).finally(() => this.activeTasks.delete(task)); const task = this.process(run, prepared).finally(() => this.activeTasks.delete(task));
this.activeTasks.add(task); this.activeTasks.add(task);
} }
const previousSuccessfulRun = run.status === 'succeeded' ? undefined const previousSuccessfulRun = run.status === 'succeeded' ? undefined
: (await this.repository.listAiOverviewRuns(projectId, run.scopeKey)).find(item => item.status === 'succeeded'); : (await this.store.listAiOverviewRuns(projectId, run.scopeKey)).find(item => item.status === 'succeeded');
return responseFor(projectId, scope, true, run, false, previousSuccessfulRun); return responseFor(projectId, scope, true, run, false, previousSuccessfulRun);
} }
@ -161,7 +179,7 @@ export class AiOverviewService {
leaseExpiresAt: new Date(this.now().getTime() + this.leaseDurationMs).toISOString() leaseExpiresAt: new Date(this.now().getTime() + this.leaseDurationMs).toISOString()
}; };
try { try {
run = await this.repository.updateAiOverviewRun(run); run = await this.store.updateAiOverviewRun(run);
const completion = await generateAiOverview(prepared.parsed, { const completion = await generateAiOverview(prepared.parsed, {
client: this.client!, client: this.client!,
maximumChunkCharacters: this.maximumChunkCharacters, maximumChunkCharacters: this.maximumChunkCharacters,
@ -171,7 +189,7 @@ export class AiOverviewService {
factProperties: prepared.factProperties, factProperties: prepared.factProperties,
onCall: async usage => { onCall: async usage => {
const updatedAt = this.now().toISOString(); const updatedAt = this.now().toISOString();
run = await this.repository.updateAiOverviewRun({ run = await this.store.updateAiOverviewRun({
...run, ...run,
callCount: run.callCount + 1, callCount: run.callCount + 1,
tokenUsage: addUsage(run.tokenUsage, usage), tokenUsage: addUsage(run.tokenUsage, usage),
@ -181,7 +199,7 @@ export class AiOverviewService {
} }
}); });
const completedAt = this.now().toISOString(); const completedAt = this.now().toISOString();
await this.repository.updateAiOverviewRun({ await this.store.updateAiOverviewRun({
...run, ...run,
status: 'succeeded', status: 'succeeded',
result: completion, result: completion,
@ -194,7 +212,7 @@ export class AiOverviewService {
const completedAt = this.now().toISOString(); const completedAt = this.now().toISOString();
const failure = safeGenerationFailure(error); const failure = safeGenerationFailure(error);
try { try {
await this.repository.updateAiOverviewRun({ await this.store.updateAiOverviewRun({
...run, ...run,
status: 'failed', status: 'failed',
updatedAt: completedAt, updatedAt: completedAt,
@ -211,13 +229,13 @@ export class AiOverviewService {
} }
private async validateScope(projectId: string, requested: AiOverviewScope): Promise<AiOverviewScope> { private async validateScope(projectId: string, requested: AiOverviewScope): Promise<AiOverviewScope> {
if (!await this.repository.getProject(projectId)) throw new RepositoryNotFoundError(`Projekt ${projectId} ist nicht vorhanden.`); if (!await this.read.getProject(projectId)) throw new RepositoryNotFoundError(`Projekt ${projectId} ist nicht vorhanden.`);
const scope = normalizeAiOverviewScope(requested); const scope = normalizeAiOverviewScope(requested);
if (!scope.assessmentIds.length && !scope.scanRunIds.length) throw new RepositoryValidationError('A selected timeslot scope is required.'); if (!scope.assessmentIds.length && !scope.scanRunIds.length) throw new RepositoryValidationError('A selected timeslot scope is required.');
const assessments = await this.repository.listAssessments(projectId); const assessments = await this.read.listAssessments(projectId);
const allowedAssessmentIds = new Set(assessments.map(item => item.id)); const allowedAssessmentIds = new Set(assessments.map(item => item.id));
if (scope.assessmentIds.some(id => !allowedAssessmentIds.has(id))) throw new RepositoryValidationError('The AI overview scope contains an assessment outside this project.'); if (scope.assessmentIds.some(id => !allowedAssessmentIds.has(id))) throw new RepositoryValidationError('The AI overview scope contains an assessment outside this project.');
const scanRuns = await this.repository.listProjectScanRuns(projectId); const scanRuns = await this.read.listProjectScanRuns(projectId);
const allowedRuns = new Map(scanRuns.filter(item => !item.supersededByScanRunId).map(item => [item.id, item])); const allowedRuns = new Map(scanRuns.filter(item => !item.supersededByScanRunId).map(item => [item.id, item]));
if (scope.scanRunIds.some(id => !allowedRuns.has(id))) throw new RepositoryValidationError('The AI overview scope contains an unavailable scan run.'); if (scope.scanRunIds.some(id => !allowedRuns.has(id))) throw new RepositoryValidationError('The AI overview scope contains an unavailable scan run.');
if (scope.assessmentIds.length && scope.scanRunIds.some(id => !scope.assessmentIds.includes(allowedRuns.get(id)!.assessmentId))) { if (scope.assessmentIds.length && scope.scanRunIds.some(id => !scope.assessmentIds.includes(allowedRuns.get(id)!.assessmentId))) {
@ -227,9 +245,9 @@ export class AiOverviewService {
} }
private async prepareInput(projectId: string, scope: AiOverviewScope): Promise<PreparedInput> { private async prepareInput(projectId: string, scope: AiOverviewScope): Promise<PreparedInput> {
const items = await queryAllUnifiedResults(this.repository, projectId, scope); const items = await queryAllUnifiedResults(this.read, projectId, scope);
const ordered = items.slice().sort((left, right) => stableResultKey(left).localeCompare(stableResultKey(right))); // The same function builds the Result DB `Export JSON`, so the export is exactly what the model receives.
const json = resultsToPrivacySafeExportJson(ordered); const json = aiOverviewInputJson(items);
const parsed = parsePrivacySafeInput(json); const parsed = parsePrivacySafeInput(json);
const findingIds = new Set(parsed.findings.flatMap(item => typeof item.id === 'string' ? [item.id] : [])); const findingIds = new Set(parsed.findings.flatMap(item => typeof item.id === 'string' ? [item.id] : []));
const gapIds = new Set([ const gapIds = new Set([
@ -309,7 +327,7 @@ export function splitPrivacySafeInput(input: PrivacySafeInput, maximumCharacters
return chunks; return chunks;
} }
async function queryAllUnifiedResults(repository: AssessorRepository, projectId: string, scope: AiOverviewScope): Promise<UnifiedResultProjection[]> { async function queryAllUnifiedResults(repository: ComplianceReadPort, projectId: string, scope: AiOverviewScope): Promise<UnifiedResultProjection[]> {
const items: UnifiedResultProjection[] = []; const items: UnifiedResultProjection[] = [];
let expectedTotal: number | undefined; let expectedTotal: number | undefined;
for (let offset = 0; ; offset += PAGE_SIZE) { for (let offset = 0; ; offset += PAGE_SIZE) {
@ -351,10 +369,6 @@ function parsePrivacySafeInput(json: string): PrivacySafeInput {
return value as PrivacySafeInput; return value as PrivacySafeInput;
} }
function stableResultKey(item: UnifiedResultProjection): string {
return [item.sourceKind, item.sourceTool ?? '', item.sourceRuleId ?? '', item.resultProjectionId].join('\u0000');
}
function responseFor(projectId: string, scope: AiOverviewScope, enabled: boolean, run?: AiOverviewRun, stale = false, previousSuccessfulRun?: AiOverviewRun): AiOverviewResponse { function responseFor(projectId: string, scope: AiOverviewScope, enabled: boolean, run?: AiOverviewRun, stale = false, previousSuccessfulRun?: AiOverviewRun): AiOverviewResponse {
return { return {
version: 1, version: 1,

View File

@ -0,0 +1,97 @@
import { join } from 'node:path';
import { Pool } from 'pg';
import type { AiCompletionClient } from '../ai/litellm-client';
import { repositoryConnectionString, sslConfiguration, type PersistenceKind, type RepositoryEnvironment } from '../repository-factory';
import type { AssessorRepository } from '../../src/persistence/assessor-repository';
import { complianceReadPortFrom, type ComplianceStore } from '../../src/compliance/compliance-store';
import { LocalComplianceStore } from '../../src/compliance/local-compliance-store';
import { PostgresComplianceStore } from './postgres-compliance-store';
import { createRegulatoryRagRuntime } from './regulatory-rag-runtime';
import { RegulatoryMappingService } from './regulatory-mapping-service';
import { RegulatoryCorpusUploadService } from './regulatory-corpus-upload-service';
import { AiOverviewService, type AiOverviewServiceOptions } from './ai-overview-service';
export interface ComplianceEngineRuntime {
mappingService: RegulatoryMappingService;
corpusUploadService: RegulatoryCorpusUploadService;
aiOverviewService: AiOverviewService;
store: ComplianceStore;
/** Stops running work; ASSESSOR keeps its own repository open. */
stop(): Promise<void>;
close(): Promise<void>;
}
export interface ComplianceEngineRuntimeOptions {
repository: AssessorRepository;
persistence: PersistenceKind;
aiClient?: AiCompletionClient;
artifactRoot: string;
environment?: RepositoryEnvironment;
aiOverview?: Omit<AiOverviewServiceOptions, 'client'>;
}
/**
* Starts the Compliance Engine beside ASSESSOR. It reads the Core only through its read-only port and
* writes only its own tables through a separate small pool. If retrieval cannot start, mapping is
* refused with a reason while ASSESSOR keeps running.
*/
export async function createComplianceEngineRuntime(options: ComplianceEngineRuntimeOptions): Promise<ComplianceEngineRuntime> {
const pool = options.persistence === 'postgresql' ? compliancePool(options.environment ?? process.env as RepositoryEnvironment) : undefined;
const store: ComplianceStore = pool ? new PostgresComplianceStore(pool) : new LocalComplianceStore();
const read = complianceReadPortFrom(options.repository);
let mappingService: RegulatoryMappingService;
let rag: ReturnType<typeof createRegulatoryRagRuntime> | undefined;
let disabledReason: string | undefined;
try {
rag = createRegulatoryRagRuntime();
mappingService = new RegulatoryMappingService(read, store, {
findingRetrieval: rag.findingRetrieval,
index: rag.index,
embeddingModel: rag.embeddingModel,
client: options.aiClient,
corpusId: process.env.ASSESSOR_RAG_ACTIVE_CORPUS_ID?.trim(),
corpusVersion: process.env.ASSESSOR_RAG_ACTIVE_CORPUS_VERSION?.trim(),
...(!options.aiClient ? { disabledReason: 'Work LiteLLM is not configured.' } : {})
});
} catch (error) {
disabledReason = error instanceof Error ? error.message : 'Regulatory mapping is unavailable.';
mappingService = new RegulatoryMappingService(read, store, { disabledReason });
}
await mappingService.recoverInterruptedRuns();
const corpusUploadService = new RegulatoryCorpusUploadService({
root: join(options.artifactRoot, 'regulatory-corpora'),
ingestion: rag?.ingestion,
countIndexed: rag ? (corpusId, corpusVersion) => rag!.index.count({ corpusId, corpusVersion }) : undefined,
pruneIndexed: rag ? (corpusId, corpusVersion) => rag!.index.deleteOtherCorpusVersions(corpusId, corpusVersion) : undefined,
mapping: mappingService,
embeddingModel: rag?.embeddingModel,
disabledReason
});
await corpusUploadService.initialize();
const aiOverviewService = new AiOverviewService(read, store, { ...options.aiOverview, client: options.aiClient });
await aiOverviewService.recoverInterruptedRuns();
return {
mappingService,
corpusUploadService,
aiOverviewService,
store,
stop: () => Promise.all([corpusUploadService.stop().then(() => mappingService.close()), aiOverviewService.close()]).then(() => undefined),
close: async () => { await pool?.end(); }
};
}
function compliancePool(environment: RepositoryEnvironment): Pool {
const connectionString = repositoryConnectionString(environment);
if (!connectionString) throw new Error('The Compliance Engine needs the existing PostgreSQL connection.');
const hostname = new URL(connectionString).hostname;
const sslMode = environment.ASSESSOR_DATABASE_SSL?.trim()
|| (['localhost', '127.0.0.1', '[::1]'].includes(hostname) ? 'disable' : 'require');
return new Pool({
connectionString,
ssl: sslConfiguration(sslMode),
max: 4,
connectionTimeoutMillis: 5000,
idleTimeoutMillis: 30000,
application_name: 'assessor-compliance-engine'
});
}

View File

@ -6,7 +6,7 @@ import type {
RegulatoryControlTheme, RegulatoryControlTheme,
RegulatoryCorpusV2, RegulatoryCorpusV2,
RegulatorySectionKind RegulatorySectionKind
} from '../../src/domain/regulatory-rag'; } from '../../src/compliance/regulatory-rag';
export const ISO_PDF_PARSER_VERSION = '1.0.0'; export const ISO_PDF_PARSER_VERSION = '1.0.0';
const MAXIMUM_PDF_BYTES = 32 * 1024 * 1024; const MAXIMUM_PDF_BYTES = 32 * 1024 * 1024;

View File

@ -1,5 +1,5 @@
import { Worker } from 'node:worker_threads'; import { Worker } from 'node:worker_threads';
import type { RegulatoryCorpusV2 } from '../../src/domain/regulatory-rag'; import type { RegulatoryCorpusV2 } from '../../src/compliance/regulatory-rag';
import type { IsoPdfCorpusOptions } from './iso-pdf-corpus'; import type { IsoPdfCorpusOptions } from './iso-pdf-corpus';
export function prepareIsoCorpusInWorker(options: IsoPdfCorpusOptions, signal?: AbortSignal): Promise<RegulatoryCorpusV2> { export function prepareIsoCorpusInWorker(options: IsoPdfCorpusOptions, signal?: AbortSignal): Promise<RegulatoryCorpusV2> {

View File

@ -0,0 +1,279 @@
import type { Pool, PoolClient, QueryResult } from 'pg';
import { RepositoryConflictError, RepositoryNotFoundError, RepositoryValidationError } from '../../src/persistence/repository-errors';
import { translateDatabaseError } from '../postgres/postgres-assessor-repository';
import type { ComplianceProjectExport, ComplianceStore } from '../../src/compliance/compliance-store';
import {
regulatoryMappingBatchRunValidationErrors,
regulatoryMappingDecisionValidationErrors,
regulatoryMappingRunValidationErrors,
type RegulatoryMappingBatchRun,
type RegulatoryMappingDecision,
type RegulatoryMappingRun
} from '../../src/compliance/regulatory-mapping';
import { aiOverviewRunValidationErrors, assertAiOverviewTransition, type AiOverviewRun } from '../../src/compliance/ai-overview';
interface DataRow<T> { data: T }
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function json(value: unknown): string {
return JSON.stringify(value);
}
function dataRows<T>(result: QueryResult<DataRow<T>>): T[] {
return result.rows.map(row => clone(row.data));
}
function dataRow<T>(result: QueryResult<DataRow<T>>): T | null {
return result.rows[0] ? clone(result.rows[0].data) : null;
}
/**
* The Compliance Engine's own tables (migration 026, and the AI overview runs of migration 025). Writes run in the engine's own transactions:
* they never advance the ASSESSOR repository revision and never trigger a Result DB rebuild.
*/
export class PostgresComplianceStore implements ComplianceStore {
constructor(private readonly pool: Pool) {}
private async transaction<T>(work: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const result = await work(client);
await client.query('COMMIT');
return result;
} catch (error) {
try { await client.query('ROLLBACK'); } catch { /* connection failure keeps original error */ }
throw translateDatabaseError(error);
} finally {
client.release();
}
}
async getRegulatoryMappingRun(id: string): Promise<RegulatoryMappingRun | null> {
return dataRow(await this.pool.query<DataRow<RegulatoryMappingRun>>(
'SELECT data FROM assessor_regulatory_mappings WHERE id = $1', [id]
));
}
async claimRegulatoryMappingRun(run: RegulatoryMappingRun, forceRegeneration: boolean): Promise<{ run: RegulatoryMappingRun; claimed: boolean }> {
const errors = regulatoryMappingRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
return this.transaction(async client => {
const inserted = await client.query(`
INSERT INTO assessor_regulatory_mappings
(id, rule_key, source_tool, source_rule_id, run_status, query_hash, corpus_id, corpus_version,
embedding_model, retrieval_version, prompt_version, output_schema_version, model, created_at, updated_at, data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::jsonb)
ON CONFLICT DO NOTHING
RETURNING id
`, [run.id, run.ruleKey, run.sourceTool, run.sourceRuleId ?? null, run.status, run.queryHash, run.corpusId,
run.corpusVersion, run.embeddingModel, run.retrievalVersion, run.promptVersion, run.outputSchemaVersion,
run.model, run.createdAt, run.updatedAt, json(run)]);
if (inserted.rowCount) return { run: clone(run), claimed: true };
const existing = dataRow(await client.query<DataRow<RegulatoryMappingRun>>(
'SELECT data FROM assessor_regulatory_mappings WHERE id = $1 FOR UPDATE', [run.id]
));
if (!existing) throw new RepositoryConflictError('Regulatory mapping identity conflicted with another pipeline tuple.');
if (['queued', 'running'].includes(existing.status) || existing.status === 'succeeded' && !forceRegeneration) {
return { run: existing, claimed: false };
}
await client.query(`
UPDATE assessor_regulatory_mappings SET
run_status = $2, query_hash = $3, created_at = $4, updated_at = $5, data = $6::jsonb
WHERE id = $1
`, [run.id, run.status, run.queryHash, run.createdAt, run.updatedAt, json(run)]);
return { run: clone(run), claimed: true };
});
}
async saveRegulatoryMappingRun(run: RegulatoryMappingRun): Promise<RegulatoryMappingRun> {
const errors = regulatoryMappingRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
return this.transaction(async client => {
await client.query(`
INSERT INTO assessor_regulatory_mappings
(id, rule_key, source_tool, source_rule_id, run_status, query_hash, corpus_id, corpus_version,
embedding_model, retrieval_version, prompt_version, output_schema_version, model, created_at, updated_at, data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::jsonb)
ON CONFLICT (id) DO UPDATE SET
run_status = excluded.run_status, updated_at = excluded.updated_at, data = excluded.data
`, [run.id, run.ruleKey, run.sourceTool, run.sourceRuleId ?? null, run.status, run.queryHash, run.corpusId,
run.corpusVersion, run.embeddingModel, run.retrievalVersion, run.promptVersion, run.outputSchemaVersion,
run.model, run.createdAt, run.updatedAt, json(run)]);
return clone(run);
});
}
async listRegulatoryMappingRuns(): Promise<RegulatoryMappingRun[]> {
return dataRows(await this.pool.query<DataRow<RegulatoryMappingRun>>(
'SELECT data FROM assessor_regulatory_mappings ORDER BY created_at DESC, id DESC'
));
}
async createRegulatoryMappingBatchRun(run: RegulatoryMappingBatchRun): Promise<RegulatoryMappingBatchRun> {
const errors = regulatoryMappingBatchRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
return this.transaction(async client => {
const inserted = await client.query(`
INSERT INTO assessor_regulatory_mapping_batches
(id, project_id, scope_key, filter_hash, idempotency_key, run_status, created_at, updated_at, data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
ON CONFLICT DO NOTHING
RETURNING data
`, [run.id, run.projectId, run.scopeKey, run.filterHash, run.idempotencyKey, run.status,
run.createdAt, run.updatedAt, json(run)]);
if (inserted.rowCount) return clone(run);
const existing = dataRow(await client.query<DataRow<RegulatoryMappingBatchRun>>(`
SELECT data FROM assessor_regulatory_mapping_batches
WHERE project_id = $1 AND (idempotency_key = $2 OR (scope_key = $3 AND run_status IN ('queued', 'running')))
ORDER BY (idempotency_key = $2) DESC, created_at DESC, id DESC
LIMIT 1
`, [run.projectId, run.idempotencyKey, run.scopeKey]));
if (!existing) throw new RepositoryConflictError('Regulatory mapping batch conflicted with another active request.');
return existing;
});
}
async updateRegulatoryMappingBatchRun(run: RegulatoryMappingBatchRun): Promise<RegulatoryMappingBatchRun> {
const errors = regulatoryMappingBatchRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
return this.transaction(async client => {
const existing = dataRow(await client.query<DataRow<RegulatoryMappingBatchRun>>(
'SELECT data FROM assessor_regulatory_mapping_batches WHERE id = $1 FOR UPDATE', [run.id]
));
if (!existing) throw new RepositoryNotFoundError('Regulatory mapping batch does not exist.');
if (existing.projectId !== run.projectId || existing.scopeKey !== run.scopeKey || existing.idempotencyKey !== run.idempotencyKey) {
throw new RepositoryConflictError('Regulatory mapping batch identity is immutable.');
}
if (['completed', 'failed'].includes(existing.status)) throw new RepositoryConflictError('A completed regulatory mapping batch is immutable.');
const allowed = existing.status === 'queued' ? ['running', 'failed'] : ['running', 'completed', 'failed'];
if (!allowed.includes(run.status)) throw new RepositoryConflictError(`Invalid regulatory mapping batch transition from ${existing.status} to ${run.status}.`);
await client.query(`
UPDATE assessor_regulatory_mapping_batches SET run_status = $2, updated_at = $3, data = $4::jsonb
WHERE id = $1
`, [run.id, run.status, run.updatedAt, json(run)]);
return clone(run);
});
}
async listRegulatoryMappingBatchRuns(projectId: string, scopeKey?: string): Promise<RegulatoryMappingBatchRun[]> {
return dataRows(await this.pool.query<DataRow<RegulatoryMappingBatchRun>>(`
SELECT data FROM assessor_regulatory_mapping_batches
WHERE project_id = $1 AND ($2::text IS NULL OR scope_key = $2)
ORDER BY created_at DESC, id DESC
`, [projectId, scopeKey ?? null]));
}
async createRegulatoryMappingDecision(
decision: RegulatoryMappingDecision,
expectedLatestDecisionId?: string
): Promise<RegulatoryMappingDecision> {
const errors = regulatoryMappingDecisionValidationErrors(decision);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
return this.transaction(async client => {
// Serializes decisions for one rule and control, so two writers cannot both see the same latest decision.
await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [`regulatory-decision:${decision.ruleKey}:${decision.controlId}`]);
const run = dataRow(await client.query<DataRow<RegulatoryMappingRun>>(
'SELECT data FROM assessor_regulatory_mappings WHERE id = $1 FOR UPDATE', [decision.mappingId]
));
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<DataRow<RegulatoryMappingDecision>>(`
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]));
if ((expectedLatestDecisionId ?? undefined) !== (latest?.id ?? undefined)) {
throw new RepositoryConflictError('Regulatory mapping decision changed before this update.');
}
const stored = { ...decision, ...(latest ? { previousDecisionId: latest.id } : {}) };
await client.query(`
INSERT INTO assessor_regulatory_mapping_decisions
(id, rule_key, source_tool, source_rule_id, mapping_id, control_id, decision, previous_decision_id, created_at, data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
`, [stored.id, stored.ruleKey, stored.sourceTool, stored.sourceRuleId ?? null, stored.mappingId, stored.controlId,
stored.decision, stored.previousDecisionId ?? null, stored.createdAt, json(stored)]);
return clone(stored);
});
}
async listRegulatoryMappingDecisions(ruleKey: string): Promise<RegulatoryMappingDecision[]> {
return dataRows(await this.pool.query<DataRow<RegulatoryMappingDecision>>(`
SELECT data FROM assessor_regulatory_mapping_decisions
WHERE rule_key = $1
ORDER BY created_at DESC, id DESC
`, [ruleKey]));
}
async createAiOverviewRun(run: AiOverviewRun): Promise<AiOverviewRun> {
const errors = aiOverviewRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
return this.transaction(async client => {
const idempotent = dataRow(await client.query<DataRow<AiOverviewRun>>(`
SELECT data FROM assessor_ai_overview_runs
WHERE project_id = $1 AND scope_key = $2 AND idempotency_key = $3
`, [run.projectId, run.scopeKey, run.idempotencyKey]));
if (idempotent) return idempotent;
await client.query(`
INSERT INTO assessor_ai_overview_runs
(id, project_id, scope_key, assessment_ids, scan_run_ids, idempotency_key, run_status,
input_hash, prompt_version, output_schema_version, model, created_at, updated_at, data)
VALUES ($1, $2, $3, $4::text[], $5::text[], $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb)
`, [run.id, run.projectId, run.scopeKey, run.assessmentIds, run.scanRunIds, run.idempotencyKey,
run.status, run.inputHash, run.promptVersion, run.outputSchemaVersion, run.model,
run.createdAt, run.updatedAt, json(run)]);
return clone(run);
});
}
async updateAiOverviewRun(run: AiOverviewRun): Promise<AiOverviewRun> {
const errors = aiOverviewRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
return this.transaction(async client => {
const existing = dataRow(await client.query<DataRow<AiOverviewRun>>(
'SELECT data FROM assessor_ai_overview_runs WHERE id = $1 FOR UPDATE', [run.id]
));
if (!existing) throw new RepositoryNotFoundError(`AI overview run ${run.id} does not exist.`);
assertAiOverviewTransition(existing, run);
await client.query(`
UPDATE assessor_ai_overview_runs SET
run_status = $2, updated_at = $3, data = $4::jsonb
WHERE id = $1
`, [run.id, run.status, run.updatedAt, json(run)]);
return clone(run);
});
}
async listAiOverviewRuns(projectId: string, scopeKey?: string): Promise<AiOverviewRun[]> {
return dataRows(await this.pool.query<DataRow<AiOverviewRun>>(`
SELECT data FROM assessor_ai_overview_runs
WHERE project_id = $1 AND ($2::text IS NULL OR scope_key = $2)
ORDER BY created_at DESC, id DESC
`, [projectId, scopeKey ?? null]));
}
async deleteAiOverviewRuns(projectId: string, runIds: string[]): Promise<void> {
if (!runIds.length) return;
await this.transaction(client => client.query(
'DELETE FROM assessor_ai_overview_runs WHERE project_id = $1 AND id = ANY($2::text[])', [projectId, runIds]
));
}
async exportProject(projectId: string): Promise<ComplianceProjectExport> {
return {
regulatoryMappingBatchRuns: await this.listRegulatoryMappingBatchRuns(projectId),
regulatoryMappingRuns: (await this.listRegulatoryMappingRuns()).filter(item => item.status === 'succeeded'),
regulatoryMappingDecisions: dataRows(await this.pool.query<DataRow<RegulatoryMappingDecision>>(
'SELECT data FROM assessor_regulatory_mapping_decisions ORDER BY created_at, id'
)),
aiOverviewRuns: (await this.listAiOverviewRuns(projectId)).filter(item => item.status === 'succeeded')
};
}
}

View File

@ -3,7 +3,7 @@ import type {
RegulatoryChunk, RegulatoryChunk,
RegulatoryRetrievalCandidate, RegulatoryRetrievalCandidate,
RegulatoryRetrievalFilters RegulatoryRetrievalFilters
} from '../../src/domain/regulatory-rag'; } from '../../src/compliance/regulatory-rag';
export const REGULATORY_QDRANT_COLLECTION = 'assessor_regulatory_chunks_v2'; export const REGULATORY_QDRANT_COLLECTION = 'assessor_regulatory_chunks_v2';
export const REGULATORY_DENSE_VECTOR = 'dense'; export const REGULATORY_DENSE_VECTOR = 'dense';

View File

@ -3,7 +3,7 @@ import {
REGULATORY_CHUNK_SCHEMA_VERSION, REGULATORY_CHUNK_SCHEMA_VERSION,
type RegulatoryChunk, type RegulatoryChunk,
type RegulatoryControlDocument type RegulatoryControlDocument
} from '../../src/domain/regulatory-rag'; } from '../../src/compliance/regulatory-rag';
export interface RegulatoryChunkingOptions { export interface RegulatoryChunkingOptions {
maximumCharacters: number; maximumCharacters: number;

View File

@ -1,8 +1,8 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'; import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
import { join, resolve, sep } from 'node:path'; import { join, resolve, sep } from 'node:path';
import type { RegulatoryCorpusV2 } from '../../src/domain/regulatory-rag'; import type { RegulatoryCorpusV2 } from '../../src/compliance/regulatory-rag';
import type { RegulatoryCorpusJob, RegulatoryCorpusStatus, RegulatoryCorpusSummary } from '../../src/domain/regulatory-corpus-management'; import type { RegulatoryCorpusJob, RegulatoryCorpusStatus, RegulatoryCorpusSummary } from '../../src/compliance/regulatory-corpus-management';
import type { IsoPdfCorpusOptions } from './iso-pdf-corpus'; import type { IsoPdfCorpusOptions } from './iso-pdf-corpus';
import { prepareIsoCorpusInWorker } from './iso-pdf-worker-client'; import { prepareIsoCorpusInWorker } from './iso-pdf-worker-client';
import type { RegulatoryIngestionService } from './regulatory-rag-service'; import type { RegulatoryIngestionService } from './regulatory-rag-service';

View File

@ -3,9 +3,9 @@ import type {
RegulatoryRetrievalCandidate, RegulatoryRetrievalCandidate,
RegulatoryRetrievalFilters, RegulatoryRetrievalFilters,
RegulatoryRetriever RegulatoryRetriever
} from '../../src/domain/regulatory-rag'; } from '../../src/compliance/regulatory-rag';
import type { UnifiedResultProjection } from '../../src/domain/unified-result'; import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { buildRegulatoryFindingQuery } from '../../src/profiles/public-search/regulatory-query'; import { buildRegulatoryFindingQuery } from '../../src/compliance/regulatory-query';
export interface RegulatoryFindingRetrievalResult { export interface RegulatoryFindingRetrievalResult {
sourceResultId: string; sourceResultId: string;

View File

@ -1,6 +1,6 @@
import { readFile, stat } from 'node:fs/promises'; import { readFile, stat } from 'node:fs/promises';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import type { RegulatoryControlDocument } from '../../src/domain/regulatory-rag'; import type { RegulatoryControlDocument } from '../../src/compliance/regulatory-rag';
import { validateRegulatoryControlDocument } from './regulatory-chunker'; import { validateRegulatoryControlDocument } from './regulatory-chunker';
import { createRegulatoryRagRuntime } from './regulatory-rag-runtime'; import { createRegulatoryRagRuntime } from './regulatory-rag-runtime';

View File

@ -1,12 +1,13 @@
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import type { AiCompletionClient } from '../ai/litellm-client'; import type { AiCompletionClient } from '../ai/litellm-client';
import { LiteLlmClientError } from '../ai/litellm-client'; import { LiteLlmClientError } from '../ai/litellm-client';
import type { AssessorRepository } from '../../src/persistence/assessor-repository'; import type { ComplianceReadPort, ComplianceStore } from '../../src/compliance/compliance-store';
import { RepositoryConflictError, RepositoryNotFoundError, RepositoryValidationError } from '../../src/persistence/repository-errors'; import { RepositoryConflictError, RepositoryNotFoundError, RepositoryValidationError } from '../../src/persistence/repository-errors';
import { import {
REGULATORY_MAPPING_OUTPUT_SCHEMA_VERSION, REGULATORY_MAPPING_OUTPUT_SCHEMA_VERSION,
REGULATORY_MAPPING_PROMPT_VERSION, REGULATORY_MAPPING_PROMPT_VERSION,
REGULATORY_RETRIEVAL_VERSION, REGULATORY_RETRIEVAL_VERSION,
REGULATORY_GPO_QUERY_VERSION,
emptyRegulatoryMappingBatchCounts, emptyRegulatoryMappingBatchCounts,
groundRegulatoryMappingResult, groundRegulatoryMappingResult,
normalizeRegulatoryMappingBatchScope, normalizeRegulatoryMappingBatchScope,
@ -18,18 +19,24 @@ import {
type RegulatoryMappingCandidate, type RegulatoryMappingCandidate,
type RegulatoryMappingCreateRequest, type RegulatoryMappingCreateRequest,
type RegulatoryMappingDecision, type RegulatoryMappingDecision,
type RegulatoryEarlierControl,
type RegulatoryMappingDecisionCreateRequest, type RegulatoryMappingDecisionCreateRequest,
type RegulatoryMappingEvidence, type RegulatoryMappingEvidence,
type RegulatoryMappingResponse, type RegulatoryMappingResponse,
type RegulatoryMappingResult, type RegulatoryMappingResult,
type RegulatoryMappingRun type RegulatoryMappingRun,
} from '../../src/domain/regulatory-mapping'; type RegulatoryControlFinding,
import type { RegulatoryRetrievalCandidate } from '../../src/domain/regulatory-rag'; type RegulatoryControlOverviewItem,
type RegulatoryControlOverviewResponse,
acceptedControlIds,
regulatoryControlTheme
} from '../../src/compliance/regulatory-mapping';
import type { RegulatoryRetrievalCandidate } from '../../src/compliance/regulatory-rag';
import type { UnifiedResultProjection } from '../../src/domain/unified-result'; import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { privacySafeFinding } from '../../src/domain/privacy-safe-finding'; import { privacySafeFinding } from '../../src/domain/privacy-safe-finding';
import type { RegulatoryFindingRetrievalService } from './regulatory-finding-retrieval'; import type { RegulatoryFindingRetrievalService } from './regulatory-finding-retrieval';
import type { RegulatoryChunkIndex } from './qdrant-regulatory-index'; import type { RegulatoryChunkIndex } from './qdrant-regulatory-index';
import { buildRegulatoryFindingQuery, isRegulatoryMappableResult } from '../../src/profiles/public-search/regulatory-query'; import { buildRegulatoryFindingQuery, isRegulatoryMappableResult } from '../../src/compliance/regulatory-query';
import { canonicalJson } from '../../src/shared/canonical-json'; import { canonicalJson } from '../../src/shared/canonical-json';
const POLL_AFTER_MS = 1_500; const POLL_AFTER_MS = 1_500;
@ -37,6 +44,9 @@ const UNSUPPORTED_KIND_MESSAGE = 'Only canonical scanner findings and GPO baseli
const MAXIMUM_PROMPT_CHARACTERS = 48_000; const MAXIMUM_PROMPT_CHARACTERS = 48_000;
const BATCH_PAGE_SIZE = 500; const BATCH_PAGE_SIZE = 500;
const DEFAULT_MAXIMUM_CONCURRENT_RUNS = 2; const DEFAULT_MAXIMUM_CONCURRENT_RUNS = 2;
// User decision 2026-10-05 (provisional): the first proposals of a finished mapping are accepted automatically.
export const AUTO_ACCEPTED_PROPOSALS = 2;
export const AUTO_ACCEPT_COMMENT = 'Accepted automatically: one of the first two proposals of the mapping.';
export interface RegulatoryMappingServiceOptions { export interface RegulatoryMappingServiceOptions {
findingRetrieval?: RegulatoryFindingRetrievalService; findingRetrieval?: RegulatoryFindingRetrievalService;
@ -61,7 +71,11 @@ export class RegulatoryMappingService {
private activeWorkerCount = 0; private activeWorkerCount = 0;
private activationPending = false; private activationPending = false;
constructor(private readonly repository: AssessorRepository, private readonly options: RegulatoryMappingServiceOptions = {}) { constructor(
private readonly read: ComplianceReadPort,
private readonly store: ComplianceStore,
private readonly options: RegulatoryMappingServiceOptions = {}
) {
this.now = options.now ?? (() => new Date()); this.now = options.now ?? (() => new Date());
this.maximumConcurrentRuns = options.maximumConcurrentRuns ?? DEFAULT_MAXIMUM_CONCURRENT_RUNS; this.maximumConcurrentRuns = options.maximumConcurrentRuns ?? DEFAULT_MAXIMUM_CONCURRENT_RUNS;
if (!Number.isInteger(this.maximumConcurrentRuns) || this.maximumConcurrentRuns < 1 || this.maximumConcurrentRuns > 8) { if (!Number.isInteger(this.maximumConcurrentRuns) || this.maximumConcurrentRuns < 1 || this.maximumConcurrentRuns > 8) {
@ -87,8 +101,8 @@ export class RegulatoryMappingService {
async recoverInterruptedRuns(): Promise<void> { async recoverInterruptedRuns(): Promise<void> {
const completedAt = this.now().toISOString(); const completedAt = this.now().toISOString();
for (const run of (await this.repository.listRegulatoryMappingRuns()).filter(item => ['queued', 'running'].includes(item.status))) { for (const run of (await this.store.listRegulatoryMappingRuns()).filter(item => ['queued', 'running'].includes(item.status))) {
await this.repository.saveRegulatoryMappingRun({ await this.store.saveRegulatoryMappingRun({
...run, ...run,
status: 'failed', status: 'failed',
updatedAt: completedAt, updatedAt: completedAt,
@ -96,9 +110,9 @@ export class RegulatoryMappingService {
errorCode: 'regulatory_mapping_interrupted' errorCode: 'regulatory_mapping_interrupted'
}); });
} }
for (const project of await this.repository.listProjects()) { for (const project of await this.read.listProjects()) {
for (const run of (await this.repository.listRegulatoryMappingBatchRuns(project.id)).filter(item => ['queued', 'running'].includes(item.status))) { for (const run of (await this.store.listRegulatoryMappingBatchRuns(project.id)).filter(item => ['queued', 'running'].includes(item.status))) {
await this.repository.updateRegulatoryMappingBatchRun({ await this.store.updateRegulatoryMappingBatchRun({
...run, ...run,
status: 'failed', status: 'failed',
updatedAt: completedAt, updatedAt: completedAt,
@ -116,8 +130,9 @@ export class RegulatoryMappingService {
const availability = supported ? await this.availability() : { enabled: false }; const availability = supported ? await this.availability() : { enabled: false };
const enabled = availability.enabled && supported; const enabled = availability.enabled && supported;
const ruleKey = supported ? this.ruleKey(finding) : undefined; const ruleKey = supported ? this.ruleKey(finding) : undefined;
const run = ruleKey && this.options.client ? await this.repository.getRegulatoryMappingRun(this.mappingId(ruleKey)) : null; const run = ruleKey && this.options.client ? await this.store.getRegulatoryMappingRun(this.mappingId(ruleKey)) : null;
const decisions = ruleKey ? await this.repository.listRegulatoryMappingDecisions(ruleKey) : []; const decisions = ruleKey ? await this.store.listRegulatoryMappingDecisions(ruleKey) : [];
const earlierControls = ruleKey ? await this.earlierControls(ruleKey, run, decisions) : [];
return { return {
version: 1, version: 1,
projectId, projectId,
@ -130,10 +145,40 @@ export class RegulatoryMappingService {
: !availability.enabled ? { disabledReason: availability.reason ?? this.options.disabledReason ?? 'Regulatory mapping is not configured.' } : {}), : !availability.enabled ? { disabledReason: availability.reason ?? this.options.disabledReason ?? 'Regulatory mapping is not configured.' } : {}),
...(run && ['queued', 'running'].includes(run.status) ? { pollAfterMs: POLL_AFTER_MS } : {}), ...(run && ['queued', 'running'].includes(run.status) ? { pollAfterMs: POLL_AFTER_MS } : {}),
...(run ? { run: withoutEvidenceText(run) } : {}), ...(run ? { run: withoutEvidenceText(run) } : {}),
decisions decisions,
...(earlierControls.length ? { earlierControls } : {})
}; };
} }
/**
* The run that grounds a decision: the current one, or for an earlier decision the newest older succeeded
* run of the rule that retrieved the control, so it stays reviewable after a pipeline version change.
*/
private async groundingMappingId(ruleKey: string, controlId: string): Promise<string> {
const currentId = this.mappingId(ruleKey);
const current = await this.store.getRegulatoryMappingRun(currentId);
if (!current || current.candidates.some(candidate => candidate.controlId === controlId)) return currentId;
const older = (await this.store.listRegulatoryMappingRuns()).find(run => run.ruleKey === ruleKey && run.status === 'succeeded'
&& run.candidates.some(candidate => candidate.controlId === controlId));
return older?.id ?? currentId;
}
/** Decided controls the current run did not retrieve, titled from the newest older run of the rule that did. */
private async earlierControls(ruleKey: string, run: RegulatoryMappingRun | null, decisions: RegulatoryMappingDecision[]): Promise<RegulatoryEarlierControl[]> {
const current = new Set(run?.candidates.map(candidate => candidate.controlId) ?? []);
const missing = [...new Set(decisions.map(decision => decision.controlId))].filter(controlId => !current.has(controlId));
if (!missing.length) return [];
const known = new Map<string, RegulatoryMappingCandidate>();
for (const older of await this.store.listRegulatoryMappingRuns()) {
if (older.ruleKey !== ruleKey) continue;
for (const candidate of older.candidates) if (!known.has(candidate.controlId)) known.set(candidate.controlId, candidate);
}
return missing.sort((left, right) => left.localeCompare(right, 'en', { numeric: true })).map(controlId => {
const candidate = known.get(controlId);
return { controlId, annexAControlId: candidate?.annexAControlId ?? `A.${controlId}`, ...(candidate?.title ? { title: candidate.title } : {}) };
});
}
async start(projectId: string, request: RegulatoryMappingCreateRequest): Promise<RegulatoryMappingResponse> { async start(projectId: string, request: RegulatoryMappingCreateRequest): Promise<RegulatoryMappingResponse> {
if (!this.generationEnabled) throw new RegulatoryMappingServiceError('regulatory_mapping_disabled', this.options.disabledReason ?? 'Regulatory mapping is not configured.', 503); if (!this.generationEnabled) throw new RegulatoryMappingServiceError('regulatory_mapping_disabled', this.options.disabledReason ?? 'Regulatory mapping is not configured.', 503);
const finding = await this.finding(projectId, request.resultProjectionId); const finding = await this.finding(projectId, request.resultProjectionId);
@ -147,7 +192,7 @@ export class RegulatoryMappingService {
async getBatch(projectId: string, requestedScope: RegulatoryMappingBatchScope): Promise<RegulatoryMappingBatchResponse> { async getBatch(projectId: string, requestedScope: RegulatoryMappingBatchScope): Promise<RegulatoryMappingBatchResponse> {
const scope = await this.validateBatchScope(projectId, requestedScope); const scope = await this.validateBatchScope(projectId, requestedScope);
const scopeKey = this.batchScopeKey(projectId, scope); const scopeKey = this.batchScopeKey(projectId, scope);
const run = (await this.repository.listRegulatoryMappingBatchRuns(projectId, scopeKey))[0]; const run = (await this.store.listRegulatoryMappingBatchRuns(projectId, scopeKey))[0];
const availability = await this.availability(); const availability = await this.availability();
return { return {
version: 1, version: 1,
@ -183,7 +228,7 @@ export class RegulatoryMappingService {
createdAt: timestamp, createdAt: timestamp,
updatedAt: timestamp updatedAt: timestamp
}; };
const run = await this.repository.createRegulatoryMappingBatchRun(candidate); const run = await this.store.createRegulatoryMappingBatchRun(candidate);
if (run.id === candidate.id) this.track(this.processBatch(run)); if (run.id === candidate.id) this.track(this.processBatch(run));
return this.getBatch(projectId, { return this.getBatch(projectId, {
assessmentIds: run.assessmentIds, assessmentIds: run.assessmentIds,
@ -198,22 +243,97 @@ export class RegulatoryMappingService {
if (!isRegulatoryMappableResult(finding)) throw new RegulatoryMappingServiceError('unsupported_finding_kind', UNSUPPORTED_KIND_MESSAGE, 422); if (!isRegulatoryMappableResult(finding)) throw new RegulatoryMappingServiceError('unsupported_finding_kind', UNSUPPORTED_KIND_MESSAGE, 422);
const ruleKey = this.ruleKey(finding); const ruleKey = this.ruleKey(finding);
const comment = request.comment?.trim(); const comment = request.comment?.trim();
const mappingId = await this.groundingMappingId(ruleKey, request.controlId);
const decision: RegulatoryMappingDecision = { const decision: RegulatoryMappingDecision = {
version: 1, version: 1,
id: `regulatory-decision-${randomUUID()}`, id: `regulatory-decision-${randomUUID()}`,
ruleKey, ruleKey,
sourceTool: finding.sourceTool ?? 'unknown', sourceTool: finding.sourceTool ?? 'unknown',
...(finding.sourceRuleId ? { sourceRuleId: finding.sourceRuleId } : {}), ...(finding.sourceRuleId ? { sourceRuleId: finding.sourceRuleId } : {}),
mappingId: this.mappingId(ruleKey), mappingId,
controlId: request.controlId, controlId: request.controlId,
decision: request.decision, decision: request.decision,
...(comment ? { comment } : {}), ...(comment ? { comment } : {}),
createdAt: this.now().toISOString() createdAt: this.now().toISOString()
}; };
await this.repository.createRegulatoryMappingDecision(decision, request.expectedLatestDecisionId); await this.store.createRegulatoryMappingDecision(decision, request.expectedLatestDecisionId);
return this.get(projectId, request.resultProjectionId); return this.get(projectId, request.resultProjectionId);
} }
/** 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<RegulatoryMappingBatchScope, 'assessmentIds' | 'scanRunIds'>): Promise<RegulatoryControlOverviewResponse> {
return this.controlOverview(projectId, requested, { deviationsOnly: false });
}
/**
* Accepted controls of the deviations in one client timeslot. Decisions are global per scanner rule;
* a GPO setting counts once, like its rolled-up Work record, however many baseline comparisons it has.
*/
async controlOverview(projectId: string, requested: Pick<RegulatoryMappingBatchScope, 'assessmentIds' | 'scanRunIds'>,
{ deviationsOnly = true }: { deviationsOnly?: boolean } = {}): Promise<RegulatoryControlOverviewResponse> {
const scope = await this.validateBatchScope(projectId, { assessmentIds: requested.assessmentIds, scanRunIds: requested.scanRunIds,
filters: deviationsOnly ? { deviation: true } : {} });
const byRule = new Map<string, Map<string, RegulatoryControlFinding>>();
const results = (await this.queryBatchResults(projectId, scope)).filter(isRegulatoryMappableResult);
const settingIds = await this.gpoSettingIds(projectId, scope, results);
for (const finding of results) {
const settingId = settingIds.get(finding.resultProjectionId);
const identity = findingIdentity(finding, settingId);
const findings = byRule.get(this.ruleKey(finding)) ?? new Map<string, RegulatoryControlFinding>();
// The setting ID only lets Work match its rolled-up GPO setting; it is never displayed or exported.
if (!findings.has(identity)) findings.set(identity, { resultProjectionId: finding.resultProjectionId, sourceKind: finding.sourceKind,
sourceResultId: finding.sourceResultId, sourceRuleId: settingId ?? finding.sourceRuleId, policyScope: finding.policyScope });
byRule.set(this.ruleKey(finding), findings);
}
// Control titles come from the newest succeeded run of each rule that retrieved the control.
const titles = new Map<string, Map<string, RegulatoryMappingCandidate>>();
for (const run of await this.store.listRegulatoryMappingRuns()) {
if (run.status !== 'succeeded' || !byRule.has(run.ruleKey)) continue;
const known = titles.get(run.ruleKey) ?? new Map<string, RegulatoryMappingCandidate>();
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<string, { item: RegulatoryControlOverviewItem; findings: Map<string, RegulatoryControlFinding> }>();
const covered = new Set<string>();
for (const [ruleKey, findings] of byRule) {
for (const controlId of acceptedControlIds(await this.store.listRegulatoryMappingDecisions(ruleKey))) {
const candidate = titles.get(ruleKey)?.get(controlId);
const entry = controls.get(controlId) ?? { findings: new Map(), item: {
controlId, annexAControlId: candidate?.annexAControlId ?? `A.${controlId}`, findings: [],
...(candidate?.title ? { title: candidate.title } : {}),
...(regulatoryControlTheme(controlId) ? { theme: regulatoryControlTheme(controlId) } : {}) } };
if (!entry.item.title && candidate?.title) entry.item.title = candidate.title;
for (const [identity, finding] of findings) { entry.findings.set(identity, finding); covered.add(identity); }
controls.set(controlId, entry);
}
}
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 }))
};
}
/**
* Accepts the first proposals of a finished mapping, in the model's order. A control that already has a
* decision keeps it, so a manual reject is never overridden; a failure here never fails the mapping.
*/
private async autoAcceptFirstProposals(run: RegulatoryMappingRun, result: RegulatoryMappingResult): Promise<void> {
if (result.outcome !== 'mapped') return;
try {
const decided = new Set((await this.store.listRegulatoryMappingDecisions(run.ruleKey)).map(item => item.controlId));
for (const proposal of result.mappings.slice(0, AUTO_ACCEPTED_PROPOSALS)) {
if (decided.has(proposal.controlId)) continue;
await this.store.createRegulatoryMappingDecision({
version: 1, id: `regulatory-decision-${randomUUID()}`, ruleKey: run.ruleKey, sourceTool: run.sourceTool,
...(run.sourceRuleId ? { sourceRuleId: run.sourceRuleId } : {}), mappingId: run.id, controlId: proposal.controlId,
decision: 'accepted', comment: AUTO_ACCEPT_COMMENT, createdAt: this.now().toISOString()
});
}
} catch { /* A concurrent manual decision wins; the mapping itself has succeeded. */ }
}
// The global sharing key: the rule identity `tool:ruleId`, shared across every tenant. // The global sharing key: the rule identity `tool:ruleId`, shared across every tenant.
// Rules without an id fall back to a stable hash of the non-personal check title so // Rules without an id fall back to a stable hash of the non-personal check title so
// identical findings still share, while never keying on tenant or occurrence. // identical findings still share, while never keying on tenant or occurrence.
@ -228,10 +348,18 @@ export class RegulatoryMappingService {
// Deterministic id per (ruleKey, pipeline versions) so one row exists per rule per // Deterministic id per (ruleKey, pipeline versions) so one row exists per rule per
// version tuple: regeneration reuses it, and a version bump yields a new mapping. // version tuple: regeneration reuses it, and a version bump yields a new mapping.
private mappingId(ruleKey: string): string { private mappingId(ruleKey: string): string {
const tuple = [ruleKey, this.options.corpusVersion, REGULATORY_RETRIEVAL_VERSION, REGULATORY_MAPPING_PROMPT_VERSION, this.options.client!.model].join(''); const tuple = [ruleKey, this.options.corpusVersion, REGULATORY_RETRIEVAL_VERSION, REGULATORY_MAPPING_PROMPT_VERSION, this.options.client!.model,
// GPO rules carry the baseline context of their query version; scanner mapping ids stay unchanged.
...(isGpoRuleKey(ruleKey) ? [REGULATORY_GPO_QUERY_VERSION] : [])].join('');
return `regulatory-mapping-${createHash('sha256').update(tuple, 'utf8').digest('hex').slice(0, 40)}`; return `regulatory-mapping-${createHash('sha256').update(tuple, 'utf8').digest('hex').slice(0, 40)}`;
} }
// The stored prompt version must differ wherever the id differs: the table's unique
// identity tuple has no column for the GPO query version, so GPO runs record it here.
private promptVersion(ruleKey: string): string {
return isGpoRuleKey(ruleKey) ? `${REGULATORY_MAPPING_PROMPT_VERSION}+${REGULATORY_GPO_QUERY_VERSION}` : REGULATORY_MAPPING_PROMPT_VERSION;
}
async close(): Promise<void> { async close(): Promise<void> {
while (this.activeTasks.size) await Promise.allSettled([...this.activeTasks]); while (this.activeTasks.size) await Promise.allSettled([...this.activeTasks]);
} }
@ -277,7 +405,7 @@ export class RegulatoryMappingService {
sourceHashes: [], sourceHashes: [],
embeddingModel: this.options.embeddingModel!, embeddingModel: this.options.embeddingModel!,
retrievalVersion: REGULATORY_RETRIEVAL_VERSION, retrievalVersion: REGULATORY_RETRIEVAL_VERSION,
promptVersion: REGULATORY_MAPPING_PROMPT_VERSION, promptVersion: this.promptVersion(ruleKey),
outputSchemaVersion: REGULATORY_MAPPING_OUTPUT_SCHEMA_VERSION, outputSchemaVersion: REGULATORY_MAPPING_OUTPUT_SCHEMA_VERSION,
model: this.options.client!.model, model: this.options.client!.model,
createdAt: timestamp, createdAt: timestamp,
@ -287,17 +415,17 @@ export class RegulatoryMappingService {
} }
private async claim(finding: UnifiedResultProjection, forceRegeneration: boolean) { private async claim(finding: UnifiedResultProjection, forceRegeneration: boolean) {
return this.repository.claimRegulatoryMappingRun(this.queuedRun(finding), forceRegeneration); return this.store.claimRegulatoryMappingRun(this.queuedRun(finding), forceRegeneration);
} }
private async validateBatchScope(projectId: string, requested: RegulatoryMappingBatchScope): Promise<RegulatoryMappingBatchScope> { private async validateBatchScope(projectId: string, requested: RegulatoryMappingBatchScope): Promise<RegulatoryMappingBatchScope> {
if (!await this.repository.getProject(projectId)) throw new RepositoryNotFoundError('The selected project does not exist.'); if (!await this.read.getProject(projectId)) throw new RepositoryNotFoundError('The selected project does not exist.');
const scope = normalizeRegulatoryMappingBatchScope(requested); const scope = normalizeRegulatoryMappingBatchScope(requested);
if (!scope.assessmentIds.length && !scope.scanRunIds.length) throw new RepositoryValidationError('A selected timeslot scope is required.'); if (!scope.assessmentIds.length && !scope.scanRunIds.length) throw new RepositoryValidationError('A selected timeslot scope is required.');
const assessments = await this.repository.listAssessments(projectId); const assessments = await this.read.listAssessments(projectId);
const allowedAssessmentIds = new Set(assessments.map(item => item.id)); const allowedAssessmentIds = new Set(assessments.map(item => item.id));
if (scope.assessmentIds.some(id => !allowedAssessmentIds.has(id))) throw new RepositoryValidationError('The batch scope contains an assessment outside this project.'); if (scope.assessmentIds.some(id => !allowedAssessmentIds.has(id))) throw new RepositoryValidationError('The batch scope contains an assessment outside this project.');
const scanRuns = await this.repository.listProjectScanRuns(projectId); const scanRuns = await this.read.listProjectScanRuns(projectId);
const allowedRuns = new Map(scanRuns.filter(item => !item.supersededByScanRunId).map(item => [item.id, item])); const allowedRuns = new Map(scanRuns.filter(item => !item.supersededByScanRunId).map(item => [item.id, item]));
if (scope.scanRunIds.some(id => !allowedRuns.has(id))) throw new RepositoryValidationError('The batch scope contains an unavailable scan run.'); if (scope.scanRunIds.some(id => !allowedRuns.has(id))) throw new RepositoryValidationError('The batch scope contains an unavailable scan run.');
if (scope.assessmentIds.length && scope.scanRunIds.some(id => !scope.assessmentIds.includes(allowedRuns.get(id)!.assessmentId))) { if (scope.assessmentIds.length && scope.scanRunIds.some(id => !scope.assessmentIds.includes(allowedRuns.get(id)!.assessmentId))) {
@ -321,7 +449,7 @@ export class RegulatoryMappingService {
const items: UnifiedResultProjection[] = []; const items: UnifiedResultProjection[] = [];
let expectedTotal: number | undefined; let expectedTotal: number | undefined;
for (let offset = 0; ; offset += BATCH_PAGE_SIZE) { for (let offset = 0; ; offset += BATCH_PAGE_SIZE) {
const page = await this.repository.queryUnifiedResults({ ...query, offset, limit: BATCH_PAGE_SIZE }); const page = await this.read.queryUnifiedResults({ ...query, offset, limit: BATCH_PAGE_SIZE });
expectedTotal ??= page.total; expectedTotal ??= page.total;
if (page.total !== expectedTotal) throw new RepositoryConflictError('The filtered result set changed while the ISO mapping batch was being prepared.'); if (page.total !== expectedTotal) throw new RepositoryConflictError('The filtered result set changed while the ISO mapping batch was being prepared.');
items.push(...page.items); items.push(...page.items);
@ -331,21 +459,52 @@ export class RegulatoryMappingService {
return items; return items;
} }
/**
* The internal technical setting ID of each GPO comparison the Result DB delivered without one
* (user decision 2026-10-05). Read-only, limited to policy-evaluation rows of the same timeslot,
* and used only to identify the rolled-up Work setting; no other internal field is kept.
*/
private async gpoSettingIds(projectId: string, scope: Pick<RegulatoryMappingBatchScope, 'assessmentIds' | 'scanRunIds'>,
results: UnifiedResultProjection[]): Promise<Map<string, string>> {
const wanted = new Set(results.filter(item => item.sourceKind === 'policy-evaluation' && !item.sourceRuleId?.trim())
.map(item => item.resultProjectionId));
const settingIds = new Map<string, string>();
if (!wanted.size) return settingIds;
const query = { audience: 'internal-analysis' as const, projectId, assessmentIds: scope.assessmentIds, scanRunIds: scope.scanRunIds,
sourceKind: ['policy-evaluation' as const] };
for (let offset = 0; ; offset += BATCH_PAGE_SIZE) {
const page = await this.read.queryUnifiedResults({ ...query, offset, limit: BATCH_PAGE_SIZE });
for (const item of page.items) {
const settingId = item.sourceRuleId?.trim();
if (settingId && wanted.has(item.resultProjectionId)) settingIds.set(item.resultProjectionId, settingId);
}
if (offset + page.items.length >= page.total || page.items.length === 0) break;
}
return settingIds;
}
private async processBatch(queued: RegulatoryMappingBatchRun): Promise<void> { private async processBatch(queued: RegulatoryMappingBatchRun): Promise<void> {
const startedAt = this.now().toISOString(); const startedAt = this.now().toISOString();
let run = await this.repository.updateRegulatoryMappingBatchRun({ ...queued, status: 'running', startedAt, updatedAt: startedAt }); let run = await this.store.updateRegulatoryMappingBatchRun({ ...queued, status: 'running', startedAt, updatedAt: startedAt });
try { try {
const results = (await this.queryBatchResults(run.projectId, run)).slice() const results = (await this.queryBatchResults(run.projectId, run)).slice()
.sort((left, right) => left.resultProjectionId.localeCompare(right.resultProjectionId)); .sort((left, right) => left.resultProjectionId.localeCompare(right.resultProjectionId));
const representatives = new Map<string, UnifiedResultProjection>(); const representatives = new Map<string, UnifiedResultProjection>();
const counts = { ...emptyRegulatoryMappingBatchCounts(), filteredResultCount: results.length }; const counts = { ...emptyRegulatoryMappingBatchCounts(), filteredResultCount: results.length };
// Findings count like Work records: a GPO setting once, however many baseline comparisons it has.
const eligible = new Set<string>();
const unsupported = new Set<string>();
const settingIds = await this.gpoSettingIds(run.projectId, run, results);
for (const finding of results) { for (const finding of results) {
if (!isRegulatoryMappableResult(finding)) { counts.excludedNonScannerCount += 1; continue; } if (!isRegulatoryMappableResult(finding)) { counts.excludedNonScannerCount += 1; continue; }
if (!buildRegulatoryFindingQuery(finding)) { counts.unsupportedFindingCount += 1; continue; } const identity = findingIdentity(finding, settingIds.get(finding.resultProjectionId));
counts.eligibleFindingCount += 1; if (!buildRegulatoryFindingQuery(finding)) { unsupported.add(identity); continue; }
eligible.add(identity);
const ruleKey = this.ruleKey(finding); const ruleKey = this.ruleKey(finding);
if (!representatives.has(ruleKey)) representatives.set(ruleKey, finding); if (!representatives.has(ruleKey)) representatives.set(ruleKey, finding);
} }
counts.eligibleFindingCount = eligible.size;
counts.unsupportedFindingCount = unsupported.size;
counts.distinctRuleCount = representatives.size; counts.distinctRuleCount = representatives.size;
const scheduled: Array<Promise<'succeeded' | 'failed'>> = []; const scheduled: Array<Promise<'succeeded' | 'failed'>> = [];
for (const finding of representatives.values()) { for (const finding of representatives.values()) {
@ -360,24 +519,24 @@ export class RegulatoryMappingService {
this.track(completion.then(() => undefined)); this.track(completion.then(() => undefined));
scheduled.push(completion); scheduled.push(completion);
} }
run = await this.repository.updateRegulatoryMappingBatchRun({ ...run, counts, updatedAt: this.now().toISOString() }); run = await this.store.updateRegulatoryMappingBatchRun({ ...run, counts, updatedAt: this.now().toISOString() });
let progress = Promise.resolve(); let progress = Promise.resolve();
const completions = scheduled.map(completion => completion.then(status => { const completions = scheduled.map(completion => completion.then(status => {
progress = progress.then(async () => { progress = progress.then(async () => {
if (status === 'succeeded') counts.succeededRuleCount += 1; if (status === 'succeeded') counts.succeededRuleCount += 1;
else counts.failedRuleCount += 1; else counts.failedRuleCount += 1;
run = await this.repository.updateRegulatoryMappingBatchRun({ ...run, counts: { ...counts }, updatedAt: this.now().toISOString() }); run = await this.store.updateRegulatoryMappingBatchRun({ ...run, counts: { ...counts }, updatedAt: this.now().toISOString() });
}); });
return progress; return progress;
})); }));
await Promise.all(completions); await Promise.all(completions);
await progress; await progress;
const completedAt = this.now().toISOString(); const completedAt = this.now().toISOString();
await this.repository.updateRegulatoryMappingBatchRun({ ...run, status: 'completed', counts, updatedAt: completedAt, completedAt }); await this.store.updateRegulatoryMappingBatchRun({ ...run, status: 'completed', counts, updatedAt: completedAt, completedAt });
} catch (error) { } catch (error) {
const completedAt = this.now().toISOString(); const completedAt = this.now().toISOString();
try { try {
await this.repository.updateRegulatoryMappingBatchRun({ await this.store.updateRegulatoryMappingBatchRun({
...run, ...run,
status: 'failed', status: 'failed',
updatedAt: completedAt, updatedAt: completedAt,
@ -406,8 +565,9 @@ export class RegulatoryMappingService {
private async finding(projectId: string, resultProjectionId: string) { private async finding(projectId: string, resultProjectionId: string) {
if (!resultProjectionId?.trim()) throw new RepositoryValidationError('A resultProjectionId is required.'); if (!resultProjectionId?.trim()) throw new RepositoryValidationError('A resultProjectionId is required.');
const finding = await this.repository.getUnifiedResultById(resultProjectionId); // The Result DB row carries no real scope ids; the repository scopes the lookup to the project.
if (!finding || finding.projectId !== projectId) throw new RepositoryNotFoundError('The selected finding does not exist in this project.'); const finding = await this.read.getUnifiedResultById(resultProjectionId, projectId);
if (!finding) throw new RepositoryNotFoundError('The selected finding does not exist in this project.');
return finding; return finding;
} }
@ -466,7 +626,7 @@ export class RegulatoryMappingService {
const candidates = await this.groupCandidates(retrieval.candidates); const candidates = await this.groupCandidates(retrieval.candidates);
if (!candidates.length) throw new RegulatoryMappingServiceError('regulatory_corpus_empty', 'The active regulatory corpus returned no eligible controls.', 503); if (!candidates.length) throw new RegulatoryMappingServiceError('regulatory_corpus_empty', 'The active regulatory corpus returned no eligible controls.', 503);
const startedAt = this.now().toISOString(); const startedAt = this.now().toISOString();
run = await this.repository.saveRegulatoryMappingRun({ run = await this.store.saveRegulatoryMappingRun({
...queued, ...queued,
status: 'running', status: 'running',
preparedQuery: retrieval.preparedQuery, preparedQuery: retrieval.preparedQuery,
@ -485,7 +645,7 @@ export class RegulatoryMappingService {
const errors = regulatoryMappingResultValidationErrors(grounded, allowed); const errors = regulatoryMappingResultValidationErrors(grounded, allowed);
if (errors.length) throw new RegulatoryMappingServiceError('regulatory_mapping_invalid_output', errors.join(' '), 502); if (errors.length) throw new RegulatoryMappingServiceError('regulatory_mapping_invalid_output', errors.join(' '), 502);
const completedAt = this.now().toISOString(); const completedAt = this.now().toISOString();
await this.repository.saveRegulatoryMappingRun({ await this.store.saveRegulatoryMappingRun({
...run, ...run,
status: 'succeeded', status: 'succeeded',
result: grounded as RegulatoryMappingResult, result: grounded as RegulatoryMappingResult,
@ -493,13 +653,14 @@ export class RegulatoryMappingService {
updatedAt: completedAt, updatedAt: completedAt,
completedAt completedAt
}); });
await this.autoAcceptFirstProposals(run, grounded as RegulatoryMappingResult);
return 'succeeded'; return 'succeeded';
} catch (error) { } catch (error) {
const completedAt = this.now().toISOString(); const completedAt = this.now().toISOString();
const failure = safeFailure(error); const failure = safeFailure(error);
const detail = safeFailureDetail(error); const detail = safeFailureDetail(error);
try { try {
await this.repository.saveRegulatoryMappingRun({ await this.store.saveRegulatoryMappingRun({
...run, status: 'failed', updatedAt: completedAt, completedAt, errorCode: failure, ...run, status: 'failed', updatedAt: completedAt, completedAt, errorCode: failure,
...(detail ? { errorDetail: detail } : {}) ...(detail ? { errorDetail: detail } : {})
}); });
@ -563,6 +724,20 @@ function safeFailureDetail(error: unknown): string | undefined {
return detail || undefined; return detail || undefined;
} }
/**
* One finding as Work counts it: a scanner finding by occurrence, a GPO setting by scope and technical
* setting identity. The Result DB withholds ASSESSOR's internal identity hash, which the engine reads
* separately; only if that is unavailable does the disclosed title, the technical setting identity, stand in.
*/
function findingIdentity(finding: UnifiedResultProjection, settingId?: string): string {
if (finding.sourceKind === 'scanner-finding') return `scanner:${finding.sourceResultId}`;
return `gpo:${finding.policyScope ?? ''}:${settingId ?? (finding.sourceRuleId?.trim() || `title:${finding.title.trim()}`)}`;
}
function isGpoRuleKey(ruleKey: string): boolean {
return ruleKey.startsWith('GPOs:') || ruleKey.startsWith('GPOs#');
}
function sha256(value: string): string { function sha256(value: string): string {
return createHash('sha256').update(value, 'utf8').digest('hex'); return createHash('sha256').update(value, 'utf8').digest('hex');
} }

View File

@ -4,7 +4,7 @@ import type {
RegulatoryRetrievalCandidate, RegulatoryRetrievalCandidate,
RegulatoryRetrievalRequest, RegulatoryRetrievalRequest,
RegulatoryRetriever RegulatoryRetriever
} from '../../src/domain/regulatory-rag'; } from '../../src/compliance/regulatory-rag';
import type { TextEmbeddingClient } from './embedding-client'; import type { TextEmbeddingClient } from './embedding-client';
import { chunkRegulatoryControl, regulatoryChunkEmbeddingText, validateRegulatoryControlDocument } from './regulatory-chunker'; import { chunkRegulatoryControl, regulatoryChunkEmbeddingText, validateRegulatoryControlDocument } from './regulatory-chunker';
import type { RegulatoryChunkIndex } from './qdrant-regulatory-index'; import type { RegulatoryChunkIndex } from './qdrant-regulatory-index';

View File

@ -2,7 +2,7 @@ import type {
RegulatoryRetrievalCandidate, RegulatoryRetrievalCandidate,
RegulatoryRetrievalFilters, RegulatoryRetrievalFilters,
RegulatoryRetriever RegulatoryRetriever
} from '../../src/domain/regulatory-rag'; } from '../../src/compliance/regulatory-rag';
export interface RegulatoryExpectedControl { export interface RegulatoryExpectedControl {
framework: string; framework: string;

View File

@ -9,7 +9,7 @@ import type { BackendArtifactStore } from '../artifacts/backend-artifact-store';
import { GPO_BACKUP_ANALYZER_VERSION } from './gpo-backup-submission-service'; import { GPO_BACKUP_ANALYZER_VERSION } from './gpo-backup-submission-service';
import type { GpoBackupWorkerExecutor } from './gpo-inventory-executor'; import type { GpoBackupWorkerExecutor } from './gpo-inventory-executor';
export const GPO_BACKUP_JOB_PROCESSOR_VERSION = '2.2.5'; export const GPO_BACKUP_JOB_PROCESSOR_VERSION = '2.2.6';
export interface GpoBackupJobProcessorOptions { export interface GpoBackupJobProcessorOptions {
leaseDurationMs?: number; leaseDurationMs?: number;

View File

@ -7,7 +7,7 @@ import type { BackendArtifactStore } from '../artifacts/backend-artifact-store';
import type { GpoInventoryExecutor } from './gpo-inventory-executor'; import type { GpoInventoryExecutor } from './gpo-inventory-executor';
import { gpoSourceAvailability, requireGpoSource } from './gpo-source-availability'; import { gpoSourceAvailability, requireGpoSource } from './gpo-source-availability';
export const GPO_BACKUP_ANALYZER_VERSION = 'gpo-backup-policy-analysis:2.2.5'; export const GPO_BACKUP_ANALYZER_VERSION = 'gpo-backup-policy-analysis:2.2.6';
export interface SubmitGpoBackupRequest { export interface SubmitGpoBackupRequest {
restoreArtifactId?: string; restoreArtifactId?: string;

View File

@ -21,10 +21,7 @@ import { MitreCatalogService } from './reference-data/mitre-catalog-service';
import { FileSystemReportArchive, resolveReportArchiveRoot, type ReportArchiveEnvironment } from './reporting/report-archive'; import { FileSystemReportArchive, resolveReportArchiveRoot, type ReportArchiveEnvironment } from './reporting/report-archive';
import { createPostgresReportReadRuntime, memoryReportReadRuntime, type ReportReadRuntime } from './reporting/report-read-runtime'; import { createPostgresReportReadRuntime, memoryReportReadRuntime, type ReportReadRuntime } from './reporting/report-read-runtime';
import { createLiteLlmClientFromEnvironment } from './ai/litellm-client'; import { createLiteLlmClientFromEnvironment } from './ai/litellm-client';
import { AiOverviewService } from './ai/ai-overview-service'; import { createComplianceEngineRuntime } from './compliance/compliance-engine-runtime';
import { createRegulatoryRagRuntime } from './regulatory/regulatory-rag-runtime';
import { RegulatoryMappingService } from './regulatory/regulatory-mapping-service';
import { RegulatoryCorpusUploadService } from './regulatory/regulatory-corpus-upload-service';
function boundedInteger(value: string | undefined, fallback: number, minimum: number, maximum: number, name: string): number { function boundedInteger(value: string | undefined, fallback: number, minimum: number, maximum: number, name: string): number {
if (value === undefined || value.trim() === '') return fallback; if (value === undefined || value.trim() === '') return fallback;
@ -79,46 +76,18 @@ async function main(): Promise<void> {
console.warn('Report Engine nicht verfügbar:', error instanceof Error ? error.message : 'Unbekannter Fehler'); console.warn('Report Engine nicht verfügbar:', error instanceof Error ? error.message : 'Unbekannter Fehler');
} }
const aiClient = createLiteLlmClientFromEnvironment(); const aiClient = createLiteLlmClientFromEnvironment();
const aiOverviewService = new AiOverviewService(runtime.repository, { // The Compliance Engine runs beside ASSESSOR with its own store and reads the Core only through its read-only port.
client: aiClient, const complianceEngine = await createComplianceEngineRuntime({
maximumChunkCharacters: boundedInteger(process.env.ASSESSOR_AI_CHUNK_CHARS, 80_000, 10_000, 500_000, 'ASSESSOR_AI_CHUNK_CHARS'), repository: runtime.repository,
maximumCalls: boundedInteger(process.env.ASSESSOR_AI_MAX_CALLS, 64, 1, 64, 'ASSESSOR_AI_MAX_CALLS'), persistence: runtime.persistence,
leaseDurationMs: boundedInteger(process.env.ASSESSOR_AI_JOB_LEASE_MS, 5 * 60_000, 10_000, 30 * 60_000, 'ASSESSOR_AI_JOB_LEASE_MS') aiClient,
artifactRoot,
aiOverview: {
maximumChunkCharacters: boundedInteger(process.env.ASSESSOR_AI_CHUNK_CHARS, 80_000, 10_000, 500_000, 'ASSESSOR_AI_CHUNK_CHARS'),
maximumCalls: boundedInteger(process.env.ASSESSOR_AI_MAX_CALLS, 64, 1, 64, 'ASSESSOR_AI_MAX_CALLS'),
leaseDurationMs: boundedInteger(process.env.ASSESSOR_AI_JOB_LEASE_MS, 5 * 60_000, 10_000, 30 * 60_000, 'ASSESSOR_AI_JOB_LEASE_MS')
}
}); });
await aiOverviewService.recoverInterruptedRuns();
let regulatoryMappingService: RegulatoryMappingService;
let rag: ReturnType<typeof createRegulatoryRagRuntime> | undefined;
let regulatoryDisabledReason: string | undefined;
try {
const corpusId = process.env.ASSESSOR_RAG_ACTIVE_CORPUS_ID?.trim();
const corpusVersion = process.env.ASSESSOR_RAG_ACTIVE_CORPUS_VERSION?.trim();
rag = createRegulatoryRagRuntime();
regulatoryMappingService = new RegulatoryMappingService(runtime.repository, {
findingRetrieval: rag.findingRetrieval,
index: rag.index,
embeddingModel: rag.embeddingModel,
client: aiClient,
corpusId,
corpusVersion,
...(!aiClient ? { disabledReason: 'Work LiteLLM is not configured.' } : {})
});
} catch (error) {
regulatoryDisabledReason = error instanceof Error ? error.message : 'Regulatory mapping is unavailable.';
regulatoryMappingService = new RegulatoryMappingService(runtime.repository, {
disabledReason: regulatoryDisabledReason
});
}
await regulatoryMappingService.recoverInterruptedRuns();
const regulatoryCorpusUploadService = new RegulatoryCorpusUploadService({
root: join(artifactRoot, 'regulatory-corpora'),
ingestion: rag?.ingestion,
countIndexed: rag ? (corpusId, corpusVersion) => rag!.index.count({ corpusId, corpusVersion }) : undefined,
pruneIndexed: rag ? (corpusId, corpusVersion) => rag!.index.deleteOtherCorpusVersions(corpusId, corpusVersion) : undefined,
mapping: regulatoryMappingService,
embeddingModel: rag?.embeddingModel,
disabledReason: regulatoryDisabledReason
});
await regulatoryCorpusUploadService.initialize();
const artifactStore = new FileSystemBackendArtifactStore(artifactRoot); const artifactStore = new FileSystemBackendArtifactStore(artifactRoot);
const mitreCatalogService = new MitreCatalogService(join(artifactRoot, 'reference-data', 'mitre-enterprise.json')); const mitreCatalogService = new MitreCatalogService(join(artifactRoot, 'reference-data', 'mitre-enterprise.json'));
const gpoBackupSubmissionService = new GpoBackupSubmissionService( const gpoBackupSubmissionService = new GpoBackupSubmissionService(
@ -179,9 +148,10 @@ async function main(): Promise<void> {
gpoPolicyEvaluationService, gpoPolicyEvaluationService,
gpoPolicyFindingService, gpoPolicyFindingService,
gpoPolicyAnalysisOverviewService, gpoPolicyAnalysisOverviewService,
aiOverviewService, aiOverviewService: complianceEngine.aiOverviewService,
regulatoryMappingService, regulatoryMappingService: complianceEngine.mappingService,
regulatoryCorpusUploadService, regulatoryCorpusUploadService: complianceEngine.corpusUploadService,
complianceExport: complianceEngine.store,
isoUploadToken: process.env.ASSESSOR_ISO_UPLOAD_TOKEN?.trim(), isoUploadToken: process.env.ASSESSOR_ISO_UPLOAD_TOKEN?.trim(),
automaticGpoAnalysis, automaticGpoAnalysis,
importExecutor importExecutor
@ -198,10 +168,11 @@ async function main(): Promise<void> {
if (shuttingDown) return; if (shuttingDown) return;
shuttingDown = true; shuttingDown = true;
console.log(`${signal}: ASSESSOR wird beendet.`); console.log(`${signal}: ASSESSOR wird beendet.`);
void Promise.all([gpoJobRunner.stop(), gpoArtifactReconciliationRunner.stop(), aiOverviewService.close(), regulatoryCorpusUploadService.stop()]).then(() => regulatoryMappingService.close()).then(() => { void Promise.all([gpoJobRunner.stop(), gpoArtifactReconciliationRunner.stop(), complianceEngine.stop()]).then(() => {
server.close(async error => { server.close(async error => {
try { try {
await reportRuntime?.close(); await reportRuntime?.close();
await complianceEngine.close();
await runtime.close(); await runtime.close();
} catch (closeError) { } catch (closeError) {
console.error(closeError); console.error(closeError);

View File

@ -34,10 +34,6 @@ import { POLICY_EVALUATION_ENGINE, storedPolicyEvaluationValidationErrors } from
import type { StoredPolicyFindingRun } from '../../src/domain/policy-finding'; import type { StoredPolicyFindingRun } from '../../src/domain/policy-finding';
import type { EnvironmentObservation, EnvironmentSnapshot, EnvironmentSnapshotQuery, ScannerReportSection } from '../../src/domain/environment-context'; import type { EnvironmentObservation, EnvironmentSnapshot, EnvironmentSnapshotQuery, ScannerReportSection } from '../../src/domain/environment-context';
import { environmentCoverage } from '../../src/domain/environment-context'; import { environmentCoverage } from '../../src/domain/environment-context';
import type { AiOverviewRun } from '../../src/domain/ai-overview';
import { aiOverviewRunValidationErrors } from '../../src/domain/ai-overview';
import type { RegulatoryMappingBatchRun, RegulatoryMappingDecision, RegulatoryMappingRun } from '../../src/domain/regulatory-mapping';
import { regulatoryMappingBatchRunValidationErrors, regulatoryMappingDecisionValidationErrors, regulatoryMappingRunValidationErrors } from '../../src/domain/regulatory-mapping';
import { storedPolicyFindingRunValidationErrors } from '../../src/domain/policy-finding'; import { storedPolicyFindingRunValidationErrors } from '../../src/domain/policy-finding';
import { latestReferenceRuns, type ReferenceRecognitionRun, type ReferenceCatalogSnapshot } from '../../src/domain/reference-recognition'; import { latestReferenceRuns, type ReferenceRecognitionRun, type ReferenceCatalogSnapshot } from '../../src/domain/reference-recognition';
import { validateReferenceRun } from '../../src/persistence/reference-run-validation'; import { validateReferenceRun } from '../../src/persistence/reference-run-validation';
@ -278,7 +274,7 @@ function isRepositoryError(error: unknown): error is Error {
|| error instanceof ImportSessionStateError; || error instanceof ImportSessionStateError;
} }
function translateDatabaseError(error: unknown): Error { export function translateDatabaseError(error: unknown): Error {
if (isRepositoryError(error)) return error; if (isRepositoryError(error)) return error;
const code = typeof error === 'object' && error && 'code' in error ? String(error.code) : ''; const code = typeof error === 'object' && error && 'code' in error ? String(error.code) : '';
if (['23505', '40001', '40P01'].includes(code)) return new RepositoryConflictError('Die Daten wurden parallel geändert oder verletzen eine Eindeutigkeitsregel.'); if (['23505', '40001', '40P01'].includes(code)) return new RepositoryConflictError('Die Daten wurden parallel geändert oder verletzen eine Eindeutigkeitsregel.');
@ -654,219 +650,6 @@ export class PostgresAssessorRepository implements AssessorRepository {
WHERE r.project_id = $1 AND rc.catalog_sha256 = c.sha256) ORDER BY c.sha256`, [projectId])); WHERE r.project_id = $1 AND rc.catalog_sha256 = c.sha256) ORDER BY c.sha256`, [projectId]));
} }
async createAiOverviewRun(run: AiOverviewRun): Promise<AiOverviewRun> {
const errors = aiOverviewRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
return this.mutate(async client => {
const idempotent = dataRow(await client.query<DataRow<AiOverviewRun>>(`
SELECT data FROM assessor_ai_overview_runs
WHERE project_id = $1 AND scope_key = $2 AND idempotency_key = $3
`, [run.projectId, run.scopeKey, run.idempotencyKey]));
if (idempotent) return idempotent;
await client.query(`
INSERT INTO assessor_ai_overview_runs
(id, project_id, scope_key, assessment_ids, scan_run_ids, idempotency_key, run_status,
input_hash, prompt_version, output_schema_version, model, created_at, updated_at, data)
VALUES ($1, $2, $3, $4::text[], $5::text[], $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb)
`, [run.id, run.projectId, run.scopeKey, run.assessmentIds, run.scanRunIds, run.idempotencyKey,
run.status, run.inputHash, run.promptVersion, run.outputSchemaVersion, run.model,
run.createdAt, run.updatedAt, json(run)]);
return clone(run);
}, undefined, result => result.id === run.id);
}
async updateAiOverviewRun(run: AiOverviewRun): Promise<AiOverviewRun> {
const errors = aiOverviewRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
return this.mutate(async client => {
const existing = dataRow(await client.query<DataRow<AiOverviewRun>>(
'SELECT data FROM assessor_ai_overview_runs WHERE id = $1 FOR UPDATE', [run.id]
));
if (!existing) throw new RepositoryNotFoundError(`AI overview run ${run.id} does not exist.`);
if (existing.projectId !== run.projectId || existing.scopeKey !== run.scopeKey || existing.inputHash !== run.inputHash
|| existing.idempotencyKey !== run.idempotencyKey || existing.model !== run.model) {
throw new RepositoryConflictError('Immutable AI overview run identity cannot be changed.');
}
if (['succeeded', 'failed'].includes(existing.status)) throw new RepositoryConflictError('A completed AI overview run is immutable.');
if (existing.status === 'queued' && !['running', 'failed'].includes(run.status)
|| existing.status === 'running' && !['running', 'succeeded', 'failed'].includes(run.status)) {
throw new RepositoryConflictError(`Invalid AI overview transition from ${existing.status} to ${run.status}.`);
}
await client.query(`
UPDATE assessor_ai_overview_runs SET
run_status = $2, updated_at = $3, data = $4::jsonb
WHERE id = $1
`, [run.id, run.status, run.updatedAt, json(run)]);
return clone(run);
});
}
async listAiOverviewRuns(projectId: string, scopeKey?: string): Promise<AiOverviewRun[]> {
return dataRows(await this.pool.query<DataRow<AiOverviewRun>>(`
SELECT data FROM assessor_ai_overview_runs
WHERE project_id = $1 AND ($2::text IS NULL OR scope_key = $2)
ORDER BY created_at DESC, id DESC
`, [projectId, scopeKey ?? null]));
}
async getRegulatoryMappingRun(id: string): Promise<RegulatoryMappingRun | null> {
return dataRow(await this.pool.query<DataRow<RegulatoryMappingRun>>(
'SELECT data FROM assessor_regulatory_mappings WHERE id = $1', [id]
));
}
async claimRegulatoryMappingRun(run: RegulatoryMappingRun, forceRegeneration: boolean): Promise<{ run: RegulatoryMappingRun; claimed: boolean }> {
const errors = regulatoryMappingRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
return this.mutate(async client => {
const inserted = await client.query(`
INSERT INTO assessor_regulatory_mappings
(id, rule_key, source_tool, source_rule_id, run_status, query_hash, corpus_id, corpus_version,
embedding_model, retrieval_version, prompt_version, output_schema_version, model, created_at, updated_at, data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::jsonb)
ON CONFLICT DO NOTHING
RETURNING id
`, [run.id, run.ruleKey, run.sourceTool, run.sourceRuleId ?? null, run.status, run.queryHash, run.corpusId,
run.corpusVersion, run.embeddingModel, run.retrievalVersion, run.promptVersion, run.outputSchemaVersion,
run.model, run.createdAt, run.updatedAt, json(run)]);
if (inserted.rowCount) return { run: clone(run), claimed: true };
const existing = dataRow(await client.query<DataRow<RegulatoryMappingRun>>(
'SELECT data FROM assessor_regulatory_mappings WHERE id = $1 FOR UPDATE', [run.id]
));
if (!existing) throw new RepositoryConflictError('Regulatory mapping identity conflicted with another pipeline tuple.');
if (['queued', 'running'].includes(existing.status) || existing.status === 'succeeded' && !forceRegeneration) {
return { run: existing, claimed: false };
}
await client.query(`
UPDATE assessor_regulatory_mappings SET
run_status = $2, query_hash = $3, created_at = $4, updated_at = $5, data = $6::jsonb
WHERE id = $1
`, [run.id, run.status, run.queryHash, run.createdAt, run.updatedAt, json(run)]);
return { run: clone(run), claimed: true };
});
}
async saveRegulatoryMappingRun(run: RegulatoryMappingRun): Promise<RegulatoryMappingRun> {
const errors = regulatoryMappingRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
return this.mutate(async client => {
await client.query(`
INSERT INTO assessor_regulatory_mappings
(id, rule_key, source_tool, source_rule_id, run_status, query_hash, corpus_id, corpus_version,
embedding_model, retrieval_version, prompt_version, output_schema_version, model, created_at, updated_at, data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::jsonb)
ON CONFLICT (id) DO UPDATE SET
run_status = excluded.run_status, updated_at = excluded.updated_at, data = excluded.data
`, [run.id, run.ruleKey, run.sourceTool, run.sourceRuleId ?? null, run.status, run.queryHash, run.corpusId,
run.corpusVersion, run.embeddingModel, run.retrievalVersion, run.promptVersion, run.outputSchemaVersion,
run.model, run.createdAt, run.updatedAt, json(run)]);
return clone(run);
});
}
async listRegulatoryMappingRuns(): Promise<RegulatoryMappingRun[]> {
return dataRows(await this.pool.query<DataRow<RegulatoryMappingRun>>(
'SELECT data FROM assessor_regulatory_mappings ORDER BY created_at DESC, id DESC'
));
}
async createRegulatoryMappingBatchRun(run: RegulatoryMappingBatchRun): Promise<RegulatoryMappingBatchRun> {
const errors = regulatoryMappingBatchRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
return this.mutate(async client => {
const inserted = await client.query(`
INSERT INTO assessor_regulatory_mapping_batches
(id, project_id, scope_key, filter_hash, idempotency_key, run_status, created_at, updated_at, data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
ON CONFLICT DO NOTHING
RETURNING data
`, [run.id, run.projectId, run.scopeKey, run.filterHash, run.idempotencyKey, run.status,
run.createdAt, run.updatedAt, json(run)]);
if (inserted.rowCount) return clone(run);
const existing = dataRow(await client.query<DataRow<RegulatoryMappingBatchRun>>(`
SELECT data FROM assessor_regulatory_mapping_batches
WHERE project_id = $1 AND (idempotency_key = $2 OR (scope_key = $3 AND run_status IN ('queued', 'running')))
ORDER BY (idempotency_key = $2) DESC, created_at DESC, id DESC
LIMIT 1
`, [run.projectId, run.idempotencyKey, run.scopeKey]));
if (!existing) throw new RepositoryConflictError('Regulatory mapping batch conflicted with another active request.');
return existing;
});
}
async updateRegulatoryMappingBatchRun(run: RegulatoryMappingBatchRun): Promise<RegulatoryMappingBatchRun> {
const errors = regulatoryMappingBatchRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
return this.mutate(async client => {
const existing = dataRow(await client.query<DataRow<RegulatoryMappingBatchRun>>(
'SELECT data FROM assessor_regulatory_mapping_batches WHERE id = $1 FOR UPDATE', [run.id]
));
if (!existing) throw new RepositoryNotFoundError('Regulatory mapping batch does not exist.');
if (existing.projectId !== run.projectId || existing.scopeKey !== run.scopeKey || existing.idempotencyKey !== run.idempotencyKey) {
throw new RepositoryConflictError('Regulatory mapping batch identity is immutable.');
}
if (['completed', 'failed'].includes(existing.status)) throw new RepositoryConflictError('A completed regulatory mapping batch is immutable.');
const allowed = existing.status === 'queued' ? ['running', 'failed'] : ['running', 'completed', 'failed'];
if (!allowed.includes(run.status)) throw new RepositoryConflictError(`Invalid regulatory mapping batch transition from ${existing.status} to ${run.status}.`);
await client.query(`
UPDATE assessor_regulatory_mapping_batches SET run_status = $2, updated_at = $3, data = $4::jsonb
WHERE id = $1
`, [run.id, run.status, run.updatedAt, json(run)]);
return clone(run);
});
}
async listRegulatoryMappingBatchRuns(projectId: string, scopeKey?: string): Promise<RegulatoryMappingBatchRun[]> {
return dataRows(await this.pool.query<DataRow<RegulatoryMappingBatchRun>>(`
SELECT data FROM assessor_regulatory_mapping_batches
WHERE project_id = $1 AND ($2::text IS NULL OR scope_key = $2)
ORDER BY created_at DESC, id DESC
`, [projectId, scopeKey ?? null]));
}
async createRegulatoryMappingDecision(
decision: RegulatoryMappingDecision,
expectedLatestDecisionId?: string
): Promise<RegulatoryMappingDecision> {
const errors = regulatoryMappingDecisionValidationErrors(decision);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
return this.mutate(async client => {
const run = dataRow(await client.query<DataRow<RegulatoryMappingRun>>(
'SELECT data FROM assessor_regulatory_mappings WHERE id = $1 FOR UPDATE', [decision.mappingId]
));
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<DataRow<RegulatoryMappingDecision>>(`
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]));
if ((expectedLatestDecisionId ?? undefined) !== (latest?.id ?? undefined)) {
throw new RepositoryConflictError('Regulatory mapping decision changed before this update.');
}
const stored = { ...decision, ...(latest ? { previousDecisionId: latest.id } : {}) };
await client.query(`
INSERT INTO assessor_regulatory_mapping_decisions
(id, rule_key, source_tool, source_rule_id, mapping_id, control_id, decision, previous_decision_id, created_at, data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
`, [stored.id, stored.ruleKey, stored.sourceTool, stored.sourceRuleId ?? null, stored.mappingId, stored.controlId,
stored.decision, stored.previousDecisionId ?? null, stored.createdAt, json(stored)]);
return clone(stored);
});
}
async listRegulatoryMappingDecisions(ruleKey: string): Promise<RegulatoryMappingDecision[]> {
return dataRows(await this.pool.query<DataRow<RegulatoryMappingDecision>>(`
SELECT data FROM assessor_regulatory_mapping_decisions
WHERE rule_key = $1
ORDER BY created_at DESC, id DESC
`, [ruleKey]));
}
async beginImport(start: ImportSessionStart): Promise<ImportSession> { async beginImport(start: ImportSessionStart): Promise<ImportSession> {
if (!start.sessionId.trim()) throw new RepositoryValidationError('Eine Importsitzung benötigt eine ID.'); if (!start.sessionId.trim()) throw new RepositoryValidationError('Eine Importsitzung benötigt eine ID.');
if (this.activeImportSessions.has(start.sessionId)) throw new RepositoryConflictError(`Importsitzung ${start.sessionId} ist bereits aktiv.`); if (this.activeImportSessions.has(start.sessionId)) throw new RepositoryConflictError(`Importsitzung ${start.sessionId} ist bereits aktiv.`);
@ -1659,10 +1442,13 @@ export class PostgresAssessorRepository implements AssessorRepository {
return deriveUnifiedResultFacets(await this.allUnifiedResults(query), query); return deriveUnifiedResultFacets(await this.allUnifiedResults(query), query);
} }
async getUnifiedResultById(resultProjectionId: string): Promise<UnifiedResultProjection | null> { async getUnifiedResultById(resultProjectionId: string, projectId?: string): Promise<UnifiedResultProjection | null> {
await this.ensureResultDb(); await this.ensureResultDb();
const result = await this.pool.query<{ row_data: UnifiedResultProjection } & QueryResultRow>( const result = projectId === undefined
'SELECT row_data FROM assessor_result_db_rows WHERE result_projection_id = $1', [resultProjectionId]); ? await this.pool.query<{ row_data: UnifiedResultProjection } & QueryResultRow>(
'SELECT row_data FROM assessor_result_db_rows WHERE result_projection_id = $1', [resultProjectionId])
: await this.pool.query<{ row_data: UnifiedResultProjection } & QueryResultRow>(
'SELECT row_data FROM assessor_result_db_rows WHERE result_projection_id = $1 AND project_id = $2', [resultProjectionId, projectId]);
return result.rows[0] ? removeResultDbScopeIdentifiers(result.rows[0].row_data) : null; return result.rows[0] ? removeResultDbScopeIdentifiers(result.rows[0].row_data) : null;
} }
@ -2935,14 +2721,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
environmentProfiles: await this.listEnvironmentProfiles(projectId), environmentProfiles: await this.listEnvironmentProfiles(projectId),
policySnapshotAggregations: await this.listPolicySnapshotAggregations(projectId), policySnapshotAggregations: await this.listPolicySnapshotAggregations(projectId),
policyEvaluations: await this.listPolicyEvaluations(projectId), policyEvaluations: await this.listPolicyEvaluations(projectId),
policyFindingRuns, policyFindingRuns
aiOverviewRuns: (await this.listAiOverviewRuns(projectId)).filter(item => item.status === 'succeeded'),
regulatoryMappingBatchRuns: await this.listRegulatoryMappingBatchRuns(projectId),
// Regulatory mappings and decisions are global reference data, not project-scoped.
regulatoryMappingRuns: (await this.listRegulatoryMappingRuns()).filter(item => item.status === 'succeeded'),
regulatoryMappingDecisions: dataRows(await this.pool.query<DataRow<RegulatoryMappingDecision>>(
'SELECT data FROM assessor_regulatory_mapping_decisions ORDER BY created_at, id'
))
}; };
} }
@ -2966,7 +2745,6 @@ export class PostgresAssessorRepository implements AssessorRepository {
[scanRunId] [scanRunId]
); );
await client.query('DELETE FROM assessor_comparisons WHERE assessment_id = $1', [row.assessment_id]); await client.query('DELETE FROM assessor_comparisons WHERE assessment_id = $1', [row.assessment_id]);
await client.query('DELETE FROM assessor_ai_overview_runs WHERE project_id = $1 AND $2 = ANY(scan_run_ids)', [projectId, scanRunId]);
const deleted = await client.query('DELETE FROM assessor_scan_runs WHERE id = $1 RETURNING id', [scanRunId]); const deleted = await client.query('DELETE FROM assessor_scan_runs WHERE id = $1 RETURNING id', [scanRunId]);
if (!deleted.rowCount) throw new RepositoryConflictError('Scanbericht wurde zwischen Prüfung und Löschung bereits entfernt.'); if (!deleted.rowCount) throw new RepositoryConflictError('Scanbericht wurde zwischen Prüfung und Löschung bereits entfernt.');
await client.query(` await client.query(`
@ -3011,7 +2789,6 @@ export class PostgresAssessorRepository implements AssessorRepository {
await client.query('DELETE FROM assessor_asset_edges WHERE assessment_id = $1', [assessmentId]); await client.query('DELETE FROM assessor_asset_edges WHERE assessment_id = $1', [assessmentId]);
await client.query('DELETE FROM assessor_edge_coverage WHERE assessment_id = $1', [assessmentId]); await client.query('DELETE FROM assessor_edge_coverage WHERE assessment_id = $1', [assessmentId]);
await client.query('DELETE FROM assessor_canonical_assets WHERE assessment_id = $1', [assessmentId]); await client.query('DELETE FROM assessor_canonical_assets WHERE assessment_id = $1', [assessmentId]);
await client.query('DELETE FROM assessor_ai_overview_runs WHERE project_id = $1 AND $2 = ANY(assessment_ids)', [projectId, assessmentId]);
const deleted = await client.query('DELETE FROM assessor_assessments WHERE id = $1 RETURNING id', [assessmentId]); const deleted = await client.query('DELETE FROM assessor_assessments WHERE id = $1 RETURNING id', [assessmentId]);
if (!deleted.rowCount) throw new RepositoryConflictError('The timeslot was removed between validation and deletion.'); if (!deleted.rowCount) throw new RepositoryConflictError('The timeslot was removed between validation and deletion.');
await client.query(` await client.query(`

View File

@ -16,7 +16,7 @@ import type { EnvironmentSnapshot, EnvironmentSnapshotQuery } from '../domain/en
import type { ClientInformationQuery, ClientInformationResult } from '../domain/client-information'; import type { ClientInformationQuery, ClientInformationResult } from '../domain/client-information';
import type { ImportedDataset } from '../domain/dataset'; import type { ImportedDataset } from '../domain/dataset';
import type { MicrosoftSecurityBaselineSelectionContext } from '../domain/microsoft-security-baseline'; import type { MicrosoftSecurityBaselineSelectionContext } from '../domain/microsoft-security-baseline';
import type { RegulatoryCorpusStatus } from '../domain/regulatory-corpus-management'; import type { RegulatoryCorpusStatus } from '../compliance/regulatory-corpus-management';
import type { ImportBatchPreview, ImportBatchSelection } from '../domain/import-workflow'; import type { ImportBatchPreview, ImportBatchSelection } from '../domain/import-workflow';
import type { ResultDbMaterializationStatus } from '../domain/result-db-materialization'; import type { ResultDbMaterializationStatus } from '../domain/result-db-materialization';
import { import {
@ -62,7 +62,8 @@ import {
type RegulatoryMappingBatchScope, type RegulatoryMappingBatchScope,
type RegulatoryMappingCreateRequest, type RegulatoryMappingCreateRequest,
type RegulatoryMappingDecisionCreateRequest, type RegulatoryMappingDecisionCreateRequest,
type RegulatoryMappingResponse type RegulatoryMappingResponse,
type RegulatoryControlOverviewResponse
} from './assessor-api-contract'; } from './assessor-api-contract';
export class AssessorApiError extends Error { export class AssessorApiError extends Error {
@ -259,6 +260,20 @@ export class AssessorApiClient {
})); }));
} }
getRegulatoryControlAssignments(projectId: string, scope: { assessmentIds: string[]; scanRunIds: string[] }): Promise<RegulatoryControlOverviewResponse> {
return this.request(unifiedQueryPath(`/projects/${encodeURIComponent(projectId)}/regulatory-control-assignments`, {
assessmentIds: scope.assessmentIds,
scanRunIds: scope.scanRunIds
}));
}
getRegulatoryControlOverview(projectId: string, scope: { assessmentIds: string[]; scanRunIds: string[] }): Promise<RegulatoryControlOverviewResponse> {
return this.request(unifiedQueryPath(`/projects/${encodeURIComponent(projectId)}/regulatory-control-overview`, {
assessmentIds: scope.assessmentIds,
scanRunIds: scope.scanRunIds
}));
}
startRegulatoryMappingBatch(projectId: string, request: RegulatoryMappingBatchCreateRequest): Promise<RegulatoryMappingBatchResponse> { startRegulatoryMappingBatch(projectId: string, request: RegulatoryMappingBatchCreateRequest): Promise<RegulatoryMappingBatchResponse> {
return this.request(`/projects/${encodeURIComponent(projectId)}/regulatory-mapping-batches`, { return this.request(`/projects/${encodeURIComponent(projectId)}/regulatory-mapping-batches`, {
method: 'POST', body: JSON.stringify(request) method: 'POST', body: JSON.stringify(request)

View File

@ -17,15 +17,16 @@ import type { PolicyEvaluationRuleCatalog } from '../domain/policy-evaluation-ru
import type { PolicyAnalysisOverview } from '../domain/policy-analysis-overview'; import type { PolicyAnalysisOverview } from '../domain/policy-analysis-overview';
import type { MitreGraphData } from '../features/mitre-graph/mitre-graph'; import type { MitreGraphData } from '../features/mitre-graph/mitre-graph';
import type { AssessmentContext } from '../assessment-context/context-engine'; import type { AssessmentContext } from '../assessment-context/context-engine';
export type { AiOverviewCreateRequest, AiOverviewResponse } from '../domain/ai-overview'; export type { AiOverviewCreateRequest, AiOverviewResponse } from '../compliance/ai-overview';
export type { export type {
RegulatoryMappingBatchCreateRequest, RegulatoryMappingBatchCreateRequest,
RegulatoryMappingBatchResponse, RegulatoryMappingBatchResponse,
RegulatoryMappingBatchScope, RegulatoryMappingBatchScope,
RegulatoryMappingCreateRequest, RegulatoryMappingCreateRequest,
RegulatoryMappingDecisionCreateRequest, RegulatoryMappingDecisionCreateRequest,
RegulatoryMappingResponse RegulatoryMappingResponse,
} from '../domain/regulatory-mapping'; RegulatoryControlOverviewResponse
} from '../compliance/regulatory-mapping';
export const ASSESSOR_API_PREFIX = '/api/v1'; export const ASSESSOR_API_PREFIX = '/api/v1';
export const ASSESSOR_MUTATION_HEADER = 'X-Assessor-Request'; export const ASSESSOR_MUTATION_HEADER = 'X-Assessor-Request';

View File

@ -4,6 +4,8 @@ export function requestActivity(path: string, method = 'GET'): { area: string; l
if (route === '/health') return undefined; if (route === '/health') return undefined;
if (route.includes('/assessment-context')) return { area: 'Context Engine', label: 'Loading and calculating assessment context' }; if (route.includes('/assessment-context')) return { area: 'Context Engine', label: 'Loading and calculating assessment context' };
if (route.includes('/report-runs')) return { area: 'Report Engine', label: 'Generating assessment report' }; if (route.includes('/report-runs')) return { area: 'Report Engine', label: 'Generating assessment report' };
const compliance = complianceActivity(route, method);
if (compliance) return { area: 'Compliance Engine', label: compliance };
if (route.includes('/mitre-attack')) return { area: 'Core Engine', label: route.endsWith('/source') ? 'Downloading MITRE source' : route.endsWith('/refresh') ? 'Updating MITRE catalog' : 'Loading MITRE catalog' }; if (route.includes('/mitre-attack')) return { area: 'Core Engine', label: route.endsWith('/source') ? 'Downloading MITRE source' : route.endsWith('/refresh') ? 'Updating MITRE catalog' : 'Loading MITRE catalog' };
const resources: [RegExp, string][] = [ const resources: [RegExp, string][] = [
[/db-structure/, 'database structure'], [/references/, 'reference recognition'], [/db-structure/, 'database structure'], [/references/, 'reference recognition'],
@ -22,3 +24,16 @@ export function requestActivity(path: string, method = 'GET'): { area: string; l
const action = method === 'DELETE' ? 'Deleting' : method === 'GET' ? 'Loading' : 'Processing'; const action = method === 'DELETE' ? 'Deleting' : method === 'GET' ? 'Loading' : 'Processing';
return { area: 'Core Engine', label: `${action} ${noun}` }; return { area: 'Core Engine', label: `${action} ${noun}` };
} }
/** Compliance Engine routes; checked before the generic project patterns so they never read as client work. */
function complianceActivity(route: string, method: string): string | undefined {
const write = method !== 'GET';
if (route.includes('/regulatory-control-assignments')) return 'Loading ISO control filters';
if (route.includes('/regulatory-control-overview')) return 'Loading ISO control overview';
if (route.includes('/regulatory-mapping-batches')) return write ? 'Starting ISO mapping batch' : 'Loading ISO mapping batch status';
if (route.includes('/regulatory-mapping-runs')) return 'Generating ISO control proposals';
if (route.includes('/regulatory-mapping-decisions')) return 'Saving ISO mapping decision';
if (route.includes('/regulatory-mappings/')) return 'Loading ISO mapping';
if (route.endsWith('/reference-data/iso-corpus')) return write ? 'Uploading ISO corpus' : 'Loading ISO corpus status';
return undefined;
}

View File

@ -119,10 +119,11 @@ export function AppProvider({ children }: PropsWithChildren) {
const facetFilters = { ...current.facetFilters }; const facetFilters = { ...current.facetFilters };
if (!engines.context) delete facetFilters['context:cluster']; if (!engines.context) delete facetFilters['context:cluster'];
if (!engines.context || !engines.report) delete facetFilters['report:business-impact']; if (!engines.context || !engines.report) delete facetFilters['report:business-impact'];
if (!engines.compliance) { delete facetFilters['compliance:iso-control']; delete facetFilters['compliance:iso-theme']; }
if (Object.keys(facetFilters).length === Object.keys(current.facetFilters).length) return current; if (Object.keys(facetFilters).length === Object.keys(current.facetFilters).length) return current;
return { ...current, facetFilters }; return { ...current, facetFilters };
}); });
}, [engines.context, engines.report, state.facetFilters]); }, [engines.compliance, engines.context, engines.report, state.facetFilters]);
useEffect(() => { useEffect(() => {
let active = true; let active = true;

View File

@ -4,11 +4,12 @@ import { useSyncExternalStore } from 'react';
export const ENGINES = [ export const ENGINES = [
{ id: 'core', label: 'Core Engine', required: true }, { id: 'core', label: 'Core Engine', required: true },
{ id: 'context', label: 'Context Engine', required: false }, { id: 'context', label: 'Context Engine', required: false },
{ id: 'report', label: 'Report Engine', required: false } { id: 'report', label: 'Report Engine', required: false },
{ id: 'compliance', label: 'Compliance Engine', required: false }
] as const; ] as const;
export type EngineId = typeof ENGINES[number]['id']; export type EngineId = typeof ENGINES[number]['id'];
export const ENGINE_PREFERENCES_KEY = 'ASSESSOR.engines.v1'; export const ENGINE_PREFERENCES_KEY = 'ASSESSOR.engines.v1';
const defaults = Object.freeze({ core: true, context: true, report: true }); const defaults = Object.freeze({ core: true, context: true, report: true, compliance: true });
type Preferences = Readonly<Record<EngineId, boolean>>; type Preferences = Readonly<Record<EngineId, boolean>>;
let snapshot: Preferences | undefined; let snapshot: Preferences | undefined;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
@ -17,7 +18,8 @@ export function readEnginePreferences(): Preferences {
if (snapshot) return snapshot; if (snapshot) return snapshot;
try { try {
const saved = JSON.parse(window.localStorage.getItem(ENGINE_PREFERENCES_KEY) ?? '{}'); const saved = JSON.parse(window.localStorage.getItem(ENGINE_PREFERENCES_KEY) ?? '{}');
snapshot = Object.freeze({ core: true, context: saved?.context !== false, report: saved?.report !== false }); snapshot = Object.freeze({ core: true, context: saved?.context !== false, report: saved?.report !== false,
compliance: saved?.compliance !== false });
} catch { snapshot = defaults; } } catch { snapshot = defaults; }
return snapshot; return snapshot;
} }

View File

@ -1,3 +1,5 @@
import { RepositoryConflictError } from '../persistence/repository-errors';
export const AI_OVERVIEW_PROMPT_VERSION = '1.1.0'; export const AI_OVERVIEW_PROMPT_VERSION = '1.1.0';
export const AI_OVERVIEW_OUTPUT_SCHEMA_VERSION = 1; export const AI_OVERVIEW_OUTPUT_SCHEMA_VERSION = 1;
@ -239,6 +241,19 @@ function containsDirectIdentifier(value: unknown): boolean {
return isObject(value) && Object.values(value).some(containsDirectIdentifier); return isObject(value) && Object.values(value).some(containsDirectIdentifier);
} }
/** A stored run keeps its identity; queued -> running|failed, running -> running|succeeded|failed, completed is final. */
export function assertAiOverviewTransition(existing: AiOverviewRun, run: AiOverviewRun): void {
if (existing.projectId !== run.projectId || existing.scopeKey !== run.scopeKey || existing.inputHash !== run.inputHash
|| existing.idempotencyKey !== run.idempotencyKey || existing.model !== run.model) {
throw new RepositoryConflictError('Immutable AI overview run identity cannot be changed.');
}
if (['succeeded', 'failed'].includes(existing.status)) throw new RepositoryConflictError('A completed AI overview run is immutable.');
if (existing.status === 'queued' && !['running', 'failed'].includes(run.status)
|| existing.status === 'running' && !['running', 'succeeded', 'failed'].includes(run.status)) {
throw new RepositoryConflictError(`Invalid AI overview transition from ${existing.status} to ${run.status}.`);
}
}
export function aiOverviewRunValidationErrors(run: AiOverviewRun): string[] { export function aiOverviewRunValidationErrors(run: AiOverviewRun): string[] {
const errors: string[] = []; const errors: string[] = [];
if (!run.id.trim() || !run.projectId.trim() || !run.scopeKey.trim() || !run.idempotencyKey.trim()) errors.push('AI overview run identity is incomplete.'); if (!run.id.trim() || !run.projectId.trim() || !run.scopeKey.trim() || !run.idempotencyKey.trim()) errors.push('AI overview run identity is incomplete.');

View File

@ -0,0 +1,50 @@
import type { AssessorRepository } from '../persistence/assessor-repository';
import type { RegulatoryMappingBatchRun, RegulatoryMappingDecision, RegulatoryMappingRun } from './regulatory-mapping';
import type { AiOverviewRun } from './ai-overview';
/**
* The only Core reads the Compliance Engine performs (AGENTS.md, "Core Engine and engines"). The engine
* never receives a repository method that writes Core data.
*/
export type ComplianceReadPort = Pick<AssessorRepository,
'getUnifiedResultById' | 'queryUnifiedResults' | 'getProject' | 'listProjects' | 'listAssessments' | 'listProjectScanRuns'>;
export function complianceReadPortFrom(repository: ComplianceReadPort): ComplianceReadPort {
return {
getUnifiedResultById: (resultProjectionId, projectId) => repository.getUnifiedResultById(resultProjectionId, projectId),
queryUnifiedResults: query => repository.queryUnifiedResults(query),
getProject: projectId => repository.getProject(projectId),
listProjects: () => repository.listProjects(),
listAssessments: projectId => repository.listAssessments(projectId),
listProjectScanRuns: projectId => repository.listProjectScanRuns(projectId)
};
}
/** The engine's project data, delivered beside the Core project export. */
export interface ComplianceProjectExport {
regulatoryMappingBatchRuns: RegulatoryMappingBatchRun[];
/** Mappings and decisions are global and keyed by scanner rule, not by project. */
regulatoryMappingRuns: RegulatoryMappingRun[];
regulatoryMappingDecisions: RegulatoryMappingDecision[];
/** Succeeded AI overview runs of the project (table of migration 025, owned by the engine). */
aiOverviewRuns: AiOverviewRun[];
}
/** The engine's own storage: mapping runs, accept/reject decisions, batches and AI overview runs. */
export interface ComplianceStore {
createAiOverviewRun(run: AiOverviewRun): Promise<AiOverviewRun>;
updateAiOverviewRun(run: AiOverviewRun): Promise<AiOverviewRun>;
listAiOverviewRuns(projectId: string, scopeKey?: string): Promise<AiOverviewRun[]>;
/** Removes runs of a project; the engine prunes runs whose timeslot no longer exists in the Core. */
deleteAiOverviewRuns(projectId: string, runIds: string[]): Promise<void>;
getRegulatoryMappingRun(id: string): Promise<RegulatoryMappingRun | null>;
claimRegulatoryMappingRun(run: RegulatoryMappingRun, forceRegeneration: boolean): Promise<{ run: RegulatoryMappingRun; claimed: boolean }>;
saveRegulatoryMappingRun(run: RegulatoryMappingRun): Promise<RegulatoryMappingRun>;
listRegulatoryMappingRuns(): Promise<RegulatoryMappingRun[]>;
createRegulatoryMappingBatchRun(run: RegulatoryMappingBatchRun): Promise<RegulatoryMappingBatchRun>;
updateRegulatoryMappingBatchRun(run: RegulatoryMappingBatchRun): Promise<RegulatoryMappingBatchRun>;
listRegulatoryMappingBatchRuns(projectId: string, scopeKey?: string): Promise<RegulatoryMappingBatchRun[]>;
createRegulatoryMappingDecision(decision: RegulatoryMappingDecision, expectedLatestDecisionId?: string): Promise<RegulatoryMappingDecision>;
listRegulatoryMappingDecisions(ruleKey: string): Promise<RegulatoryMappingDecision[]>;
exportProject(projectId: string): Promise<ComplianceProjectExport>;
}

View File

@ -0,0 +1,152 @@
import { RepositoryConflictError, RepositoryNotFoundError, RepositoryValidationError } from '../persistence/repository-errors';
import type { ComplianceProjectExport, ComplianceStore } from './compliance-store';
import {
regulatoryMappingBatchRunValidationErrors,
regulatoryMappingDecisionValidationErrors,
regulatoryMappingRunValidationErrors,
type RegulatoryMappingBatchRun,
type RegulatoryMappingDecision,
type RegulatoryMappingRun
} from './regulatory-mapping';
import { aiOverviewRunValidationErrors, assertAiOverviewTransition, type AiOverviewRun } from './ai-overview';
function clone<T>(value: T): T {
return structuredClone(value);
}
const newestFirst = (left: { createdAt: string; id: string }, right: { createdAt: string; id: string }) =>
right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id);
/** In-memory engine storage for the memory persistence mode and tests. */
export class LocalComplianceStore implements ComplianceStore {
private runs: RegulatoryMappingRun[] = [];
private batches: RegulatoryMappingBatchRun[] = [];
private decisions: RegulatoryMappingDecision[] = [];
private aiOverviewRuns: AiOverviewRun[] = [];
async getRegulatoryMappingRun(id: string): Promise<RegulatoryMappingRun | null> {
const run = this.runs.find(item => item.id === id);
return run ? clone(run) : null;
}
async claimRegulatoryMappingRun(run: RegulatoryMappingRun, forceRegeneration: boolean): Promise<{ run: RegulatoryMappingRun; claimed: boolean }> {
const errors = regulatoryMappingRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
const existing = this.runs.find(item => item.id === run.id);
if (existing && (['queued', 'running'].includes(existing.status) || existing.status === 'succeeded' && !forceRegeneration)) {
return { run: clone(existing), claimed: false };
}
const stored = await this.saveRegulatoryMappingRun(run);
return { run: stored, claimed: true };
}
async saveRegulatoryMappingRun(run: RegulatoryMappingRun): Promise<RegulatoryMappingRun> {
const errors = regulatoryMappingRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
const index = this.runs.findIndex(item => item.id === run.id);
if (index >= 0) this.runs[index] = clone(run);
else this.runs.push(clone(run));
return clone(run);
}
async listRegulatoryMappingRuns(): Promise<RegulatoryMappingRun[]> {
return this.runs.map(clone).sort(newestFirst);
}
async createRegulatoryMappingBatchRun(run: RegulatoryMappingBatchRun): Promise<RegulatoryMappingBatchRun> {
const errors = regulatoryMappingBatchRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
const idempotent = this.batches.find(item => item.projectId === run.projectId && item.idempotencyKey === run.idempotencyKey);
if (idempotent) return clone(idempotent);
const active = this.batches.find(item => item.projectId === run.projectId && item.scopeKey === run.scopeKey
&& ['queued', 'running'].includes(item.status));
if (active) return clone(active);
this.batches.push(clone(run));
return clone(run);
}
async updateRegulatoryMappingBatchRun(run: RegulatoryMappingBatchRun): Promise<RegulatoryMappingBatchRun> {
const errors = regulatoryMappingBatchRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
const index = this.batches.findIndex(item => item.id === run.id);
const existing = this.batches[index];
if (!existing) throw new RepositoryNotFoundError('Regulatory mapping batch does not exist.');
if (existing.projectId !== run.projectId || existing.scopeKey !== run.scopeKey || existing.idempotencyKey !== run.idempotencyKey) {
throw new RepositoryConflictError('Regulatory mapping batch identity is immutable.');
}
if (['completed', 'failed'].includes(existing.status)) throw new RepositoryConflictError('A completed regulatory mapping batch is immutable.');
const allowed = existing.status === 'queued' ? ['running', 'failed'] : ['running', 'completed', 'failed'];
if (!allowed.includes(run.status)) throw new RepositoryConflictError(`Invalid regulatory mapping batch transition from ${existing.status} to ${run.status}.`);
this.batches[index] = clone(run);
return clone(run);
}
async listRegulatoryMappingBatchRuns(projectId: string, scopeKey?: string): Promise<RegulatoryMappingBatchRun[]> {
return this.batches.filter(item => item.projectId === projectId && (!scopeKey || item.scopeKey === scopeKey))
.map(clone).reverse();
}
async createRegulatoryMappingDecision(decision: RegulatoryMappingDecision, expectedLatestDecisionId?: string): Promise<RegulatoryMappingDecision> {
const errors = regulatoryMappingDecisionValidationErrors(decision);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
const run = this.runs.find(item => item.id === decision.mappingId);
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];
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);
return clone(stored);
}
async listRegulatoryMappingDecisions(ruleKey: string): Promise<RegulatoryMappingDecision[]> {
return this.decisions.filter(item => item.ruleKey === ruleKey).map(clone).sort(newestFirst);
}
async createAiOverviewRun(run: AiOverviewRun): Promise<AiOverviewRun> {
const errors = aiOverviewRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
const idempotent = this.aiOverviewRuns.find(item =>
item.projectId === run.projectId && item.scopeKey === run.scopeKey && item.idempotencyKey === run.idempotencyKey);
if (idempotent) return clone(idempotent);
if (this.aiOverviewRuns.some(item => item.projectId === run.projectId && item.scopeKey === run.scopeKey
&& ['queued', 'running'].includes(item.status))) {
throw new RepositoryConflictError('For this timeslot, an AI overview is already being generated.');
}
if (this.aiOverviewRuns.some(item => item.id === run.id)) throw new RepositoryConflictError(`AI overview run ${run.id} already exists.`);
this.aiOverviewRuns.push(clone(run));
return clone(run);
}
async updateAiOverviewRun(run: AiOverviewRun): Promise<AiOverviewRun> {
const errors = aiOverviewRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
const index = this.aiOverviewRuns.findIndex(item => item.id === run.id);
if (index < 0) throw new RepositoryNotFoundError(`AI overview run ${run.id} does not exist.`);
assertAiOverviewTransition(this.aiOverviewRuns[index], run);
this.aiOverviewRuns[index] = clone(run);
return clone(run);
}
async listAiOverviewRuns(projectId: string, scopeKey?: string): Promise<AiOverviewRun[]> {
return this.aiOverviewRuns.filter(item => item.projectId === projectId && (!scopeKey || item.scopeKey === scopeKey))
.map(clone).sort(newestFirst);
}
async deleteAiOverviewRuns(projectId: string, runIds: string[]): Promise<void> {
const ids = new Set(runIds);
this.aiOverviewRuns = this.aiOverviewRuns.filter(item => item.projectId !== projectId || !ids.has(item.id));
}
async exportProject(projectId: string): Promise<ComplianceProjectExport> {
return {
regulatoryMappingBatchRuns: this.batches.filter(item => item.projectId === projectId).map(clone),
regulatoryMappingRuns: this.runs.filter(item => item.status === 'succeeded').map(clone),
regulatoryMappingDecisions: this.decisions.map(clone),
aiOverviewRuns: this.aiOverviewRuns.filter(item => item.projectId === projectId && item.status === 'succeeded')
.map(clone).sort(newestFirst)
};
}
}

View File

@ -2,6 +2,9 @@ import type { UnifiedResultProjection } from '../domain/unified-result';
import { POLICY_CLASSIFICATION_LABELS } from '../domain/policy-status-labels'; import { POLICY_CLASSIFICATION_LABELS } from '../domain/policy-status-labels';
import { discloseResultDbProjection } from '../domain/result-db-disclosure'; import { discloseResultDbProjection } from '../domain/result-db-disclosure';
import { privacySafeFinding, privacySafeText } from '../domain/privacy-safe-finding'; import { privacySafeFinding, privacySafeText } from '../domain/privacy-safe-finding';
import { canonicalJson } from '../shared/canonical-json';
import type { RegulatoryFindingQuery } from './regulatory-rag';
import { buildRegulatoryFindingQuery } from './regulatory-query';
const SAFE_ENVIRONMENT_PROPERTIES = new Set([ const SAFE_ENVIRONMENT_PROPERTIES = new Set([
'domain.controller_count', 'domain.controller_count',
@ -18,6 +21,46 @@ export function privacySafeResultJsonFileName(generatedAt: string): string {
return `assessor-result-db-privacy-safe-${timestamp}.json`; return `assessor-result-db-privacy-safe-${timestamp}.json`;
} }
function stableResultKey(item: UnifiedResultProjection): string {
return [item.sourceKind, item.sourceTool ?? '', item.sourceRuleId ?? '', item.resultProjectionId].join('\u0000');
}
/** The exact JSON the AI overview sends to the model, in its deterministic order. */
export function aiOverviewInputJson(items: UnifiedResultProjection[]): string {
return resultsToPrivacySafeExportJson(items.slice().sort((left, right) => stableResultKey(left).localeCompare(stableResultKey(right))));
}
export interface ComplianceAiExport {
schemaVersion: 2;
/** Exactly what the AI overview receives for these rows (it splits the same object into chunks when it is large). */
aiOverviewInput: unknown;
/** Each distinct finding query the ISO mapping model receives, with the Result DB rows that produce it. */
isoMappingQueries: Array<{ query: RegulatoryFindingQuery; resultProjectionIds: string[] }>;
}
/**
* The Result DB `Export JSON` of the Compliance Engine: a 1:1 copy of what its AI features receive,
* built by the same functions they use. The ISO model additionally receives the retrieved candidate
* controls, which depend on the index and are not part of this offline export.
*/
export function complianceAiExportJson(items: UnifiedResultProjection[]): string {
const queries = new Map<string, ComplianceAiExport['isoMappingQueries'][number]>();
for (const item of items.slice().sort((left, right) => left.resultProjectionId.localeCompare(right.resultProjectionId))) {
const query = buildRegulatoryFindingQuery(item);
if (!query) continue;
const key = canonicalJson(query);
const entry = queries.get(key) ?? { query, resultProjectionIds: [] };
entry.resultProjectionIds.push(item.resultProjectionId);
queries.set(key, entry);
}
const value: ComplianceAiExport = {
schemaVersion: 2,
aiOverviewInput: JSON.parse(aiOverviewInputJson(items)) as unknown,
isoMappingQueries: [...queries.values()]
};
return JSON.stringify(value, null, 2);
}
export function resultsToPrivacySafeExportJson(items: UnifiedResultProjection[]): string { export function resultsToPrivacySafeExportJson(items: UnifiedResultProjection[]): string {
// A raw GPO observation is context for its rolled-up comparison, not a finding of its own. // A raw GPO observation is context for its rolled-up comparison, not a finding of its own.
const findings = items.filter(item => item.sourceKind !== 'environment-observation' && item.sourceKind !== 'policy-observation'); const findings = items.filter(item => item.sourceKind !== 'environment-observation' && item.sourceKind !== 'policy-observation');

View File

@ -1,10 +1,13 @@
import type { AiOverviewTokenUsage } from './ai-overview'; import type { AiOverviewTokenUsage } from './ai-overview';
import type { RegulatoryFindingQuery, RegulatorySectionKind } from './regulatory-rag'; import type { RegulatoryFindingQuery, RegulatorySectionKind } from './regulatory-rag';
import type { UnifiedResultQuery } from './unified-result'; import type { ResultDbFilters } from '../domain/result-db-query';
import type { UnifiedResultProjection } from '../domain/unified-result';
export const REGULATORY_MAPPING_PROMPT_VERSION = '1.2.0'; export const REGULATORY_MAPPING_PROMPT_VERSION = '1.2.0';
export const REGULATORY_MAPPING_OUTPUT_SCHEMA_VERSION = 1 as const; export const REGULATORY_MAPPING_OUTPUT_SCHEMA_VERSION = 1 as const;
export const REGULATORY_RETRIEVAL_VERSION = 'dense-control-grouping-1.1.0'; export const REGULATORY_RETRIEVAL_VERSION = 'dense-control-grouping-1.1.0';
/** Version of the GPO comparison context in the query; only GPO rules are regenerated when it changes. */
export const REGULATORY_GPO_QUERY_VERSION = 'gpo-baseline-context-1.1.0';
export type RegulatoryMappingRunStatus = 'queued' | 'running' | 'succeeded' | 'failed'; export type RegulatoryMappingRunStatus = 'queued' | 'running' | 'succeeded' | 'failed';
export type RegulatoryMappingRelationship = 'primary' | 'supporting'; export type RegulatoryMappingRelationship = 'primary' | 'supporting';
@ -12,8 +15,7 @@ export type RegulatoryMappingConfidence = 'low' | 'medium' | 'high';
export type RegulatoryMappingDecisionValue = 'accepted' | 'rejected'; export type RegulatoryMappingDecisionValue = 'accepted' | 'rejected';
export type RegulatoryMappingBatchStatus = 'queued' | 'running' | 'completed' | 'failed'; export type RegulatoryMappingBatchStatus = 'queued' | 'running' | 'completed' | 'failed';
export type RegulatoryMappingBatchFilters = Omit<UnifiedResultQuery, export type RegulatoryMappingBatchFilters = ResultDbFilters;
'audience' | 'projectId' | 'assessmentId' | 'assessmentIds' | 'scanRunId' | 'scanRunIds' | 'offset' | 'limit'>;
export interface RegulatoryMappingBatchScope { export interface RegulatoryMappingBatchScope {
assessmentIds: string[]; assessmentIds: string[];
@ -151,6 +153,52 @@ export interface RegulatoryMappingDecision {
createdAt: string; createdAt: string;
} }
/**
* Control ids whose latest decision is 'accepted'. Each stored decision names the one it replaced
* (`previousDecisionId`), so the latest is the one no other decision replaced; time and id only break
* a tie between unchained entries.
*/
export function acceptedControlIds(decisions: readonly RegulatoryMappingDecision[]): string[] {
const replaced = new Set(decisions.flatMap(item => item.previousDecisionId ? [item.previousDecisionId] : []));
const latest = new Map<string, RegulatoryMappingDecision>();
for (const decision of decisions) {
if (replaced.has(decision.id)) continue;
const current = latest.get(decision.controlId);
if (!current || decision.createdAt > current.createdAt || decision.createdAt === current.createdAt && decision.id > current.id) {
latest.set(decision.controlId, decision);
}
}
return [...latest.values()].filter(item => item.decision === 'accepted').map(item => item.controlId);
}
export type RegulatoryControlTheme = 'Organizational' | 'People' | 'Physical' | 'Technological';
/** ISO/IEC 27002:2022 groups its controls by clause: 5 Organizational, 6 People, 7 Physical, 8 Technological. */
export function regulatoryControlTheme(controlId: string): RegulatoryControlTheme | undefined {
return ({ 5: 'Organizational', 6: 'People', 7: 'Physical', 8: 'Technological' } as const)[Number(controlId.split('.')[0]) as 5 | 6 | 7 | 8];
}
/** A Result DB row named precisely enough to open its Work record. */
export type RegulatoryControlFinding = Pick<UnifiedResultProjection, 'resultProjectionId' | 'sourceKind' | 'sourceResultId' | 'sourceRuleId' | 'policyScope'>;
export interface RegulatoryControlOverviewItem {
controlId: string;
annexAControlId: string;
title?: string;
theme?: RegulatoryControlTheme;
findings: RegulatoryControlFinding[];
}
/** Accepted controls of the deviations in one client timeslot; decisions stay global per scanner rule. */
export interface RegulatoryControlOverviewResponse {
version: 1;
projectId: string;
scope: { assessmentIds: string[]; scanRunIds: string[] };
/** Distinct findings that carry at least one accepted control. */
findingCount: number;
controls: RegulatoryControlOverviewItem[];
}
export interface RegulatoryMappingResponse { export interface RegulatoryMappingResponse {
version: 1; version: 1;
projectId: string; projectId: string;
@ -161,6 +209,17 @@ export interface RegulatoryMappingResponse {
pollAfterMs?: number; pollAfterMs?: number;
run?: RegulatoryMappingRun; run?: RegulatoryMappingRun;
decisions: RegulatoryMappingDecision[]; decisions: RegulatoryMappingDecision[];
/**
* Controls decided for this rule that the current run did not retrieve (for example after a query
* version change), so an earlier accept stays reviewable. Titles come from older runs of the rule.
*/
earlierControls?: RegulatoryEarlierControl[];
}
export interface RegulatoryEarlierControl {
controlId: string;
annexAControlId: string;
title?: string;
} }
export interface RegulatoryMappingCreateRequest { export interface RegulatoryMappingCreateRequest {

View File

@ -0,0 +1,166 @@
import type { RegulatoryFindingQuery } from './regulatory-rag';
import { REGULATORY_FINDING_QUERY_SCHEMA_VERSION } from './regulatory-rag';
import type { UnifiedResultProjection } from '../domain/unified-result';
import { privacySafeFinding, privacySafeText } from '../domain/privacy-safe-finding';
import { discloseResultDbProjection } from '../domain/result-db-disclosure';
import { annotateWindowsCodes } from '../shared/windows-codes';
import catalog from '../profiles/public-search/catalog.json';
import { publicSecurityTopics } from '../profiles/public-search/public-security-topics';
const MAXIMUM_TITLE_CHARACTERS = 500;
const MAXIMUM_DETAIL_CHARACTERS = 1_200;
const MAXIMUM_RECOMMENDATION_CHARACTERS = 600;
const MAXIMUM_TECHNICAL_CHARACTERS = 1_200;
const publicTools: Record<string, string> = {
'cis-cat': 'CIS-CAT / CIS Benchmarks',
gpos: 'Microsoft Group Policy',
maester: 'Maester',
pingcastle: 'PingCastle',
'purple-knight': 'Semperis Purple Knight'
};
const publicRuleIds: Record<string, readonly string[]> = {
maester: catalog.maesterIds,
pingcastle: catalog.pingcastleIds
};
/**
* Builds an embedding query from the finding's rule-level check text — its title,
* description and recommendation projected through the shared privacy boundary
* ({@link privacySafeFinding}). That text describes the check, not the tenant: the
* result-table / occurrence data (affectedAsset, per-object values) is never read,
* and email addresses are redacted. Every scanner finding or GPO baseline comparison
* with a check title maps; the configured GPO customer value is never part of that text;
* catalog rule IDs and fixed public topics only sharpen the query when present.
*/
export function buildRegulatoryFindingQuery(finding: UnifiedResultProjection): RegulatoryFindingQuery | undefined {
if (!isRegulatoryMappableResult(finding)) return undefined;
// A GPO comparison crosses only through the Result DB disclosure policy, which withholds the customer value.
const disclosed = finding.sourceKind === 'policy-evaluation' ? discloseResultDbProjection(finding) : finding;
const safe = privacySafeFinding(disclosed);
const title = safe.title.trim().slice(0, MAXIMUM_TITLE_CHARACTERS);
if (!title) return undefined;
const toolId = finding.sourceTool?.trim().toLowerCase() ?? '';
const publicTool = publicTools[toolId];
const publicRuleId = recognizedRuleId(toolId, finding.sourceRuleId);
const detail = safe.description?.slice(0, MAXIMUM_DETAIL_CHARACTERS);
// A scanner's technical explanation names the mechanism behind the check and sharpens retrieval.
const technical = safe.technicalExplanation?.slice(0, MAXIMUM_TECHNICAL_CHARACTERS);
const recommendation = safe.recommendation?.slice(0, MAXIMUM_RECOMMENDATION_CHARACTERS);
const policy = finding.sourceKind === 'policy-evaluation' ? policyContext(disclosed, title) : [];
const topics = publicSecurityTopics([title, finding.sourceKind === 'policy-evaluation' && readableName(title), detail, technical, recommendation].filter(Boolean).join('\n'));
const query = [
'Retrieve regulatory controls whose normative requirements directly address this security finding.',
publicTool && `Public assessment tool: ${publicTool}.`,
publicRuleId && `Public rule ID: ${publicRuleId}.`,
`Security check: ${title}.`,
...policy,
detail && `Check detail: ${detail}.`,
technical && `Technical explanation: ${technical}.`,
recommendation && `Recommended action: ${recommendation}.`,
topics.length && `Public technical topics: ${topics.join('; ')}.`,
'Prefer controls with direct requirement text over merely related guidance. Do not infer customer scope, assets, configuration values, or compliance status.'
].filter(Boolean).join(' ');
return {
schemaVersion: REGULATORY_FINDING_QUERY_SCHEMA_VERSION,
query,
specificity: publicRuleId ? 'rule' : 'topic',
...(publicTool ? { publicTool } : {}),
...(publicRuleId ? { publicRuleId } : {}),
publicTopics: topics
};
}
const MAXIMUM_POLICY_VALUE_CHARACTERS = 300;
const MAXIMUM_POLICY_SET_ENTRIES = 12;
const deliveryMechanisms: Record<string, string> = {
'registry-policy': 'administrative template (Registry.pol)',
'security-template': 'security template (GptTmpl.inf)',
'advanced-audit-policy': 'advanced audit policy (audit.csv)',
'group-policy-preference': 'Group Policy preference'
};
/** `MinimumPasswordLength` -> `Minimum Password Length`, so the setting reads as words and topics match. */
function readableName(title: string): string | undefined {
const separator = title.lastIndexOf(' · ');
const name = (separator < 0 ? title : title.slice(separator + 3)).trim();
const words = name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
return words !== name ? words : undefined;
}
/**
* `*S-1-5-32-544` -> `*S-1-5-32-544 (Administrators)`: the documented public meaning of a standard
* Windows code, from the same list the Finding Reader shows; unknown codes stay plain.
*/
function withCodeMeanings(text: string): string {
return annotateWindowsCodes(text).map(segment => segment.meaning ? `${segment.text} (${segment.meaning})` : segment.text).join('');
}
/** A disclosed baseline value as text; customer values never reach this function. */
function baselineValueText(value: unknown): string | undefined {
if (!value || typeof value !== 'object') return undefined;
const item = value as Record<string, unknown>;
let text: string | undefined;
if ((item.kind === 'integer' || item.kind === 'text') && typeof item.value === 'string') text = item.value;
else if (item.kind === 'string-set' && Array.isArray(item.values)) {
const values = item.values.filter((entry): entry is string => typeof entry === 'string');
text = values.length ? values.slice(0, MAXIMUM_POLICY_SET_ENTRIES).join(', ')
+ (values.length > MAXIMUM_POLICY_SET_ENTRIES ? ', …' : '') : 'no entries';
} else if (item.kind === 'registry-state' && typeof item.state === 'string') {
text = { absent: 'value absent', 'key-empty': 'key present without values', 'key-present': 'key present' }[item.state];
} else if (item.kind === 'audit-flags') {
text = [item.success === true && 'Success', item.failure === true && 'Failure'].filter(Boolean).join(' and ') || 'No auditing';
} else if (item.kind === 'none') text = 'not configured';
return text ? withCodeMeanings(privacySafeText(text)).slice(0, MAXIMUM_POLICY_VALUE_CHARACTERS) : undefined;
}
/**
* Public context of a GPO baseline comparison, read only from fields the Result DB discloses: the
* delivery mechanism and section, the baseline family and recommended value, and what the comparison
* proves. The customer's configured value is never read. A direction (weaker/stronger) is stated only
* where every baseline variant's comparator recorded it; an exact comparison proves only a difference.
*/
function policyContext(item: UnifiedResultProjection, title: string): string[] {
const separator = title.lastIndexOf(' · ');
const section = separator > 0 ? title.slice(0, separator).trim() : undefined;
const mechanism = item.policyDeliveryMechanism ? deliveryMechanisms[item.policyDeliveryMechanism] : undefined;
const scope = item.policyScope === 'computer' ? 'Computer Configuration' : item.policyScope === 'user' ? 'User Configuration' : undefined;
// `SeBackupPrivilege` adds its documented name `Back up files and directories`.
const settingName = (separator < 0 ? title : title.slice(separator + 3)).trim();
const meaning = annotateWindowsCodes(settingName).find(segment => segment.meaning)?.meaning;
const name = [readableName(title) ?? (meaning && settingName), meaning && `(${meaning})`].filter(Boolean).join(' ') || undefined;
const family = item.policyBaselineLabel?.trim();
const expected = baselineValueText(item.policyBaselineValue);
const relations = new Set((item.policyBaselineVariantAssessment?.variants ?? []).map(variant => variant.comparator.relation));
const relation = relations.size === 1 ? [...relations][0] : item.policyComparisonRelation;
const state = item.policyCustomerState === 'missing'
? 'The recommended setting is not configured in the analysed Group Policy backups.'
: item.policyCustomerState === 'conflicted'
? 'Group Policy backups configure conflicting values for this setting.'
: item.policyCustomerState === 'configured' && item.isDeviation
? relation === 'weaker' ? 'The configured value is weaker than the baseline recommendation.'
: relation === 'stronger' ? 'The configured value is stronger than the baseline recommendation but does not match it.'
: 'The configured value differs from the baseline recommendation.'
: undefined;
return [
(mechanism || scope) && `Group Policy setting type: ${[scope, mechanism].filter(Boolean).join(', ')}.`,
section && `${item.policyDeliveryMechanism === 'registry-policy' ? 'Registry key' : 'Policy section'}: ${privacySafeText(section)}.`,
name && `Setting name: ${name}.`,
family && `Baseline: ${privacySafeText(family)}.`,
expected && `Baseline recommended value: ${expected}.`,
state
].filter((value): value is string => Boolean(value));
}
function recognizedRuleId(toolId: string, value: string | undefined): string | undefined {
const candidate = value?.trim();
if (!candidate) return undefined;
return publicRuleIds[toolId]?.find(id => id.toLowerCase() === candidate.toLowerCase());
}
/** Rule-level results only: raw GPO observations and environment facts describe context, not a check. */
export function isRegulatoryMappableResult(finding: UnifiedResultProjection): boolean {
return finding.sourceKind === 'scanner-finding' || finding.sourceKind === 'policy-evaluation';
}

View File

@ -1,11 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client'; import { AssessorApiClient } from '../../api/assessor-api-client';
import type { AiOverviewResponse, AiOverviewResult } from '../../domain/ai-overview'; import { useEnginePreferences } from '../../app/engine-preferences';
import type { AiOverviewResponse, AiOverviewResult } from '../ai-overview';
import './ai-overview.css';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
const assessorApi = new AssessorApiClient(); const assessorApi = new AssessorApiClient();
export interface AiOverviewController { export interface AiOverviewController {
/** False while the Compliance Engine is switched off: no request, no tab. */
available: boolean;
response?: AiOverviewResponse; response?: AiOverviewResponse;
loading: boolean; loading: boolean;
generating: boolean; generating: boolean;
@ -13,7 +17,9 @@ export interface AiOverviewController {
generate(): Promise<void>; generate(): Promise<void>;
} }
export function useAiOverview(projectId: string | undefined, assessmentIds: string[] = [], scanRunIds: string[] = []): AiOverviewController { export function useAiOverview(requestedProjectId: string | undefined, assessmentIds: string[] = [], scanRunIds: string[] = []): AiOverviewController {
const available = useEnginePreferences().compliance;
const projectId = available ? requestedProjectId : undefined;
const [storedResponse, setStoredResponse] = useState<{ scope: { key: string }; value: AiOverviewResponse }>(); const [storedResponse, setStoredResponse] = useState<{ scope: { key: string }; value: AiOverviewResponse }>();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
@ -83,7 +89,7 @@ export function useAiOverview(projectId: string | undefined, assessmentIds: stri
} }
}, [normalizedAssessmentIds, normalizedScanRunIds, projectId, requestScope]); }, [normalizedAssessmentIds, normalizedScanRunIds, projectId, requestScope]);
return { response, loading, generating, error, generate }; return { available, response, loading, generating, error, generate };
} }
export function AiOverviewPanel({ controller, navigation }: { controller: AiOverviewController; navigation?: ReactNode }) { export function AiOverviewPanel({ controller, navigation }: { controller: AiOverviewController; navigation?: ReactNode }) {

View File

@ -0,0 +1,61 @@
import { useEffect, useId, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import type { BackendStatus } from '../../app/app-state';
import { useEnginePreferences } from '../../app/engine-preferences';
import type { RegulatoryControlOverviewResponse } from '../regulatory-mapping';
import { useRegulatoryDecisionRevision } from './decision-revision';
import './compliance.css';
const assessorApi = new AssessorApiClient();
interface Props {
projectId?: string;
assessmentIds?: readonly string[];
scanRunIds?: readonly string[];
backendStatus: BackendStatus;
/** Opens Work filtered to this accepted control, like a Context heatmap cell. */
onOpenControl(controlId: string): void;
}
type OverviewState = { status: 'idle' | 'loading' } | { status: 'error' } | { status: 'ready'; overview: RegulatoryControlOverviewResponse };
/** Client Info `ISO controls`: accepted controls of the timeslot's deviations, laid out like the Context heatmap. */
export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl }: Props) {
const available = useEnginePreferences().compliance;
const revision = useRegulatoryDecisionRevision();
const id = useId();
const [state, setState] = useState<OverviewState>({ status: 'idle' });
const scopeKey = JSON.stringify([projectId, assessmentIds, scanRunIds]);
const enabled = available && backendStatus === 'connected' && Boolean(projectId) && (assessmentIds.length > 0 || scanRunIds.length > 0);
useEffect(() => {
if (!enabled || !projectId) { setState({ status: 'idle' }); return; }
let active = true;
setState({ status: 'loading' });
assessorApi.getRegulatoryControlOverview(projectId, { assessmentIds: [...assessmentIds], scanRunIds: [...scanRunIds] })
.then(overview => { if (active) setState({ status: 'ready', overview }); })
.catch(() => { if (active) setState({ status: 'error' }); });
return () => { active = false; };
}, [enabled, revision, scopeKey]); // eslint-disable-line react-hooks/exhaustive-deps
if (!available || state.status === 'idle') return null;
return <section className="iso-controls" aria-labelledby={`${id}-title`} aria-busy={state.status === 'loading'}>
<header className="iso-controls__header">
<h2 id={`${id}-title`}>ISO controls</h2>
{state.status === 'ready' && <span className="iso-controls__total" aria-label="Unique findings with an accepted ISO control">
Findings: {state.overview.findingCount.toLocaleString('en-US')}</span>}
</header>
{state.status === 'error' && <p role="alert">ISO controls could not be loaded.</p>}
{state.status === 'ready' && !state.overview.controls.length && <p className="iso-controls__empty">No accepted ISO controls in this timeslot.</p>}
{state.status === 'ready' && state.overview.controls.length > 0 && <div className="iso-controls__grid" aria-label="Accepted ISO controls">
{state.overview.controls.map(control => <button key={control.controlId} type="button" className="iso-controls__cell"
data-control={control.controlId}
aria-label={`Open ${control.annexAControlId} findings in Work (${control.findings.length})`}
onClick={() => onOpenControl(control.controlId)}>
{control.theme && <span className="iso-controls__category">{control.theme}</span>}
<strong>{control.annexAControlId}{control.title ? ` · ${control.title}` : ''}</strong>
<span className="iso-controls__amount">{control.findings.length.toLocaleString('en-US')} {control.findings.length === 1 ? 'finding' : 'findings'}</span>
</button>)}
</div>}
</section>;
}

View File

@ -0,0 +1,13 @@
import type { AcceptedRegulatoryControl } from './use-regulatory-mapping';
import './compliance.css';
/** Finding Reader section of the controls accepted for this finding, read like `Description`. */
export function FindingIsoControls({ controls }: { controls: AcceptedRegulatoryControl[] }) {
if (!controls.length) return null;
return <div className="finding-iso-controls" data-iso-controls>
<h4>ISO controls</h4>
<ul>{controls.map(control => <li key={control.controlId}>
<strong>{control.annexAControlId}</strong>{control.title && <span>{control.title}</span>}
</li>)}</ul>
</div>;
}

View File

@ -1,26 +1,36 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react'; import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client'; import { AssessorApiClient } from '../../api/assessor-api-client';
import type { RegulatoryCorpusStatus } from '../../domain/regulatory-corpus-management'; import { useEnginePreferences } from '../../app/engine-preferences';
import { useBackendWorkActivity } from './use-backend-work-activity';
import { statusPollApi } from './status-poll-client';
import type { RegulatoryCorpusStatus } from '../regulatory-corpus-management';
import { TriangleIcon } from '../../components/TriangleIcon'; import { TriangleIcon } from '../../components/TriangleIcon';
const api = new AssessorApiClient(); const api = new AssessorApiClient();
const MAXIMUM_PDF_BYTES = 32 * 1024 * 1024; const MAXIMUM_PDF_BYTES = 32 * 1024 * 1024;
/** Data Hub ISO corpus tile; a switched-off Compliance Engine renders nothing and requests nothing. */
export function IsoCorpusLibrary() { export function IsoCorpusLibrary() {
return useEnginePreferences().compliance ? <IsoCorpusLibraryPanel/> : null;
}
function IsoCorpusLibraryPanel() {
const [status, setStatus] = useState<RegulatoryCorpusStatus | null>(null); const [status, setStatus] = useState<RegulatoryCorpusStatus | null>(null);
const [iso27001, setIso27001] = useState<File | null>(null); const [iso27001, setIso27001] = useState<File | null>(null);
const [iso27002, setIso27002] = useState<File | null>(null); const [iso27002, setIso27002] = useState<File | null>(null);
const [token, setToken] = useState(''); const [token, setToken] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => { const load = useCallback(async (poll = false) => {
try { setStatus(await api.getIsoCorpusStatus()); setError(null); } try { setStatus(await (poll ? statusPollApi : api).getIsoCorpusStatus()); setError(null); }
catch (reason) { setError(reason instanceof Error ? reason.message : 'ISO corpus status could not be loaded.'); } catch (reason) { setError(reason instanceof Error ? reason.message : 'ISO corpus status could not be loaded.'); }
}, []); }, []);
useEffect(() => { void load(); }, [load]); useEffect(() => { void load(); }, [load]);
const job = status?.job?.status;
useBackendWorkActivity('Embedding ISO corpus', job === 'running' ? 'running' : job === 'failed' ? 'failed' : job === 'succeeded' ? 'finished' : 'idle');
useEffect(() => { useEffect(() => {
if (status?.job?.status !== 'running') return; if (status?.job?.status !== 'running') return;
const interval = setInterval(() => { void load(); }, 2_000); const interval = setInterval(() => { void load(true); }, 2_000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [load, status?.job?.status]); }, [load, status?.job?.status]);

View File

@ -1,11 +1,16 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client'; import { AssessorApiClient } from '../../api/assessor-api-client';
import { useEnginePreferences } from '../../app/engine-preferences';
import { useBackendWorkActivity } from './use-backend-work-activity';
import { statusPollApi } from './status-poll-client';
import { Dialog } from '../../components/Dialog/Dialog'; import { Dialog } from '../../components/Dialog/Dialog';
import type { RegulatoryMappingBatchResponse, RegulatoryMappingBatchScope } from '../../domain/regulatory-mapping'; import type { RegulatoryMappingBatchResponse, RegulatoryMappingBatchScope } from '../regulatory-mapping';
const assessorApi = new AssessorApiClient(); const assessorApi = new AssessorApiClient();
export interface RegulatoryMappingBatchController { export interface RegulatoryMappingBatchController {
/** False while the Compliance Engine is switched off. */
available: boolean;
response?: RegulatoryMappingBatchResponse; response?: RegulatoryMappingBatchResponse;
loading: boolean; loading: boolean;
starting: boolean; starting: boolean;
@ -13,7 +18,10 @@ export interface RegulatoryMappingBatchController {
start(): Promise<void>; start(): Promise<void>;
} }
export function useRegulatoryMappingBatch(projectId: string | undefined, scope: RegulatoryMappingBatchScope | undefined): RegulatoryMappingBatchController { export function useRegulatoryMappingBatch(requestedProjectId: string | undefined, scope: RegulatoryMappingBatchScope | undefined): RegulatoryMappingBatchController {
// A switched-off Compliance Engine requests no batch status and offers no batch action.
const available = useEnginePreferences().compliance;
const projectId = available ? requestedProjectId : undefined;
const [response, setResponse] = useState<RegulatoryMappingBatchResponse>(); const [response, setResponse] = useState<RegulatoryMappingBatchResponse>();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
@ -23,13 +31,13 @@ export function useRegulatoryMappingBatch(projectId: string | undefined, scope:
const activeRequestKey = useRef(requestKey); const activeRequestKey = useRef(requestKey);
activeRequestKey.current = requestKey; activeRequestKey.current = requestKey;
const refresh = useCallback(async () => { const refresh = useCallback(async (poll = false) => {
if (!projectId || !scope || (!scope.assessmentIds.length && !scope.scanRunIds.length)) { if (!projectId || !scope || (!scope.assessmentIds.length && !scope.scanRunIds.length)) {
setResponse(undefined); setResponse(undefined);
return; return;
} }
try { try {
const next = await assessorApi.getRegulatoryMappingBatch(projectId, scope); const next = await (poll ? statusPollApi : assessorApi).getRegulatoryMappingBatch(projectId, scope);
if (activeRequestKey.current !== requestKey) return; if (activeRequestKey.current !== requestKey) return;
setResponse(next); setResponse(next);
setError(undefined); setError(undefined);
@ -51,7 +59,7 @@ export function useRegulatoryMappingBatch(projectId: string | undefined, scope:
useEffect(() => { useEffect(() => {
if (!response || !['queued', 'running'].includes(response.state)) return; if (!response || !['queued', 'running'].includes(response.state)) return;
const timer = window.setTimeout(() => void refresh(), response.pollAfterMs ?? 1_500); const timer = window.setTimeout(() => void refresh(true), response.pollAfterMs ?? 1_500);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [refresh, response]); }, [refresh, response]);
@ -74,7 +82,10 @@ export function useRegulatoryMappingBatch(projectId: string | undefined, scope:
} }
}, [projectId, requestKey, scopeKey]); // eslint-disable-line react-hooks/exhaustive-deps }, [projectId, requestKey, scopeKey]); // eslint-disable-line react-hooks/exhaustive-deps
return { response, loading, starting, error, start }; useBackendWorkActivity('Mapping all ISO controls', !response || response.state === 'empty' ? 'idle'
: response.state === 'completed' ? 'finished' : response.state);
return { available, response, loading, starting, error, start };
} }
export function RegulatoryMappingBatchDialog({ controller, filteredResultCount, onClose }: { export function RegulatoryMappingBatchDialog({ controller, filteredResultCount, onClose }: {

View File

@ -1,20 +1,31 @@
import { useState } from 'react'; import { useState, type ReactNode } from 'react';
import { Dialog } from '../../components/Dialog/Dialog';
import './compliance.css';
import type { import type {
RegulatoryMappingCandidate, RegulatoryMappingCandidate,
RegulatoryMappingDecision, RegulatoryMappingDecision,
RegulatoryMappingProposal, RegulatoryMappingProposal,
RegulatoryMappingResponse RegulatoryMappingResponse
} from '../../domain/regulatory-mapping'; } from '../regulatory-mapping';
interface Props { interface Props {
response?: RegulatoryMappingResponse; response?: RegulatoryMappingResponse;
loading: boolean; loading: boolean;
error: string; error: string;
onStart(): void; onStart(): void;
onDecision(candidate: RegulatoryMappingCandidate, decision: 'accepted' | 'rejected', comment?: string): void; onDecision(control: Pick<RegulatoryMappingCandidate, 'controlId'>, decision: 'accepted' | 'rejected', comment?: string): void;
/** Inside the window the dialog title already names the mapping. */
embedded?: boolean;
} }
export function RegulatoryMappingPanel({ response, loading, error, onStart, onDecision }: Props) { /** Finding Reader `Map ISO controls` window, opened like `Accepted Risk`. */
export function RegulatoryMappingDialog({ onClose, ...props }: Omit<Props, 'embedded'> & { onClose(): void }) {
return <Dialog title="ISO control mapping" className="regulatory-mapping-dialog" onClose={onClose}>
<RegulatoryMappingPanel {...props} embedded/>
</Dialog>;
}
export function RegulatoryMappingPanel({ response, loading, error, onStart, onDecision, embedded = false }: Props) {
const run = response?.run; const run = response?.run;
const proposals = run?.result?.mappings ?? []; const proposals = run?.result?.mappings ?? [];
const proposalIds = new Set(proposals.map(item => item.controlId)); const proposalIds = new Set(proposals.map(item => item.controlId));
@ -23,9 +34,10 @@ export function RegulatoryMappingPanel({ response, loading, error, onStart, onDe
return candidate ? [{ candidate, proposal }] : []; return candidate ? [{ candidate, proposal }] : [];
}); });
const otherCandidates = run?.candidates.filter(item => !proposalIds.has(item.controlId)) ?? []; const otherCandidates = run?.candidates.filter(item => !proposalIds.has(item.controlId)) ?? [];
const earlierControls = response?.earlierControls ?? [];
return <section className="regulatory-mapping-panel" aria-label="ISO control mapping"> return <section className={`regulatory-mapping-panel${embedded ? ' is-embedded' : ''}`} aria-label="ISO control mapping">
<header><div><h3>ISO control mapping</h3><p>Proposals indicate relevance to this finding, not proof of compliance.</p></div> <header><div>{!embedded && <h3>ISO control mapping</h3>}<p>Proposals indicate relevance to this finding, not proof of compliance.</p></div>
{response?.generationEnabled && !['queued', 'running'].includes(response.state) && <button className="btn" type="button" disabled={loading} onClick={onStart}>{run ? 'Generate again' : 'Generate proposals'}</button>} {response?.generationEnabled && !['queued', 'running'].includes(response.state) && <button className="btn" type="button" disabled={loading} onClick={onStart}>{run ? 'Generate again' : 'Generate proposals'}</button>}
</header> </header>
{loading && !response && <p role="status">Loading mapping state…</p>} {loading && !response && <p role="status">Loading mapping state…</p>}
@ -40,6 +52,12 @@ export function RegulatoryMappingPanel({ response, loading, error, onStart, onDe
{otherCandidates.length > 0 && <details className="regulatory-other-candidates"><summary>Other retrieved controls ({otherCandidates.length})</summary> {otherCandidates.length > 0 && <details className="regulatory-other-candidates"><summary>Other retrieved controls ({otherCandidates.length})</summary>
<div className="regulatory-control-list">{otherCandidates.map(candidate => <ControlCandidate key={candidate.controlId} candidate={candidate} decisions={response?.decisions ?? []} loading={loading} onDecision={onDecision}/>)}</div> <div className="regulatory-control-list">{otherCandidates.map(candidate => <ControlCandidate key={candidate.controlId} candidate={candidate} decisions={response?.decisions ?? []} loading={loading} onDecision={onDecision}/>)}</div>
</details>} </details>}
{earlierControls.length > 0 && <div className="regulatory-earlier-controls" aria-label="Earlier decisions">
<h4>Earlier decisions</h4>
<p>Decided in an earlier mapping run of this rule and not retrieved by the current run.</p>
<div className="regulatory-control-list">{earlierControls.map(control => <ControlDecision key={control.controlId} control={control}
heading={`${control.annexAControlId}${control.title ? ` — ${control.title}` : ''}`} decisions={response?.decisions ?? []} loading={loading} onDecision={onDecision}/>)}</div>
</div>}
</section>; </section>;
} }
@ -48,25 +66,40 @@ function ControlCandidate({ candidate, proposal, decisions, loading, onDecision
proposal?: RegulatoryMappingProposal; proposal?: RegulatoryMappingProposal;
decisions: RegulatoryMappingDecision[]; decisions: RegulatoryMappingDecision[];
loading: boolean; loading: boolean;
onDecision(candidate: RegulatoryMappingCandidate, decision: 'accepted' | 'rejected', comment?: string): void; onDecision: Props['onDecision'];
}) { }) {
const [comment, setComment] = useState(''); return <ControlDecision control={candidate} heading={`${candidate.annexAControlId} — ${candidate.title}`} decisions={decisions} loading={loading} onDecision={onDecision}
const current = decisions.find(item => item.controlId === candidate.controlId); meta={<>{proposal ? <>{proposal.relationship} · {proposal.confidence} confidence</> : 'Retrieved candidate'} · score {candidate.score.toFixed(3)}</>}>
return <article className="regulatory-control-candidate">
<header><div><h4>{candidate.annexAControlId} — {candidate.title}</h4>
<p>{proposal ? <>{proposal.relationship} · {proposal.confidence} confidence</> : 'Retrieved candidate'} · score {candidate.score.toFixed(3)}</p></div>
{current && <span className="status-badge">{current.decision}</span>}
</header>
{proposal?.rationale && <p>{proposal.rationale}</p>} {proposal?.rationale && <p>{proposal.rationale}</p>}
<details><summary>Evidence ({candidate.evidence.length})</summary> <details><summary>Evidence ({candidate.evidence.length})</summary>
<ul className="regulatory-evidence-list">{candidate.evidence.map(item => <li key={item.chunkId}> <ul className="regulatory-evidence-list">{candidate.evidence.map(item => <li key={item.chunkId}>
{item.sourceStandard} · {item.sectionKind} · {item.pageStart === item.pageEnd ? `page ${item.pageStart}` : `pages ${item.pageStart}–${item.pageEnd}`} {item.sourceStandard} · {item.sectionKind} · {item.pageStart === item.pageEnd ? `page ${item.pageStart}` : `pages ${item.pageStart}–${item.pageEnd}`}
</li>)}</ul> </li>)}</ul>
</details> </details>
</ControlDecision>;
}
/** One control with its latest decision, an optional comment and Accept/Reject. */
function ControlDecision({ control, heading, meta, children, decisions, loading, onDecision }: {
control: Pick<RegulatoryMappingCandidate, 'controlId'>;
heading: string;
meta?: ReactNode;
children?: ReactNode;
decisions: RegulatoryMappingDecision[];
loading: boolean;
onDecision: Props['onDecision'];
}) {
const [comment, setComment] = useState('');
const current = decisions.find(item => item.controlId === control.controlId);
return <article className="regulatory-control-candidate">
<header><div><h4>{heading}</h4>{meta && <p>{meta}</p>}</div>
{current && <span className="status-badge">{current.decision}</span>}
</header>
{children}
<label>Review comment (optional)<input value={comment} maxLength={1000} onChange={event => setComment(event.target.value)} /></label> <label>Review comment (optional)<input value={comment} maxLength={1000} onChange={event => setComment(event.target.value)} /></label>
<div className="regulatory-decision-actions"> <div className="regulatory-decision-actions">
<button className="btn" type="button" disabled={loading} onClick={() => onDecision(candidate, 'accepted', comment)}>Accept</button> <button className="btn" type="button" disabled={loading} onClick={() => onDecision(control, 'accepted', comment)}>Accept</button>
<button className="btn" type="button" disabled={loading} onClick={() => onDecision(candidate, 'rejected', comment)}>Reject</button> <button className="btn" type="button" disabled={loading} onClick={() => onDecision(control, 'rejected', comment)}>Reject</button>
</div> </div>
</article>; </article>;
} }

View File

@ -0,0 +1,126 @@
import { useEffect, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { useApp } from '../../app/AppProvider';
import { useEnginePreferences } from '../../app/engine-preferences';
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 { RegulatoryControlOverviewResponse, RegulatoryControlTheme } from '../regulatory-mapping';
import { useRegulatoryDecisionRevision } 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';
export const ISO_THEME_FACET_ID = 'compliance:iso-theme';
export const COMPLIANCE_FACET_IDS = [ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID] as const;
const SOURCE_SCAN_FACET_ID = 'provenance:source-scan';
const THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological'];
export interface ComplianceWorkFacetIndex {
facets: [DynamicFacet, DynamicFacet];
}
const indexes = new WeakMap<RegulatoryControlOverviewResponse, ComplianceWorkFacetIndex>();
/** One shared index per loaded response, so every Work consumer uses identical cache keys. */
export function complianceWorkFacetIndex(response: RegulatoryControlOverviewResponse): ComplianceWorkFacetIndex {
const existing = indexes.get(response);
if (existing) return existing;
const byOccurrence = new Map<string, Set<string>>();
const bySetting = new Map<string, Set<string>>();
const add = (map: Map<string, Set<string>>, key: string, controlId: string) => map.set(key, (map.get(key) ?? new Set()).add(controlId));
for (const control of response.controls) {
for (const finding of control.findings) {
if (finding.sourceKind === 'scanner-finding') add(byOccurrence, finding.sourceResultId, control.controlId);
else if (finding.sourceRuleId) add(bySetting, `${finding.policyScope ?? ''}\u0000${finding.sourceRuleId}`, control.controlId);
}
}
// Scanner findings match by occurrence; GPO settings by scope and technical setting identity, like Result DB drill-downs.
const controlsFor = (record: FindingRecord): string[] => {
if (record.findingOccurrenceId) return [...byOccurrence.get(record.findingOccurrenceId) ?? []];
const identity = gpoWorkSettingIdentity(record.id);
return identity ? [...bySetting.get(`${identity.scope ?? ''}\u0000${identity.settingId}`) ?? []] : [];
};
const themeOf = new Map(response.controls.map(control => [control.controlId, control.theme]));
const controls = [...response.controls].sort((left, right) => left.controlId.localeCompare(right.controlId, 'en', { numeric: true }));
const index: ComplianceWorkFacetIndex = {
facets: [{
id: ISO_CONTROL_FACET_ID,
label: 'ISO control',
source: 'derived',
kind: 'category',
description: 'Compliance Engine: findings and GPO settings with this accepted ISO/IEC 27002 control. It does not change results.',
values: controls.map(control => ({ value: control.controlId, label: `${control.annexAControlId}${control.title ? ` · ${control.title}` : ''}`, count: 0 })),
valuesFor: controlsFor
}, {
id: ISO_THEME_FACET_ID,
label: 'ISO theme',
source: 'derived',
kind: 'category',
description: 'Compliance Engine: findings and GPO settings with an accepted ISO/IEC 27002 control of this theme. It does not change results.',
// Facet values are matched in lower case; the label keeps the ISO spelling.
values: THEMES.filter(theme => response.controls.some(control => control.theme === theme))
.map(theme => ({ value: theme.toLowerCase(), label: theme, count: 0 })),
valuesFor: record => [...new Set(controlsFor(record).flatMap(controlId => themeOf.get(controlId)?.toLowerCase() ?? []))]
}]
};
indexes.set(response, index);
return index;
}
/** Adds the ISO facets directly before the permanent final `Source: Scan` control. */
export function withComplianceFacets(facets: DynamicFacet[], index: ComplianceWorkFacetIndex | undefined): DynamicFacet[] {
if (!index) return facets;
const sourceScan = facets.findIndex(facet => facet.id === SOURCE_SCAN_FACET_ID);
return sourceScan < 0 ? [...facets, ...index.facets]
: [...facets.slice(0, sourceScan), ...index.facets, ...facets.slice(sourceScan)];
}
const assessorApi = new AssessorApiClient();
// One request per timeslot and decision revision, shared by every Work consumer.
const requests = new Map<string, Promise<RegulatoryControlOverviewResponse>>();
const ready = new Map<string, RegulatoryControlOverviewResponse>();
const lastReadyByScope = new Map<string, RegulatoryControlOverviewResponse>();
/** 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 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 enabled = available && app.backendStatus === 'connected' && Boolean(projectId) && (assessmentIds.length > 0 || scanRunIds.length > 0);
const [, setLoaded] = useState(0);
useEffect(() => {
if (!enabled || !projectId || ready.has(key)) return;
let active = true;
let request = requests.get(key);
if (!request) {
request = assessorApi.getRegulatoryControlAssignments(projectId, { assessmentIds, scanRunIds });
requests.set(key, request);
request.then(response => {
ready.set(key, response);
lastReadyByScope.set(scope, response);
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));
}
request.then(() => { if (active) setLoaded(value => value + 1); }, () => undefined);
return () => { active = false; };
}, [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);
return response ? complianceWorkFacetIndex(response) : undefined;
}
/** Test support. */
export function resetComplianceWorkFacetsForTests(): void {
requests.clear();
ready.clear();
lastReadyByScope.clear();
}

View File

@ -0,0 +1,37 @@
/* Compliance Engine UI. Shared tokens only; no status or severity colour. */
/* `Map ISO controls` window, sized like the Accepted Risk window. */
.dialog-window.regulatory-mapping-dialog { width: min(1040px, 100%); }
.regulatory-mapping-panel.is-embedded { margin-top: 0; padding: 0; border: 0; background: transparent; }
/* Finding Reader section, read like `Description`. */
.finding-iso-controls ul { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
.finding-iso-controls li { display: flex; flex-wrap: wrap; gap: var(--space-2); font-size: var(--type-body-size); line-height: 1.5; }
.finding-iso-controls li strong { font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
/* Client Info section, laid out like the Context heatmap with neutral cells. */
.iso-controls { margin-block: var(--space-6) var(--space-8); color: var(--text-primary); min-width: 0; }
.iso-controls h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
.iso-controls p { margin: var(--space-2) 0; font-size: var(--type-body-size); }
.iso-controls__header { display: flex; align-items: baseline; justify-content: flex-start; gap: var(--space-4); flex-wrap: wrap; }
.iso-controls__total { font-size: var(--type-body-size); font-weight: var(--fw-regular); color: var(--text-secondary); font-variant-numeric: tabular-nums; white-space: nowrap; }
.iso-controls__empty { color: var(--text-secondary); }
.iso-controls__grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--tile-compact-width)), var(--tile-compact-width)));
gap: var(--space-2); margin-top: var(--space-3); grid-auto-rows: 1fr;
}
.iso-controls__cell {
display: flex; flex-direction: column; align-items: start; gap: var(--space-2);
padding: var(--space-3); min-width: 0; min-height: calc(var(--tile-h) * .75); height: auto;
border: var(--bw-emphasis) solid transparent; border-radius: var(--radius-sm);
background: var(--surface-panel-raised); color: var(--text-primary);
font-family: inherit; text-align: left; cursor: pointer; overflow-wrap: anywhere;
}
.iso-controls__cell:hover { border-color: currentColor; }
.iso-controls__cell:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-emphasis); }
.iso-controls__cell strong { font-size: var(--type-body-size); font-weight: var(--fw-semibold); line-height: var(--lh-ui); }
.iso-controls__category { font-size: var(--type-table-body-size); font-weight: var(--fw-regular); }
.iso-controls__amount { margin-top: auto; font-size: var(--type-body-size); font-weight: var(--fw-semibold); }
.regulatory-earlier-controls { margin-top: 16px; }
.regulatory-earlier-controls > h4 { margin-bottom: 4px; }
.regulatory-earlier-controls > p { color: var(--text-muted); }

View File

@ -0,0 +1,20 @@
import { useSyncExternalStore } from 'react';
// Counts saved control decisions in this browser session, so views that summarize accepted
// controls (the Client Info overview) reload after a decision made elsewhere.
let revision = 0;
const listeners = new Set<() => void>();
export function markRegulatoryDecisionSaved(): void {
revision += 1;
listeners.forEach(listener => listener());
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
export function useRegulatoryDecisionRevision(): number {
return useSyncExternalStore(subscribe, () => revision, () => 0);
}

View File

@ -0,0 +1,53 @@
import { readEnginePreferences } from '../../app/engine-preferences';
import { beginActivity } from '../../platform/activity-store';
import type { RegulatoryMappingResponse } from '../regulatory-mapping';
import { statusPollApi } from './status-poll-client';
const tracked = new Set<string>();
const listeners = new Set<(key: string, response: RegulatoryMappingResponse) => void>();
const isActive = (response: RegulatoryMappingResponse) => response.state === 'queued' || response.state === 'running';
const wait = (milliseconds: number) => new Promise(resolve => setTimeout(resolve, milliseconds));
export const mappingRunKey = (projectId: string, resultProjectionId: string) => `${projectId}\u0000${resultProjectionId}`;
/** Views showing a mapping receive the tracker's status updates instead of polling themselves. */
export function subscribeRegulatoryMappingRuns(listener: (key: string, response: RegulatoryMappingResponse) => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
/**
* Keeps one `Mapping ISO controls` activity for a queued or running mapping until the backend ends it,
* independent of the open window, the selected finding or the active view. Switching the Compliance
* Engine off stops observing; the backend work itself is never cancelled.
*/
export function trackRegulatoryMappingRun(projectId: string, resultProjectionId: string, response: RegulatoryMappingResponse): void {
const key = mappingRunKey(projectId, resultProjectionId);
if (!isActive(response) || tracked.has(key)) return;
tracked.add(key);
const activity = beginActivity('Compliance Engine', 'Mapping ISO controls', `compliance-mapping:${key}`);
if (response.state === 'queued') activity.queue();
void (async () => {
let current = response;
try {
while (isActive(current)) {
await wait(current.pollAfterMs ?? 1_500);
if (!readEnginePreferences().compliance) {
activity.update('Mapping ISO controls continues in the backend');
activity.complete();
return;
}
current = await statusPollApi.getRegulatoryMappings(projectId, resultProjectionId);
if (current.state === 'running') activity.run();
listeners.forEach(listener => listener(key, current));
}
if (current.state === 'failed') activity.fail();
else activity.complete();
} catch {
activity.update('Mapping ISO controls: status could not be checked');
activity.fail();
} finally {
tracked.delete(key);
}
})();
}

View File

@ -0,0 +1,8 @@
import { AssessorApiClient } from '../../api/assessor-api-client';
/**
* Client for repeated status checks of running Compliance Engine work. The running job already has
* one activity entry; logging every check would start a new activity each time and make the activity
* panel reopen and jump while the job runs.
*/
export const statusPollApi = new AssessorApiClient({ describeActivity: () => undefined });

View File

@ -0,0 +1,36 @@
import { useEffect, useRef } from 'react';
import { beginActivity } from '../../platform/activity-store';
/** What a view currently observes of a backend job; `idle` means it no longer observes one. */
export type BackendWorkState = 'idle' | 'queued' | 'running' | 'finished' | 'failed';
/**
* Keeps one Compliance Engine activity open while backend work runs, so the activity log is not
* `Ready` between status polls. A view that stops observing the job ends the entry with a note,
* because the backend work itself is not cancelled.
*/
export function useBackendWorkActivity(label: string, state: BackendWorkState): void {
const activity = useRef<ReturnType<typeof beginActivity> | undefined>(undefined);
useEffect(() => {
if (state === 'queued' || state === 'running') {
activity.current ??= beginActivity('Compliance Engine', label);
if (state === 'queued') activity.current.queue(); else activity.current.run();
return;
}
const current = activity.current;
if (!current) return;
activity.current = undefined;
if (state === 'failed') current.fail();
else if (state === 'finished') current.complete();
else detach(current, label);
}, [label, state]);
useEffect(() => () => {
if (activity.current) detach(activity.current, label);
activity.current = undefined;
}, [label]);
}
function detach(activity: ReturnType<typeof beginActivity>, label: string) {
activity.update(`${label} continues in the backend`);
activity.complete();
}

View File

@ -2,11 +2,22 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { BackendStatus } from '../../app/app-state'; import type { BackendStatus } from '../../app/app-state';
import type { FindingRecord } from '../../domain/finding'; import type { FindingRecord } from '../../domain/finding';
import { AssessorApiClient } from '../../api/assessor-api-client'; import { AssessorApiClient } from '../../api/assessor-api-client';
import type { import { useEnginePreferences } from '../../app/engine-preferences';
RegulatoryMappingCandidate, import { mappingRunKey, subscribeRegulatoryMappingRuns, trackRegulatoryMappingRun } from './mapping-run-tracker';
RegulatoryMappingDecisionValue, import { markRegulatoryDecisionSaved } from './decision-revision';
RegulatoryMappingResponse import {
} from '../../domain/regulatory-mapping'; acceptedControlIds,
type RegulatoryMappingCandidate,
type RegulatoryMappingDecisionValue,
type RegulatoryMappingResponse
} from '../regulatory-mapping';
/** An accepted control as the Finding Reader names it. */
export interface AcceptedRegulatoryControl {
controlId: string;
annexAControlId: string;
title?: string;
}
const assessorApi = new AssessorApiClient(); const assessorApi = new AssessorApiClient();
@ -16,7 +27,12 @@ export function useRegulatoryMapping(
resultProjectionId: string, resultProjectionId: string,
backendStatus: BackendStatus backendStatus: BackendStatus
) { ) {
const [open, setOpen] = useState(false); // A switched-off Compliance Engine shows no control, opens nothing and sends no request.
const available = useEnginePreferences().compliance;
const [requestedOpen, setOpen] = useState(false);
const open = requestedOpen && available;
// Re-enabling the engine starts with the window closed again.
useEffect(() => { if (!available) setOpen(false); }, [available]);
const [storedResponse, setStoredResponse] = useState<{ scope: { key: string }; value: RegulatoryMappingResponse }>(); const [storedResponse, setStoredResponse] = useState<{ scope: { key: string }; value: RegulatoryMappingResponse }>();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -38,6 +54,7 @@ export function useRegulatoryMapping(
setLoading(true); setLoading(true);
try { try {
const next = await assessorApi.getRegulatoryMappings(projectId, resultProjectionId); const next = await assessorApi.getRegulatoryMappings(projectId, resultProjectionId);
trackRegulatoryMappingRun(projectId, resultProjectionId, next);
if (activeScope.current !== requestScope) return; if (activeScope.current !== requestScope) return;
setStoredResponse({ scope: requestScope, value: next }); setStoredResponse({ scope: requestScope, value: next });
setError(''); setError('');
@ -49,18 +66,19 @@ export function useRegulatoryMapping(
} }
}, [backendStatus, projectId, requestScope, resultProjectionId]); }, [backendStatus, projectId, requestScope, resultProjectionId]);
useEffect(() => { if (open && !response && !loading && !error) void load(); }, [error, load, loading, open, response]); // Loaded with the finding, so the reader can name its accepted controls. A running mapping is polled
useEffect(() => { // once by the run tracker, which keeps its activity entry until the backend ends it.
if (!open || !response || !['queued', 'running'].includes(response.state)) return; useEffect(() => { if (available && !response && !loading && !error) void load(); }, [available, error, load, loading, response]);
const timeout = window.setTimeout(() => void load(), response.pollAfterMs ?? 1_500); useEffect(() => subscribeRegulatoryMappingRuns((key, next) => {
return () => window.clearTimeout(timeout); if (key === mappingRunKey(projectId, resultProjectionId) && activeScope.current === requestScope) setStoredResponse({ scope: requestScope, value: next });
}, [load, open, response]); }), [projectId, requestScope, resultProjectionId]);
const start = useCallback(async () => { const start = useCallback(async () => {
if (!projectId || !resultProjectionId || activeScope.current !== requestScope) return; if (!projectId || !resultProjectionId || activeScope.current !== requestScope) return;
setLoading(true); setLoading(true);
try { try {
const next = await assessorApi.generateRegulatoryMappings(projectId, { resultProjectionId }); const next = await assessorApi.generateRegulatoryMappings(projectId, { resultProjectionId });
trackRegulatoryMappingRun(projectId, resultProjectionId, next);
if (activeScope.current !== requestScope) return; if (activeScope.current !== requestScope) return;
setStoredResponse({ scope: requestScope, value: next }); setStoredResponse({ scope: requestScope, value: next });
setError(''); setError('');
@ -72,7 +90,7 @@ export function useRegulatoryMapping(
} }
}, [projectId, requestScope, resultProjectionId]); }, [projectId, requestScope, resultProjectionId]);
const decide = useCallback(async (candidate: RegulatoryMappingCandidate, decision: RegulatoryMappingDecisionValue, comment?: string) => { const decide = useCallback(async (candidate: Pick<RegulatoryMappingCandidate, 'controlId'>, decision: RegulatoryMappingDecisionValue, comment?: string) => {
if (!projectId || !resultProjectionId || !response?.run || activeScope.current !== requestScope) return; if (!projectId || !resultProjectionId || !response?.run || activeScope.current !== requestScope) return;
const latest = response.decisions.find(item => item.controlId === candidate.controlId); const latest = response.decisions.find(item => item.controlId === candidate.controlId);
setLoading(true); setLoading(true);
@ -84,6 +102,7 @@ export function useRegulatoryMapping(
...(comment?.trim() ? { comment: comment.trim() } : {}), ...(comment?.trim() ? { comment: comment.trim() } : {}),
...(latest ? { expectedLatestDecisionId: latest.id } : {}) ...(latest ? { expectedLatestDecisionId: latest.id } : {})
}); });
markRegulatoryDecisionSaved();
if (activeScope.current !== requestScope) return; if (activeScope.current !== requestScope) return;
setStoredResponse({ scope: requestScope, value: next }); setStoredResponse({ scope: requestScope, value: next });
setError(''); setError('');
@ -95,17 +114,21 @@ export function useRegulatoryMapping(
} }
}, [projectId, requestScope, response, resultProjectionId]); }, [projectId, requestScope, response, resultProjectionId]);
const acceptedCount = useMemo(() => { const acceptedControls = useMemo<AcceptedRegulatoryControl[]>(() => available && response
const latest = new Map<string, RegulatoryMappingDecisionValue>(); ? acceptedControlIds(response.decisions).map(controlId => {
response?.decisions.forEach(item => { if (!latest.has(item.controlId)) latest.set(item.controlId, item.decision); }); // A control decided in an earlier run keeps its title after the current run stopped retrieving it.
return [...latest.values()].filter(value => value === 'accepted').length; const candidate = response.run?.candidates.find(item => item.controlId === controlId)
}, [response]); ?? response.earlierControls?.find(item => item.controlId === controlId);
return { controlId, annexAControlId: candidate?.annexAControlId ?? `A.${controlId}`, ...(candidate?.title ? { title: candidate.title } : {}) };
}).sort((left, right) => left.controlId.localeCompare(right.controlId, 'en', { numeric: true }))
: [], [available, response]);
const localDisabledReason = !resultProjectionId ? 'This record has no stored finding or current GPO baseline comparison.' const localDisabledReason = !resultProjectionId ? 'This record has no stored finding or current GPO baseline comparison.'
: backendStatus !== 'connected' ? 'The backend is not connected.' : backendStatus !== 'connected' ? 'The backend is not connected.'
: response && !response.generationEnabled ? response.disabledReason ?? 'Regulatory mapping is unavailable.' : undefined; : response && !response.generationEnabled ? response.disabledReason ?? 'Regulatory mapping is unavailable.' : undefined;
return { return {
available,
open, open,
setOpen, setOpen,
response, response,
@ -113,7 +136,8 @@ export function useRegulatoryMapping(
error, error,
start, start,
decide, decide,
acceptedCount, acceptedControls,
acceptedCount: acceptedControls.length,
disabledReason: localDisabledReason disabledReason: localDisabledReason
}; };
} }

View File

@ -56,9 +56,22 @@ export function aggregateGpoBackupMetadata(documents: GpoBackupMetadataDocument[
} }
return values.length === 1 ? values[0] : undefined; return values.length === 1 ? values[0] : undefined;
}; };
// A Backup-GPO folder names its domain by GUID in bkupInfo.xml and by DNS name in
// Backup.xml/GPReport.xml. Different kinds identify the same domain; only two values
// of one kind conflict. The GUID is preferred because it survives a domain rename.
const domainKeyOf = (kind: 'domain-guid' | 'dns'): string | undefined => {
const values = unique(documents.map(document => document.domainKey?.startsWith(`${kind}:`) ? document.domainKey : undefined));
if (values.length > 1) {
diagnostics.add('gpo-metadata-domainKey-conflict');
invalid = true;
}
return values.length === 1 ? values[0] : undefined;
};
const gpoId = field('gpoId'); const gpoId = field('gpoId');
const backupId = field('backupId'); const backupId = field('backupId');
const domainKey = field('domainKey'); const domainGuidKey = domainKeyOf('domain-guid');
const domainDnsKey = domainKeyOf('dns');
const domainKey = diagnostics.has('gpo-metadata-domainKey-conflict') ? undefined : domainGuidKey ?? domainDnsKey;
const backupCreatedAt = field('backupCreatedAt'); const backupCreatedAt = field('backupCreatedAt');
const complete = Boolean(gpoId && backupId && domainKey && backupCreatedAt); const complete = Boolean(gpoId && backupId && domainKey && backupCreatedAt);
// Several reports with link facts cannot be ordered; no link status is claimed then. // Several reports with link facts cannot be ordered; no link status is claimed then.

View File

@ -3,7 +3,7 @@ import type { UnifiedResultProjection } from './unified-result';
/** /**
* The single privacy boundary shared by the AI overview and the ISO control mapping. * The single privacy boundary shared by the AI overview and the ISO control mapping.
* It projects a canonical finding to its rule-level check fields only — title, * It projects a canonical finding to its rule-level check fields only — title,
* description, recommendation and normalized metadata — and never includes the * description, technical explanation, recommendation and normalized metadata — and never includes the
* result-table / occurrence data (affectedAsset, source locators, per-object values). * result-table / occurrence data (affectedAsset, source locators, per-object values).
* Definitions are deduplicated per rule (canonicalKey = tool:ruleId), so this text * Definitions are deduplicated per rule (canonicalKey = tool:ruleId), so this text
* describes the check, not the tenant. Email addresses are redacted defensively. * describes the check, not the tenant. Email addresses are redacted defensively.
@ -12,6 +12,7 @@ export interface PrivacySafeFinding {
id: string | null; id: string | null;
title: string; title: string;
description?: string; description?: string;
technicalExplanation?: string;
recommendation?: string; recommendation?: string;
severity: string; severity: string;
outcome: string; outcome: string;
@ -32,11 +33,13 @@ export function privacySafeCheckText(value: string | undefined): string | undefi
export function privacySafeFinding(item: UnifiedResultProjection): PrivacySafeFinding { export function privacySafeFinding(item: UnifiedResultProjection): PrivacySafeFinding {
const description = privacySafeCheckText(item.description); const description = privacySafeCheckText(item.description);
const technicalExplanation = privacySafeCheckText(item.technicalExplanation);
const recommendation = privacySafeCheckText(item.recommendation); const recommendation = privacySafeCheckText(item.recommendation);
return { return {
id: item.sourceRuleId ?? null, id: item.sourceRuleId ?? null,
title: privacySafeText(item.title), title: privacySafeText(item.title),
...(description ? { description } : {}), ...(description ? { description } : {}),
...(technicalExplanation ? { technicalExplanation } : {}),
...(recommendation ? { recommendation } : {}), ...(recommendation ? { recommendation } : {}),
severity: item.severityNormalized, severity: item.severityNormalized,
outcome: item.outcomeNormalized, outcome: item.outcomeNormalized,

View File

@ -6,7 +6,7 @@ import { wellKnownSidName } from '../shared/windows-codes';
import { applyResultDbIdentityGuard, RESULT_DB_IDENTITY_GUARD_VERSION, type ResultDbIdentityGuard } from './result-db-identity-guard'; import { applyResultDbIdentityGuard, RESULT_DB_IDENTITY_GUARD_VERSION, type ResultDbIdentityGuard } from './result-db-identity-guard';
import { addedPlaceholders, type ResultDbDisclosureTrace } from './result-db-trace'; import { addedPlaceholders, type ResultDbDisclosureTrace } from './result-db-trace';
export const RESULT_DB_DISCLOSURE_POLICY_VERSION = '3.5.0'; export const RESULT_DB_DISCLOSURE_POLICY_VERSION = '3.6.0';
const SAFE_TIMESTAMP = '1970-01-01T00:00:00.000Z'; const SAFE_TIMESTAMP = '1970-01-01T00:00:00.000Z';
@ -668,7 +668,8 @@ const RESULT_DB_FIELD_DISCLOSURE: Record<keyof UnifiedResultProjection, FieldDis
policyBaselineId: 'withhold', policyBaselineLabel: 'disclose', policyBaselineValue: 'disclose', policyBaselineId: 'withhold', policyBaselineLabel: 'disclose', policyBaselineValue: 'disclose',
policyBaselineVariantAssessment: 'disclose', policyActualValue: 'withhold', policyComparisonRelation: 'disclose', policyBaselineVariantAssessment: 'disclose', policyActualValue: 'withhold', policyComparisonRelation: 'disclose',
policyCustomerState: 'disclose', policyClassification: 'disclose', policySourceCount: 'withhold', policyCustomerState: 'disclose', policyClassification: 'disclose', policySourceCount: 'withhold',
title: 'disclose', description: 'disclose', recommendation: 'disclose', affectedAsset: 'withhold', score: 'withhold', title: 'disclose', description: 'disclose', recommendation: 'disclose', technicalExplanation: 'disclose',
affectedAsset: 'withhold', score: 'withhold',
severityOriginal: 'disclose', severityNormalized: 'disclose', outcomeOriginal: 'disclose', severityOriginal: 'disclose', severityNormalized: 'disclose', outcomeOriginal: 'disclose',
outcomeNormalized: 'disclose', technicalExecutionState: 'disclose', outcomeNormalized: 'disclose', technicalExecutionState: 'disclose',
resultType: 'disclose', isIoe: 'disclose', isDeviation: 'disclose', exposureState: 'disclose', resultType: 'disclose', isIoe: 'disclose', isDeviation: 'disclose', exposureState: 'disclose',
@ -696,7 +697,7 @@ export function discloseResultDbProjection(item: UnifiedResultProjection, identi
} }
const tracedFields = [ const tracedFields = [
'title', 'description', 'recommendation', 'sourceRuleId', 'policyBaselineLabel', 'policyBaselineValue', 'title', 'description', 'recommendation', 'technicalExplanation', 'sourceRuleId', 'policyBaselineLabel', 'policyBaselineValue',
'policyBaselineVariantAssessment', 'environmentNormalizedValue', 'categories' 'policyBaselineVariantAssessment', 'environmentNormalizedValue', 'categories'
] as const satisfies ReadonlyArray<keyof UnifiedResultProjection>; ] as const satisfies ReadonlyArray<keyof UnifiedResultProjection>;
@ -799,6 +800,8 @@ function disclosedValues(item: UnifiedResultProjection): UnifiedResultProjection
recommendation: policyRedactor recommendation: policyRedactor
? safePolicyNarrative(item, policyRedactor, item.recommendation) ? safePolicyNarrative(item, policyRedactor, item.recommendation)
: redactNarrative(item, item.recommendation, 'recommendation'), : redactNarrative(item, item.recommendation, 'recommendation'),
// Public rule guidance like the description, so it is redacted as strictly as a description.
technicalExplanation: policyEvaluation ? undefined : redactNarrative(item, item.technicalExplanation, 'description'),
affectedAsset: undefined, affectedAsset: undefined,
severityOriginal: item.severityNormalized, severityOriginal: item.severityNormalized,
outcomeOriginal: item.outcomeNormalized, outcomeOriginal: item.outcomeNormalized,

View File

@ -205,7 +205,7 @@ export function buildResultDbIdentityGuards(input: {
/** Content fields of a disclosed row; identifiers, enums and version fields are never rewritten. */ /** Content fields of a disclosed row; identifiers, enums and version fields are never rewritten. */
const guardedFields = [ const guardedFields = [
'title', 'description', 'recommendation', 'sourceRuleId', 'policyBaselineLabel', 'policyBaselineValue', 'title', 'description', 'recommendation', 'technicalExplanation', 'sourceRuleId', 'policyBaselineLabel', 'policyBaselineValue',
'policyBaselineVariantAssessment', 'environmentNormalizedValue', 'categories' 'policyBaselineVariantAssessment', 'environmentNormalizedValue', 'categories'
] as const satisfies ReadonlyArray<keyof UnifiedResultProjection>; ] as const satisfies ReadonlyArray<keyof UnifiedResultProjection>;

View File

@ -1,6 +1,9 @@
import type { RegulatoryMappingBatchFilters } from './regulatory-mapping';
import type { UnifiedResultQuery } from './unified-result'; import type { UnifiedResultQuery } from './unified-result';
/** Result DB filter values without the client/timeslot scope, paging or audience. */
export type ResultDbFilters = Omit<UnifiedResultQuery,
'audience' | 'projectId' | 'assessmentId' | 'assessmentIds' | 'scanRunId' | 'scanRunIds' | 'offset' | 'limit'>;
export function buildScopedResultDbQuery( export function buildScopedResultDbQuery(
filters: UnifiedResultQuery, filters: UnifiedResultQuery,
projectId: string, projectId: string,
@ -19,7 +22,7 @@ export function buildScopedResultDbQuery(
}; };
} }
export function resultDbBatchFilters(query: UnifiedResultQuery): RegulatoryMappingBatchFilters { export function resultDbBatchFilters(query: UnifiedResultQuery): ResultDbFilters {
const { audience: _audience, projectId: _projectId, assessmentId: _assessmentId, const { audience: _audience, projectId: _projectId, assessmentId: _assessmentId,
assessmentIds: _assessmentIds, scanRunId: _scanRunId, scanRunIds: _scanRunIds, assessmentIds: _assessmentIds, scanRunId: _scanRunId, scanRunIds: _scanRunIds,
offset: _offset, limit: _limit, ...filters } = query; offset: _offset, limit: _limit, ...filters } = query;

View File

@ -146,6 +146,8 @@ export interface UnifiedResultProjection {
title: string; title: string;
description?: string; description?: string;
recommendation?: string; recommendation?: string;
/** The rule's technical explanation, where the scanner report provides one (for example PingCastle). */
technicalExplanation?: string;
affectedAsset?: string; affectedAsset?: string;
score?: number; score?: number;
severityOriginal: JsonValue; severityOriginal: JsonValue;
@ -273,6 +275,16 @@ function scannerSourceToolScope(source: ScannerUnifiedResultSource): UnifiedResu
return undefined; return undefined;
} }
/**
* A scanner's technical explanation of a rule, read from the retained original fields: the extractor key
* `technical-explanation` or the source label "Technical explanation". Tools without one leave it empty.
*/
export function technicalExplanation(fields: ReadonlyArray<{ key: string; label: string; value: unknown }>): string | undefined {
const field = fields.find(item => item.key === 'technical-explanation')
?? fields.find(item => item.label.trim().toLocaleLowerCase('en-US') === 'technical explanation');
return typeof field?.value === 'string' ? field.value.trim() || undefined : undefined;
}
export function resultSourceToolLabel(value?: string): string { export function resultSourceToolLabel(value?: string): string {
const labels: Record<string, string> = { const labels: Record<string, string> = {
'purple-knight-ad': 'Purple Knight for Active Directory', 'purple-knight-ad': 'Purple Knight for Active Directory',
@ -442,6 +454,7 @@ export function createScannerUnifiedResult(source: ScannerUnifiedResultSource):
title: source.definition.title, title: source.definition.title,
description: source.definition.description, description: source.definition.description,
recommendation: source.definition.recommendation, recommendation: source.definition.recommendation,
technicalExplanation: technicalExplanation(source.occurrence.originalFields),
affectedAsset: source.occurrence.affectedAsset?.normalizedValue ?? undefined, affectedAsset: source.occurrence.affectedAsset?.normalizedValue ?? undefined,
score: source.occurrence.score?.normalizedValue ?? undefined, score: source.occurrence.score?.normalizedValue ?? undefined,
severityOriginal: source.occurrence.severity.originalValue, severityOriginal: source.occurrence.severity.originalValue,

View File

@ -11,7 +11,7 @@ export function buildUnifiedResultCsv(query: UnifiedResultQuery, results: Unifie
const [manifest, ...entries] = buildUnifiedResultJsonl(query, results, generatedAt).trimEnd().split('\n') const [manifest, ...entries] = buildUnifiedResultJsonl(query, results, generatedAt).trimEnd().split('\n')
.map(line => JSON.parse(line) as Record<string, unknown>); .map(line => JSON.parse(line) as Record<string, unknown>);
const records = entries.map(entry => entry.result as Record<string, unknown>); const records = entries.map(entry => entry.result as Record<string, unknown>);
const preferred = ['title', 'sourceRuleId', 'sourceTool', 'severityNormalized', 'outcomeNormalized', 'resultType', 'isIoe', 'isDeviation', 'description', 'recommendation']; const preferred = ['title', 'sourceRuleId', 'sourceTool', 'severityNormalized', 'outcomeNormalized', 'resultType', 'isIoe', 'isDeviation', 'description', 'technicalExplanation', 'recommendation'];
const fields = [...new Set(records.flatMap(record => Object.keys(record)))]; const fields = [...new Set(records.flatMap(record => Object.keys(record)))];
const rank = (field: string) => preferred.includes(field) ? preferred.indexOf(field) : preferred.length; const rank = (field: string) => preferred.includes(field) ? preferred.indexOf(field) : preferred.length;
fields.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)); fields.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));

View File

@ -15,7 +15,7 @@ export function buildUnifiedResultExcel(query: UnifiedResultQuery, results: Unif
const [manifest, ...entries] = buildUnifiedResultJsonl(query, results, generatedAt).trimEnd().split('\n') const [manifest, ...entries] = buildUnifiedResultJsonl(query, results, generatedAt).trimEnd().split('\n')
.map(line => JSON.parse(line) as Record<string, unknown>); .map(line => JSON.parse(line) as Record<string, unknown>);
const records = entries.map(entry => entry.result as Record<string, unknown>); const records = entries.map(entry => entry.result as Record<string, unknown>);
const preferred = ['title', 'sourceRuleId', 'sourceTool', 'severityNormalized', 'outcomeNormalized', 'resultType', 'isIoe', 'isDeviation', 'description', 'recommendation']; const preferred = ['title', 'sourceRuleId', 'sourceTool', 'severityNormalized', 'outcomeNormalized', 'resultType', 'isIoe', 'isDeviation', 'description', 'technicalExplanation', 'recommendation'];
const fields = [...new Set(records.flatMap(record => Object.keys(record)))]; const fields = [...new Set(records.flatMap(record => Object.keys(record)))];
fields.sort((a, b) => { fields.sort((a, b) => {
const rank = (field: string) => preferred.includes(field) ? preferred.indexOf(field) : preferred.length; const rank = (field: string) => preferred.includes(field) ? preferred.indexOf(field) : preferred.length;
@ -47,7 +47,7 @@ export function buildUnifiedResultExcel(query: UnifiedResultQuery, results: Unif
sheet['!autofilter'] = { ref: sheet['!ref']! }; sheet['!autofilter'] = { ref: sheet['!ref']! };
utils.book_append_sheet(workbook, sheet, name); utils.book_append_sheet(workbook, sheet, name);
}; };
append('Results', rows, [12, ...fields.map(field => ['title', 'description', 'recommendation'].includes(field) ? 65 : 25)]); append('Results', rows, [12, ...fields.map(field => ['title', 'description', 'technicalExplanation', 'recommendation'].includes(field) ? 65 : 25)]);
append('Export info', [['Property', 'Value'], ...Object.entries(manifest).map(([key, value]) => append('Export info', [['Property', 'Value'], ...Object.entries(manifest).map(([key, value]) =>
[key, cell(value, 0, key)]), ['Excel notes', 'Nested values are JSON text. Long values are complete in Long text, ordered by Position, Field and Part. Position 0 refers to Export info.']], [28, 100]); [key, cell(value, 0, key)]), ['Excel notes', 'Nested values are JSON text. Long values are complete in Long text, ordered by Position, Field and Part. Position 0 refers to Export info.']], [28, 100]);
if (overflow.length > 1) append('Long text', overflow, [12, 30, 10, 100]); if (overflow.length > 1) append('Long text', overflow, [12, 30, 10, 100]);

View File

@ -8,18 +8,20 @@ import { browserDownloadAdapter } from '../../platform/browser/browser-download-
import { useFilteredRecords } from '../search-filter/use-filtered-records'; import { useFilteredRecords } from '../search-filter/use-filtered-records';
import { selectWorkFacets } from '../search-filter/work-facets'; import { selectWorkFacets } from '../search-filter/work-facets';
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index'; import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
import { useComplianceWorkFacets } from '../../compliance/ui/compliance-work-facets';
export function ActionlistControls() { export function ActionlistControls() {
const app = useApp(); const app = useApp();
const records = useFilteredRecords(); const records = useFilteredRecords();
const contextClusters = useContextClusterIndex(); const contextClusters = useContextClusterIndex();
const complianceFacets = useComplianceWorkFacets();
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const exportView = async (format: 'csv' | 'md' | 'html') => { const exportView = async (format: 'csv' | 'md' | 'html') => {
if (!app.dataset || !app.activeSheet || !records.length) return; if (!app.dataset || !app.activeSheet || !records.length) return;
const activity = beginActivity('Core Engine', `Preparing Work ${format.toUpperCase()} export`); const activity = beginActivity('Core Engine', `Preparing Work ${format.toUpperCase()} export`);
try { try {
await showActivityFrame(); await showActivityFrame();
const facets = selectWorkFacets(app.activeSheet, { ...app, contextClusters }); const facets = selectWorkFacets(app.activeSheet, { ...app, contextClusters, complianceFacets });
const statusLabel = app.activeProfile.presentation.statusFilters.find(filter => filter.value === app.statusFilter)?.label ?? app.statusFilter; const statusLabel = app.activeProfile.presentation.statusFilters.find(filter => filter.value === app.statusFilter)?.label ?? app.statusFilter;
const filters = Object.entries(app.facetFilters).map(([id, filter]) => { const filters = Object.entries(app.facetFilters).map(([id, filter]) => {
const facet = facets.find(item => item.id === id); const facet = facets.find(item => item.id === id);

View File

@ -1,5 +1,7 @@
import { readEnginePreferences, useEnginePreferences } from '../../app/engine-preferences'; import { readEnginePreferences, useEnginePreferences } from '../../app/engine-preferences';
import { ClientContextHeatmap } from '../context-layer/ContextHeatmap'; import { ClientContextHeatmap } from '../context-layer/ContextHeatmap';
import { ClientIsoControls } from '../../compliance/ui/ClientIsoControls';
import { ISO_CONTROL_FACET_ID } from '../../compliance/ui/compliance-work-facets';
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet'; import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
import { useAssessmentContext } from '../context-layer/use-assessment-context'; import { useAssessmentContext } from '../context-layer/use-assessment-context';
import { publishContextReport } from '../../assessment-context/context-report-publication'; import { publishContextReport } from '../../assessment-context/context-report-publication';
@ -38,7 +40,7 @@ import { WorkTimeslotControl } from '../search-filter/WorkTimeslotControl';
import { dashboardResultState, dashboardScoreFromStates, type DashboardResultState } from './dashboard-result-state'; import { dashboardResultState, dashboardScoreFromStates, type DashboardResultState } from './dashboard-result-state';
export { dashboardResultState } from './dashboard-result-state'; export { dashboardResultState } from './dashboard-result-state';
import { AiOverviewPanel, useAiOverview } from './AiOverviewPanel'; import { AiOverviewPanel, useAiOverview } from '../../compliance/ui/AiOverviewPanel';
import { DEFENDER_FOR_IOT_PROFILE_ID } from '../../domain/defender-for-iot'; import { DEFENDER_FOR_IOT_PROFILE_ID } from '../../domain/defender-for-iot';
import { DefenderVulnerabilityPanel } from '../defender-for-iot/DefenderVulnerabilityPanel'; import { DefenderVulnerabilityPanel } from '../defender-for-iot/DefenderVulnerabilityPanel';
import { defenderModelForDataset } from '../defender-for-iot/defender-dataset-model'; import { defenderModelForDataset } from '../defender-for-iot/defender-dataset-model';
@ -1418,7 +1420,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const pureDefender = dataset?.profileId === DEFENDER_FOR_IOT_PROFILE_ID; const pureDefender = dataset?.profileId === DEFENDER_FOR_IOT_PROFILE_ID;
const environment = useEnvironmentSnapshot(app.targetProjectId, app.activeScanRunIds); const environment = useEnvironmentSnapshot(app.targetProjectId, app.activeScanRunIds);
const aiOverview = useAiOverview(app.targetProjectId, app.activeAssessmentIds ?? [], app.activeScanRunIds ?? []); const aiOverview = useAiOverview(app.targetProjectId, app.activeAssessmentIds ?? [], app.activeScanRunIds ?? []);
const showAiOverview = Boolean(aiOverview.loading || aiOverview.response?.generationEnabled || aiOverview.response?.run); const showAiOverview = aiOverview.available && Boolean(aiOverview.loading || aiOverview.response?.generationEnabled || aiOverview.response?.run);
useEffect(() => { useEffect(() => {
if (pureDefender && clientInfoView === 'overview') setClientInfoView('defender'); if (pureDefender && clientInfoView === 'overview') setClientInfoView('defender');
@ -1676,6 +1678,17 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
setFacetFilter(CONTEXT_CLUSTER_FACET_ID, { values: [clusterId] }); setFacetFilter(CONTEXT_CLUSTER_FACET_ID, { values: [clusterId] });
}, false)} /> }, false)} />
{/* Compliance Engine: accepted ISO controls of this timeslot; a tile selects its control in the Work ISO control filter. */}
<ClientIsoControls projectId={app.targetProjectId} assessmentIds={app.activeAssessmentIds} scanRunIds={app.activeScanRunIds}
backendStatus={app.backendStatus} onOpenControl={controlId => jump(() => {
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
if (targetSheet) {
app.setActiveSheet(targetSheet.id);
setStatusFilter('deviation');
}
setFacetFilter(ISO_CONTROL_FACET_ID, { values: [controlId] });
}, false)} />
{scoringOpen && <ScoringMethodology sources={sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(false)} />} {scoringOpen && <ScoringMethodology sources={sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(false)} />}
<div className="client-environment-grid" aria-label="Environment overview"> <div className="client-environment-grid" aria-label="Environment overview">

View File

@ -8,12 +8,16 @@ const nodes = [
{ id: 'input', name: 'Source inputs', caption: 'Scanner reports · GPO backups · catalogs', description: 'Original scanner reports, GPO backups and reference catalogs enter the Core import and evaluation pipeline.' }, { id: 'input', name: 'Source inputs', caption: 'Scanner reports · GPO backups · catalogs', description: 'Original scanner reports, GPO backups and reference catalogs enter the Core import and evaluation pipeline.' },
{ id: 'core', engine: true, name: 'Core Engine', caption: 'Authoritative results · always active', description: 'All existing ASSESSOR functions: import, normalization, evaluation, scoring, client records and disclosure. Owns canonical PostgreSQL data. Works independently of optional engines.' }, { id: 'core', engine: true, name: 'Core Engine', caption: 'Authoritative results · always active', description: 'All existing ASSESSOR functions: import, normalization, evaluation, scoring, client records and disclosure. Owns canonical PostgreSQL data. Works independently of optional engines.' },
{ id: 'database', name: 'Canonical PostgreSQL', caption: 'Core-owned data · disclosed Result DB', description: 'Canonical local datastore. The Core owns persistence, including the stored and verified Result DB projection.' }, { id: 'database', name: 'Canonical PostgreSQL', caption: 'Core-owned data · disclosed Result DB', description: 'Canonical local datastore. The Core owns persistence, including the stored and verified Result DB projection.' },
{ id: 'port', name: 'Core read-only port', caption: 'Scoped results · provenance', description: 'Declared read-only Core port backed by read-only database access. Optional engines read authoritative results without changing stored data.' }, { id: 'port', name: 'Core read-only port', caption: 'Scoped results · provenance', description: 'Declared read-only Core port. Optional engines read authoritative results without changing stored data. Context and Report Engine read through read-only database access; the Compliance Engine still reads through the running Core repository.' },
{ id: 'context', engine: true, name: 'Context Engine', caption: 'Derived context · optional', description: 'Reads findings and GPO verdicts through the read-only Core port and read-only database access. Produces versioned clusters, confidence and evidence. In-memory results; no own persistence or Core writes.' }, { id: 'context', engine: true, name: 'Context Engine', caption: 'Derived context · optional', description: 'Reads findings and GPO verdicts through the read-only Core port and read-only database access. Produces versioned clusters, confidence and evidence. In-memory results; no own persistence or Core writes.' },
{ id: 'report', engine: true, name: 'Report Engine', caption: 'Read-only reporting · optional', description: 'Reads Core results through the read-only Core port and a separate read-only PostgreSQL pool. Produces report projections, management and technical reports. Own file archive; no Core writes.' }, { id: 'report', engine: true, name: 'Report Engine', caption: 'Read-only reporting · optional', description: 'Reads Core results through the read-only Core port and a separate read-only PostgreSQL pool. Produces report projections, management and technical reports. Own file archive; no Core writes.' },
{ id: 'ui', name: 'Core UI & exports', caption: 'Findings · scores · Result DB', description: 'Application views show authoritative Core findings, verdicts and scores. Result DB exports obey the Core disclosure policy.' }, { id: 'ui', name: 'Core UI & exports', caption: 'Findings · scores · Result DB', description: 'Application views show authoritative Core findings, verdicts and scores. Result DB exports obey the Core disclosure policy.' },
{ id: 'context-ui', name: 'Context in the UI', caption: 'Work · Client Info · Pathfinder', description: 'Published Context results appear through existing UI extension points. They never override technical results or client records.' }, { id: 'context-ui', name: 'Context in the UI', caption: 'Work · Client Info · Pathfinder', description: 'Published Context results appear through existing UI extension points. They never override technical results or client records.' },
{ id: 'archive', name: 'Report outputs', caption: 'File archive · downloads', description: 'Generated reports are stored in the separate Report Engine file archive and offered for download; no report storage in Core tables.' } { id: 'archive', name: 'Report outputs', caption: 'File archive · downloads', description: 'Generated reports are stored in the separate Report Engine file archive and offered for download; no report storage in Core tables.' },
{ id: 'compliance', engine: true, name: 'Compliance Engine', caption: 'Regulatory control mapping · optional', description: 'Maps findings and GPO baseline comparisons to regulatory controls; ISO/IEC 27001/27002 is the first framework. Reads Core results through the read-only Core port and writes only its own tables. Proposals never change verdicts, scores or client records.' },
{ id: 'compliance-tables', name: 'Compliance tables', caption: 'Mappings · decisions · batches', description: 'Mapping runs, accept/reject decisions and batches in three engine-owned tables of the canonical PostgreSQL. Written through a separate small pool; engine writes never advance the Core revision.' },
{ id: 'retrieval', name: 'Retrieval & model', caption: 'Qdrant index · LiteLLM', description: 'The rebuildable Qdrant index of the uploaded control corpus and the configured LiteLLM model. They receive privacy-safe finding text and corpus evidence, never assets or customer values.' },
{ id: 'compliance-ui', name: 'ISO mapping in the UI', caption: 'Finding Reader · Result DB · Data Hub', description: 'Control proposals, accept/reject decisions, filtered batches and the ISO corpus upload appear through existing UI extension points. A proposal indicates relevance, not proof of compliance.' }
]; ];
const relationships = [ const relationships = [
{ from: 'input', to: 'core', label: 'Import', detail: 'Import and evaluation', path: 'M240 340 H310' }, { from: 'input', to: 'core', label: 'Import', detail: 'Import and evaluation', path: 'M240 340 H310' },
@ -24,7 +28,11 @@ const relationships = [
{ from: 'core', to: 'ui', label: 'Core results', detail: 'Authoritative findings, verdicts, scores and disclosure rules', path: 'M420 290 V160' }, { from: 'core', to: 'ui', label: 'Core results', detail: 'Authoritative findings, verdicts, scores and disclosure rules', path: 'M420 290 V160' },
{ from: 'context', to: 'context-ui', label: 'Context', detail: 'Published context results through existing UI extension points', path: 'M710 290 V160' }, { from: 'context', to: 'context-ui', label: 'Context', detail: 'Published context results through existing UI extension points', path: 'M710 290 V160' },
{ from: 'report', to: 'archive', label: 'Reports', detail: 'Generated reports; separate from Core database storage', path: 'M1000 520 V390' }, { from: 'report', to: 'archive', label: 'Reports', detail: 'Generated reports; separate from Core database storage', path: 'M1000 520 V390' },
{ from: 'context', to: 'report', label: 'Planned', detail: 'Published context results; documented target, not yet connected in report generation', path: 'M820 340 H855 V490 H940 V520', planned: true } { from: 'context', to: 'report', label: 'Planned', detail: 'Published context results; documented target, not yet connected in report generation', path: 'M820 340 H855 V490 H940 V520', planned: true },
{ from: 'port', to: 'compliance', label: 'Read only', detail: 'Single Result DB row by client, filtered Result DB pages, client, assessment and scan-run lists', path: 'M710 620 V750' },
{ from: 'compliance', to: 'compliance-tables', label: 'Own data', detail: 'Mapping runs, decisions and batches; never Core tables', path: 'M600 800 H530', bidirectional: true },
{ from: 'compliance', to: 'retrieval', label: 'Retrieval', detail: 'Privacy-safe query, retrieved control evidence and model proposals', path: 'M820 800 H890', bidirectional: true },
{ from: 'compliance', to: 'compliance-ui', label: 'Proposals', detail: 'Grounded control proposals and decisions through existing UI extension points', path: 'M710 850 V900 H130 V850' }
]; ];
const nodeName = (id: string) => nodes.find(node => node.id === id)!.name; const nodeName = (id: string) => nodes.find(node => node.id === id)!.name;
@ -38,7 +46,7 @@ export function EngineDataFlow() {
<header className="db-engine-flow__heading"><h3>Engines & relationships</h3><span>Solid: implemented · Dashed: planned</span></header> <header className="db-engine-flow__heading"><h3>Engines & relationships</h3><span>Solid: implemented · Dashed: planned</span></header>
<div className="db-engine-flow__scroll" role="region" aria-label="Interactive engine diagram" tabIndex={0}> <div className="db-engine-flow__scroll" role="region" aria-label="Interactive engine diagram" tabIndex={0}>
<div className="db-engine-flow__map"> <div className="db-engine-flow__map">
<svg className="db-engine-flow__edges" width="1130" height="650" aria-hidden="true"> <svg className="db-engine-flow__edges" width="1130" height="940" aria-hidden="true">
<defs><marker id={markerId} viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 Z" /></marker></defs> <defs><marker id={markerId} viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 Z" /></marker></defs>
{relationships.map(edge => <path key={`${edge.from}-${edge.to}`} d={edge.path} markerEnd={`url(#${markerId})`} markerStart={edge.bidirectional ? `url(#${markerId})` : undefined} {relationships.map(edge => <path key={`${edge.from}-${edge.to}`} d={edge.path} markerEnd={`url(#${markerId})`} markerStart={edge.bidirectional ? `url(#${markerId})` : undefined}
className={`${edge.planned ? 'is-planned' : ''} ${connections.includes(edge) ? 'is-related' : ''}`} />)} className={`${edge.planned ? 'is-planned' : ''} ${connections.includes(edge) ? 'is-related' : ''}`} />)}
@ -56,7 +64,7 @@ export function EngineDataFlow() {
<ul>{connections.map(edge => <li key={`${edge.from}-${edge.to}`}><strong>{nodeName(edge.from)} {edge.bidirectional ? '↔' : '→'} {nodeName(edge.to)}</strong><span>{edge.detail}{edge.planned ? ' · Planned' : ''}</span></li>)}</ul> <ul>{connections.map(edge => <li key={`${edge.from}-${edge.to}`}><strong>{nodeName(edge.from)} {edge.bidirectional ? '↔' : '→'} {nodeName(edge.to)}</strong><span>{edge.detail}{edge.planned ? ' · Planned' : ''}</span></li>)}</ul>
</section> : <p className="db-engine-flow__boundary">Select a module to inspect its data flow. Modules also support keyboard selection.</p>} </section> : <p className="db-engine-flow__boundary">Select a module to inspect its data flow. Modules also support keyboard selection.</p>}
<p className="db-engine-flow__boundary">Arrows show data delivery. Engine dependencies point toward the Core; the Core never depends on an optional engine. Engines cannot override verdicts, scores or client records. Every output follows Core privacy and Result DB disclosure rules.</p> <p className="db-engine-flow__boundary">Arrows show data delivery. Engine dependencies point toward the Core; the Core never depends on an optional engine. Engines cannot override verdicts, scores or client records. Every output follows Core privacy and Result DB disclosure rules.</p>
<details className="db-engine-flow__dictionary"><summary>All relationships (9)</summary><div className="db-engine-flow__scroll"><table aria-label="Directed engine relationships"> <details className="db-engine-flow__dictionary"><summary>All relationships ({relationships.length})</summary><div className="db-engine-flow__scroll"><table aria-label="Directed engine relationships">
<thead><tr><th scope="col">From</th><th scope="col">To</th><th scope="col">Data flow</th><th scope="col">Implementation</th></tr></thead> <thead><tr><th scope="col">From</th><th scope="col">To</th><th scope="col">Data flow</th><th scope="col">Implementation</th></tr></thead>
<tbody>{relationships.map(edge => <tr key={`${edge.from}-${edge.to}`}><td>{nodeName(edge.from)}</td><td>{edge.bidirectional ? '↔' : '→'} {nodeName(edge.to)}</td><td>{edge.detail}</td><td>{edge.planned ? 'Planned' : 'Implemented'}</td></tr>)}</tbody> <tbody>{relationships.map(edge => <tr key={`${edge.from}-${edge.to}`}><td>{nodeName(edge.from)}</td><td>{edge.bidirectional ? '↔' : '→'} {nodeName(edge.to)}</td><td>{edge.detail}</td><td>{edge.planned ? 'Planned' : 'Implemented'}</td></tr>)}</tbody>
</table></div></details> </table></div></details>

View File

@ -37,8 +37,9 @@ import { findingWebSearch } from '../../profiles/public-search/search';
import { policyEvidenceResultSheets, POLICY_CONFLICT_DETAILS, POLICY_DUPLICATE_DETAILS } from '../../profiles/group-policy/conflict-presentation'; import { policyEvidenceResultSheets, POLICY_CONFLICT_DETAILS, POLICY_DUPLICATE_DETAILS } from '../../profiles/group-policy/conflict-presentation';
import { maesterExecutionErrorText, maesterFindingTitle, maesterTagText } from '../../profiles/maester/presentation'; import { maesterExecutionErrorText, maesterFindingTitle, maesterTagText } from '../../profiles/maester/presentation';
import type { BackendStatus } from '../../app/app-state'; import type { BackendStatus } from '../../app/app-state';
import { RegulatoryMappingPanel } from './RegulatoryMappingPanel'; import { RegulatoryMappingDialog } from '../../compliance/ui/RegulatoryMappingPanel';
import { regulatoryMappingResultId, useRegulatoryMapping } from './use-regulatory-mapping'; import { FindingIsoControls } from '../../compliance/ui/FindingIsoControls';
import { regulatoryMappingResultId, useRegulatoryMapping } from '../../compliance/ui/use-regulatory-mapping';
interface Props { interface Props {
record: FindingRecord; record: FindingRecord;
@ -486,9 +487,9 @@ export function FindingReader({ record, title, review: storedReview, previousPol
<div className="reader-heading-actions"> <div className="reader-heading-actions">
<FindingContextBadges record={record} isPolicySetting={resolvedToolId === 'group-policy'}/> <FindingContextBadges record={record} isPolicySetting={resolvedToolId === 'group-policy'}/>
{webSearch && <a className="btn" href={webSearch.url} target="_blank" rel="noopener noreferrer" referrerPolicy="no-referrer" data-search-specificity={webSearch.specificity} title={`${webSearch.specificity === 'topic' ? 'Topic search — ' : ''}${webSearch.query}`}>AI search</a>} {webSearch && <a className="btn" href={webSearch.url} target="_blank" rel="noopener noreferrer" referrerPolicy="no-referrer" data-search-specificity={webSearch.specificity} title={`${webSearch.specificity === 'topic' ? 'Topic search — ' : ''}${webSearch.query}`}>AI search</a>}
<button className="btn" type="button" aria-expanded={regulatoryMapping.open} disabled={!regulatoryMapping.open && Boolean(regulatoryMapping.disabledReason)} title={regulatoryMapping.disabledReason} onClick={() => regulatoryMapping.setOpen(!regulatoryMapping.open)}> {regulatoryMapping.available && <button className="btn" type="button" aria-expanded={regulatoryMapping.open} disabled={!regulatoryMapping.open && Boolean(regulatoryMapping.disabledReason)} title={regulatoryMapping.disabledReason} onClick={() => regulatoryMapping.setOpen(!regulatoryMapping.open)}>
Map ISO controls{regulatoryMapping.acceptedCount ? ` (${regulatoryMapping.acceptedCount})` : ''} Map ISO controls{regulatoryMapping.acceptedCount ? ` (${regulatoryMapping.acceptedCount})` : ''}
</button> </button>}
{/* Gleiche Kennzeichnung wie auf den Kacheln: ist das Ergebnisfenster {/* Gleiche Kennzeichnung wie auf den Kacheln: ist das Ergebnisfenster
zu diesem Fund offen, traegt die Schaltflaeche den Fensterton. */} zu diesem Fund offen, traegt die Schaltflaeche den Fensterton. */}
<button className={`btn results-detail-btn results-btn${resultsOpen ? ' is-open' : ''}`} style={resultButtonStyle} type="button" disabled={!onResults} onClick={onResults}>Results</button> <button className={`btn results-detail-btn results-btn${resultsOpen ? ' is-open' : ''}`} style={resultButtonStyle} type="button" disabled={!onResults} onClick={onResults}>Results</button>
@ -552,6 +553,8 @@ export function FindingReader({ record, title, review: storedReview, previousPol
: resolvedToolId === 'group-policy' && /^(?:description|remediation|sources)$/.test(normalizeLabel(field.label)) : resolvedToolId === 'group-policy' && /^(?:description|remediation|sources)$/.test(normalizeLabel(field.label))
? <p className="policy-guidance-text"><PathText value={field.value} search={search} label={field.label} /></p> ? <p className="policy-guidance-text"><PathText value={field.value} search={search} label={field.label} /></p>
: <RichTextValue value={field.value} search={search} label={field.label} onResults={onResults} />}</div>)} : <RichTextValue value={field.value} search={search} label={field.label} onResults={onResults} />}</div>)}
{/* Compliance Engine: controls accepted for this finding, read like Description. */}
<FindingIsoControls controls={regulatoryMapping.acceptedControls}/>
{optionalFields.map(field => <details className="finding-optional-detail" key={`${field.index}-${field.label}`}> {optionalFields.map(field => <details className="finding-optional-detail" key={`${field.index}-${field.label}`}>
<summary><TriangleIcon direction="right" />{highlightText(field.label, search)}</summary> <summary><TriangleIcon direction="right" />{highlightText(field.label, search)}</summary>
{resolvedToolId === 'maester' && normalizeLabel(field.label) === 'test details' {resolvedToolId === 'maester' && normalizeLabel(field.label) === 'test details'
@ -559,8 +562,9 @@ export function FindingReader({ record, title, review: storedReview, previousPol
: <RichTextValue value={field.value} search={search} label={field.label} onResults={onResults} />} : <RichTextValue value={field.value} search={search} label={field.label} onResults={onResults} />}
</details>)} </details>)}
</section> </section>
{regulatoryMapping.open && <RegulatoryMappingPanel response={regulatoryMapping.response} loading={regulatoryMapping.loading} error={regulatoryMapping.error} {regulatoryMapping.open && <RegulatoryMappingDialog response={regulatoryMapping.response} loading={regulatoryMapping.loading} error={regulatoryMapping.error}
onStart={() => void regulatoryMapping.start()} onDecision={(candidate, decision, comment) => void regulatoryMapping.decide(candidate, decision, comment)} />} onStart={() => void regulatoryMapping.start()} onDecision={(candidate, decision, comment) => void regulatoryMapping.decide(candidate, decision, comment)}
onClose={() => regulatoryMapping.setOpen(false)} />}
{/* Work tracking is opt-in below the finding so the first screen states the problem. */} {/* Work tracking is opt-in below the finding so the first screen states the problem. */}
<FindingReferences record={record}/> <FindingReferences record={record}/>
<details className="reader-tracking"> <details className="reader-tracking">

View File

@ -16,7 +16,7 @@ import { PolicyAnalysisPanel } from './PolicyAnalysisPanel';
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState'; import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog'; import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog';
import { MicrosoftBaselineLibrary } from './MicrosoftBaselineLibrary'; import { MicrosoftBaselineLibrary } from './MicrosoftBaselineLibrary';
import { IsoCorpusLibrary } from './IsoCorpusLibrary'; import { IsoCorpusLibrary } from '../../compliance/ui/IsoCorpusLibrary';
import { MitreMatrixLibrary } from './MitreMatrixLibrary'; import { MitreMatrixLibrary } from './MitreMatrixLibrary';
import { ReferenceRecognitionPanel } from './ReferenceRecognitionPanel'; import { ReferenceRecognitionPanel } from './ReferenceRecognitionPanel';
import { activeMitreMatrix, clearStoredMitreMatrix, loadStoredMitreMatrix, MITRE_MATRIX_CHANGED_EVENT, saveStoredMitreMatrix, setServerMitreMatrix, validateMitreMatrixData } from '../mitre-graph/mitre-graph'; import { activeMitreMatrix, clearStoredMitreMatrix, loadStoredMitreMatrix, MITRE_MATRIX_CHANGED_EVENT, saveStoredMitreMatrix, setServerMitreMatrix, validateMitreMatrixData } from '../mitre-graph/mitre-graph';

View File

@ -11,14 +11,14 @@ import { TriangleIcon } from '../../components/TriangleIcon';
import { browserDownloadAdapter, downloadBrowserBlob } from '../../platform/browser/browser-download-adapter'; import { browserDownloadAdapter, downloadBrowserBlob } from '../../platform/browser/browser-download-adapter';
import { downloadText } from '../../exports/text-download'; import { downloadText } from '../../exports/text-download';
import { buildUnifiedResultJsonl, queryAllUnifiedResults, unifiedResultJsonlFileName } from '../../exports/unified-result-jsonl'; import { buildUnifiedResultJsonl, queryAllUnifiedResults, unifiedResultJsonlFileName } from '../../exports/unified-result-jsonl';
import { privacySafeResultJsonFileName, resultsToPrivacySafeExportJson } from '../../exports/result-db-copy'; import { complianceAiExportJson, privacySafeResultJsonFileName } from '../../compliance/privacy-safe-export';
import { hasSelectedTimeslotScope } from '../../app/work-scope'; import { hasSelectedTimeslotScope } from '../../app/work-scope';
import { POLICY_CLASSIFICATION_LABELS } from '../../domain/policy-status-labels'; import { POLICY_CLASSIFICATION_LABELS } from '../../domain/policy-status-labels';
import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog'; import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog';
import { formatPolicyComparisonValue } from '../../profiles/group-policy/presentation'; import { formatPolicyComparisonValue } from '../../profiles/group-policy/presentation';
import type { ResultDbMaterializationStatus } from '../../domain/result-db-materialization'; import type { ResultDbMaterializationStatus } from '../../domain/result-db-materialization';
import { buildScopedResultDbQuery, resultDbBatchFilters } from '../../domain/result-db-query'; import { buildScopedResultDbQuery, resultDbBatchFilters } from '../../domain/result-db-query';
import { RegulatoryMappingBatchDialog, useRegulatoryMappingBatch } from './RegulatoryMappingBatchDialog'; import { RegulatoryMappingBatchDialog, useRegulatoryMappingBatch } from '../../compliance/ui/RegulatoryMappingBatchDialog';
const assessorApi = new AssessorApiClient(); const assessorApi = new AssessorApiClient();
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@ -58,6 +58,7 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
filters: resultDbBatchFilters(scopedQuery) filters: resultDbBatchFilters(scopedQuery)
} : undefined, [scopedQuery]); } : undefined, [scopedQuery]);
const regulatoryBatch = useRegulatoryMappingBatch(backendStatus === 'connected' ? projectId : undefined, batchScope); const regulatoryBatch = useRegulatoryMappingBatch(backendStatus === 'connected' ? projectId : undefined, batchScope);
useEffect(() => { if (!regulatoryBatch.available) setBatchDialogOpen(false); }, [regulatoryBatch.available]);
const load = useCallback(async () => { const load = useCallback(async () => {
if (backendStatus !== 'connected' || !projectId || !hasTimeslotScope) { if (backendStatus !== 'connected' || !projectId || !hasTimeslotScope) {
@ -123,7 +124,8 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
downloadText( downloadText(
browserDownloadAdapter, browserDownloadAdapter,
privacySafeResultJsonFileName(generatedAt), privacySafeResultJsonFileName(generatedAt),
resultsToPrivacySafeExportJson(results), // Compliance Engine extension point: exactly what its AI features receive for these rows.
complianceAiExportJson(results),
'application/json;charset=utf-8' 'application/json;charset=utf-8'
); );
} else downloadText( } else downloadText(
@ -178,19 +180,19 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
<p className="db-structure-meta">{(queryResult?.total ?? 0).toLocaleString('en-US')} hits{queryResult && queryResult.total > queryResult.items.length ? ` · ${queryResult.items.length.toLocaleString('en-US')} loaded` : ''}{resultDbStatus && <><br/><span className="results-db-status" data-state={resultDbStatus.state}>{resultDbStatusText(resultDbStatus)}</span></>}</p> <p className="db-structure-meta">{(queryResult?.total ?? 0).toLocaleString('en-US')} hits{queryResult && queryResult.total > queryResult.items.length ? ` · ${queryResult.items.length.toLocaleString('en-US')} loaded` : ''}{resultDbStatus && <><br/><span className="results-db-status" data-state={resultDbStatus.state}>{resultDbStatusText(resultDbStatus)}</span></>}</p>
<div className="page-action-buttons"> <div className="page-action-buttons">
<button className="btn" type="button" disabled={loading} onClick={() => void load()}>{loading ? 'Loading …' : 'Refresh'}</button> <button className="btn" type="button" disabled={loading} onClick={() => void load()}>{loading ? 'Loading …' : 'Refresh'}</button>
<button className="btn" type="button" {regulatoryBatch.available && <button className="btn" type="button"
disabled={loading || regulatoryBatch.loading || Boolean(regulatoryBatch.error) || !queryResult?.total || regulatoryBatch.response?.generationEnabled === false} disabled={loading || regulatoryBatch.loading || Boolean(regulatoryBatch.error) || !queryResult?.total || regulatoryBatch.response?.generationEnabled === false}
title={regulatoryBatch.error ?? regulatoryBatch.response?.disabledReason ?? 'Generate ISO control proposals for every result matching the current filters'} title={regulatoryBatch.error ?? regulatoryBatch.response?.disabledReason ?? 'Generate ISO control proposals for every result matching the current filters'}
onClick={() => setBatchDialogOpen(true)}>Map all ISO controls</button> onClick={() => setBatchDialogOpen(true)}>Map all ISO controls</button>}
<button className="btn primary" type="button" disabled={loading || exporting !== null || !queryResult?.total} <button className="btn primary" type="button" disabled={loading || exporting !== null || !queryResult?.total}
onClick={() => void exportFilteredResults('jsonl')}>{exporting === 'jsonl' ? 'Exporting …' : 'Export JSONL'}</button> onClick={() => void exportFilteredResults('jsonl')}>{exporting === 'jsonl' ? 'Exporting …' : 'Export JSONL'}</button>
<button className="btn primary" type="button" disabled={loading || exporting !== null || !queryResult?.total} <button className="btn primary" type="button" disabled={loading || exporting !== null || !queryResult?.total}
onClick={() => void exportFilteredResults('excel')}>{exporting === 'excel' ? 'Exporting …' : 'Export Excel'}</button> onClick={() => void exportFilteredResults('excel')}>{exporting === 'excel' ? 'Exporting …' : 'Export Excel'}</button>
<button className="btn primary" type="button" disabled={loading || exporting !== null || !queryResult?.total} <button className="btn primary" type="button" disabled={loading || exporting !== null || !queryResult?.total}
onClick={() => void exportFilteredResults('csv')}>{exporting === 'csv' ? 'Exporting …' : 'Export CSV'}</button> onClick={() => void exportFilteredResults('csv')}>{exporting === 'csv' ? 'Exporting …' : 'Export CSV'}</button>
<button className="btn primary" type="button" disabled={loading || exporting !== null || !queryResult?.total} {regulatoryBatch.available && <button className="btn primary" type="button" disabled={loading || exporting !== null || !queryResult?.total}
title="Export all filtered results as JSON while excluding identifying details" title="Export exactly what the AI overview and the ISO mapping receive for the filtered results"
onClick={() => void exportFilteredResults('json')}>{exporting === 'json' ? 'Exporting …' : 'Export JSON'}</button> onClick={() => void exportFilteredResults('json')}>{exporting === 'json' ? 'Exporting …' : 'Export JSON'}</button>}
</div> </div>
</div> </div>
{!queryResult?.items.length ? <FilterEmptyState title="No hits" onReset={() => updateFilters({ ...DEFAULT_RESULT_DB_FILTERS })} onSearchAll={() => updateFilters({})}>No result matches the current filter combination.</FilterEmptyState> : <div className="results-browser-table" role="table" aria-label="Results"> {!queryResult?.items.length ? <FilterEmptyState title="No hits" onReset={() => updateFilters({ ...DEFAULT_RESULT_DB_FILTERS })} onSearchAll={() => updateFilters({})}>No result matches the current filter combination.</FilterEmptyState> : <div className="results-browser-table" role="table" aria-label="Results">
@ -208,7 +210,7 @@ export function ResultsBrowser({ backendStatus, projectId, scanRunIds, assessmen
</div>} </div>}
</div> </div>
</div>} </div>}
{batchDialogOpen && <RegulatoryMappingBatchDialog controller={regulatoryBatch} {batchDialogOpen && regulatoryBatch.available && <RegulatoryMappingBatchDialog controller={regulatoryBatch}
filteredResultCount={queryResult?.total ?? 0} onClose={() => setBatchDialogOpen(false)}/>} filteredResultCount={queryResult?.total ?? 0} onClose={() => setBatchDialogOpen(false)}/>}
</div>; </div>;
} }
@ -247,6 +249,7 @@ export function ResultRow({ item: sourceItem, expanded, onToggle, workRecordIds
<p>{displayEnvironmentValue(item.policyValues)}</p> <p>{displayEnvironmentValue(item.policyValues)}</p>
</div>} </div>}
{item.description && <p>{item.description}</p>} {item.description && <p>{item.description}</p>}
{item.technicalExplanation && <p><strong>Technical explanation:</strong> {item.technicalExplanation}</p>}
{item.recommendation && <p><strong>Recommendation:</strong> {item.recommendation}</p>} {item.recommendation && <p><strong>Recommendation:</strong> {item.recommendation}</p>}
{policyComparison && <div className="results-environment-value"> {policyComparison && <div className="results-environment-value">
<strong>Expected baseline value</strong> <strong>Expected baseline value</strong>
@ -328,7 +331,7 @@ function processingStatusLabel(value: string | undefined): string {
} }
const traceFieldLabels: Record<string, string> = { const traceFieldLabels: Record<string, string> = {
title: 'Title', description: 'Description', recommendation: 'Recommendation', sourceRuleId: 'Source Rule ID', title: 'Title', description: 'Description', technicalExplanation: 'Technical explanation', recommendation: 'Recommendation', sourceRuleId: 'Source Rule ID',
policyBaselineLabel: 'Baseline', policyBaselineValue: 'Expected baseline value', policyBaselineLabel: 'Baseline', policyBaselineValue: 'Expected baseline value',
policyBaselineVariantAssessment: 'Baseline expectations', environmentNormalizedValue: 'Value', categories: 'Categories' policyBaselineVariantAssessment: 'Baseline expectations', environmentNormalizedValue: 'Value', categories: 'Categories'
}; };

View File

@ -1,24 +1,33 @@
import type { ImportedDataset } from '../../domain/dataset'; import type { ImportedDataset } from '../../domain/dataset';
import type { UnifiedResultProjection } from '../../domain/unified-result'; import type { UnifiedResultProjection } from '../../domain/unified-result';
/** Scope and technical setting identity of a rolled-up GPO Work setting, from its record id. */
export function gpoWorkSettingIdentity(recordId: string): { scope: unknown; settingId: unknown } | undefined {
if (!recordId.startsWith('gpo-work-setting:')) return undefined;
try {
const key = JSON.parse(decodeURIComponent(recordId.slice('gpo-work-setting:'.length))) as unknown[];
return { scope: key[2], settingId: key[3] };
} catch {
// Not an evaluated setting key; unevaluated diagnostics have no comparison original.
return undefined;
}
}
/** /**
* Work records holding the original of a Result DB row (user decision 2026-09-28: the Result DB shows * Work records holding the original of a Result DB row (user decision 2026-09-28: the Result DB shows
* only the checked content; the original stays in Work). Scanner rows match their finding occurrence; * only the checked content; the original stays in Work). Scanner rows match their finding occurrence;
* GPO comparison rows match the Work setting of the same scope and technical setting identity. * GPO comparison rows match the Work setting of the same scope and technical setting identity.
*/ */
export function workRecordIdsForResult(dataset: ImportedDataset | null | undefined, row: UnifiedResultProjection): string[] { export function workRecordIdsForResult(dataset: ImportedDataset | null | undefined,
row: Pick<UnifiedResultProjection, 'sourceKind' | 'sourceResultId' | 'sourceRuleId' | 'policyScope'>): string[] {
if (!dataset) return []; if (!dataset) return [];
const ids = new Set<string>(); const ids = new Set<string>();
for (const sheet of dataset.sheets) { for (const sheet of dataset.sheets) {
for (const record of sheet.analysis.records) { for (const record of sheet.analysis.records) {
if (row.sourceKind === 'scanner-finding' && record.findingOccurrenceId === row.sourceResultId) ids.add(record.id); if (row.sourceKind === 'scanner-finding' && record.findingOccurrenceId === row.sourceResultId) ids.add(record.id);
if (row.sourceKind === 'policy-evaluation' && row.sourceRuleId && record.id.startsWith('gpo-work-setting:')) { if (row.sourceKind === 'policy-evaluation' && row.sourceRuleId) {
try { const identity = gpoWorkSettingIdentity(record.id);
const key = JSON.parse(decodeURIComponent(record.id.slice('gpo-work-setting:'.length))) as unknown[]; if (identity && identity.scope === row.policyScope && identity.settingId === row.sourceRuleId) ids.add(record.id);
if (key[2] === row.policyScope && key[3] === row.sourceRuleId) ids.add(record.id);
} catch {
// Not an evaluated setting key; unevaluated diagnostics have no comparison original.
}
} }
} }
} }

View File

@ -6,6 +6,7 @@ import { GPO_FAILING_BACKUP_FACET_ID, sourceScanNavigationSheet } from './dynami
import { DynamicFilterControls } from './DynamicFilterControls'; import { DynamicFilterControls } from './DynamicFilterControls';
import { selectWorkFacets } from './work-facets'; import { selectWorkFacets } from './work-facets';
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index'; import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
import { COMPLIANCE_FACET_IDS, useComplianceWorkFacets } from '../../compliance/ui/compliance-work-facets';
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet'; import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
import { measureNavigationStep } from '../../app/navigation-diagnostics'; import { measureNavigationStep } from '../../app/navigation-diagnostics';
export { WorkTimeslotControl } from './WorkTimeslotControl'; export { WorkTimeslotControl } from './WorkTimeslotControl';
@ -92,11 +93,12 @@ export function SearchFilterBar({ hiddenFacetLabels = [] }: SearchFilterBarProps
const engines = useEnginePreferences(); const engines = useEnginePreferences();
// The facet uses the shared context for the current scope. // The facet uses the shared context for the current scope.
const contextClusters = useContextClusterIndex(); const contextClusters = useContextClusterIndex();
const complianceFacets = useComplianceWorkFacets();
const countedFacets = useMemo( const countedFacets = useMemo(
() => measureNavigationStep('filter-counts', () => selectWorkFacets(activeSheet, { () => measureNavigationStep('filter-counts', () => selectWorkFacets(activeSheet, {
activeProfile, search, statusFilter, facetFilters, dataset, actionOnly, reviews, pathFindingIds, contextClusters activeProfile, search, statusFilter, facetFilters, dataset, actionOnly, reviews, pathFindingIds, contextClusters, complianceFacets
})), })),
[actionOnly, activeProfile, activeSheet, contextClusters, dataset, facetFilters, pathFindingIds, reviews, search, statusFilter] [actionOnly, activeProfile, activeSheet, complianceFacets, contextClusters, dataset, facetFilters, pathFindingIds, reviews, search, statusFilter]
); );
const visibleFacets = useMemo( const visibleFacets = useMemo(
() => countedFacets.filter(facet => !hiddenFacetLabels.includes(facet.label)), () => countedFacets.filter(facet => !hiddenFacetLabels.includes(facet.label)),
@ -123,6 +125,8 @@ export function SearchFilterBar({ hiddenFacetLabels = [] }: SearchFilterBarProps
Object.keys(facetFilters).forEach(facetId => { Object.keys(facetFilters).forEach(facetId => {
// Keep the selected context filter while its context is loading. // Keep the selected context filter while its context is loading.
if (facetId === CONTEXT_CLUSTER_FACET_ID && engines.context) return; if (facetId === CONTEXT_CLUSTER_FACET_ID && engines.context) return;
// Likewise keep a selected ISO filter while the Compliance Engine assignments load.
if ((COMPLIANCE_FACET_IDS as readonly string[]).includes(facetId) && engines.compliance && !complianceFacets) return;
if (!contextualFacetIds.has(facetId)) setFacetFilter(facetId, null); if (!contextualFacetIds.has(facetId)) setFacetFilter(facetId, null);
}); });
countedFacets.forEach(facet => { countedFacets.forEach(facet => {
@ -137,7 +141,7 @@ export function SearchFilterBar({ hiddenFacetLabels = [] }: SearchFilterBarProps
if (statusFilter !== 'all' && statusFacet && !statusFacet.values.some(value => value.value === statusFilter)) { if (statusFilter !== 'all' && statusFacet && !statusFacet.values.some(value => value.value === statusFilter)) {
setStatusFilter('all'); setStatusFilter('all');
} }
}, [engines.context, countedFacets, facetFilters, setFacetFilter, setStatusFilter, statusFilter]); }, [complianceFacets, engines.compliance, engines.context, countedFacets, facetFilters, setFacetFilter, setStatusFilter, statusFilter]);
return ( return (
<div className="search-tools"> <div className="search-tools">

View File

@ -13,6 +13,7 @@ import {
import { createSheetDerivationCache } from '../../shared/sheet-derivation-cache'; import { createSheetDerivationCache } from '../../shared/sheet-derivation-cache';
import { withContextClusterFacet, type ContextClusterIndex } from '../context-layer/context-cluster-facet'; import { withContextClusterFacet, type ContextClusterIndex } from '../context-layer/context-cluster-facet';
import { withBusinessImpactFacet } from './business-impact-controls'; import { withBusinessImpactFacet } from './business-impact-controls';
import { withComplianceFacets, type ComplianceWorkFacetIndex } from '../../compliance/ui/compliance-work-facets';
import { readEnginePreferences } from '../../app/engine-preferences'; import { readEnginePreferences } from '../../app/engine-preferences';
export interface RecordFilterOptions { export interface RecordFilterOptions {
@ -28,6 +29,8 @@ export interface RecordFilterOptions {
reviews: Record<string, Review>; reviews: Record<string, Review>;
/** Context layer facet (Phase 13E); absent while the layer is off, so Work filters exactly as without it. */ /** Context layer facet (Phase 13E); absent while the layer is off, so Work filters exactly as without it. */
contextClusters?: ContextClusterIndex; contextClusters?: ContextClusterIndex;
/** Compliance Engine ISO control and ISO theme facets; absent while the engine is off. */
complianceFacets?: ComplianceWorkFacetIndex;
} }
const cachedRecords = createSheetDerivationCache<FindingRecord[]>(); const cachedRecords = createSheetDerivationCache<FindingRecord[]>();
@ -37,7 +40,7 @@ export function recordFilterKeys(options: RecordFilterOptions): readonly unknown
return [options.activeProfile, options.dataset, options.reviews, options.search, return [options.activeProfile, options.dataset, options.reviews, options.search,
options.sortColumn, options.sortDirection, options.statusFilter, options.actionOnly, options.sortColumn, options.sortDirection, options.statusFilter, options.actionOnly,
options.pathFindingIds?.length ? options.pathFindingIds : undefined, options.pathFindingIds?.length ? options.pathFindingIds : undefined,
JSON.stringify(options.facetFilters ?? {}), options.contextClusters, readEnginePreferences()]; JSON.stringify(options.facetFilters ?? {}), options.contextClusters, options.complianceFacets, readEnginePreferences()];
} }
export function filterAndSortRecords(sheet: DatasetSheet | undefined, options: RecordFilterOptions): FindingRecord[] { export function filterAndSortRecords(sheet: DatasetSheet | undefined, options: RecordFilterOptions): FindingRecord[] {
@ -50,9 +53,9 @@ function deriveFilteredRecords(sheet: DatasetSheet, options: RecordFilterOptions
const normalizedSearch = options.search.trim().toLocaleLowerCase('en-US'); const normalizedSearch = options.search.trim().toLocaleLowerCase('en-US');
const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews); const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews);
const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews); const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews);
const facets = withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy' const facets = withComplianceFacets(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy') ? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy')
: baseFacets, options.contextClusters), options.contextClusters); : baseFacets, options.contextClusters), options.contextClusters), options.complianceFacets);
const pathFindingIds = options.pathFindingIds?.length ? new Set(options.pathFindingIds) : undefined; const pathFindingIds = options.pathFindingIds?.length ? new Set(options.pathFindingIds) : undefined;
const filtered = sheet.analysis.records.filter(record => { const filtered = sheet.analysis.records.filter(record => {
if (pathFindingIds && !pathFindingIds.has(record.id)) return false; if (pathFindingIds && !pathFindingIds.has(record.id)) return false;

View File

@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { useApp } from '../../app/AppProvider'; import { useApp } from '../../app/AppProvider';
import { filterAndSortRecords } from './filter-records'; import { filterAndSortRecords } from './filter-records';
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index'; import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
import { useComplianceWorkFacets } from '../../compliance/ui/compliance-work-facets';
export function useFilteredRecords(statusFilterOverride?: string) { export function useFilteredRecords(statusFilterOverride?: string) {
const { const {
@ -18,6 +19,7 @@ export function useFilteredRecords(statusFilterOverride?: string) {
reviews reviews
} = useApp(); } = useApp();
const contextClusters = useContextClusterIndex(); const contextClusters = useContextClusterIndex();
const complianceFacets = useComplianceWorkFacets();
const effectiveStatusFilter = statusFilterOverride ?? statusFilter; const effectiveStatusFilter = statusFilterOverride ?? statusFilter;
return useMemo(() => filterAndSortRecords(activeSheet, { return useMemo(() => filterAndSortRecords(activeSheet, {
@ -31,6 +33,7 @@ export function useFilteredRecords(statusFilterOverride?: string) {
pathFindingIds, pathFindingIds,
actionOnly, actionOnly,
reviews, reviews,
contextClusters contextClusters,
}), [activeProfile, activeSheet, actionOnly, contextClusters, dataset, effectiveStatusFilter, facetFilters, pathFindingIds, reviews, search, sortColumn, sortDirection]); complianceFacets
}), [activeProfile, activeSheet, actionOnly, complianceFacets, contextClusters, dataset, effectiveStatusFilter, facetFilters, pathFindingIds, reviews, search, sortColumn, sortDirection]);
} }

View File

@ -9,6 +9,7 @@ import {
import { filterAndSortRecords, recordFilterKeys, type RecordFilterOptions } from './filter-records'; import { filterAndSortRecords, recordFilterKeys, type RecordFilterOptions } from './filter-records';
import { withContextClusterFacet } from '../context-layer/context-cluster-facet'; import { withContextClusterFacet } from '../context-layer/context-cluster-facet';
import { withBusinessImpactFacet } from './business-impact-controls'; import { withBusinessImpactFacet } from './business-impact-controls';
import { withComplianceFacets } from '../../compliance/ui/compliance-work-facets';
export type WorkFacetOptions = Omit<RecordFilterOptions, 'sortColumn' | 'sortDirection'>; export type WorkFacetOptions = Omit<RecordFilterOptions, 'sortColumn' | 'sortDirection'>;
const cache = createSheetDerivationCache<DynamicFacet[]>(); const cache = createSheetDerivationCache<DynamicFacet[]>();
@ -22,9 +23,9 @@ export function selectWorkFacets(sheet: DatasetSheet | undefined, options: WorkF
return cache(sheet, recordFilterKeys(query), () => { return cache(sheet, recordFilterKeys(query), () => {
const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews); const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews);
const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews); const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews);
const facets = withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy' const facets = withComplianceFacets(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy') ? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy')
: baseFacets, options.contextClusters), options.contextClusters); : baseFacets, options.contextClusters), options.contextClusters), options.complianceFacets);
const records = filterAndSortRecords(sheet, { ...query, statusFilter: 'all', facetFilters: emptyFilters }); const records = filterAndSortRecords(sheet, { ...query, statusFilter: 'all', facetFilters: emptyFilters });
// The engine retains its complete vocabulary; the Work control offers only reachable clusters. // The engine retains its complete vocabulary; the Work control offers only reachable clusters.
const selectableFacets = facets.map(facet => facet.derivedKind === 'context-cluster' const selectableFacets = facets.map(facet => facet.derivedKind === 'context-cluster'

View File

@ -9,6 +9,7 @@ import { type DynamicFacetFilters } from '../search-filter/dynamic-facets';
import { selectWorkFacets } from '../search-filter/work-facets'; import { selectWorkFacets } from '../search-filter/work-facets';
import type { ContextClusterIndex } from '../context-layer/context-cluster-facet'; import type { ContextClusterIndex } from '../context-layer/context-cluster-facet';
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index'; import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
import { useComplianceWorkFacets, type ComplianceWorkFacetIndex } from '../../compliance/ui/compliance-work-facets';
import { businessImpactControls, BUSINESS_IMPACT_FACET_ID } from '../search-filter/business-impact-controls'; import { businessImpactControls, BUSINESS_IMPACT_FACET_ID } from '../search-filter/business-impact-controls';
import { useEnginePreferences } from '../../app/engine-preferences'; import { useEnginePreferences } from '../../app/engine-preferences';
@ -23,6 +24,7 @@ interface SecurityDomainOptions {
search: string; search: string;
actionOnly: boolean; actionOnly: boolean;
contextClusters?: ContextClusterIndex; contextClusters?: ContextClusterIndex;
complianceFacets?: ComplianceWorkFacetIndex;
} }
export interface SidebarSecurityDomain { export interface SidebarSecurityDomain {
@ -38,11 +40,11 @@ export interface SidebarSecurityDomainSummary {
/** Uses the exact shared Client Info facet, ordering and self-excluding counts. */ /** Uses the exact shared Client Info facet, ordering and self-excluding counts. */
export function summarizeSecurityDomains(options: SecurityDomainOptions): SidebarSecurityDomainSummary | undefined { export function summarizeSecurityDomains(options: SecurityDomainOptions): SidebarSecurityDomainSummary | undefined {
const { sheet, dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters } = options; const { sheet, dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, complianceFacets } = options;
if (!sheet) return undefined; if (!sheet) return undefined;
const facets = selectWorkFacets(sheet, { const facets = selectWorkFacets(sheet, {
dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, complianceFacets
}); });
const domainFacet = facets.find(facet => facet.id === SECURITY_DOMAIN_FACET_ID); const domainFacet = facets.find(facet => facet.id === SECURITY_DOMAIN_FACET_ID);
if (!domainFacet) return undefined; if (!domainFacet) return undefined;
@ -74,15 +76,16 @@ export function SidebarSecurityDomains({ sheet }: { sheet: DatasetSheet | undefi
setStatusFilter setStatusFilter
} = useApp(); } = useApp();
const contextClusters = useContextClusterIndex(); const contextClusters = useContextClusterIndex();
const complianceFacets = useComplianceWorkFacets();
const engines = useEnginePreferences(); const engines = useEnginePreferences();
const impactCounts = useMemo(() => { const impactCounts = useMemo(() => {
if (!engines.report || !engines.context || !contextClusters) return undefined; if (!engines.report || !engines.context || !contextClusters) return undefined;
const facet = selectWorkFacets(sheet, { dataset, activeProfile, reviews, statusFilter: 'deviation', facetFilters, const facet = selectWorkFacets(sheet, { dataset, activeProfile, reviews, statusFilter: 'deviation', facetFilters,
pathFindingIds, search, actionOnly, contextClusters }).find(item => item.id === BUSINESS_IMPACT_FACET_ID); pathFindingIds, search, actionOnly, contextClusters, complianceFacets }).find(item => item.id === BUSINESS_IMPACT_FACET_ID);
if (!facet) return undefined; if (!facet) return undefined;
const counts = new Map(facet.values.map(value => [value.value, value.count])); const counts = new Map(facet.values.map(value => [value.value, value.count]));
return businessImpactControls.map(theme => ({ ...theme, count: counts.get(theme.id) ?? 0 })); return businessImpactControls.map(theme => ({ ...theme, count: counts.get(theme.id) ?? 0 }));
}, [engines.report, engines.context, contextClusters, sheet, dataset, activeProfile, reviews, facetFilters, pathFindingIds, search, actionOnly]); }, [engines.report, engines.context, complianceFacets, contextClusters, sheet, dataset, activeProfile, reviews, facetFilters, pathFindingIds, search, actionOnly]);
const summary = useMemo(() => measureNavigationStep('security-domain-summary', () => summarizeSecurityDomains({ const summary = useMemo(() => measureNavigationStep('security-domain-summary', () => summarizeSecurityDomains({
sheet, sheet,
dataset, dataset,
@ -93,8 +96,9 @@ export function SidebarSecurityDomains({ sheet }: { sheet: DatasetSheet | undefi
pathFindingIds, pathFindingIds,
search, search,
actionOnly, actionOnly,
contextClusters contextClusters,
})), [actionOnly, activeProfile, contextClusters, dataset, facetFilters, pathFindingIds, reviews, search, sheet, statusFilter]); complianceFacets
})), [actionOnly, activeProfile, complianceFacets, contextClusters, dataset, facetFilters, pathFindingIds, reviews, search, sheet, statusFilter]);
if (!summary) return null; if (!summary) return null;
const selected = facetFilters[summary.facetId]?.values ?? []; const selected = facetFilters[summary.facetId]?.values ?? [];

View File

@ -28,7 +28,6 @@ import './styles/obsidian-graph.css';
import './styles/functional-icons.css'; import './styles/functional-icons.css';
import './styles/tile-contract.css'; import './styles/tile-contract.css';
import './styles/filter-empty-state.css'; import './styles/filter-empty-state.css';
import './styles/ai-overview.css';
import './styles/defender-for-iot.css'; import './styles/defender-for-iot.css';
if (import.meta.env.DEV && new URLSearchParams(location.search).get('measureNavigation') === '1') { if (import.meta.env.DEV && new URLSearchParams(location.search).get('measureNavigation') === '1') {

View File

@ -35,8 +35,6 @@ import type {
UnifiedResultQueryResult UnifiedResultQueryResult
} from '../domain/unified-result'; } from '../domain/unified-result';
import type { EnvironmentObservation, EnvironmentSnapshot, EnvironmentSnapshotQuery, ScannerReportSection } from '../domain/environment-context'; import type { EnvironmentObservation, EnvironmentSnapshot, EnvironmentSnapshotQuery, ScannerReportSection } from '../domain/environment-context';
import type { AiOverviewRun } from '../domain/ai-overview';
import type { RegulatoryMappingBatchRun, RegulatoryMappingDecision, RegulatoryMappingRun } from '../domain/regulatory-mapping';
export interface FindingOccurrenceQuery { export interface FindingOccurrenceQuery {
projectId?: string; projectId?: string;
@ -148,10 +146,6 @@ export interface ProjectDataExport {
gpoPolicyAnalyses: StoredGpoPolicyAnalysis[]; gpoPolicyAnalyses: StoredGpoPolicyAnalysis[];
environmentProfiles: EnvironmentProfile[]; environmentProfiles: EnvironmentProfile[];
policySnapshotAggregations: StoredPolicySnapshotAggregation[]; policySnapshotAggregations: StoredPolicySnapshotAggregation[];
aiOverviewRuns: AiOverviewRun[];
regulatoryMappingBatchRuns: RegulatoryMappingBatchRun[];
regulatoryMappingRuns: RegulatoryMappingRun[];
regulatoryMappingDecisions: RegulatoryMappingDecision[];
} }
export interface ControlledDeleteConfirmation { export interface ControlledDeleteConfirmation {
@ -220,23 +214,12 @@ export interface AssessorRepository {
findOccurrences(query?: FindingOccurrenceQuery): Promise<FindingOccurrenceQueryResult>; findOccurrences(query?: FindingOccurrenceQuery): Promise<FindingOccurrenceQueryResult>;
queryUnifiedResults(query?: UnifiedResultQuery): Promise<UnifiedResultQueryResult>; queryUnifiedResults(query?: UnifiedResultQuery): Promise<UnifiedResultQueryResult>;
queryUnifiedResultFacets(query?: UnifiedResultQuery): Promise<UnifiedResultFacetResult>; queryUnifiedResultFacets(query?: UnifiedResultQuery): Promise<UnifiedResultFacetResult>;
getUnifiedResultById(resultProjectionId: string): Promise<UnifiedResultProjection | null>; /** Returns the scope-stripped Result DB row; with projectId, only a row stored for that project. */
getUnifiedResultById(resultProjectionId: string, projectId?: string): Promise<UnifiedResultProjection | null>;
/** State of the materialized, disclosure-checked Result DB (see result-db-materialization). */ /** State of the materialized, disclosure-checked Result DB (see result-db-materialization). */
getResultDbStatus(): Promise<ResultDbMaterializationStatus>; getResultDbStatus(): Promise<ResultDbMaterializationStatus>;
listResultCategories(query?: UnifiedResultQuery): Promise<UnifiedResultCategory[]>; listResultCategories(query?: UnifiedResultQuery): Promise<UnifiedResultCategory[]>;
getEnvironmentSnapshot(query: EnvironmentSnapshotQuery): Promise<EnvironmentSnapshot>; getEnvironmentSnapshot(query: EnvironmentSnapshotQuery): Promise<EnvironmentSnapshot>;
createAiOverviewRun(run: AiOverviewRun): Promise<AiOverviewRun>;
updateAiOverviewRun(run: AiOverviewRun): Promise<AiOverviewRun>;
listAiOverviewRuns(projectId: string, scopeKey?: string): Promise<AiOverviewRun[]>;
getRegulatoryMappingRun(id: string): Promise<RegulatoryMappingRun | null>;
claimRegulatoryMappingRun(run: RegulatoryMappingRun, forceRegeneration: boolean): Promise<{ run: RegulatoryMappingRun; claimed: boolean }>;
saveRegulatoryMappingRun(run: RegulatoryMappingRun): Promise<RegulatoryMappingRun>;
listRegulatoryMappingRuns(): Promise<RegulatoryMappingRun[]>;
createRegulatoryMappingBatchRun(run: RegulatoryMappingBatchRun): Promise<RegulatoryMappingBatchRun>;
updateRegulatoryMappingBatchRun(run: RegulatoryMappingBatchRun): Promise<RegulatoryMappingBatchRun>;
listRegulatoryMappingBatchRuns(projectId: string, scopeKey?: string): Promise<RegulatoryMappingBatchRun[]>;
createRegulatoryMappingDecision(decision: RegulatoryMappingDecision, expectedLatestDecisionId?: string): Promise<RegulatoryMappingDecision>;
listRegulatoryMappingDecisions(ruleKey: string): Promise<RegulatoryMappingDecision[]>;
saveImportArtifact(artifact: ImportArtifact): Promise<void>; saveImportArtifact(artifact: ImportArtifact): Promise<void>;
saveImportArtifactAndEnqueueJob(artifact: ImportArtifact, job: IngestionJob): Promise<ArtifactJobCommitResult>; saveImportArtifactAndEnqueueJob(artifact: ImportArtifact, job: IngestionJob): Promise<ArtifactJobCommitResult>;

View File

@ -33,10 +33,6 @@ import { storedPolicyEvaluationValidationErrors } from '../domain/policy-evaluat
import type { StoredPolicyFindingRun } from '../domain/policy-finding'; import type { StoredPolicyFindingRun } from '../domain/policy-finding';
import type { EnvironmentObservation, EnvironmentSnapshot, EnvironmentSnapshotQuery, ScannerReportSection } from '../domain/environment-context'; import type { EnvironmentObservation, EnvironmentSnapshot, EnvironmentSnapshotQuery, ScannerReportSection } from '../domain/environment-context';
import { environmentCoverage } from '../domain/environment-context'; import { environmentCoverage } from '../domain/environment-context';
import type { AiOverviewRun } from '../domain/ai-overview';
import { aiOverviewRunValidationErrors } from '../domain/ai-overview';
import type { RegulatoryMappingBatchRun, RegulatoryMappingDecision, RegulatoryMappingRun } from '../domain/regulatory-mapping';
import { regulatoryMappingBatchRunValidationErrors, regulatoryMappingDecisionValidationErrors, regulatoryMappingRunValidationErrors } from '../domain/regulatory-mapping';
import { storedPolicyFindingRunValidationErrors } from '../domain/policy-finding'; import { storedPolicyFindingRunValidationErrors } from '../domain/policy-finding';
import { import {
environmentProfileValidationErrors, environmentProfileValidationErrors,
@ -112,10 +108,6 @@ interface LocalRepositoryState {
environmentObservations: EnvironmentObservation[]; environmentObservations: EnvironmentObservation[];
findingDefinitions: FindingDefinition[]; findingDefinitions: FindingDefinition[];
findingOccurrences: FindingOccurrence[]; findingOccurrences: FindingOccurrence[];
aiOverviewRuns: AiOverviewRun[];
regulatoryMappingBatchRuns: RegulatoryMappingBatchRun[];
regulatoryMappingRuns: RegulatoryMappingRun[];
regulatoryMappingDecisions: RegulatoryMappingDecision[];
evidence: EvidenceRecord[]; evidence: EvidenceRecord[];
reviews: FindingReview[]; reviews: FindingReview[];
scopedRiskAcceptances: ScopedRiskAcceptance[]; scopedRiskAcceptances: ScopedRiskAcceptance[];
@ -151,10 +143,6 @@ const emptyState = (): LocalRepositoryState => ({
environmentObservations: [], environmentObservations: [],
findingDefinitions: [], findingDefinitions: [],
findingOccurrences: [], findingOccurrences: [],
aiOverviewRuns: [],
regulatoryMappingBatchRuns: [],
regulatoryMappingRuns: [],
regulatoryMappingDecisions: [],
evidence: [], evidence: [],
reviews: [], reviews: [],
scopedRiskAcceptances: [], scopedRiskAcceptances: [],
@ -398,142 +386,6 @@ export class LocalAssessorRepository implements AssessorRepository {
return clone(this.state.referenceCatalogs.filter(catalog => hashes.has(catalog.sha256))); return clone(this.state.referenceCatalogs.filter(catalog => hashes.has(catalog.sha256)));
} }
async createAiOverviewRun(run: AiOverviewRun): Promise<AiOverviewRun> {
const errors = aiOverviewRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
if (!this.state.projects.some(project => project.id === run.projectId)) {
throw new RepositoryNotFoundError(`Projekt ${run.projectId} ist nicht vorhanden.`);
}
const idempotent = this.state.aiOverviewRuns.find(item =>
item.projectId === run.projectId && item.scopeKey === run.scopeKey && item.idempotencyKey === run.idempotencyKey);
if (idempotent) return clone(idempotent);
if (this.state.aiOverviewRuns.some(item => item.scopeKey === run.scopeKey && ['queued', 'running'].includes(item.status))) {
throw new RepositoryConflictError('For this timeslot, an AI overview is already being generated.');
}
if (this.state.aiOverviewRuns.some(item => item.id === run.id)) throw new RepositoryConflictError(`AI overview run ${run.id} already exists.`);
const next = clone(this.state);
next.aiOverviewRuns.push(clone(run));
this.replaceState(next);
return clone(run);
}
async updateAiOverviewRun(run: AiOverviewRun): Promise<AiOverviewRun> {
const errors = aiOverviewRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
const existing = this.state.aiOverviewRuns.find(item => item.id === run.id);
if (!existing) throw new RepositoryNotFoundError(`AI overview run ${run.id} does not exist.`);
if (existing.projectId !== run.projectId || existing.scopeKey !== run.scopeKey || existing.inputHash !== run.inputHash
|| existing.idempotencyKey !== run.idempotencyKey || existing.model !== run.model) {
throw new RepositoryConflictError('Immutable AI overview run identity cannot be changed.');
}
if (['succeeded', 'failed'].includes(existing.status)) throw new RepositoryConflictError('A completed AI overview run is immutable.');
if (existing.status === 'queued' && !['running', 'failed'].includes(run.status)
|| existing.status === 'running' && !['running', 'succeeded', 'failed'].includes(run.status)) {
throw new RepositoryConflictError(`Invalid AI overview transition from ${existing.status} to ${run.status}.`);
}
const next = clone(this.state);
next.aiOverviewRuns[next.aiOverviewRuns.findIndex(item => item.id === run.id)] = clone(run);
this.replaceState(next);
return clone(run);
}
async listAiOverviewRuns(projectId: string, scopeKey?: string): Promise<AiOverviewRun[]> {
return this.state.aiOverviewRuns
.filter(item => item.projectId === projectId && (!scopeKey || item.scopeKey === scopeKey))
.map(clone)
.sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id));
}
async getRegulatoryMappingRun(id: string): Promise<RegulatoryMappingRun | null> {
const run = this.state.regulatoryMappingRuns.find(item => item.id === id);
return run ? clone(run) : null;
}
async claimRegulatoryMappingRun(run: RegulatoryMappingRun, forceRegeneration: boolean): Promise<{ run: RegulatoryMappingRun; claimed: boolean }> {
const errors = regulatoryMappingRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
const existing = this.state.regulatoryMappingRuns.find(item => item.id === run.id);
if (existing && (['queued', 'running'].includes(existing.status) || existing.status === 'succeeded' && !forceRegeneration)) {
return { run: clone(existing), claimed: false };
}
const stored = await this.saveRegulatoryMappingRun(run);
return { run: stored, claimed: true };
}
async saveRegulatoryMappingRun(run: RegulatoryMappingRun): Promise<RegulatoryMappingRun> {
const errors = regulatoryMappingRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
const next = clone(this.state);
const index = next.regulatoryMappingRuns.findIndex(item => item.id === run.id);
if (index >= 0) next.regulatoryMappingRuns[index] = clone(run);
else next.regulatoryMappingRuns.push(clone(run));
this.replaceState(next);
return clone(run);
}
async listRegulatoryMappingRuns(): Promise<RegulatoryMappingRun[]> {
return this.state.regulatoryMappingRuns.map(clone)
.sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id));
}
async createRegulatoryMappingBatchRun(run: RegulatoryMappingBatchRun): Promise<RegulatoryMappingBatchRun> {
const errors = regulatoryMappingBatchRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
const idempotent = this.state.regulatoryMappingBatchRuns.find(item => item.projectId === run.projectId && item.idempotencyKey === run.idempotencyKey);
if (idempotent) return clone(idempotent);
const active = this.state.regulatoryMappingBatchRuns.find(item => item.projectId === run.projectId && item.scopeKey === run.scopeKey
&& ['queued', 'running'].includes(item.status));
if (active) return clone(active);
const next = clone(this.state);
next.regulatoryMappingBatchRuns.push(clone(run));
this.replaceState(next);
return clone(run);
}
async updateRegulatoryMappingBatchRun(run: RegulatoryMappingBatchRun): Promise<RegulatoryMappingBatchRun> {
const errors = regulatoryMappingBatchRunValidationErrors(run);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
const existing = this.state.regulatoryMappingBatchRuns.find(item => item.id === run.id);
if (!existing) throw new RepositoryNotFoundError('Regulatory mapping batch does not exist.');
if (existing.projectId !== run.projectId || existing.scopeKey !== run.scopeKey || existing.idempotencyKey !== run.idempotencyKey) {
throw new RepositoryConflictError('Regulatory mapping batch identity is immutable.');
}
if (['completed', 'failed'].includes(existing.status)) throw new RepositoryConflictError('A completed regulatory mapping batch is immutable.');
const allowed = existing.status === 'queued' ? ['running', 'failed'] : ['running', 'completed', 'failed'];
if (!allowed.includes(run.status)) throw new RepositoryConflictError(`Invalid regulatory mapping batch transition from ${existing.status} to ${run.status}.`);
const next = clone(this.state);
next.regulatoryMappingBatchRuns[next.regulatoryMappingBatchRuns.findIndex(item => item.id === run.id)] = clone(run);
this.replaceState(next);
return clone(run);
}
async listRegulatoryMappingBatchRuns(projectId: string, scopeKey?: string): Promise<RegulatoryMappingBatchRun[]> {
return this.state.regulatoryMappingBatchRuns.filter(item => item.projectId === projectId && (!scopeKey || item.scopeKey === scopeKey))
.map(clone).reverse();
}
async createRegulatoryMappingDecision(decision: RegulatoryMappingDecision, expectedLatestDecisionId?: string): Promise<RegulatoryMappingDecision> {
const errors = regulatoryMappingDecisionValidationErrors(decision);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
const run = this.state.regulatoryMappingRuns.find(item => item.id === decision.mappingId);
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.state.regulatoryMappingDecisions.filter(item => item.ruleKey === decision.ruleKey && item.controlId === decision.controlId)
.sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id))[0];
if ((expectedLatestDecisionId ?? undefined) !== (latest?.id ?? undefined)) throw new RepositoryConflictError('Regulatory mapping decision changed before this update.');
const next = clone(this.state);
next.regulatoryMappingDecisions.push(clone({ ...decision, ...(latest ? { previousDecisionId: latest.id } : {}) }));
this.replaceState(next);
return clone(next.regulatoryMappingDecisions.at(-1)!);
}
async listRegulatoryMappingDecisions(ruleKey: string): Promise<RegulatoryMappingDecision[]> {
return this.state.regulatoryMappingDecisions.filter(item => item.ruleKey === ruleKey)
.map(clone).sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id));
}
async beginImport(start: ImportSessionStart): Promise<ImportSession> { async beginImport(start: ImportSessionStart): Promise<ImportSession> {
if (!start.sessionId.trim()) throw new RepositoryValidationError('Eine Importsitzung benötigt eine ID.'); if (!start.sessionId.trim()) throw new RepositoryValidationError('Eine Importsitzung benötigt eine ID.');
if (this.activeImportSessions.has(start.sessionId)) throw new RepositoryConflictError(`Importsitzung ${start.sessionId} ist bereits aktiv.`); if (this.activeImportSessions.has(start.sessionId)) throw new RepositoryConflictError(`Importsitzung ${start.sessionId} ist bereits aktiv.`);
@ -863,8 +715,8 @@ export class LocalAssessorRepository implements AssessorRepository {
return clone(deriveUnifiedResultFacets(this.unifiedResults(query.audience), query)); return clone(deriveUnifiedResultFacets(this.unifiedResults(query.audience), query));
} }
async getUnifiedResultById(resultProjectionId: string): Promise<UnifiedResultProjection | null> { async getUnifiedResultById(resultProjectionId: string, projectId?: string): Promise<UnifiedResultProjection | null> {
const result = this.unifiedResults().find(item => item.resultProjectionId === resultProjectionId); const result = this.unifiedResults().find(item => item.resultProjectionId === resultProjectionId && (projectId === undefined || item.projectId === projectId));
return result ? clone(removeResultDbScopeIdentifiers(result)) : null; return result ? clone(removeResultDbScopeIdentifiers(result)) : null;
} }
@ -1711,12 +1563,7 @@ export class LocalAssessorRepository implements AssessorRepository {
environmentProfiles: this.state.environmentProfiles.filter(item => item.projectId === projectId).map(clone), environmentProfiles: this.state.environmentProfiles.filter(item => item.projectId === projectId).map(clone),
policySnapshotAggregations: this.state.policySnapshotAggregations.filter(item => item.run.projectId === projectId).map(clone), policySnapshotAggregations: this.state.policySnapshotAggregations.filter(item => item.run.projectId === projectId).map(clone),
policyEvaluations: this.state.policyEvaluations.filter(item => item.run.projectId === projectId).map(clone), policyEvaluations: this.state.policyEvaluations.filter(item => item.run.projectId === projectId).map(clone),
policyFindingRuns: policyFindingRuns.map(clone), policyFindingRuns: policyFindingRuns.map(clone)
aiOverviewRuns: this.state.aiOverviewRuns.filter(item => item.projectId === projectId && item.status === 'succeeded').map(clone),
regulatoryMappingBatchRuns: this.state.regulatoryMappingBatchRuns.filter(item => item.projectId === projectId).map(clone),
// Regulatory mappings and decisions are global reference data, not project-scoped.
regulatoryMappingRuns: this.state.regulatoryMappingRuns.filter(item => item.status === 'succeeded').map(clone),
regulatoryMappingDecisions: this.state.regulatoryMappingDecisions.map(clone)
}); });
} }
@ -1778,8 +1625,6 @@ export class LocalAssessorRepository implements AssessorRepository {
next.gpoBackupInstances = next.gpoBackupInstances.filter(item => !processingRunIds.has(item.processingRunId)); next.gpoBackupInstances = next.gpoBackupInstances.filter(item => !processingRunIds.has(item.processingRunId));
next.gpoPolicyAnalyses = next.gpoPolicyAnalyses.filter(item => !processingRunIds.has(item.run.sourceProcessingRunId)); next.gpoPolicyAnalyses = next.gpoPolicyAnalyses.filter(item => !processingRunIds.has(item.run.sourceProcessingRunId));
next.artifactDeletionRequests = next.artifactDeletionRequests.filter(item => !artifactIds.has(item.artifactId)); next.artifactDeletionRequests = next.artifactDeletionRequests.filter(item => !artifactIds.has(item.artifactId));
next.aiOverviewRuns = next.aiOverviewRuns.filter(item => !item.scanRunIds.includes(scanRunId));
// Regulatory mappings and decisions are global and survive scan-run removal.
const remainingHashes = new Set(next.importFiles.map(item => item.sha256).filter(Boolean) as string[]); const remainingHashes = new Set(next.importFiles.map(item => item.sha256).filter(Boolean) as string[]);
next.importFingerprints = next.importFingerprints.filter(item => !removedHashes.has(item.sha256) || remainingHashes.has(item.sha256)); next.importFingerprints = next.importFingerprints.filter(item => !removedHashes.has(item.sha256) || remainingHashes.has(item.sha256));
this.replaceState(next); this.replaceState(next);
@ -1846,8 +1691,6 @@ export class LocalAssessorRepository implements AssessorRepository {
next.policyEvaluations = next.policyEvaluations.filter(item => !evaluationRunIds.has(item.run.id)); next.policyEvaluations = next.policyEvaluations.filter(item => !evaluationRunIds.has(item.run.id));
next.policyFindingRuns = next.policyFindingRuns.filter(item => !evaluationRunIds.has(item.run.evaluationRunId)); next.policyFindingRuns = next.policyFindingRuns.filter(item => !evaluationRunIds.has(item.run.evaluationRunId));
next.artifactDeletionRequests = next.artifactDeletionRequests.filter(item => !artifactIds.has(item.artifactId)); next.artifactDeletionRequests = next.artifactDeletionRequests.filter(item => !artifactIds.has(item.artifactId));
next.aiOverviewRuns = next.aiOverviewRuns.filter(item => !item.assessmentIds.includes(assessmentId));
// Regulatory mappings and decisions are global and survive assessment removal.
const remainingDefinitionIds = new Set([ const remainingDefinitionIds = new Set([
...next.findingOccurrences.map(item => item.definitionId), ...next.findingOccurrences.map(item => item.definitionId),
...next.policyFindingRuns.flatMap(item => item.occurrences.map(occurrence => occurrence.definitionId)) ...next.policyFindingRuns.flatMap(item => item.occurrences.map(occurrence => occurrence.definitionId))
@ -1903,10 +1746,7 @@ export class LocalAssessorRepository implements AssessorRepository {
next.environmentProfiles = next.environmentProfiles.filter(item => item.projectId !== projectId); next.environmentProfiles = next.environmentProfiles.filter(item => item.projectId !== projectId);
next.policySnapshotAggregations = next.policySnapshotAggregations.filter(item => item.run.projectId !== projectId); next.policySnapshotAggregations = next.policySnapshotAggregations.filter(item => item.run.projectId !== projectId);
next.policyEvaluations = next.policyEvaluations.filter(item => item.run.projectId !== projectId); next.policyEvaluations = next.policyEvaluations.filter(item => item.run.projectId !== projectId);
next.aiOverviewRuns = next.aiOverviewRuns.filter(item => item.projectId !== projectId);
next.regulatoryMappingBatchRuns = next.regulatoryMappingBatchRuns.filter(item => item.projectId !== projectId);
next.scopedRiskAcceptances = next.scopedRiskAcceptances.filter(item => item.projectId !== projectId); next.scopedRiskAcceptances = next.scopedRiskAcceptances.filter(item => item.projectId !== projectId);
// Regulatory mappings and decisions are global and survive project deletion.
this.replaceState(next); this.replaceState(next);
return true; return true;
} }

View File

@ -1,76 +0,0 @@
import type { RegulatoryFindingQuery } from '../../domain/regulatory-rag';
import { REGULATORY_FINDING_QUERY_SCHEMA_VERSION } from '../../domain/regulatory-rag';
import type { UnifiedResultProjection } from '../../domain/unified-result';
import { privacySafeFinding } from '../../domain/privacy-safe-finding';
import { discloseResultDbProjection } from '../../domain/result-db-disclosure';
import catalog from './catalog.json';
import { publicSecurityTopics } from './public-security-topics';
const MAXIMUM_TITLE_CHARACTERS = 500;
const MAXIMUM_DETAIL_CHARACTERS = 1_200;
const MAXIMUM_RECOMMENDATION_CHARACTERS = 600;
const publicTools: Record<string, string> = {
'cis-cat': 'CIS-CAT / CIS Benchmarks',
gpos: 'Microsoft Group Policy',
maester: 'Maester',
pingcastle: 'PingCastle',
'purple-knight': 'Semperis Purple Knight'
};
const publicRuleIds: Record<string, readonly string[]> = {
maester: catalog.maesterIds,
pingcastle: catalog.pingcastleIds
};
/**
* Builds an embedding query from the finding's rule-level check text — its title,
* description and recommendation projected through the shared privacy boundary
* ({@link privacySafeFinding}). That text describes the check, not the tenant: the
* result-table / occurrence data (affectedAsset, per-object values) is never read,
* and email addresses are redacted. Every scanner finding or GPO baseline comparison
* with a check title maps; the configured GPO customer value is never part of that text;
* catalog rule IDs and fixed public topics only sharpen the query when present.
*/
export function buildRegulatoryFindingQuery(finding: UnifiedResultProjection): RegulatoryFindingQuery | undefined {
if (!isRegulatoryMappableResult(finding)) return undefined;
// A GPO comparison crosses only through the Result DB disclosure policy, which withholds the customer value.
const safe = privacySafeFinding(finding.sourceKind === 'policy-evaluation' ? discloseResultDbProjection(finding) : finding);
const title = safe.title.trim().slice(0, MAXIMUM_TITLE_CHARACTERS);
if (!title) return undefined;
const toolId = finding.sourceTool?.trim().toLowerCase() ?? '';
const publicTool = publicTools[toolId];
const publicRuleId = recognizedRuleId(toolId, finding.sourceRuleId);
const detail = safe.description?.slice(0, MAXIMUM_DETAIL_CHARACTERS);
const recommendation = safe.recommendation?.slice(0, MAXIMUM_RECOMMENDATION_CHARACTERS);
const topics = publicSecurityTopics([title, detail, recommendation].filter(Boolean).join('\n'));
const query = [
'Retrieve regulatory controls whose normative requirements directly address this security finding.',
publicTool && `Public assessment tool: ${publicTool}.`,
publicRuleId && `Public rule ID: ${publicRuleId}.`,
`Security check: ${title}.`,
detail && `Check detail: ${detail}.`,
recommendation && `Recommended action: ${recommendation}.`,
topics.length && `Public technical topics: ${topics.join('; ')}.`,
'Prefer controls with direct requirement text over merely related guidance. Do not infer customer scope, assets, configuration values, or compliance status.'
].filter(Boolean).join(' ');
return {
schemaVersion: REGULATORY_FINDING_QUERY_SCHEMA_VERSION,
query,
specificity: publicRuleId ? 'rule' : 'topic',
...(publicTool ? { publicTool } : {}),
...(publicRuleId ? { publicRuleId } : {}),
publicTopics: topics
};
}
function recognizedRuleId(toolId: string, value: string | undefined): string | undefined {
const candidate = value?.trim();
if (!candidate) return undefined;
return publicRuleIds[toolId]?.find(id => id.toLowerCase() === candidate.toLowerCase());
}
/** Rule-level results only: raw GPO observations and environment facts describe context, not a check. */
export function isRegulatoryMappableResult(finding: UnifiedResultProjection): boolean {
return finding.sourceKind === 'scanner-finding' || finding.sourceKind === 'policy-evaluation';
}

View File

@ -1909,7 +1909,7 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.db-engine-flow__heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--space-3); } .db-engine-flow__heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--space-3); }
.db-engine-flow__heading h3 { margin: 0; color: var(--heading); font-size: var(--type-h3-size); font-weight: var(--fw-semibold); } .db-engine-flow__heading h3 { margin: 0; color: var(--heading); font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
.db-engine-flow__heading > span { color: var(--muted); font-size: var(--type-table-body-size); } .db-engine-flow__heading > span { color: var(--muted); font-size: var(--type-table-body-size); }
.db-engine-flow__map { position: relative; width: 1130px; height: 650px; margin: 0 auto; } .db-engine-flow__map { position: relative; width: 1130px; height: 940px; margin: 0 auto; }
.db-engine-flow__edges { position: absolute; inset: 0; overflow: visible; pointer-events: none; } .db-engine-flow__edges { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
.db-engine-flow__edges > path { fill: none; stroke: var(--line-strong); stroke-width: 2; } .db-engine-flow__edges > path { fill: none; stroke: var(--line-strong); stroke-width: 2; }
.db-engine-flow__edges > path.is-related { stroke: var(--marking-color); } .db-engine-flow__edges > path.is-related { stroke: var(--marking-color); }
@ -1933,6 +1933,10 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.db-engine-flow__node[data-node="ui"] { left: 310px; top: 60px; } .db-engine-flow__node[data-node="ui"] { left: 310px; top: 60px; }
.db-engine-flow__node[data-node="context-ui"] { left: 600px; top: 60px; } .db-engine-flow__node[data-node="context-ui"] { left: 600px; top: 60px; }
.db-engine-flow__node[data-node="archive"] { left: 890px; top: 290px; } .db-engine-flow__node[data-node="archive"] { left: 890px; top: 290px; }
.db-engine-flow__node[data-node="compliance"] { left: 600px; top: 750px; }
.db-engine-flow__node[data-node="compliance-tables"] { left: 310px; top: 750px; }
.db-engine-flow__node[data-node="retrieval"] { left: 890px; top: 750px; }
.db-engine-flow__node[data-node="compliance-ui"] { left: 20px; top: 750px; }
.db-engine-flow__edge-label[data-edge="input-core"] { left: 275px; top: 312px; } .db-engine-flow__edge-label[data-edge="input-core"] { left: 275px; top: 312px; }
.db-engine-flow__edge-label[data-edge="core-database"] { left: 420px; top: 455px; } .db-engine-flow__edge-label[data-edge="core-database"] { left: 420px; top: 455px; }
.db-engine-flow__edge-label[data-edge="database-port"] { left: 565px; top: 540px; } .db-engine-flow__edge-label[data-edge="database-port"] { left: 565px; top: 540px; }
@ -1942,6 +1946,10 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.db-engine-flow__edge-label[data-edge="context-context-ui"] { left: 710px; top: 225px; } .db-engine-flow__edge-label[data-edge="context-context-ui"] { left: 710px; top: 225px; }
.db-engine-flow__edge-label[data-edge="report-archive"] { left: 1000px; top: 455px; } .db-engine-flow__edge-label[data-edge="report-archive"] { left: 1000px; top: 455px; }
.db-engine-flow__edge-label[data-edge="context-report"] { left: 855px; top: 416px; } .db-engine-flow__edge-label[data-edge="context-report"] { left: 855px; top: 416px; }
.db-engine-flow__edge-label[data-edge="port-compliance"] { left: 710px; top: 685px; }
.db-engine-flow__edge-label[data-edge="compliance-compliance-tables"] { left: 565px; top: 800px; }
.db-engine-flow__edge-label[data-edge="compliance-retrieval"] { left: 855px; top: 800px; }
.db-engine-flow__edge-label[data-edge="compliance-compliance-ui"] { left: 420px; top: 900px; }
.db-engine-flow__inspector { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-5); padding: var(--tile-inset); background: var(--tile-surface); border: var(--border-hair) solid var(--tile-border); } .db-engine-flow__inspector { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-5); padding: var(--tile-inset); background: var(--tile-surface); border: var(--border-hair) solid var(--tile-border); }
.db-engine-flow__inspector h4 { margin: 0 0 var(--space-2); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); } .db-engine-flow__inspector h4 { margin: 0 0 var(--space-2); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
.db-engine-flow__inspector p { margin: 0; font-size: var(--type-body-size); line-height: 1.5; } .db-engine-flow__inspector p { margin: 0; font-size: var(--type-body-size); line-height: 1.5; }

View File

@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor
describe('ASSESSOR version in the brand header', () => { describe('ASSESSOR version in the brand header', () => {
it('shows the centrally maintained product version and opens About from the brand', () => { it('shows the centrally maintained product version and opens About from the brand', () => {
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.17.0'); expect(ASSESSOR_PRODUCT_VERSION).toBe('2.19.0');
expect(app).toContain("'db-structure': 'Data Flow'"); expect(app).toContain("'db-structure': 'Data Flow'");
expect(app).toContain("['db-structure', 'Data Flow']"); expect(app).toContain("['db-structure', 'Data Flow']");
expect(app).not.toContain("'DB Structure'"); expect(app).not.toContain("'DB Structure'");

View File

@ -0,0 +1,63 @@
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { beforeAll, describe, expect, it } from 'vitest';
// The Core guard's area table (AGENTS.md, "Core Engine and engines"): engine paths come before the
// Core areas, an unregistered source path stays Core, and a bare folder name belongs to the prefix
// it names, so renaming a whole engine folder is not mistaken for a Core change.
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
interface AreaTable {
classifyPath(relative: string | null): string | null;
relativeToProject(filePath: string, projectDir: string): string | null;
resolveAreaName(raw: string): string | null;
isReleased(area: string, releasedAreas: string[]): boolean;
}
let areas: AreaTable;
beforeAll(async () => {
areas = await import(/* @vite-ignore */ pathToFileURL(join(root, '.claude', 'hooks', 'core-areas.mjs')).href) as AreaTable;
});
describe('Core guard area table', () => {
it.each([
'src/compliance', 'src/compliance/', 'src/compliance/ui/regulatorymappingpanel.tsx',
'server/compliance', 'tests/compliance', 'src/assessment-context', 'src/reporting', 'server/reporting'
])('treats engine path %s as engine code', path => {
expect(areas.classifyPath(path)).toBeNull();
});
it.each([
['src/compliance-x', 'Unclassified Source'],
['src/compliance-x/file.ts', 'Unclassified Source'],
['src/newthing', 'Unclassified Source'],
['src/persistence', 'Store'],
['src/persistence/local-assessor-repository.ts', 'Store'],
['server/postgres', 'Store'],
['src/app', 'Presentation'],
['src/features/finding-reader/findingreader.tsx', 'Presentation'],
['server/index.ts', 'Platform'],
['tests/architecture/core-areas.test.ts', 'Core Tests'],
['agents.md', 'Governance'],
['.claude/hooks', 'Governance'],
['src/domain/review.ts', 'Client Records']
])('keeps %s in Core area %s', (path, area) => {
expect(areas.classifyPath(path)).toBe(area);
});
it('leaves paths outside the project and documentation unguarded', () => {
expect(areas.classifyPath(null)).toBeNull();
expect(areas.relativeToProject(join(root, '..', 'elsewhere.ts'), root)).toBeNull();
expect(areas.classifyPath('docs/implementation_plan.md')).toBeNull();
expect(areas.relativeToProject(join(root, 'src', 'Compliance', 'X.ts'), root)).toBe('src/compliance/x.ts');
});
it('resolves release names and never lets "alle" cover Governance', () => {
expect(areas.resolveAreaName(' Core Tests ')).toBe('Core Tests');
expect(areas.resolveAreaName('unclassified source')).toBe('Unclassified Source');
expect(areas.resolveAreaName('compliance')).toBeNull();
expect(areas.isReleased('Store', ['alle'])).toBe(true);
expect(areas.isReleased('Governance', ['alle'])).toBe(false);
expect(areas.isReleased('Governance', ['Governance'])).toBe(true);
expect(areas.isReleased('Store', ['Platform'])).toBe(false);
});
});

View File

@ -53,23 +53,29 @@ const allSources = () => [...sourceFiles(join(root, 'src')), ...sourceFiles(join
/** /**
* Pinned Core -> Engine dependencies: the documented extension points ( * Pinned Core -> Engine dependencies: the documented extension points (
* `Context Cluster` Work facet, Finding Reader `Context` section, Pathfinder and sidebar cluster * `Context Cluster` Work facet, Finding Reader `Context` section, Pathfinder and sidebar cluster
* readers), the type-only API contract and the server wiring of both engines. * readers; Finding Reader ISO mapping, Result DB ISO batch and `Export JSON`, Data Hub ISO corpus upload,
* the Client Info `AI Overview` tab and the Work `ISO control`/`ISO theme` facets), the
* type-only API contract and client, and the server wiring of the engines.
*/ */
const EXTENSION_POINTS: Record<string, { engine: string | readonly string[]; typeOnly?: boolean }> = { const EXTENSION_POINTS: Record<string, { engine: string | readonly string[]; typeOnly?: boolean }> = {
'src/features/search-filter/business-impact-controls.ts': { engine: ['Report Engine', 'Context Engine'] }, 'src/features/search-filter/business-impact-controls.ts': { engine: ['Report Engine', 'Context Engine'] },
'src/features/dashboard/DashboardPanel.tsx': { engine: ['Context Engine', 'Report Engine'] }, 'src/features/dashboard/DashboardPanel.tsx': { engine: ['Context Engine', 'Report Engine', 'Compliance Engine'] },
'src/api/assessor-api-contract.ts': { engine: 'Context Engine', typeOnly: true }, 'src/api/assessor-api-contract.ts': { engine: ['Context Engine', 'Compliance Engine'], typeOnly: true },
'src/features/actionlist/ActionlistControls.tsx': { engine: 'Context Engine' }, 'src/api/assessor-api-client.ts': { engine: 'Compliance Engine', typeOnly: true },
'src/features/finding-reader/FindingReader.tsx': { engine: 'Compliance Engine' },
'src/features/results-browser/ResultsBrowser.tsx': { engine: 'Compliance Engine' },
'src/features/project-import/DataHub.tsx': { engine: 'Compliance Engine' },
'src/features/actionlist/ActionlistControls.tsx': { engine: ['Context Engine', 'Compliance Engine'] },
'src/features/finding-reader/FindingContext.tsx': { engine: 'Context Engine' }, 'src/features/finding-reader/FindingContext.tsx': { engine: 'Context Engine' },
'src/features/mitre-graph/graph-filter-bridge.ts': { engine: 'Context Engine' }, 'src/features/mitre-graph/graph-filter-bridge.ts': { engine: 'Context Engine' },
'src/features/mitre-graph/ObsidianGraphView.tsx': { engine: 'Context Engine' }, 'src/features/mitre-graph/ObsidianGraphView.tsx': { engine: 'Context Engine' },
'src/features/search-filter/filter-records.ts': { engine: 'Context Engine' }, 'src/features/search-filter/filter-records.ts': { engine: ['Context Engine', 'Compliance Engine'] },
'src/features/search-filter/SearchFilterBar.tsx': { engine: 'Context Engine' }, 'src/features/search-filter/SearchFilterBar.tsx': { engine: ['Context Engine', 'Compliance Engine'] },
'src/features/search-filter/use-filtered-records.ts': { engine: 'Context Engine' }, 'src/features/search-filter/use-filtered-records.ts': { engine: ['Context Engine', 'Compliance Engine'] },
'src/features/search-filter/work-facets.ts': { engine: 'Context Engine' }, 'src/features/search-filter/work-facets.ts': { engine: ['Context Engine', 'Compliance Engine'] },
'src/features/sidebar/SidebarSecurityDomains.tsx': { engine: 'Context Engine' }, 'src/features/sidebar/SidebarSecurityDomains.tsx': { engine: ['Context Engine', 'Compliance Engine'] },
'server/assessor-http-server.ts': { engine: 'any' }, 'server/assessor-http-server.ts': { engine: 'any' },
'server/index.ts': { engine: 'Report Engine' } 'server/index.ts': { engine: ['Report Engine', 'Compliance Engine'] }
}; };
// Core areas that hold data, evaluation and rules. No file in them may reach any engine, // Core areas that hold data, evaluation and rules. No file in them may reach any engine,
@ -80,7 +86,7 @@ const ENGINE_FREE_AREAS = ['Ingest', 'Normalization', 'Evaluation', 'Store', 'Di
describe('Core Engine and engines boundary', () => { describe('Core Engine and engines boundary', () => {
it('knows the registered engines and finds source files for each of them', () => { it('knows the registered engines and finds source files for each of them', () => {
const engines = areas.ENGINE_PATHS.map(([engine]) => engine); const engines = areas.ENGINE_PATHS.map(([engine]) => engine);
expect(engines).toEqual(expect.arrayContaining(['Context Engine', 'Report Engine'])); expect(engines).toEqual(expect.arrayContaining(['Context Engine', 'Report Engine', 'Compliance Engine']));
for (const engine of engines) { for (const engine of engines) {
expect(allSources().some(file => engineOf(posix(file)) === engine), engine).toBe(true); expect(allSources().some(file => engineOf(posix(file)) === engine), engine).toBe(true);
} }

View File

@ -1,4 +1,4 @@
import type { AiOverviewResult } from '../../src/domain/ai-overview'; import type { AiOverviewResult } from '../../src/compliance/ai-overview';
export function validAiOverviewResult(): AiOverviewResult { export function validAiOverviewResult(): AiOverviewResult {
return { return {

View File

@ -1,8 +1,8 @@
import { renderToStaticMarkup } from 'react-dom/server'; import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { AiOverviewPanel, AiOverviewResultView } from '../../src/features/dashboard/AiOverviewPanel'; import { AiOverviewPanel, AiOverviewResultView } from '../../src/compliance/ui/AiOverviewPanel';
import type { AiOverviewResponse, AiOverviewRun } from '../../src/domain/ai-overview'; import type { AiOverviewResponse, AiOverviewRun } from '../../src/compliance/ai-overview';
import { validAiOverviewResult } from '../fixtures/ai-overview'; import { validAiOverviewResult } from './ai-overview-fixtures';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
describe('Client Info AI Overview', () => { describe('Client Info AI Overview', () => {
@ -31,7 +31,7 @@ describe('Client Info AI Overview', () => {
run: { ...base, id: 'run-2', status: 'failed', result: undefined, errorMessage: 'Generation failed safely.' }, run: { ...base, id: 'run-2', status: 'failed', result: undefined, errorMessage: 'Generation failed safely.' },
previousSuccessfulRun: base previousSuccessfulRun: base
}; };
const html = renderToStaticMarkup(<AiOverviewPanel controller={{ response, loading: false, generating: false, generate: async () => {} }} />); const html = renderToStaticMarkup(<AiOverviewPanel controller={{ available: true, response, loading: false, generating: false, generate: async () => {} }} />);
expect(html).toContain('Generation failed safely.'); expect(html).toContain('Generation failed safely.');
expect(html).toContain('Synthetic executive summary.'); expect(html).toContain('Synthetic executive summary.');
expect(html).toContain('previous saved overview'); expect(html).toContain('previous saved overview');

View File

@ -4,8 +4,8 @@ import { act, type ReactNode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest'; import { afterEach, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client'; import { AssessorApiClient } from '../../src/api/assessor-api-client';
import type { AiOverviewResponse } from '../../src/domain/ai-overview'; import type { AiOverviewResponse } from '../../src/compliance/ai-overview';
import { useAiOverview } from '../../src/features/dashboard/AiOverviewPanel'; import { useAiOverview } from '../../src/compliance/ui/AiOverviewPanel';
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();

View File

@ -1,8 +1,9 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { AiOverviewService } from '../../server/ai/ai-overview-service'; import { AiOverviewService } from '../../server/compliance/ai-overview-service';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository'; import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
import { LocalComplianceStore } from '../../src/compliance/local-compliance-store';
import type { UnifiedResultProjection } from '../../src/domain/unified-result'; import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { validAiOverviewResult } from '../fixtures/ai-overview'; import { validAiOverviewResult } from './ai-overview-fixtures';
const stamp = '2026-09-14T08:00:00.000Z'; const stamp = '2026-09-14T08:00:00.000Z';
@ -21,6 +22,7 @@ function syntheticResult(): UnifiedResultProjection {
describe('AI overview service', () => { describe('AI overview service', () => {
it('uses the complete timeslot query, persists a successful run, and honors idempotency', async () => { it('uses the complete timeslot query, persists a successful run, and honors idempotency', async () => {
const repository = new LocalAssessorRepository(); const repository = new LocalAssessorRepository();
const store = new LocalComplianceStore();
await repository.saveProject({ id: 'project-1', name: 'Synthetic', description: '', attributes: {}, createdAt: stamp, updatedAt: stamp }); await repository.saveProject({ id: 'project-1', name: 'Synthetic', description: '', attributes: {}, createdAt: stamp, updatedAt: stamp });
await repository.saveAssessment({ id: 'assessment-1', projectId: 'project-1', name: 'Synthetic timeslot', createdAt: stamp, updatedAt: stamp }); await repository.saveAssessment({ id: 'assessment-1', projectId: 'project-1', name: 'Synthetic timeslot', createdAt: stamp, updatedAt: stamp });
const query = vi.spyOn(repository, 'queryUnifiedResults').mockResolvedValue({ total: 1, items: [syntheticResult()] }); const query = vi.spyOn(repository, 'queryUnifiedResults').mockResolvedValue({ total: 1, items: [syntheticResult()] });
@ -28,7 +30,7 @@ describe('AI overview service', () => {
serviceResult.coverageGaps = []; serviceResult.coverageGaps = [];
serviceResult.environmentOverview.factsUsed = []; serviceResult.environmentOverview.factsUsed = [];
const complete = vi.fn(async () => ({ value: serviceResult, usage: { totalTokens: 30 } })); const complete = vi.fn(async () => ({ value: serviceResult, usage: { totalTokens: 30 } }));
const service = new AiOverviewService(repository, { const service = new AiOverviewService(repository, store, {
client: { model: 'work-model', complete }, now: () => new Date(stamp) client: { model: 'work-model', complete }, now: () => new Date(stamp)
}); });
@ -42,16 +44,19 @@ describe('AI overview service', () => {
expect(query).toHaveBeenCalledWith({ expect(query).toHaveBeenCalledWith({
audience: 'result-db', projectId: 'project-1', assessmentIds: ['assessment-1'], scanRunIds: [], offset: 0, limit: 500 audience: 'result-db', projectId: 'project-1', assessmentIds: ['assessment-1'], scanRunIds: [], offset: 0, limit: 500
}); });
expect((await repository.exportProject('project-1')).aiOverviewRuns).toHaveLength(1); // The runs live in the engine store and reach the project export beside the Core export, never inside it.
expect((await store.exportProject('project-1')).aiOverviewRuns).toHaveLength(1);
expect('aiOverviewRuns' in await repository.exportProject('project-1')).toBe(false);
await service.start('project-1', request); await service.start('project-1', request);
await service.close(); await service.close();
expect(complete).toHaveBeenCalledTimes(1); expect(complete).toHaveBeenCalledTimes(1);
expect(await repository.listAiOverviewRuns('project-1')).toHaveLength(1); expect(await store.listAiOverviewRuns('project-1')).toHaveLength(1);
}); });
it('keeps the previous success when a later regeneration fails', async () => { it('keeps the previous success when a later regeneration fails', async () => {
const repository = new LocalAssessorRepository(); const repository = new LocalAssessorRepository();
const store = new LocalComplianceStore();
await repository.saveProject({ id: 'project-1', name: 'Synthetic', description: '', attributes: {}, createdAt: stamp, updatedAt: stamp }); await repository.saveProject({ id: 'project-1', name: 'Synthetic', description: '', attributes: {}, createdAt: stamp, updatedAt: stamp });
await repository.saveAssessment({ id: 'assessment-1', projectId: 'project-1', name: 'Synthetic timeslot', createdAt: stamp, updatedAt: stamp }); await repository.saveAssessment({ id: 'assessment-1', projectId: 'project-1', name: 'Synthetic timeslot', createdAt: stamp, updatedAt: stamp });
vi.spyOn(repository, 'queryUnifiedResults').mockResolvedValue({ total: 1, items: [syntheticResult()] }); vi.spyOn(repository, 'queryUnifiedResults').mockResolvedValue({ total: 1, items: [syntheticResult()] });
@ -62,7 +67,7 @@ describe('AI overview service', () => {
.mockResolvedValueOnce({ value: serviceResult }) .mockResolvedValueOnce({ value: serviceResult })
.mockResolvedValueOnce({ value: { schemaVersion: 1 } }); .mockResolvedValueOnce({ value: { schemaVersion: 1 } });
let currentTime = stamp; let currentTime = stamp;
const service = new AiOverviewService(repository, { client: { model: 'work-model', complete }, now: () => new Date(currentTime) }); const service = new AiOverviewService(repository, store, { client: { model: 'work-model', complete }, now: () => new Date(currentTime) });
await service.start('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [], idempotencyKey: 'request-good' }); await service.start('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [], idempotencyKey: 'request-good' });
await service.close(); await service.close();
@ -70,7 +75,7 @@ describe('AI overview service', () => {
await service.start('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [], idempotencyKey: 'request-fail' }); await service.start('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [], idempotencyKey: 'request-fail' });
await service.close(); await service.close();
const runs = await repository.listAiOverviewRuns('project-1'); const runs = await store.listAiOverviewRuns('project-1');
expect(runs.map(run => run.status).sort()).toEqual(['failed', 'succeeded']); expect(runs.map(run => run.status).sort()).toEqual(['failed', 'succeeded']);
expect(runs.find(run => run.status === 'succeeded')?.result?.executiveSummary).toBe('Synthetic executive summary.'); expect(runs.find(run => run.status === 'succeeded')?.result?.executiveSummary).toBe('Synthetic executive summary.');
expect(runs.find(run => run.status === 'failed')?.errorCode).toBe('ai_output_invalid'); expect(runs.find(run => run.status === 'failed')?.errorCode).toBe('ai_output_invalid');
@ -83,6 +88,7 @@ describe('AI overview service', () => {
it('marks the previous success stale while a regeneration is queued', async () => { it('marks the previous success stale while a regeneration is queued', async () => {
const repository = new LocalAssessorRepository(); const repository = new LocalAssessorRepository();
const store = new LocalComplianceStore();
await repository.saveProject({ id: 'project-1', name: 'Synthetic', description: '', attributes: {}, createdAt: stamp, updatedAt: stamp }); await repository.saveProject({ id: 'project-1', name: 'Synthetic', description: '', attributes: {}, createdAt: stamp, updatedAt: stamp });
await repository.saveAssessment({ id: 'assessment-1', projectId: 'project-1', name: 'Synthetic timeslot', createdAt: stamp, updatedAt: stamp }); await repository.saveAssessment({ id: 'assessment-1', projectId: 'project-1', name: 'Synthetic timeslot', createdAt: stamp, updatedAt: stamp });
vi.spyOn(repository, 'queryUnifiedResults').mockResolvedValue({ total: 1, items: [syntheticResult()] }); vi.spyOn(repository, 'queryUnifiedResults').mockResolvedValue({ total: 1, items: [syntheticResult()] });
@ -90,11 +96,11 @@ describe('AI overview service', () => {
serviceResult.coverageGaps = []; serviceResult.coverageGaps = [];
serviceResult.environmentOverview.factsUsed = []; serviceResult.environmentOverview.factsUsed = [];
const complete = vi.fn().mockResolvedValueOnce({ value: serviceResult }); const complete = vi.fn().mockResolvedValueOnce({ value: serviceResult });
const service = new AiOverviewService(repository, { client: { model: 'work-model', complete }, now: () => new Date(stamp) }); const service = new AiOverviewService(repository, store, { client: { model: 'work-model', complete }, now: () => new Date(stamp) });
await service.start('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [], idempotencyKey: 'request-good' }); await service.start('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [], idempotencyKey: 'request-good' });
await service.close(); await service.close();
const [saved] = await repository.listAiOverviewRuns('project-1'); const [saved] = await store.listAiOverviewRuns('project-1');
await repository.createAiOverviewRun({ await store.createAiOverviewRun({
...saved, id: 'ai-overview-queued', idempotencyKey: 'request-queued', status: 'queued', inputHash: 'b'.repeat(64), ...saved, id: 'ai-overview-queued', idempotencyKey: 'request-queued', status: 'queued', inputHash: 'b'.repeat(64),
result: undefined, completedAt: undefined, createdAt: '2026-09-14T09:00:00.000Z', updatedAt: '2026-09-14T09:00:00.000Z', callCount: 0 result: undefined, completedAt: undefined, createdAt: '2026-09-14T09:00:00.000Z', updatedAt: '2026-09-14T09:00:00.000Z', callCount: 0
}); });
@ -103,4 +109,29 @@ describe('AI overview service', () => {
const response = await service.get('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [] }); const response = await service.get('project-1', { assessmentIds: ['assessment-1'], scanRunIds: [] });
expect(response).toMatchObject({ state: 'queued', stale: true, previousSuccessfulRun: { id: saved.id } }); expect(response).toMatchObject({ state: 'queued', stale: true, previousSuccessfulRun: { id: saved.id } });
}); });
it('removes the overview of a deleted timeslot from the engine store', async () => {
const repository = new LocalAssessorRepository();
const store = new LocalComplianceStore();
await repository.saveProject({ id: 'project-1', name: 'Synthetic', description: '', attributes: {}, createdAt: stamp, updatedAt: stamp });
await repository.saveAssessment({ id: 'assessment-1', projectId: 'project-1', name: 'Synthetic timeslot', createdAt: stamp, updatedAt: stamp });
await repository.saveAssessment({ id: 'assessment-2', projectId: 'project-1', name: 'Second timeslot', createdAt: stamp, updatedAt: stamp });
vi.spyOn(repository, 'queryUnifiedResults').mockResolvedValue({ total: 1, items: [syntheticResult()] });
const serviceResult = validAiOverviewResult();
serviceResult.coverageGaps = [];
serviceResult.environmentOverview.factsUsed = [];
const service = new AiOverviewService(repository, store, {
client: { model: 'work-model', complete: vi.fn(async () => ({ value: serviceResult })) }, now: () => new Date(stamp)
});
for (const assessmentId of ['assessment-1', 'assessment-2']) {
await service.start('project-1', { assessmentIds: [assessmentId], scanRunIds: [], idempotencyKey: `request-${assessmentId}` });
await service.close();
}
expect(await store.listAiOverviewRuns('project-1')).toHaveLength(2);
// The Core deletes the timeslot without knowing the engine; the engine prunes its own derived run.
await repository.deleteAssessment('project-1', 'assessment-1', { projectId: 'project-1', assessmentId: 'assessment-1', cascade: true });
await service.get('project-1', { assessmentIds: ['assessment-2'], scanRunIds: [] });
expect((await store.listAiOverviewRuns('project-1')).map(run => run.assessmentIds)).toEqual([['assessment-2']]);
});
}); });

View File

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { aiOverviewResultValidationErrors, aiOverviewScopeKey, normalizeAiOverviewScope } from '../../src/domain/ai-overview'; import { aiOverviewResultValidationErrors, aiOverviewScopeKey, normalizeAiOverviewScope } from '../../src/compliance/ai-overview';
import { splitPrivacySafeInput, type PrivacySafeInput } from '../../server/ai/ai-overview-service'; import { splitPrivacySafeInput, type PrivacySafeInput } from '../../server/compliance/ai-overview-service';
import { validAiOverviewResult } from '../fixtures/ai-overview'; import { validAiOverviewResult } from './ai-overview-fixtures';
describe('AI overview contract', () => { describe('AI overview contract', () => {
it('normalizes timeslot identity deterministically', () => { it('normalizes timeslot identity deterministically', () => {

View File

@ -0,0 +1,108 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
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 } from '../../src/compliance/ui/ClientIsoControls';
import { markRegulatoryDecisionSaved } from '../../src/compliance/ui/decision-revision';
import { workRecordIdsForResult } from '../../src/features/results-browser/work-record-lookup';
import { makeSheet } from '../../src/importers/sheet-factory';
// Client Info `ISO controls`: one tile per accepted control with its number of findings, laid out like
// the Context heatmap; a tile opens exactly its findings in Work. Switched off, nothing renders or loads.
const overview: RegulatoryControlOverviewResponse = {
version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [] }, findingCount: 3,
controls: [
{ controlId: '8.20', annexAControlId: 'A.8.20', title: 'Networks security', theme: 'Technological', findings: [
{ resultProjectionId: 'scanner-finding:dummy-a', sourceKind: 'scanner-finding', sourceResultId: 'dummy-a' },
{ resultProjectionId: 'scanner-finding:dummy-b', sourceKind: 'scanner-finding', sourceResultId: 'dummy-b' }] },
{ controlId: '5.15', annexAControlId: 'A.5.15', theme: 'Organizational', findings: [
{ resultProjectionId: 'scanner-finding:dummy-c', sourceKind: 'scanner-finding', sourceResultId: 'dummy-c' }] }
]
};
let host: HTMLDivElement;
let root: Root;
const onOpenControl = vi.fn();
const render = () => act(async () => {
root.render(<ClientIsoControls projectId="dummy-project" assessmentIds={['dummy-assessment']} scanRunIds={[]}
backendStatus="connected" onOpenControl={onOpenControl}/>);
await new Promise(resolve => setTimeout(resolve, 0));
});
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
localStorage.clear();
resetEnginePreferencesForTests();
onOpenControl.mockReset();
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => { root.unmount(); });
host.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
resetEnginePreferencesForTests();
});
describe('Client Info ISO controls', () => {
it('shows each accepted control with its finding count and opens exactly its findings', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview);
await render();
expect(request).toHaveBeenCalledWith('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [] });
expect(host.querySelector('h2')?.textContent).toBe('ISO controls');
expect(host.querySelector('.iso-controls__total')?.textContent).toBe('Findings: 3');
const tiles = [...host.querySelectorAll<HTMLButtonElement>('.iso-controls__cell')];
expect(tiles.map(tile => [...tile.children].map(part => part.textContent))).toEqual([
['Technological', 'A.8.20 · Networks security', '2 findings'],
['Organizational', 'A.5.15', '1 finding']
]);
await act(async () => tiles[0].click());
expect(onOpenControl).toHaveBeenCalledWith('8.20');
});
it('reloads after a control decision and says so when nothing is accepted', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview')
.mockResolvedValueOnce({ ...overview, findingCount: 0, controls: [] }).mockResolvedValue(overview);
await render();
expect(host.querySelector('.iso-controls__empty')?.textContent).toBe('No accepted ISO controls in this timeslot.');
await act(async () => { markRegulatoryDecisionSaved(); await new Promise(resolve => setTimeout(resolve, 0)); });
expect(request).toHaveBeenCalledTimes(2);
expect(host.querySelectorAll('.iso-controls__cell')).toHaveLength(2);
});
it('renders nothing and requests nothing while the Compliance Engine is off', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview);
setEngineEnabled('compliance', false);
await render();
expect(host.innerHTML).toBe('');
expect(request).not.toHaveBeenCalled();
});
it('names the overview request in the activity log as Compliance Engine work', () => {
expect(requestActivity('/projects/p/regulatory-control-overview?assessmentIds=a')).toEqual({ area: 'Compliance Engine', label: 'Loading ISO control overview' });
expect(requestActivity('/projects/p/regulatory-control-assignments?assessmentIds=a')).toEqual({ area: 'Compliance Engine', label: 'Loading ISO control filters' });
});
it('places the section below the Context heatmap and opens Work through the ISO control filter', () => {
const source = readFileSync(join(process.cwd(), 'src/features/dashboard/DashboardPanel.tsx'), 'utf8');
expect(source.indexOf('<ClientIsoControls')).toBeGreaterThan(source.indexOf('<ClientContextHeatmap'));
expect(source.indexOf('<ClientIsoControls')).toBeLessThan(source.indexOf('client-environment-grid'));
const drilldown = source.slice(source.indexOf('<ClientIsoControls'), source.indexOf('client-environment-grid'));
expect(drilldown).toContain('setStatusFilter(\x27deviation\x27)');
expect(drilldown).toContain('setFacetFilter(ISO_CONTROL_FACET_ID, { values: [controlId] })');
expect(drilldown).not.toContain('setPathFindingIds');
// A scanner finding resolves to its Work record through the occurrence id.
const sheet = makeSheet('Dummy', [['Title', 'Status'], ['Dummy check', 'Fail']], '', 'dummy.csv', { isProjectSheet: true });
const record = { ...sheet.analysis.records[0], findingOccurrenceId: 'dummy-a' };
const dataset = { fileName: 'Dummy', sheets: [{ ...sheet, analysis: { ...sheet.analysis, records: [record] } }] } as never;
expect(workRecordIdsForResult(dataset, overview.controls[0].findings[0])).toEqual([record.id]);
});
});

View File

@ -0,0 +1,148 @@
// @vitest-environment jsdom
import { act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { requestActivity } from '../../src/api/request-activity';
import { resetEnginePreferencesForTests } from '../../src/app/engine-preferences';
import type { RegulatoryMappingBatchResponse } from '../../src/compliance/regulatory-mapping';
import { useRegulatoryMappingBatch } from '../../src/compliance/ui/RegulatoryMappingBatchDialog';
import { useRegulatoryMapping } from '../../src/compliance/ui/use-regulatory-mapping';
import type { RegulatoryMappingResponse } from '../../src/compliance/regulatory-mapping';
import { activitySnapshot, activityStatusSnapshot, clearFinishedActivities } from '../../src/platform/activity-store';
// The activity log names Compliance Engine work as such: its requests never read as Core client work,
// and backend work keeps the log busy until it really ends.
describe('Compliance Engine requests in the activity log', () => {
it.each([
['/projects/p/regulatory-mappings/scanner-finding%3Ao', 'GET', 'Loading ISO mapping'],
['/projects/p/regulatory-mapping-runs', 'POST', 'Generating ISO control proposals'],
['/projects/p/regulatory-mapping-decisions', 'POST', 'Saving ISO mapping decision'],
['/projects/p/regulatory-mapping-batches?assessmentIds=a&deviation=true', 'GET', 'Loading ISO mapping batch status'],
['/projects/p/regulatory-mapping-batches', 'POST', 'Starting ISO mapping batch'],
['/reference-data/iso-corpus', 'GET', 'Loading ISO corpus status'],
['/reference-data/iso-corpus', 'POST', 'Uploading ISO corpus']
])('labels %s %s', (path, method, label) => {
expect(requestActivity(path, method)).toEqual({ area: 'Compliance Engine', label });
});
it('keeps unrelated client requests in the Core', () => {
expect(requestActivity('/projects/p/import-files')).toEqual({ area: 'Core Engine', label: 'Loading imported files' });
expect(requestActivity('/projects')).toEqual({ area: 'Core Engine', label: 'Loading clients' });
});
});
function batchResponse(state: RegulatoryMappingBatchResponse['state']): RegulatoryMappingBatchResponse {
return { version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: {} },
generationEnabled: true, state, pollAfterMs: 5 } as RegulatoryMappingBatchResponse;
}
const scope = { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: {} };
function BatchProbe({ projectId = 'dummy-project' }: { projectId?: string }): ReactNode {
useRegulatoryMappingBatch(projectId, scope);
return null;
}
const batchRows = () => activitySnapshot().filter(row => row.area === 'Compliance Engine' && row.label.startsWith('Mapping all ISO controls'));
const settle = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
localStorage.clear();
resetEnginePreferencesForTests();
clearFinishedActivities();
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => { root.unmount(); });
host.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
clearFinishedActivities();
});
describe('Compliance Engine backend work in the activity log', () => {
it('keeps a running ISO batch busy until the backend completes it', async () => {
const states: RegulatoryMappingBatchResponse['state'][] = ['running', 'running', 'completed'];
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingBatch').mockImplementation(async () => batchResponse(states.shift() ?? 'completed'));
await act(async () => root.render(<BatchProbe/>));
await act(async () => { await Promise.resolve(); });
expect(batchRows().map(row => row.state)).toEqual(['running']);
expect(activityStatusSnapshot().ready).toBe(false);
const generation = activityStatusSnapshot().generation;
await settle(); await settle();
expect(batchRows().map(row => row.state)).toEqual(['completed']);
expect(activityStatusSnapshot().ready).toBe(true);
// Repeated batch status checks start no further entries.
expect(activityStatusSnapshot().generation).toBe(generation);
});
it('marks the entry failed when the backend batch fails', async () => {
const states: RegulatoryMappingBatchResponse['state'][] = ['running', 'failed'];
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingBatch').mockImplementation(async () => batchResponse(states.shift() ?? 'failed'));
await act(async () => root.render(<BatchProbe/>));
await settle(); await settle();
expect(batchRows().map(row => row.state)).toEqual(['failed']);
expect(activityStatusSnapshot().failed).toBe(1);
});
it('ends the entry with a note when the view stops observing a running batch', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingBatch').mockResolvedValue(batchResponse('running'));
await act(async () => root.render(<BatchProbe/>));
await act(async () => { await Promise.resolve(); });
expect(batchRows().map(row => row.state)).toEqual(['running']);
await act(async () => { root.unmount(); });
root = createRoot(host);
expect(batchRows().map(row => [row.state, row.label])).toEqual([['completed', 'Mapping all ISO controls continues in the backend']]);
expect(activityStatusSnapshot().ready).toBe(true);
});
});
function mappingResponse(state: RegulatoryMappingResponse['state'], id: string): RegulatoryMappingResponse {
return { version: 1, projectId: 'dummy-project', resultProjectionId: `scanner-finding:${id}`, generationEnabled: true, state, decisions: [], pollAfterMs: 5 };
}
function MappingProbe({ id }: { id: string }): ReactNode {
const mapping = useRegulatoryMapping('dummy-project', `scanner-finding:${id}`, 'connected');
return <button type="button" onClick={() => void mapping.start()}>Generate</button>;
}
const mappingRows = () => activitySnapshot().filter(row => row.area === 'Compliance Engine' && row.label.startsWith('Mapping ISO controls'));
describe('Compliance Engine single mapping in the activity log', () => {
it('keeps a generated mapping running after the window closes until the backend completes it', async () => {
const states: RegulatoryMappingResponse['state'][] = ['empty', 'running', 'running', 'succeeded'];
// Replaced below the activity logging, so every client logs exactly as in the application.
const transport = vi.spyOn(AssessorApiClient.prototype as unknown as { performRequest(path: string, init: RequestInit): Promise<unknown> }, 'performRequest')
.mockImplementation(async (path: string, init: RequestInit) => init.method === 'POST'
? mappingResponse('queued', 'closed-window') : mappingResponse(states.shift() ?? 'succeeded', 'closed-window'));
await act(async () => root.render(<MappingProbe id="closed-window"/>));
await act(async () => { await Promise.resolve(); });
await act(async () => host.querySelector('button')!.click());
expect(mappingRows().map(row => row.state)).toEqual(['queued']);
// Status checks while it runs start no entries of their own, so the activity panel does not reopen and jump.
const generation = activityStatusSnapshot().generation;
// Closing the window or leaving the finding does not end the observation.
await act(async () => { root.unmount(); });
root = createRoot(host);
expect(mappingRows().map(row => row.state)).toEqual(['queued']);
expect(activityStatusSnapshot().ready).toBe(false);
await settle(); await settle(); await settle();
expect(mappingRows().map(row => [row.state, row.label])).toEqual([['completed', 'Mapping ISO controls']]);
expect(activityStatusSnapshot().ready).toBe(true);
expect(transport.mock.calls.filter(([path]) => String(path).includes('/regulatory-mappings/')).length).toBeGreaterThanOrEqual(4);
expect(activityStatusSnapshot().generation).toBe(generation);
// Only the first load of the finding and the generate request are logged; the status checks are not.
expect(activitySnapshot().filter(row => row.area === 'Compliance Engine').map(row => row.label).sort())
.toEqual(['Generating ISO control proposals', 'Loading ISO mapping', 'Mapping ISO controls']);
});
it('marks the mapping failed when the backend run fails', async () => {
const states: RegulatoryMappingResponse['state'][] = ['running', 'failed'];
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockImplementation(async () => mappingResponse(states.shift() ?? 'failed', 'failing'));
await act(async () => root.render(<MappingProbe id="failing"/>));
await settle(); await settle();
expect(mappingRows().map(row => row.state)).toEqual(['failed']);
});
});

View File

@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest';
import { RegulatoryFindingRetrievalService } from '../../server/compliance/regulatory-finding-retrieval';
import { RegulatoryMappingService } from '../../server/compliance/regulatory-mapping-service';
import { complianceReadPortFrom } from '../../src/compliance/compliance-store';
import { LocalComplianceStore } from '../../src/compliance/local-compliance-store';
import type { RegulatoryChunk, RegulatoryRetrievalRequest } from '../../src/compliance/regulatory-rag';
import type { ScanRun } from '../../src/domain/canonical-model';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
// Permanent on/off invariance of the Compliance Engine (AGENTS.md, "Core Engine and engines"): the Core
// works unchanged with the engine switched off, and running the engine - a single mapping, a decision and
// a filtered batch - leaves every stored ASSESSOR value and the repository revision exactly as they were.
// The engine writes only its own store.
const stamp = '2026-10-05T08:00:00.000Z';
const confidence = { level: 'high' as const, score: 1, reasons: ['synthetic'] };
const provenance = { decisionSource: 'source-native' as const, confidence, mappingVersion: '1.0.0' };
function chunk(sourceStandard: 'ISO/IEC 27001' | 'ISO/IEC 27002'): RegulatoryChunk {
const reference = sourceStandard === 'ISO/IEC 27001';
return {
schemaVersion: 2, corpusId: 'iso-test', corpusVersion: 'v2', documentId: reference ? '27001-5.1' : '27002-5.1-purpose',
framework: 'ISO/IEC 27002', frameworkVersion: '2022', controlId: '5.1', annexAControlId: 'A.5.1',
controlTheme: 'organizational', sourceStandard, sourceEdition: '2022', nationalEdition: 'DIN 2024',
sectionKind: reference ? 'control' : 'purpose', sourcePageStart: 1, sourcePageEnd: 1,
retrievalRole: reference ? 'reference' : 'semantic', parserVersion: 'test', sourceSha256: reference ? '1'.repeat(64) : '2'.repeat(64),
chunkId: reference ? 'reference-5.1' : 'semantic-5.1', chunkIndex: 0, language: 'de', title: 'Synthetic control',
text: reference ? 'Synthetic normative statement.' : 'Synthetic semantic evidence.', textSha256: 'a'.repeat(64)
};
}
async function syntheticRepository(): Promise<LocalAssessorRepository> {
const repository = new LocalAssessorRepository();
await repository.saveProject({ id: 'dummy-project', name: 'Dummy Customer', createdAt: stamp, updatedAt: stamp, attributes: {} });
await repository.saveAssessment({ id: 'dummy-assessment', projectId: 'dummy-project', name: 'Dummy', createdAt: stamp, updatedAt: stamp });
const scanRun: ScanRun = { id: 'dummy-run', assessmentId: 'dummy-assessment', sequence: 1, label: 'Dummy',
tool: { id: 'purple-knight', name: 'Purple Knight' }, importedAt: stamp };
const session = await repository.beginImport({ sessionId: 'dummy-session', projectId: 'dummy-project', assessmentId: 'dummy-assessment', scanRun });
await session.stage({
importFiles: [{ id: 'dummy-file', scanRunId: scanRun.id, fileName: 'dummy.csv', format: 'CSV', sizeBytes: 10, importedAt: stamp,
detectedTool: scanRun.tool, detection: confidence,
processedWith: { parserId: 'csv', parserVersion: '1.0.0', profileId: 'purple-knight', profileVersion: '1.0.0', mappingVersion: '1.0.0' },
sourceArtifact: { kind: 'not-retained', reason: 'synthetic' } }],
findingDefinitions: [{ id: 'dummy-definition', canonicalKey: 'purple-knight:A-SmbSigning',
identity: { version: 1, strategy: 'tool-rule-id', durable: true, evidence: ['synthetic'] },
title: 'SMB signing is not required on domain controllers', category: 'Synthetic', frameworkReferences: [], createdAt: stamp, updatedAt: stamp }],
findingOccurrences: [{ id: 'dummy-occurrence', definitionId: 'dummy-definition', scanRunId: scanRun.id, importFileId: 'dummy-file',
sourceIdentity: { tool: scanRun.tool, originalRuleId: 'A-SmbSigning', canonicalRuleId: 'A-SmbSigning',
originalTitle: 'SMB signing is not required on domain controllers' },
sourcePosition: { rowIndex: 1 }, originalFields: [{ key: 'Status', label: 'Status', value: 'Fail', columnIndex: 0 }],
fieldMappings: [], severity: { originalValue: 'High', normalizedValue: 'high', provenance },
status: { originalValue: 'Fail', normalizedValue: 'failed', provenance } }]
});
await session.commit();
return repository;
}
describe('Compliance Engine on/off invariance', () => {
it('leaves every stored ASSESSOR value and the repository revision unchanged', async () => {
const repository = await syntheticRepository();
// Switched off: the Core alone, read before any engine work.
const off = { revision: repository.revision, data: JSON.stringify(await repository.exportProject('dummy-project')) };
const resultDbOff = JSON.stringify(await repository.queryUnifiedResults({ audience: 'result-db', projectId: 'dummy-project' }));
const store = new LocalComplianceStore();
const service = new RegulatoryMappingService(complianceReadPortFrom(repository), store, {
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve: vi.fn(async (_request: RegulatoryRetrievalRequest) => [{ score: 0.9, chunk: chunk('ISO/IEC 27002') }]) }),
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list: vi.fn(async () => [chunk('ISO/IEC 27001')]) } as never,
client: { model: 'work-model', complete: vi.fn(async () => ({ value: {
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }]
} })) } as never,
corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3', now: () => new Date(stamp)
});
const resultProjectionId = 'scanner-finding:dummy-occurrence';
await service.start('dummy-project', { resultProjectionId });
await service.close();
// The first proposal is accepted automatically; a manual decision then replaces it.
const automatic = (await service.get('dummy-project', resultProjectionId)).decisions[0];
await service.decide('dummy-project', { resultProjectionId, controlId: '5.1', decision: 'rejected', expectedLatestDecisionId: automatic.id });
await service.startBatch('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: {}, idempotencyKey: 'dummy-batch-1' });
await service.close();
// The engine really ran and kept its results in its own store.
const engine = await store.exportProject('dummy-project');
expect(engine.regulatoryMappingRuns.map(run => run.status)).toEqual(['succeeded']);
expect(engine.regulatoryMappingDecisions.map(decision => decision.decision)).toEqual(['accepted', 'rejected']);
expect(engine.regulatoryMappingBatchRuns.map(batch => batch.status)).toEqual(['completed']);
// Switched on: the Core is untouched.
expect(repository.revision).toBe(off.revision);
expect(JSON.stringify(await repository.exportProject('dummy-project'))).toBe(off.data);
expect(JSON.stringify(await repository.queryUnifiedResults({ audience: 'result-db', projectId: 'dummy-project' }))).toBe(resultDbOff);
});
it('gives the engine no Core write method', () => {
const port = complianceReadPortFrom(new LocalAssessorRepository());
expect(Object.keys(port).sort()).toEqual(['getProject', 'getUnifiedResultById', 'listAssessments', 'listProjectScanRuns',
'listProjects', 'queryUnifiedResults']);
});
});

View File

@ -0,0 +1,175 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
import { useApp } from '../../src/app/AppProvider';
import type { RegulatoryCorpusStatus } from '../../src/compliance/regulatory-corpus-management';
import type { RegulatoryMappingResponse } from '../../src/compliance/regulatory-mapping';
import { IsoCorpusLibrary } from '../../src/compliance/ui/IsoCorpusLibrary';
import { useRegulatoryMappingBatch } from '../../src/compliance/ui/RegulatoryMappingBatchDialog';
import { useAiOverview } from '../../src/compliance/ui/AiOverviewPanel';
import { FindingReader } from '../../src/features/finding-reader/FindingReader';
import { makeSheet } from '../../src/importers/sheet-factory';
vi.mock('../../src/app/AppProvider', () => {
const useApp = vi.fn();
return { useApp, useOptionalApp: useApp };
});
// Compliance Engine switch (AGENTS.md, "Core Engine and engines"): switched off, the Finding Reader,
// Result DB, Client Info AI overview and Data Hub show none of its controls and send none of its requests; switched on again,
// the Finding Reader ISO panel starts closed.
let host: HTMLDivElement;
let root: Root;
function scannerRecord() {
const record = makeSheet('Dummy', [['Title', 'Status', 'Source: Scan'], ['Dummy SMB signing check', 'Fail', 'Purple Knight']],
'', 'dummy.csv', { isProjectSheet: true }).analysis.records[0];
return { ...record, findingOccurrenceId: 'dummy-occurrence' };
}
const mappingResponse: RegulatoryMappingResponse = {
version: 1, projectId: 'dummy-project', resultProjectionId: 'scanner-finding:dummy-occurrence',
generationEnabled: true, state: 'empty', decisions: []
};
async function renderReader() {
await act(async () => {
root.render(<FindingReader record={scannerRecord()} title="Dummy SMB signing check" linkedSheets={[]} resultReferences={[]}
search="" position={0} recordCount={1} toolId="purple-knight" onPrevious={vi.fn()} onNext={vi.fn()} onReview={vi.fn()}
projectId="dummy-project" backendStatus="connected"/>);
await new Promise(resolve => setTimeout(resolve, 0));
});
}
const isoButton = () => [...host.querySelectorAll('button')].find(button => button.textContent?.startsWith('Map ISO controls'));
function BatchProbe(): ReactNode {
const batch = useRegulatoryMappingBatch('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: {} });
return <output>{batch.available ? 'available' : 'off'}</output>;
}
function AiOverviewProbe(): ReactNode {
const overview = useAiOverview('dummy-project', ['dummy-assessment'], []);
return <output>{overview.available ? 'available' : 'off'}</output>;
}
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
localStorage.clear();
resetEnginePreferencesForTests();
// The Context Engine is not under test here.
setEngineEnabled('context', false);
vi.mocked(useApp).mockReturnValue({ targetProjectId: 'dummy-project', activeAssessmentIds: [], dataset: null, setFacetFilter: vi.fn() } as unknown as ReturnType<typeof useApp>);
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => { root.unmount(); });
host.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
resetEnginePreferencesForTests();
});
describe('Compliance Engine switch', () => {
it('removes the Finding Reader ISO window, section and requests while off and reopens closed', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockResolvedValue(mappingResponse);
const isoWindow = () => document.querySelector('[role="dialog"].regulatory-mapping-dialog');
setEngineEnabled('compliance', false);
await renderReader();
expect(isoButton()).toBeUndefined();
expect(request).not.toHaveBeenCalled();
expect(host.querySelector('[data-iso-controls]')).toBeNull();
// Switched on, the mapping loads with the finding; the button opens it as a window like Accepted Risk.
await act(async () => { setEngineEnabled('compliance', true); await new Promise(resolve => setTimeout(resolve, 0)); });
expect(request).toHaveBeenCalledTimes(1);
expect(isoWindow()).toBeNull();
await act(async () => { isoButton()!.click(); await new Promise(resolve => setTimeout(resolve, 0)); });
expect(isoWindow()?.querySelector('.regulatory-mapping-panel')).not.toBeNull();
expect(host.querySelector('.regulatory-mapping-panel')).toBeNull();
await act(async () => isoWindow()!.querySelector<HTMLButtonElement>('[aria-label="Close window"]')!.click());
expect(isoWindow()).toBeNull();
await act(async () => { isoButton()!.click(); });
await act(async () => { setEngineEnabled('compliance', false); });
expect(isoButton()).toBeUndefined();
expect(isoWindow()).toBeNull();
await act(async () => { setEngineEnabled('compliance', true); });
expect(isoButton()?.getAttribute('aria-expanded')).toBe('false');
expect(isoWindow()).toBeNull();
expect(request).toHaveBeenCalledTimes(1);
});
it('lists the controls accepted for the finding as a reader section like Description', async () => {
const decision = (id: string, controlId: string, value: 'accepted' | 'rejected', createdAt: string) => ({
version: 1 as const, id, ruleKey: 'purple-knight:dummy', sourceTool: 'purple-knight', mappingId: 'dummy-mapping', controlId, decision: value, createdAt });
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockResolvedValue({ ...mappingResponse, state: 'succeeded',
run: { candidates: [{ controlId: '8.20', annexAControlId: 'A.8.20', title: 'Networks security', score: 0.9, evidence: [] },
{ controlId: '5.1', annexAControlId: 'A.5.1', title: 'Policies for information security', score: 0.8, evidence: [] }] } as never,
decisions: [decision('d3', '5.1', 'rejected', '2026-10-05T10:02:00Z'), decision('d2', '8.20', 'accepted', '2026-10-05T10:01:00Z'),
decision('d1', '5.1', 'accepted', '2026-10-05T10:00:00Z'), decision('d0', '7.4', 'accepted', '2026-10-05T09:00:00Z')] });
await renderReader();
const section = host.querySelector('[data-iso-controls]')!;
expect(section.querySelector('h4')?.textContent).toBe('ISO controls');
// A later rejection replaces an earlier acceptance; a control without a retrieved title keeps its Annex A id.
expect([...section.querySelectorAll('li')].map(item => item.textContent)).toEqual(['A.7.4', 'A.8.20Networks security']);
expect(isoButton()?.textContent).toBe('Map ISO controls (2)');
});
it('requests no Result DB batch status while off and hides the batch action', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingBatch').mockResolvedValue({
version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: {} },
generationEnabled: true, state: 'empty'
} as never);
setEngineEnabled('compliance', false);
await act(async () => root.render(<BatchProbe/>));
expect(host.textContent).toBe('off');
expect(request).not.toHaveBeenCalled();
await act(async () => { setEngineEnabled('compliance', true); });
expect(host.textContent).toBe('available');
expect(request).toHaveBeenCalledTimes(1);
const source = readFileSync(join(process.cwd(), 'src/features/results-browser/ResultsBrowser.tsx'), 'utf8');
expect(source).toContain('{regulatoryBatch.available && <button');
expect(source).toContain('{batchDialogOpen && regulatoryBatch.available && <RegulatoryMappingBatchDialog');
});
it('requests no AI overview while off and hides its Client Info tab and the Result DB Export JSON', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getAiOverview').mockResolvedValue({
version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [] },
generationEnabled: true, state: 'empty', stale: false
});
setEngineEnabled('compliance', false);
await act(async () => { root.render(<AiOverviewProbe/>); await new Promise(resolve => setTimeout(resolve, 0)); });
expect(host.textContent).toBe('off');
expect(request).not.toHaveBeenCalled();
await act(async () => { setEngineEnabled('compliance', true); await new Promise(resolve => setTimeout(resolve, 0)); });
expect(host.textContent).toBe('available');
expect(request).toHaveBeenCalledTimes(1);
const dashboard = readFileSync(join(process.cwd(), 'src/features/dashboard/DashboardPanel.tsx'), 'utf8');
expect(dashboard).toContain('const showAiOverview = aiOverview.available && ');
const browser = readFileSync(join(process.cwd(), 'src/features/results-browser/ResultsBrowser.tsx'), 'utf8');
const exportButton = browser.slice(browser.lastIndexOf('{regulatoryBatch.available && <button', browser.indexOf("'Export JSON'")),
browser.indexOf("'Export JSON'"));
expect(exportButton).toContain('title="Export exactly what the AI overview and the ISO mapping receive');
});
it('renders no Data Hub ISO corpus tile and requests no corpus status while off', async () => {
const status: RegulatoryCorpusStatus = { version: 1, configured: true, active: null, job: null };
const request = vi.spyOn(AssessorApiClient.prototype, 'getIsoCorpusStatus').mockResolvedValue(status);
setEngineEnabled('compliance', false);
await act(async () => root.render(<IsoCorpusLibrary/>));
expect(host.innerHTML).toBe('');
expect(request).not.toHaveBeenCalled();
await act(async () => { setEngineEnabled('compliance', true); });
expect(host.innerHTML).not.toBe('');
expect(request).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,56 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, expect, it } from 'vitest';
import { createAssessorHttpServer } from '../../server/assessor-http-server';
import { LocalComplianceStore } from '../../src/compliance/local-compliance-store';
import type { RegulatoryMappingBatchRun } from '../../src/compliance/regulatory-mapping';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
// The project export keeps the engine's data: the Core export and the Compliance Engine's own
// project data are combined at the HTTP route, not inside the Core repository.
const stamp = '2026-10-05T08:00:00.000Z';
let directory: string;
let server: Server | undefined;
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'assessor-compliance-export-'));
await writeFile(join(directory, 'index.html'), '<main>synthetic app</main>');
});
afterEach(async () => {
await new Promise(resolve => server ? server.close(resolve) : resolve(undefined));
server = undefined;
await rm(directory, { recursive: true, force: true });
});
async function exportVia(complianceExport?: LocalComplianceStore) {
const repository = new LocalAssessorRepository();
await repository.saveProject({ id: 'dummy-project', name: 'Dummy Customer', createdAt: stamp, updatedAt: stamp, attributes: {} });
server = createAssessorHttpServer({ repository, staticRoot: directory, ...(complianceExport ? { complianceExport } : {}) });
await new Promise<void>(resolve => server!.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
return (await fetch(`http://127.0.0.1:${port}/api/v1/projects/dummy-project/export`)).json();
}
it('adds the Compliance Engine project data to the Core project export', async () => {
const store = new LocalComplianceStore();
const batch = await store.createRegulatoryMappingBatchRun({
version: 1, id: 'dummy-batch', projectId: 'dummy-project', scopeKey: 'scope', filterHash: 'a'.repeat(64),
idempotencyKey: 'dummy-batch-1', assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: {}, status: 'queued',
counts: { filteredResultCount: 0, excludedNonScannerCount: 0, eligibleFindingCount: 0, unsupportedFindingCount: 0,
distinctRuleCount: 0, reusedSucceededRuleCount: 0, reusedActiveRuleCount: 0, scheduledRuleCount: 0,
succeededRuleCount: 0, failedRuleCount: 0 },
createdAt: stamp, updatedAt: stamp
} satisfies RegulatoryMappingBatchRun);
const exported = await exportVia(store);
expect(exported).toMatchObject({ project: { id: 'dummy-project' }, regulatoryMappingBatchRuns: [batch],
regulatoryMappingRuns: [], regulatoryMappingDecisions: [] });
});
it('keeps the Core project export complete without the Compliance Engine', async () => {
const exported = await exportVia();
expect(exported).toMatchObject({ project: { id: 'dummy-project' } });
expect(exported).not.toHaveProperty('regulatoryMappingBatchRuns');
});

View File

@ -4,7 +4,7 @@ import {
embeddingEndpoint, embeddingEndpoint,
EmbeddingClientError, EmbeddingClientError,
OpenAiCompatibleEmbeddingClient OpenAiCompatibleEmbeddingClient
} from '../../server/regulatory/embedding-client'; } from '../../server/compliance/embedding-client';
describe('regulatory embedding client', () => { describe('regulatory embedding client', () => {
it('allows loopback HTTP, requires remote HTTPS, and requires explicit dimensions', () => { it('allows loopback HTTP, requires remote HTTPS, and requires explicit dimensions', () => {

View File

@ -4,8 +4,8 @@ import { act } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest'; import { afterEach, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client'; import { AssessorApiClient } from '../../src/api/assessor-api-client';
import type { RegulatoryCorpusStatus } from '../../src/domain/regulatory-corpus-management'; import type { RegulatoryCorpusStatus } from '../../src/compliance/regulatory-corpus-management';
import { IsoCorpusLibrary } from '../../src/features/project-import/IsoCorpusLibrary'; import { IsoCorpusLibrary } from '../../src/compliance/ui/IsoCorpusLibrary';
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });

View File

@ -5,7 +5,7 @@ import {
type ParsedControl, type ParsedControl,
type ParsedPdf, type ParsedPdf,
type PdfLine type PdfLine
} from '../../server/regulatory/iso-pdf-corpus'; } from '../../server/compliance/iso-pdf-corpus';
const line = (text: string, page: number): PdfLine => ({ text, page, y: 500, spans: [{ text, page, x: 100, y: 500, width: text.length * 5, height: 10 }] }); const line = (text: string, page: number): PdfLine => ({ text, page, y: 500, spans: [{ text, page, x: 100, y: 500, width: text.length * 5, height: 10 }] });

View File

@ -0,0 +1,153 @@
// @vitest-environment jsdom
import { act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { AppProvider, useApp } from '../../src/app/AppProvider';
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
import type { RegulatoryControlOverviewResponse } from '../../src/compliance/regulatory-mapping';
import {
complianceWorkFacetIndex,
ISO_CONTROL_FACET_ID,
ISO_THEME_FACET_ID,
resetComplianceWorkFacetsForTests,
useComplianceWorkFacets
} from '../../src/compliance/ui/compliance-work-facets';
import { markRegulatoryDecisionSaved } 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';
import { genericProfile } from '../../src/profiles/generic/generic-profile';
// Work `ISO control` and `ISO theme` (Compliance Engine): a second reading of accepted controls that
// filters Work like the Context Cluster facet, sits before the final `Source: Scan`, and is absent while
// the engine is off.
const gpoKey = `gpo-work-setting:${encodeURIComponent(JSON.stringify(['dummy', 'baseline', 'computer', 'registry:dummy:setting']))}`;
const assignments: RegulatoryControlOverviewResponse = {
version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [] }, findingCount: 3,
controls: [
{ controlId: '8.20', annexAControlId: 'A.8.20', title: 'Networks security', theme: 'Technological', findings: [
{ resultProjectionId: 'scanner-finding:dummy-a', sourceKind: 'scanner-finding', sourceResultId: 'dummy-a' },
{ resultProjectionId: 'scanner-finding:dummy-b', sourceKind: 'scanner-finding', sourceResultId: 'dummy-b' },
{ resultProjectionId: 'policy-evaluation-comparison:dummy', sourceKind: 'policy-evaluation', sourceResultId: 'dummy-comparison',
sourceRuleId: 'registry:dummy:setting', policyScope: 'computer' }] },
{ controlId: '5.15', annexAControlId: 'A.5.15', theme: 'Organizational', findings: [
{ resultProjectionId: 'scanner-finding:dummy-b', sourceKind: 'scanner-finding', sourceResultId: 'dummy-b' }] }
]
};
function workSheet() {
const sheet = makeSheet('Dummy', [
['Title', 'Status', 'Source: Scan'],
['Dummy check A', 'Failed', 'Purple Knight'],
['Dummy check B', 'Passed', 'Purple Knight'],
['Dummy GPO setting', 'Failed', 'GPOs'],
['Dummy unmapped check', 'Failed', 'Purple Knight']
], '', 'dummy.csv', { isProjectSheet: true });
const [a, b, gpo] = sheet.analysis.records;
a.findingOccurrenceId = 'dummy-a';
b.findingOccurrenceId = 'dummy-b';
(gpo as { id: string }).id = gpoKey;
return sheet;
}
const options = (statusFilter = 'all', facetFilters = {}) => ({ dataset: null, activeProfile: genericProfile, reviews: {}, search: '',
actionOnly: false, statusFilter, facetFilters, complianceFacets: complianceWorkFacetIndex(assignments) });
describe('Work ISO control and ISO theme facets', () => {
it('offers accepted controls and themes before the final Source: Scan control', () => {
const facets = selectWorkFacets(workSheet(), options());
const ids = facets.map(facet => facet.id);
expect(ids.at(-1)).toBe('provenance:source-scan');
expect(ids.slice(-3, -1)).toEqual([ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID]);
const control = facets.find(facet => facet.id === ISO_CONTROL_FACET_ID)!;
expect(control.label).toBe('ISO control');
expect(control.values.map(value => [value.label, value.count])).toEqual([['A.5.15', 1], ['A.8.20 · Networks security', 3]]);
const theme = facets.find(facet => facet.id === ISO_THEME_FACET_ID)!;
expect(theme.values.map(value => [value.value, value.label, value.count])).toEqual([['organizational', 'Organizational', 1], ['technological', 'Technological', 3]]);
});
it('filters Work records by control and theme, matching scanner occurrences and GPO settings', () => {
const sheet = workSheet();
const titles = (filters: object, status = 'all') => filterAndSortRecords(sheet, { ...options(status, filters), sortColumn: '', sortDirection: 'asc' })
.map(record => record.title);
expect(titles({ [ISO_CONTROL_FACET_ID]: { values: ['5.15'] } })).toEqual(['Dummy check B']);
expect(titles({ [ISO_THEME_FACET_ID]: { values: ['technological'] } })).toEqual(['Dummy check A', 'Dummy check B', 'Dummy GPO setting']);
// Other filters still apply: the status filter narrows the ISO selection.
expect(titles({ [ISO_CONTROL_FACET_ID]: { values: ['8.20'] } }, 'deviation')).toEqual(['Dummy check A', 'Dummy GPO setting']);
});
it('counts each control against the other active filters', () => {
const facets = selectWorkFacets(workSheet(), options('deviation'));
expect(facets.find(facet => facet.id === ISO_CONTROL_FACET_ID)!.values.map(value => [value.value, value.count])).toEqual([['8.20', 2]]);
});
it('adds no ISO facet without loaded assignments', () => {
const facets = selectWorkFacets(workSheet(), { ...options(), complianceFacets: undefined });
expect(facets.some(facet => facet.id.startsWith('compliance:'))).toBe(false);
});
});
vi.mock('../../src/features/project-import/load-canonical-project-workspace', () => ({ loadCanonicalProjectWorkspace: vi.fn() }));
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
localStorage.clear();
resetEnginePreferencesForTests();
resetComplianceWorkFacetsForTests();
vi.spyOn(AssessorApiClient.prototype, 'health').mockResolvedValue({ status: 'ok' } as never);
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => { root.unmount(); });
host.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
resetEnginePreferencesForTests();
});
function Consumer(): ReactNode {
const index = useComplianceWorkFacets();
return <output data-iso-facets>{index ? index.facets.map(facet => facet.values.length).join(',') : 'none'}</output>;
}
function Harness({ withScope }: { withScope: boolean }): ReactNode {
const app = useApp();
return <>
<button type="button" onClick={() => void app.openProject('dummy-project', [], ['dummy-assessment'])}>Open</button>
<button type="button" onClick={() => app.setFacetFilter(ISO_CONTROL_FACET_ID, { values: ['8.20'] })}>Select</button>
<output data-filters>{JSON.stringify(app.facetFilters)}</output>
{withScope && <><Consumer/><Consumer/></>}
</>;
}
describe('Work ISO facet loading and the engine switch', () => {
it('shares one request for every Work consumer and reloads after a control decision', 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);
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlAssignments').mockResolvedValue(assignments);
await act(async () => root.render(<AppProvider><Harness withScope/></AppProvider>));
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(request).toHaveBeenCalledTimes(1);
expect([...host.querySelectorAll('[data-iso-facets]')].map(node => node.textContent)).toEqual(['2,2', '2,2']);
await act(async () => { markRegulatoryDecisionSaved(); await new Promise(resolve => setTimeout(resolve, 0)); });
expect(request).toHaveBeenCalledTimes(2);
});
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(<AppProvider><Harness withScope={false}/></AppProvider>));
await act(async () => host.querySelectorAll('button')[1].click());
expect(JSON.parse(host.querySelector('[data-filters]')!.textContent!)).toEqual({ [ISO_CONTROL_FACET_ID]: { values: ['8.20'] } });
await act(async () => { setEngineEnabled('compliance', false); });
expect(JSON.parse(host.querySelector('[data-filters]')!.textContent!)).toEqual({});
await act(async () => root.render(<AppProvider><Harness withScope/></AppProvider>));
expect(host.querySelector('[data-iso-facets]')?.textContent).toBe('none');
expect(request).not.toHaveBeenCalled();
});
});

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