import type { AssessorRepository } from '../persistence/assessor-repository'; import { queryAllUnifiedResults } from '../exports/unified-result-jsonl'; import { loadCanonicalProjectWorkspace } from '../features/project-import/load-canonical-project-workspace'; import type { FindingRecord } from '../domain/finding'; import { RELEASED_ASSESSMENT_CONTEXT_CATALOG, type AssessmentContextCatalog } from './context-catalog'; import { buildAssessmentContext, type AssessmentContext } from './context-engine'; import { contextGapReport } from './context-gaps'; /** The only ASSESSOR data access of the context engine: read methods only. */ export interface ContextReadPort extends Pick { currentRevision(): Promise; /** Microsoft baseline analyses: template names and the Work records' baseline guidance (2026-10-01). */ listMicrosoftSecurityBaselineAnalyses?: AssessorRepository['listMicrosoftSecurityBaselineAnalyses']; /** Assessments and canonical models: read to build the same Work records Raw Data shows (2026-10-01). */ listAssessments?: AssessorRepository['listAssessments']; loadAssessmentModel?: AssessorRepository['loadAssessmentModel']; } export interface AssessmentContextScope { projectId: string; assessmentIds: string[]; /** Timeslot restriction of scanner results, exactly as Work applies it. */ scanRunIds?: string[]; generatedAt: string; } export class ContextStateChangedError extends Error { constructor() { super('ASSESSOR data changed repeatedly while the context was read. Please retry.'); this.name = 'ContextStateChangedError'; } } const fieldOf = (record: FindingRecord, label: string) => record.fields.find(field => field.label.trim().toLowerCase() === label)?.value?.trim() ?? ''; /** * Every field Raw Data shows for a Work record (user decision 2026-10-01). The records come from the same workspace * loader Work and the Report Engine use, read-only; scanner records are keyed by finding occurrence, GPO records by * scope and technical identity. Nothing is shortened. */ async function rawDataRecords(port: ContextReadPort, scope: AssessmentContextScope) { if (!port.listAssessments || !port.loadAssessmentModel) return { findings: [], settings: [] }; const workspace = await loadCanonicalProjectWorkspace({ listProjectAssessments: projectId => port.listAssessments!(projectId), loadAssessmentModel: async assessmentId => { const model = await port.loadAssessmentModel!(assessmentId); if (!model) throw new Error(`Assessment ${assessmentId} not found.`); return model; }, queryUnifiedResults: query => port.queryUnifiedResults(query), listPolicyEvaluations: async projectId => ({ records: await port.listPolicyEvaluations(projectId) }), ...(port.listMicrosoftSecurityBaselineAnalyses ? { listMicrosoftSecurityBaselineAnalyses: async (baselineId: string) => ({ records: await port.listMicrosoftSecurityBaselineAnalyses!(baselineId) }) } : {}) }, scope.projectId, scope.scanRunIds, scope.assessmentIds); const records = [...new Map((workspace.dataset?.sheets ?? []).flatMap(sheet => sheet.analysis.records).map(record => [record.id, record])).values()]; const fields = (record: FindingRecord) => record.fields.filter(field => field.value).map(field => ({ label: field.label, value: field.value })); return { findings: records.filter(record => record.findingOccurrenceId).map(record => ({ occurrenceId: record.findingOccurrenceId!, fields: fields(record) })), settings: records.filter(record => !record.findingOccurrenceId && fieldOf(record, 'technical identity') && fieldOf(record, 'scope')) .map(record => ({ identityKey: fieldOf(record, 'technical identity'), scope: fieldOf(record, 'scope'), fields: fields(record) })) }; } /** * Reads the same current GPO observations and scanner findings Work reads and derives the context from one committed * state: a revision change during the read repeats it instead of mixing states. Nothing is written. */ export async function createAssessmentContext( port: ContextReadPort, scope: AssessmentContextScope, catalog: AssessmentContextCatalog = RELEASED_ASSESSMENT_CONTEXT_CATALOG, attempts = 3 ): Promise { for (let attempt = 0; attempt < attempts; attempt += 1) { const before = await port.currentRevision(); const [observations, scannerFindings, evaluations, analyses, referenceRuns, storedEvaluations] = await Promise.all([ queryAllUnifiedResults(query => port.queryUnifiedResults(query), { audience: 'internal-analysis', projectId: scope.projectId, assessmentIds: scope.assessmentIds, sourceKind: ['policy-observation'] }), queryAllUnifiedResults(query => port.queryUnifiedResults(query), { audience: 'internal-analysis', projectId: scope.projectId, assessmentIds: scope.assessmentIds, scanRunIds: scope.scanRunIds?.length ? scope.scanRunIds : undefined, sourceKind: ['scanner-finding'] }), queryAllUnifiedResults(query => port.queryUnifiedResults(query), { audience: 'internal-analysis', projectId: scope.projectId, assessmentIds: scope.assessmentIds, sourceKind: ['policy-evaluation'] }), port.listGpoPolicyAnalyses(scope.projectId), port.listReferenceRuns(scope.projectId), port.listPolicyEvaluations(scope.projectId) ]); // Section titles and counts of the CIS benchmark come only from the scan reports of the CIS findings in scope. const cisRunIds = [...new Set(scannerFindings.filter(item => item.sourceTool === 'cis-cat' && item.scanRunId).map(item => item.scanRunId!))].sort(); const cisReportSections = cisRunIds.length ? (await port.getEnvironmentSnapshot({ projectId: scope.projectId, scanRunIds: cisRunIds })).sections .filter(section => section.sectionKey === 'cis-cat-section-scores') : []; const input = { projectId: scope.projectId, assessmentIds: scope.assessmentIds, scanRunIds: scope.scanRunIds, generatedAt: scope.generatedAt, repositoryRevision: before, observations, evaluations, // Technical targets of evaluated settings, also for key-level identities without an own observation. identityTargets: storedEvaluations.flatMap(run => run.results.map(result => ({ id: result.identity.id, scope: result.identity.scope, target: result.identity.target }))), analyses, scannerFindings, referenceRuns, cisReportSections, catalog }; let context = buildAssessmentContext(input); // Baseline template names and everything Raw Data shows (user decisions 2026-10-01) are large reads. They run only // when deviations remain that the catalogue does not place, one after another and after the reads above: the // read-only pool is small, and parallel or unconditional loading starves it. A failing read never takes the // context down; the result then rests on the fields read above. if (context.recognitions.length || context.coverage.uncataloguedWithoutRecognition) { // Only the baselines the still unplaced GPO settings were compared with, one after another. const pendingIdentities = new Set(context.evaluations.filter(item => item.matchedBy !== 'setting' && item.verdict === 'deviation').map(item => item.identityKey)); const baselineIds = [...new Set(evaluations.filter(item => item.policyIdentityKey && pendingIdentities.has(item.policyIdentityKey) && item.policyBaselineId) .map(item => item.policyBaselineId!))].sort(); const baselineAnalyses = []; for (const baselineId of port.listMicrosoftSecurityBaselineAnalyses ? baselineIds : []) { baselineAnalyses.push(...(await port.listMicrosoftSecurityBaselineAnalyses!(baselineId).catch(() => []))); } const baselineTemplates = baselineAnalyses.flatMap(stored => stored.analysis.settings.flatMap(setting => setting.identity.status === 'resolved' && setting.template?.names.length ? [{ identityKey: setting.identity.identity.id, names: setting.template.names.map(name => name.value) }] : [])); const rawData = await rawDataRecords(port, scope).catch(() => undefined); context = buildAssessmentContext({ ...input, baselineTemplates, ...(rawData ? { recordFields: rawData.findings, settingRecordFields: rawData.settings } : {}) }); context = { ...context, rawDataRead: Boolean(rawData) }; } if (await port.currentRevision() !== before) continue; // The gap list is derived in the backend with the same catalogue; the UI only reads it. return { ...context, gapReport: contextGapReport(context, catalog) }; } throw new ContextStateChangedError(); }