import { createHash, randomUUID } from 'node:crypto'; import type { AiCompletionClient } from '../ai/litellm-client'; import { LiteLlmClientError } from '../ai/litellm-client'; import type { AssessorRepository } from '../../src/persistence/assessor-repository'; import { RepositoryConflictError, RepositoryNotFoundError, RepositoryValidationError } from '../../src/persistence/repository-errors'; import { REGULATORY_MAPPING_OUTPUT_SCHEMA_VERSION, REGULATORY_MAPPING_PROMPT_VERSION, REGULATORY_RETRIEVAL_VERSION, emptyRegulatoryMappingBatchCounts, groundRegulatoryMappingResult, normalizeRegulatoryMappingBatchScope, regulatoryMappingResultValidationErrors, type RegulatoryMappingBatchCreateRequest, type RegulatoryMappingBatchResponse, type RegulatoryMappingBatchRun, type RegulatoryMappingBatchScope, type RegulatoryMappingCandidate, type RegulatoryMappingCreateRequest, type RegulatoryMappingDecision, type RegulatoryMappingDecisionCreateRequest, type RegulatoryMappingEvidence, type RegulatoryMappingResponse, type RegulatoryMappingResult, type RegulatoryMappingRun } from '../../src/domain/regulatory-mapping'; import type { RegulatoryRetrievalCandidate } from '../../src/domain/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 } from '../../src/profiles/public-search/regulatory-query'; import { canonicalJson } from '../../src/shared/canonical-json'; const POLL_AFTER_MS = 1_500; const MAXIMUM_PROMPT_CHARACTERS = 48_000; const BATCH_PAGE_SIZE = 500; const DEFAULT_MAXIMUM_CONCURRENT_RUNS = 2; 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; constructor(private readonly repository: AssessorRepository, 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 Boolean(this.options.findingRetrieval && this.options.index && this.options.client && this.options.corpusId && this.options.corpusVersion && this.options.embeddingModel); } async recoverInterruptedRuns(): Promise { const completedAt = this.now().toISOString(); for (const run of (await this.repository.listRegulatoryMappingRuns()).filter(item => ['queued', 'running'].includes(item.status))) { await this.repository.saveRegulatoryMappingRun({ ...run, status: 'failed', updatedAt: completedAt, completedAt, errorCode: 'regulatory_mapping_interrupted' }); } for (const project of await this.repository.listProjects()) { for (const run of (await this.repository.listRegulatoryMappingBatchRuns(project.id)).filter(item => ['queued', 'running'].includes(item.status))) { await this.repository.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 = finding.sourceKind === 'scanner-finding' && 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 = enabled && ruleKey ? await this.repository.getRegulatoryMappingRun(this.mappingId(ruleKey)) : null; const decisions = ruleKey ? await this.repository.listRegulatoryMappingDecisions(ruleKey) : []; return { version: 1, projectId, resultProjectionId, generationEnabled: enabled, state: run?.status ?? (enabled ? 'empty' : 'unsupported'), ...(!supported ? { disabledReason: finding.sourceKind !== 'scanner-finding' ? 'Only canonical scanner findings can be mapped in this version.' : '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 }; } 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 (finding.sourceKind !== 'scanner-finding') throw new RegulatoryMappingServiceError('unsupported_finding_kind', 'Only canonical scanner findings can be mapped in this version.', 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.repository.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.repository.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 (finding.sourceKind !== 'scanner-finding') throw new RegulatoryMappingServiceError('unsupported_finding_kind', 'Only canonical scanner findings can be mapped in this version.', 422); const ruleKey = this.ruleKey(finding); const comment = request.comment?.trim(); const decision: RegulatoryMappingDecision = { version: 1, id: `regulatory-decision-${randomUUID()}`, ruleKey, sourceTool: finding.sourceTool ?? 'unknown', ...(finding.sourceRuleId ? { sourceRuleId: finding.sourceRuleId } : {}), mappingId: this.mappingId(ruleKey), controlId: request.controlId, decision: request.decision, ...(comment ? { comment } : {}), createdAt: this.now().toISOString() }; await this.repository.createRegulatoryMappingDecision(decision, request.expectedLatestDecisionId); return this.get(projectId, request.resultProjectionId); } // 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].join(''); return `regulatory-mapping-${createHash('sha256').update(tuple, 'utf8').digest('hex').slice(0, 40)}`; } 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: REGULATORY_MAPPING_PROMPT_VERSION, 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.repository.claimRegulatoryMappingRun(this.queuedRun(finding), forceRegeneration); } private async validateBatchScope(projectId: string, requested: RegulatoryMappingBatchScope): Promise { if (!await this.repository.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.repository.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.repository.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.repository.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; } private async processBatch(queued: RegulatoryMappingBatchRun): Promise { const startedAt = this.now().toISOString(); let run = await this.repository.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 }; for (const finding of results) { if (finding.sourceKind !== 'scanner-finding') { counts.excludedNonScannerCount += 1; continue; } if (!buildRegulatoryFindingQuery(finding)) { counts.unsupportedFindingCount += 1; continue; } counts.eligibleFindingCount += 1; const ruleKey = this.ruleKey(finding); if (!representatives.has(ruleKey)) representatives.set(ruleKey, finding); } 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.repository.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.repository.updateRegulatoryMappingBatchRun({ ...run, counts: { ...counts }, updatedAt: this.now().toISOString() }); }); return progress; })); await Promise.all(completions); await progress; const completedAt = this.now().toISOString(); await this.repository.updateRegulatoryMappingBatchRun({ ...run, status: 'completed', counts, updatedAt: completedAt, completedAt }); } catch (error) { const completedAt = this.now().toISOString(); try { await this.repository.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.'); const finding = await this.repository.getUnifiedResultById(resultProjectionId); if (!finding || finding.projectId !== projectId) 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.repository.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.repository.saveRegulatoryMappingRun({ ...run, status: 'succeeded', result: grounded as RegulatoryMappingResult, ...(completion.usage ? { tokenUsage: completion.usage } : {}), updatedAt: completedAt, completedAt }); return 'succeeded'; } catch (error) { const completedAt = this.now().toISOString(); const failure = safeFailure(error); const detail = safeFailureDetail(error); try { await this.repository.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; } function sha256(value: string): string { return createHash('sha256').update(value, 'utf8').digest('hex'); }