808 lines
44 KiB
TypeScript
808 lines
44 KiB
TypeScript
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,
|
||
AUTO_MAPPING_BATCH_KEY_PREFIXES,
|
||
MAPPING_HISTORY_LIMIT,
|
||
type RegulatoryMappingHistoryEntry
|
||
} 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<Promise<void>>();
|
||
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<void> {
|
||
this.activationPending = true;
|
||
try {
|
||
await this.close();
|
||
this.options.corpusId = corpusId;
|
||
this.options.corpusVersion = corpusVersion;
|
||
} finally {
|
||
this.activationPending = false;
|
||
}
|
||
}
|
||
|
||
async recoverInterruptedRuns(): Promise<void> {
|
||
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<RegulatoryMappingResponse> {
|
||
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<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> {
|
||
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, 'single', await this.origin(projectId, request.origin));
|
||
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<RegulatoryMappingBatchResponse> {
|
||
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<RegulatoryMappingBatchResponse> {
|
||
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<RegulatoryMappingResponse> {
|
||
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<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 })),
|
||
...(deviationsOnly ? { mappingHistory: await this.mappingHistory(projectId, scope) } : {})
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Newest first, globally (user decision 2026-10-08), because mappings and decisions are global per rule:
|
||
* every batch of every client and every single finding mapping, each marked by where it was started
|
||
* relative to the shown timeslot. Entries carry dates, trigger, that mark and counts only, never a client,
|
||
* rule or scope id. Runs from before 2.21.0 record no trigger; a run a batch started is counted by its batch.
|
||
*/
|
||
private async mappingHistory(projectId: string, scope: Pick<RegulatoryMappingBatchScope, 'assessmentIds' | 'scanRunIds'>): Promise<RegulatoryMappingHistoryEntry[]> {
|
||
const assessments = new Set(scope.assessmentIds);
|
||
const scanRuns = new Set(scope.scanRunIds);
|
||
const viewed = originTokens(projectId, scope);
|
||
const viewedTimeslot = new Set(viewed.timeslot);
|
||
const batchRuns = (await Promise.all((await this.read.listProjects())
|
||
.map(project => this.store.listRegulatoryMappingBatchRuns(project.id)))).flat();
|
||
const batches: RegulatoryMappingHistoryEntry[] = batchRuns.map(run => ({
|
||
id: run.id, kind: 'batch',
|
||
scope: run.projectId !== projectId ? 'other-client'
|
||
: run.assessmentIds.some(id => assessments.has(id)) || run.scanRunIds.some(id => scanRuns.has(id)) ? 'timeslot' : 'other-timeslot',
|
||
trigger: AUTO_MAPPING_BATCH_KEY_PREFIXES.some(prefix => run.idempotencyKey.startsWith(prefix)) ? 'automatic' : 'manual',
|
||
status: run.status, createdAt: run.createdAt, ...(run.completedAt ? { completedAt: run.completedAt } : {}),
|
||
counts: { findings: run.counts.eligibleFindingCount, sentToModel: run.counts.scheduledRuleCount,
|
||
reused: run.counts.reusedSucceededRuleCount + run.counts.reusedActiveRuleCount,
|
||
succeeded: run.counts.succeededRuleCount, failed: run.counts.failedRuleCount }
|
||
}));
|
||
// A single mapping maps one rule for every client; it has no finding count of its own.
|
||
const singles: RegulatoryMappingHistoryEntry[] = (await this.store.listRegulatoryMappingRuns())
|
||
.filter(run => run.trigger === 'single')
|
||
.map(run => ({
|
||
id: run.id, kind: 'single', trigger: 'manual',
|
||
scope: !run.origin ? 'unknown' : run.origin.timeslot.some(token => viewedTimeslot.has(token)) ? 'timeslot'
|
||
: run.origin.project === viewed.project ? 'other-timeslot' : 'other-client',
|
||
status: run.status === 'succeeded' ? 'completed' : run.status,
|
||
createdAt: run.createdAt, ...(run.completedAt ? { completedAt: run.completedAt } : {}),
|
||
counts: { sentToModel: 1, reused: 0, succeeded: run.status === 'succeeded' ? 1 : 0, failed: run.status === 'failed' ? 1 : 0 }
|
||
}));
|
||
return [...batches, ...singles]
|
||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id))
|
||
.slice(0, MAPPING_HISTORY_LIMIT);
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
// 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(' |