import { createHash, randomUUID } from 'node:crypto'; import type { AiCompletionClient } from '../ai/litellm-client'; import { LiteLlmClientError } from '../ai/litellm-client'; import type { ComplianceReadPort, ComplianceStore } from '../../src/compliance/compliance-store'; import { RepositoryConflictError, RepositoryNotFoundError, RepositoryValidationError } from '../../src/persistence/repository-errors'; import { REGULATORY_MAPPING_OUTPUT_SCHEMA_VERSION, REGULATORY_MAPPING_PROMPT_VERSION, REGULATORY_RETRIEVAL_VERSION, REGULATORY_GPO_QUERY_VERSION, emptyRegulatoryMappingBatchCounts, groundRegulatoryMappingResult, normalizeRegulatoryMappingBatchScope, regulatoryMappingResultValidationErrors, type RegulatoryMappingBatchCreateRequest, type RegulatoryMappingBatchResponse, type RegulatoryMappingBatchRun, type RegulatoryMappingBatchScope, type RegulatoryMappingCandidate, type RegulatoryMappingCreateRequest, type RegulatoryMappingDecision, type RegulatoryEarlierControl, type RegulatoryMappingDecisionCreateRequest, type RegulatoryMappingEvidence, type RegulatoryMappingResponse, type RegulatoryMappingResult, type RegulatoryMappingRun, type RegulatoryControlFinding, 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 { privacySafeFinding } from '../../src/domain/privacy-safe-finding'; import type { RegulatoryFindingRetrievalService } from './regulatory-finding-retrieval'; import type { RegulatoryChunkIndex } from './qdrant-regulatory-index'; import { buildRegulatoryFindingQuery, isRegulatoryMappableResult } from '../../src/compliance/regulatory-query'; import { canonicalJson } from '../../src/shared/canonical-json'; const POLL_AFTER_MS = 1_500; const UNSUPPORTED_KIND_MESSAGE = 'Only canonical scanner findings and GPO baseline comparisons can be mapped in this version.'; const MAXIMUM_PROMPT_CHARACTERS = 48_000; const BATCH_PAGE_SIZE = 500; 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 { findingRetrieval?: RegulatoryFindingRetrievalService; index?: RegulatoryChunkIndex; client?: AiCompletionClient; corpusId?: string; corpusVersion?: string; embeddingModel?: string; disabledReason?: string; now?: () => Date; maximumConcurrentRuns?: number; } export class RegulatoryMappingService { private readonly activeTasks = new Set>(); private readonly now: () => Date; private readonly maximumConcurrentRuns: number; private readonly workQueue: Array<{ task: () => Promise<'succeeded' | 'failed'>; resolve: (status: 'succeeded' | 'failed') => void; }> = []; private activeWorkerCount = 0; private activationPending = false; constructor( private readonly read: ComplianceReadPort, private readonly store: ComplianceStore, private readonly options: RegulatoryMappingServiceOptions = {} ) { this.now = options.now ?? (() => new Date()); this.maximumConcurrentRuns = options.maximumConcurrentRuns ?? DEFAULT_MAXIMUM_CONCURRENT_RUNS; if (!Number.isInteger(this.maximumConcurrentRuns) || this.maximumConcurrentRuns < 1 || this.maximumConcurrentRuns > 8) { throw new Error('Regulatory mapping concurrency must be an integer between 1 and 8.'); } } get generationEnabled(): boolean { return !this.activationPending && Boolean(this.options.findingRetrieval && this.options.index && this.options.client && this.options.corpusId && this.options.corpusVersion && this.options.embeddingModel); } async activateCorpus(corpusId: string, corpusVersion: string): Promise { this.activationPending = true; try { await this.close(); this.options.corpusId = corpusId; this.options.corpusVersion = corpusVersion; } finally { this.activationPending = false; } } async recoverInterruptedRuns(): Promise { const completedAt = this.now().toISOString(); for (const run of (await this.store.listRegulatoryMappingRuns()).filter(item => ['queued', 'running'].includes(item.status))) { await this.store.saveRegulatoryMappingRun({ ...run, status: 'failed', updatedAt: completedAt, completedAt, errorCode: 'regulatory_mapping_interrupted' }); } for (const project of await this.read.listProjects()) { for (const run of (await this.store.listRegulatoryMappingBatchRuns(project.id)).filter(item => ['queued', 'running'].includes(item.status))) { await this.store.updateRegulatoryMappingBatchRun({ ...run, status: 'failed', updatedAt: completedAt, completedAt, errorCode: 'regulatory_mapping_batch_interrupted', errorDetail: 'Batch processing was interrupted before completion. Start a new batch to retry remaining rules.' }); } } } async get(projectId: string, resultProjectionId: string): Promise { const finding = await this.finding(projectId, resultProjectionId); const supported = Boolean(buildRegulatoryFindingQuery(finding)); const availability = supported ? await this.availability() : { enabled: false }; const enabled = availability.enabled && supported; const ruleKey = supported ? this.ruleKey(finding) : undefined; const run = ruleKey && this.options.client ? await this.store.getRegulatoryMappingRun(this.mappingId(ruleKey)) : null; const decisions = ruleKey ? await this.store.listRegulatoryMappingDecisions(ruleKey) : []; const earlierControls = ruleKey ? await this.earlierControls(ruleKey, run, decisions) : []; return { version: 1, projectId, resultProjectionId, generationEnabled: enabled, state: run?.status ?? (enabled ? 'empty' : 'unsupported'), ...(!supported ? { disabledReason: !isRegulatoryMappableResult(finding) ? UNSUPPORTED_KIND_MESSAGE : 'This finding has insufficient public context for regulatory mapping.' } : !availability.enabled ? { disabledReason: availability.reason ?? this.options.disabledReason ?? 'Regulatory mapping is not configured.' } : {}), ...(run && ['queued', 'running'].includes(run.status) ? { pollAfterMs: POLL_AFTER_MS } : {}), ...(run ? { run: withoutEvidenceText(run) } : {}), 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 { 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 { 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(); 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 { 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); if (!isRegulatoryMappableResult(finding)) throw new RegulatoryMappingServiceError('unsupported_finding_kind', UNSUPPORTED_KIND_MESSAGE, 422); if (!buildRegulatoryFindingQuery(finding)) throw new RegulatoryMappingServiceError('insufficient_public_context', 'This finding has insufficient public context for regulatory mapping.', 422); const claimed = await this.claim(finding, true); if (claimed.claimed) this.track(this.enqueue(() => this.process(claimed.run, finding)).then(() => undefined)); return this.get(projectId, request.resultProjectionId); } async getBatch(projectId: string, requestedScope: RegulatoryMappingBatchScope): Promise { const scope = await this.validateBatchScope(projectId, requestedScope); const scopeKey = this.batchScopeKey(projectId, scope); const run = (await this.store.listRegulatoryMappingBatchRuns(projectId, scopeKey))[0]; const availability = await this.availability(); return { version: 1, projectId, scope, generationEnabled: availability.enabled, state: run?.status ?? 'empty', ...(!availability.enabled ? { disabledReason: availability.reason ?? this.options.disabledReason ?? 'Regulatory mapping is not configured.' } : {}), ...(run && ['queued', 'running'].includes(run.status) ? { pollAfterMs: POLL_AFTER_MS } : {}), ...(run ? { run } : {}) }; } async startBatch(projectId: string, request: RegulatoryMappingBatchCreateRequest): Promise { if (!this.generationEnabled) throw new RegulatoryMappingServiceError('regulatory_mapping_disabled', this.options.disabledReason ?? 'Regulatory mapping is not configured.', 503); if (typeof request.idempotencyKey !== 'string' || !/^[A-Za-z0-9._:-]{8,128}$/.test(request.idempotencyKey)) { throw new RepositoryValidationError('idempotencyKey must contain 8 to 128 safe characters.'); } const scope = await this.validateBatchScope(projectId, request); const timestamp = this.now().toISOString(); const candidate: RegulatoryMappingBatchRun = { version: 1, id: `regulatory-mapping-batch-${randomUUID()}`, projectId, scopeKey: this.batchScopeKey(projectId, scope), filterHash: sha256(canonicalJson(scope.filters)), assessmentIds: scope.assessmentIds, scanRunIds: scope.scanRunIds, filters: scope.filters, idempotencyKey: request.idempotencyKey, status: 'queued', counts: emptyRegulatoryMappingBatchCounts(), createdAt: timestamp, updatedAt: timestamp }; const run = await this.store.createRegulatoryMappingBatchRun(candidate); if (run.id === candidate.id) this.track(this.processBatch(run)); return this.getBatch(projectId, { assessmentIds: run.assessmentIds, scanRunIds: run.scanRunIds, filters: run.filters }); } async decide(projectId: string, request: RegulatoryMappingDecisionCreateRequest): Promise { 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); if (!isRegulatoryMappableResult(finding)) throw new RegulatoryMappingServiceError('unsupported_finding_kind', UNSUPPORTED_KIND_MESSAGE, 422); const ruleKey = this.ruleKey(finding); const comment = request.comment?.trim(); const mappingId = await this.groundingMappingId(ruleKey, request.controlId); const decision: RegulatoryMappingDecision = { version: 1, id: `regulatory-decision-${randomUUID()}`, ruleKey, sourceTool: finding.sourceTool ?? 'unknown', ...(finding.sourceRuleId ? { sourceRuleId: finding.sourceRuleId } : {}), mappingId, controlId: request.controlId, decision: request.decision, ...(comment ? { comment } : {}), createdAt: this.now().toISOString() }; await this.store.createRegulatoryMappingDecision(decision, request.expectedLatestDecisionId); 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): Promise { 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, { deviationsOnly = true }: { deviationsOnly?: boolean } = {}): Promise { const scope = await this.validateBatchScope(projectId, { assessmentIds: requested.assessmentIds, scanRunIds: requested.scanRunIds, filters: deviationsOnly ? { deviation: true } : {} }); const byRule = new Map>(); 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(); // 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>(); 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(); for (const candidate of run.candidates) if (!known.has(candidate.controlId)) known.set(candidate.controlId, candidate); titles.set(run.ruleKey, known); } const controls = new Map }>(); const covered = new Set(); 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 { 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. // 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. private ruleKey(finding: UnifiedResultProjection): string { const tool = finding.sourceTool?.trim() || 'unknown'; const ruleId = finding.sourceRuleId?.trim(); if (ruleId) return `${tool}:${ruleId}`.slice(0, 200); const hash = createHash('sha256').update(privacySafeFinding(finding).title.trim(), 'utf8').digest('hex').slice(0, 24); return `${tool}#title:${hash}`.slice(0, 200); } // 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. private mappingId(ruleKey: string): string { 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)}`; } // 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 { while (this.activeTasks.size) await Promise.allSettled([...this.activeTasks]); } private track(task: Promise): void { this.activeTasks.add(task); void task.finally(() => this.activeTasks.delete(task)); } private enqueue(task: () => Promise<'succeeded' | 'failed'>): Promise<'succeeded' | 'failed'> { const promise = new Promise<'succeeded' | 'failed'>(resolve => this.workQueue.push({ task, resolve })); this.drainQueue(); return promise; } private drainQueue(): void { while (this.activeWorkerCount < this.maximumConcurrentRuns && this.workQueue.length) { const work = this.workQueue.shift()!; this.activeWorkerCount += 1; void work.task().then(work.resolve, () => work.resolve('failed')).finally(() => { this.activeWorkerCount -= 1; this.drainQueue(); }); } } private queuedRun(finding: UnifiedResultProjection): RegulatoryMappingRun { const preparedQuery = buildRegulatoryFindingQuery(finding); if (!preparedQuery) throw new RegulatoryMappingServiceError('insufficient_public_context', 'This finding has insufficient public context for regulatory mapping.', 422); const timestamp = this.now().toISOString(); const ruleKey = this.ruleKey(finding); return { version: 1, id: this.mappingId(ruleKey), ruleKey, sourceTool: finding.sourceTool ?? 'unknown', ...(finding.sourceRuleId ? { sourceRuleId: finding.sourceRuleId } : {}), status: 'queued', queryHash: sha256(JSON.stringify(preparedQuery)), preparedQuery, corpusId: this.options.corpusId!, corpusVersion: this.options.corpusVersion!, sourceHashes: [], embeddingModel: this.options.embeddingModel!, retrievalVersion: REGULATORY_RETRIEVAL_VERSION, promptVersion: this.promptVersion(ruleKey), outputSchemaVersion: REGULATORY_MAPPING_OUTPUT_SCHEMA_VERSION, model: this.options.client!.model, createdAt: timestamp, updatedAt: timestamp, candidates: [] }; } private async claim(finding: UnifiedResultProjection, forceRegeneration: boolean) { return this.store.claimRegulatoryMappingRun(this.queuedRun(finding), forceRegeneration); } private async validateBatchScope(projectId: string, requested: RegulatoryMappingBatchScope): Promise { if (!await this.read.getProject(projectId)) throw new RepositoryNotFoundError('The selected project does not exist.'); const scope = normalizeRegulatoryMappingBatchScope(requested); if (!scope.assessmentIds.length && !scope.scanRunIds.length) throw new RepositoryValidationError('A selected timeslot scope is required.'); const assessments = await this.read.listAssessments(projectId); 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.'); const scanRuns = await this.read.listProjectScanRuns(projectId); 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.assessmentIds.length && scope.scanRunIds.some(id => !scope.assessmentIds.includes(allowedRuns.get(id)!.assessmentId))) { throw new RepositoryValidationError('The assessment and scan-run scope do not describe the same timeslot.'); } return scope; } private batchScopeKey(projectId: string, scope: RegulatoryMappingBatchScope): string { return sha256(canonicalJson({ projectId, ...scope })); } private async queryBatchResults(projectId: string, scope: RegulatoryMappingBatchScope): Promise { const query = { ...scope.filters, audience: 'result-db' as const, projectId, assessmentIds: scope.assessmentIds, scanRunIds: scope.scanRunIds }; const items: UnifiedResultProjection[] = []; let expectedTotal: number | undefined; for (let offset = 0; ; offset += BATCH_PAGE_SIZE) { const page = await this.read.queryUnifiedResults({ ...query, offset, limit: BATCH_PAGE_SIZE }); expectedTotal ??= page.total; if (page.total !== expectedTotal) throw new RepositoryConflictError('The filtered result set changed while the ISO mapping batch was being prepared.'); items.push(...page.items); if (items.length >= page.total || page.items.length === 0) break; } if (items.length !== expectedTotal) throw new RepositoryConflictError('The complete filtered result set could not be read consistently.'); 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, results: UnifiedResultProjection[]): Promise> { const wanted = new Set(results.filter(item => item.sourceKind === 'policy-evaluation' && !item.sourceRuleId?.trim()) .map(item => item.resultProjectionId)); const settingIds = new Map(); 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 { const startedAt = this.now().toISOString(); let run = await this.store.updateRegulatoryMappingBatchRun({ ...queued, status: 'running', startedAt, updatedAt: startedAt }); try { const results = (await this.queryBatchResults(run.projectId, run)).slice() .sort((left, right) => left.resultProjectionId.localeCompare(right.resultProjectionId)); const representatives = new Map(); 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(); const unsupported = new Set(); const settingIds = await this.gpoSettingIds(run.projectId, run, results); for (const finding of results) { if (!isRegulatoryMappableResult(finding)) { counts.excludedNonScannerCount += 1; continue; } const identity = findingIdentity(finding, settingIds.get(finding.resultProjectionId)); if (!buildRegulatoryFindingQuery(finding)) { unsupported.add(identity); continue; } eligible.add(identity); const ruleKey = this.ruleKey(finding); if (!representatives.has(ruleKey)) representatives.set(ruleKey, finding); } counts.eligibleFindingCount = eligible.size; counts.unsupportedFindingCount = unsupported.size; counts.distinctRuleCount = representatives.size; const scheduled: Array> = []; for (const finding of representatives.values()) { const claimed = await this.claim(finding, false); if (!claimed.claimed) { if (claimed.run.status === 'succeeded') counts.reusedSucceededRuleCount += 1; else counts.reusedActiveRuleCount += 1; continue; } counts.scheduledRuleCount += 1; const completion = this.enqueue(() => this.process(claimed.run, finding)); this.track(completion.then(() => undefined)); scheduled.push(completion); } run = await this.store.updateRegulatoryMappingBatchRun({ ...run, counts, updatedAt: this.now().toISOString() }); let progress = Promise.resolve(); const completions = scheduled.map(completion => completion.then(status => { progress = progress.then(async () => { if (status === 'succeeded') counts.succeededRuleCount += 1; else counts.failedRuleCount += 1; run = await this.store.updateRegulatoryMappingBatchRun({ ...run, counts: { ...counts }, updatedAt: this.now().toISOString() }); }); return progress; })); await Promise.all(completions); await progress; const completedAt = this.now().toISOString(); await this.store.updateRegulatoryMappingBatchRun({ ...run, status: 'completed', counts, updatedAt: completedAt, completedAt }); } catch (error) { const completedAt = this.now().toISOString(); try { await this.store.updateRegulatoryMappingBatchRun({ ...run, status: 'failed', updatedAt: completedAt, completedAt, errorCode: error instanceof RepositoryConflictError ? 'regulatory_mapping_batch_scope_changed' : 'regulatory_mapping_batch_failed', errorDetail: error instanceof RepositoryConflictError ? error.message : 'The batch stopped safely before all rules were scheduled.' }); } catch { /* Preserve the original safe batch failure. */ } } } private async availability(): Promise<{ enabled: boolean; reason?: string }> { if (!this.generationEnabled) return { enabled: false, reason: this.options.disabledReason ?? 'Regulatory mapping is not configured.' }; try { const sample = await this.options.index!.list({ corpusId: this.options.corpusId, corpusVersion: this.options.corpusVersion, sourceStandard: 'ISO/IEC 27002', retrievalRole: 'semantic' }, 1); return sample.length ? { enabled: true } : { enabled: false, reason: 'The active regulatory corpus is not indexed.' }; } catch { return { enabled: false, reason: 'Qdrant or the active regulatory corpus is unavailable.' }; } } private async finding(projectId: string, resultProjectionId: string) { if (!resultProjectionId?.trim()) throw new RepositoryValidationError('A resultProjectionId is required.'); // The Result DB row carries no real scope ids; the repository scopes the lookup to the 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; } private async groupCandidates(hits: RegulatoryRetrievalCandidate[]): Promise { const byControl = new Map(); for (const hit of hits) { if (!hit.chunk.annexAControlId || !hit.chunk.sectionKind || !hit.chunk.sourcePageStart || !hit.chunk.sourcePageEnd || !hit.chunk.sourceSha256) continue; const values = byControl.get(hit.chunk.controlId) ?? []; values.push(hit); byControl.set(hit.chunk.controlId, values); } const selected = [...byControl.entries()] .map(([controlId, values]) => ({ controlId, values: values.sort((left, right) => right.score - left.score), score: Math.max(...values.map(value => value.score)) })) .sort((left, right) => right.score - left.score || left.controlId.localeCompare(right.controlId, 'en', { numeric: true })) .slice(0, 8); return Promise.all(selected.map(async selection => { const semantic = selection.values.slice(0, 2).map(value => evidence(value.chunk)); const references = await this.options.index!.list({ corpusId: this.options.corpusId, corpusVersion: this.options.corpusVersion, controlId: selection.controlId, sourceStandard: 'ISO/IEC 27001', retrievalRole: 'reference' }, 1); if (references.length !== 1 || !references[0].sourceSha256) { throw new RegulatoryMappingServiceError('regulatory_reference_missing', `The active corpus is missing the ISO 27001 reference for ${selection.controlId}.`, 503); } const first = selection.values[0].chunk; return { controlId: selection.controlId, annexAControlId: first.annexAControlId!, title: first.title, score: selection.score, evidence: [...semantic, ...references.map(evidence)] }; })); } private async process(queued: RegulatoryMappingRun, finding: UnifiedResultProjection): Promise<'succeeded' | 'failed'> { let run = queued; try { let retrieval; try { retrieval = await this.options.findingRetrieval!.retrieve(finding, 32, { corpusId: this.options.corpusId, corpusVersion: this.options.corpusVersion, sourceStandard: 'ISO/IEC 27002', retrievalRole: 'semantic' }); } catch { throw new RegulatoryMappingServiceError('regulatory_retrieval_unavailable', 'Regulatory retrieval is currently unavailable.', 503); } if (retrieval.status !== 'candidates' || !retrieval.preparedQuery) { throw new RegulatoryMappingServiceError('insufficient_public_context', 'This finding has insufficient public context for regulatory mapping.', 422); } 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); const startedAt = this.now().toISOString(); run = await this.store.saveRegulatoryMappingRun({ ...queued, status: 'running', preparedQuery: retrieval.preparedQuery, queryHash: sha256(JSON.stringify(retrieval.preparedQuery)), sourceHashes: [...new Set(candidates.flatMap(candidate => candidate.evidence.map(evidence => evidence.sourceSha256)))].sort(), candidates, startedAt, updatedAt: startedAt }); const prompt = mappingPrompt(run); const completion = await this.options.client!.complete(SYSTEM_PROMPT, prompt); const allowed = new Map(run.candidates.map(candidate => [ candidate.controlId, new Set(candidate.evidence.map(item => item.chunkId)) ])); const grounded = groundRegulatoryMappingResult(completion.value, allowed); const errors = regulatoryMappingResultValidationErrors(grounded, allowed); if (errors.length) throw new RegulatoryMappingServiceError('regulatory_mapping_invalid_output', errors.join(' '), 502); const completedAt = this.now().toISOString(); await this.store.saveRegulatoryMappingRun({ ...run, status: 'succeeded', result: grounded as RegulatoryMappingResult, ...(completion.usage ? { tokenUsage: completion.usage } : {}), updatedAt: completedAt, completedAt }); await this.autoAcceptFirstProposals(run, grounded as RegulatoryMappingResult); return 'succeeded'; } catch (error) { const completedAt = this.now().toISOString(); const failure = safeFailure(error); const detail = safeFailureDetail(error); try { await this.store.saveRegulatoryMappingRun({ ...run, status: 'failed', updatedAt: completedAt, completedAt, errorCode: failure, ...(detail ? { errorDetail: detail } : {}) }); } catch { /* Preserve the original safe failure code. */ } return 'failed'; } } } export class RegulatoryMappingServiceError extends Error { constructor(readonly code: string, message: string, readonly status: number) { super(message); this.name = 'RegulatoryMappingServiceError'; } } // Response projection: the persisted run keeps the full ISO clause text (the model reads it), // but the copyrighted text is never shipped to the browser — only the citation fields are. function withoutEvidenceText(run: RegulatoryMappingRun): RegulatoryMappingRun { return { ...run, candidates: run.candidates.map(candidate => ({ ...candidate, evidence: candidate.evidence.map(({ text, ...reference }) => reference) })) }; } function evidence(chunk: RegulatoryRetrievalCandidate['chunk']): RegulatoryMappingEvidence { return { chunkId: chunk.chunkId, textSha256: chunk.textSha256, sourceSha256: chunk.sourceSha256!, sourceStandard: chunk.sourceStandard!, sectionKind: chunk.sectionKind!, pageStart: chunk.sourcePageStart!, pageEnd: chunk.sourcePageEnd!, text: chunk.text }; } function mappingPrompt(run: RegulatoryMappingRun): string { // The Annex A id is always "A." + controlId, so it is redundant for the model and // sending it invites a reply that uses the Annex A form; expose only the controlId it must return. const candidates = run.candidates.map(({ annexAControlId, ...candidate }) => candidate); const payload = JSON.stringify({ finding: run.preparedQuery, candidates }); if (payload.length > MAXIMUM_PROMPT_CHARACTERS) throw new RegulatoryMappingServiceError('regulatory_mapping_prompt_too_large', 'The bounded regulatory prompt exceeded its limit.', 422); return payload; } const SYSTEM_PROMPT = `Map one public security finding query to the supplied ISO control candidates. A mapping means the control is topically relevant to the finding, never proof of compliance. Map a candidate when it governs the same security subject as the finding (for example password or authentication management, logging, or access restriction), even when the control text does not state the finding's specific value, threshold, parameter, or configuration; ISO controls are principle-level and rarely restate such specifics. Judge relevance from the control's subject and its supplied evidence, not from whether the exact requirement text appears. Return JSON only with schemaVersion 1, outcome mapped|no_relevant_control|insufficient_context, optional bounded reason, and mappings (maximum five). Each mapping requires controlId, relationship primary|supporting, confidence low|medium|high, a concise rationale under 600 characters, and one to three evidenceChunkIds copied only from that control's supplied evidence. Use no_relevant_control only when no candidate governs the finding's subject, and insufficient_context only when the finding query itself lacks enough meaning to judge. Do not invent controls or evidence.`; function safeFailure(error: unknown): string { if (error instanceof RegulatoryMappingServiceError || error instanceof LiteLlmClientError) return error.code; return 'regulatory_mapping_failed'; } // Our own service and client error messages are developer-authored and structural // (field paths, ISO control numbers) — never finding, tenant, or report content. // Clamp the length and strip any markup-like sequence before it is stored or shown. function safeFailureDetail(error: unknown): string | undefined { if (!(error instanceof RegulatoryMappingServiceError || error instanceof LiteLlmClientError)) return undefined; const detail = error.message.replace(/<\/?[a-z][^>]*>/gi, ' ').replace(/\s+/g, ' ').trim().slice(0, 600); 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 { return createHash('sha256').update(value, 'utf8').digest('hex'); }