filegrc 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.9.2",
3
+ "version": "0.11.0",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,211 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ const excludedResourceFields = new Set([
4
+ "applicabilityReview",
5
+ "applicability",
6
+ "status",
7
+ "statusTransition",
8
+ "effectiveOn",
9
+ "procedureEffectiveOn",
10
+ "procedureRevision",
11
+ "implementationReviewedByIds",
12
+ "implementationReviewedOn"
13
+ ]);
14
+
15
+ const unorderedStringArrayFields = new Set([
16
+ "audience",
17
+ "roles",
18
+ "servicesProvided",
19
+ "exclusions",
20
+ "evidenceSourceKinds",
21
+ "tags"
22
+ ]);
23
+
24
+ export function applicabilityScopeRevision(record, program, resources, model) {
25
+ const selectedSystemIds = new Set(program?.systemIds || []);
26
+ const selectedFrameworkIds = new Set(program?.frameworkIds || []);
27
+ const selectedControlIds = new Set(program?.controlIds || []);
28
+ const selectedComponents = resources.filter(({ type, status, systemUses }) => (
29
+ type === "component"
30
+ && status !== "retired"
31
+ && (systemUses || []).some(({ systemId }) => selectedSystemIds.has(systemId))
32
+ ));
33
+ const selectedVendorIds = new Set(selectedComponents.map(({ vendorId }) => vendorId).filter(Boolean));
34
+ const selectedPolicyIds = new Set(resources
35
+ .filter(({ type, id }) => type === "control" && selectedControlIds.has(id))
36
+ .flatMap(({ policyIds }) => policyIds || []));
37
+ for (const policyId of record.policyIds || []) selectedPolicyIds.add(policyId);
38
+ const facts = {
39
+ modelVersion: model.modelVersion,
40
+ program: {
41
+ ...pick(program || {}, [
42
+ "id",
43
+ "assuranceGoal",
44
+ "systemIds",
45
+ "frameworkIds",
46
+ "requirementIds",
47
+ "controlIds",
48
+ "riskMethodology"
49
+ ], model),
50
+ ...(record.type === "requirement" ? {} : {
51
+ requirementApplicability: (program?.requirementApplicability || [])
52
+ .map((review) => pick(review, ["requirementId", "decision"], model, "program-applicability"))
53
+ .sort((left, right) => left.requirementId.localeCompare(right.requirementId))
54
+ })
55
+ },
56
+ resource: canonicalObject(model, record.type, Object.fromEntries(Object.entries(record)
57
+ .filter(([field]) => !excludedResourceFields.has(field)))),
58
+ systems: resources
59
+ .filter(({ type, id }) => type === "system" && selectedSystemIds.has(id))
60
+ .sort(compareRecordIds)
61
+ .map((system) => pick(system, [
62
+ "id",
63
+ "purpose",
64
+ "servicesProvided",
65
+ "boundary",
66
+ "exclusions",
67
+ "criticality",
68
+ "informationTypeIds",
69
+ "classificationId",
70
+ "internetExposed"
71
+ ], model)),
72
+ frameworks: resources
73
+ .filter(({ type, id }) => type === "framework" && selectedFrameworkIds.has(id))
74
+ .sort(compareRecordIds)
75
+ .map((framework) => pick(framework, [
76
+ "id",
77
+ "status",
78
+ "title",
79
+ "version",
80
+ "publisher",
81
+ "description",
82
+ "sourceReference",
83
+ "effectiveOn"
84
+ ], model)),
85
+ components: selectedComponents
86
+ .sort(compareRecordIds)
87
+ .map((component) => pick(component, [
88
+ "id",
89
+ "status",
90
+ "componentKind",
91
+ "description",
92
+ "criticality",
93
+ "environment",
94
+ "vendorId",
95
+ "systemUses",
96
+ "informationUses",
97
+ "internetExposed",
98
+ "classificationId",
99
+ "continuityObjectives"
100
+ ], model)),
101
+ vendors: resources
102
+ .filter(({ type, id }) => type === "vendor" && selectedVendorIds.has(id))
103
+ .sort(compareRecordIds)
104
+ .map((vendor) => pick(vendor, [
105
+ "id",
106
+ "status",
107
+ "category",
108
+ "criticality",
109
+ "description",
110
+ "standardAgreement",
111
+ "agreementDocumentId",
112
+ "startDate",
113
+ "endDate",
114
+ "informationTypeIds",
115
+ "classificationId"
116
+ ], model)),
117
+ policies: resources
118
+ .filter(({ type, id }) => type === "policy" && selectedPolicyIds.has(id))
119
+ .sort(compareRecordIds)
120
+ .map((policy) => pick(policy, [
121
+ "id",
122
+ "status",
123
+ "policyKind",
124
+ "version",
125
+ "programRole",
126
+ "effectiveOn",
127
+ "requirementIds",
128
+ "audience",
129
+ "acknowledgementRequired",
130
+ "relatedDocumentIds",
131
+ "approvedContentRevisions"
132
+ ], model)),
133
+ requirements: resources
134
+ .filter(({ type, frameworkId }) => type === "requirement" && selectedFrameworkIds.has(frameworkId))
135
+ .sort(compareRecordIds)
136
+ .map((requirement) => pick(requirement, ["id", "frameworkId", "reference", "description", "parentRequirementId"], model)),
137
+ commitments: resources
138
+ .filter(({ type, status, systemIds }) => (
139
+ type === "commitment"
140
+ && !["retired", "superseded"].includes(status)
141
+ && (systemIds || []).some((id) => selectedSystemIds.has(id))
142
+ ))
143
+ .sort(compareRecordIds)
144
+ .map((commitment) => pick(commitment, [
145
+ "id",
146
+ "commitmentKind",
147
+ "statement",
148
+ "systemIds",
149
+ "requirementIds",
150
+ "controlIds",
151
+ "customerFacing"
152
+ ], model))
153
+ };
154
+ return `scope:${createHash("sha256").update(stableJson(facts)).digest("hex")}`;
155
+ }
156
+
157
+ function compareRecordIds(left, right) {
158
+ return left.id.localeCompare(right.id);
159
+ }
160
+
161
+ export function applicabilityReviewIsCurrent(review, record, program, resources, model) {
162
+ if (
163
+ review?.scopeRevision
164
+ && !review.scopeRevision.startsWith("scope:")
165
+ && Number(model?.modelVersion || 0) < 7
166
+ ) return true;
167
+ return Boolean(
168
+ review?.scopeRevision
169
+ && review.scopeRevision === applicabilityScopeRevision(record, program, resources, model)
170
+ );
171
+ }
172
+
173
+ function pick(record, fields, model, objectType = null) {
174
+ return canonicalObject(model, objectType || record.type, Object.fromEntries(fields
175
+ .filter((field) => record?.[field] !== undefined)
176
+ .map((field) => [field, record[field]])));
177
+ }
178
+
179
+ function canonicalObject(model, type, value) {
180
+ const fields = model.resources?.[type]
181
+ ? { ...model.commonFields, ...model.resources[type].fields }
182
+ : model.objectTypes?.[type]?.properties || {};
183
+ return Object.fromEntries(Object.keys(value).sort().map((name) => [
184
+ name,
185
+ canonicalFieldValue(model, value[name], fields[name], name)
186
+ ]));
187
+ }
188
+
189
+ function canonicalFieldValue(model, value, field, name) {
190
+ if (Array.isArray(value)) {
191
+ const items = value.map((item) => field?.itemObjectType && item && typeof item === "object"
192
+ ? canonicalObject(model, field.itemObjectType, item)
193
+ : item);
194
+ if (field?.relation || field?.items === "id" || field?.itemObjectType || unorderedStringArrayFields.has(name)) {
195
+ items.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
196
+ }
197
+ return items;
198
+ }
199
+ if (value && typeof value === "object") {
200
+ return field?.objectType ? canonicalObject(model, field.objectType, value) : value;
201
+ }
202
+ return value;
203
+ }
204
+
205
+ function stableJson(value) {
206
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
207
+ if (value && typeof value === "object") {
208
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
209
+ }
210
+ return JSON.stringify(value);
211
+ }
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { modelSupports } from "../model/index.js";
4
+ import { applicabilityReviewIsCurrent } from "./applicability-scope.js";
4
5
  import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
5
6
  import {
6
7
  coverageContains,
@@ -13,6 +14,7 @@ import {
13
14
  import { createResource, createResources, deleteResource, updateResource } from "./files.js";
14
15
  import { getChangedDataPathsSinceRevision, getFileAtRevision, hasGitRevision } from "./git.js";
15
16
  import { createResourceId } from "./id.js";
17
+ import { planObligations } from "./obligations.js";
16
18
  import { currentPartyPeople, partiesIndependent } from "./parties.js";
17
19
  import { resolveDataPath } from "./paths.js";
18
20
  import { assessProgramReadiness } from "./program-readiness.js";
@@ -68,27 +70,31 @@ export async function assessAuditPreparation(input, options = {}) {
68
70
  : audits.find((record) => !["complete", "closed", "canceled"].includes(record.status)) || audits[0];
69
71
  if (options.auditId && !audit) throw new Error(`Audit "${options.auditId}" was not found.`);
70
72
 
73
+ const calendarAsOf = options.asOf || currentCalendarDate(loaded.workspace?.timezone || "UTC");
74
+ const formalPeriodEnd = audit?.auditKind === "soc-2-type-2" ? coverageEnd(audit.coverage) : null;
75
+ const readinessAsOf = formalPeriodEnd && formalPeriodEnd < calendarAsOf ? formalPeriodEnd : calendarAsOf;
71
76
  const programReadiness = options.programReadiness || await assessProgramReadiness(loaded, {
72
- asOf: options.asOf,
77
+ asOf: readinessAsOf,
73
78
  generatedAt: options.generatedAt,
74
79
  programId: audit?.programId
75
80
  });
76
81
  const documentActivations = audit && modelSupports(loaded.model, "governed-document-activation")
77
- ? await auditDocumentActivationAssessments(loaded, audit, byId, programReadiness.asOf)
82
+ ? await auditDocumentActivationAssessments(loaded, audit, byId, calendarAsOf)
78
83
  : [];
79
84
  const stages = [
80
- programFoundationStage(programReadiness, loaded.workspace),
85
+ programFoundationStage(programReadiness, loaded.workspace, audit),
81
86
  engagementStage(audit, byId, programReadiness),
82
87
  scopeStage(loaded, audit, records, byId, programReadiness)
83
88
  ];
84
89
  const fieldworkSections = audit
85
90
  ? [
86
- await documentsStage(loaded, audit, byId, programReadiness.asOf),
91
+ await documentsStage(loaded, audit, byId, calendarAsOf),
87
92
  evidenceStage(audit, records, byId, loaded.model),
88
- populationsStage(audit, records, byId, loaded.model)
93
+ populationsStage(audit, records, byId, loaded.model),
94
+ occurrenceContinuityStage(audit, records, loaded.model, programReadiness.asOf)
89
95
  ]
90
96
  : [];
91
- stages.push(fieldworkStage(audit, fieldworkSections));
97
+ stages.push(fieldworkStage(audit, fieldworkSections, programReadiness));
92
98
  stages.push(auditorStage(audit, byId, loaded.model.modelVersion));
93
99
 
94
100
  for (const stage of stages) {
@@ -191,7 +197,9 @@ export async function prepareAuditWorkspace(input, options = {}) {
191
197
  .filter(Boolean);
192
198
  const v4 = modelSupports(loaded.model, "program-scope");
193
199
  const sourceSystems = loaded.resources.filter((record) => record.type === (v4 ? "component" : "system"));
194
- const populations = (audit.auditKind === "soc-2-type-2" ? model.populationTemplates || [] : [])
200
+ const populations = (audit.auditKind === "soc-2-type-2"
201
+ ? applicablePopulationTemplates(audit, loaded.resources, model.populationTemplates || [])
202
+ : [])
195
203
  .filter((template) => !existingKinds.has(template.kind))
196
204
  .map((template) => {
197
205
  const id = createResourceId(
@@ -348,7 +356,15 @@ function scopeStage(loaded, audit, records, byId, programReadiness) {
348
356
  .filter((record) => record.type === "requirement" && (audit.frameworkIds || []).includes(record.frameworkId))
349
357
  .map((record) => record.id);
350
358
  const program = audit.programId ? byId.get(audit.programId) : null;
351
- const v4Decisions = new Map((program?.requirementApplicability || []).map((decision) => [decision.requirementId, decision]));
359
+ const requirementById = new Map(records
360
+ .filter(({ type }) => type === "requirement")
361
+ .map((record) => [record.id, record]));
362
+ const v4Decisions = new Map((program?.requirementApplicability || [])
363
+ .filter((decision) => (
364
+ requirementById.has(decision.requirementId)
365
+ && applicabilityReviewIsCurrent(decision, requirementById.get(decision.requirementId), program, records, loaded.model)
366
+ ))
367
+ .map((decision) => [decision.requirementId, decision]));
352
368
  const unresolvedRequirements = frameworkRequirementIds
353
369
  .map((id) => byId.get(id))
354
370
  .filter((requirement) => {
@@ -727,16 +743,21 @@ function canonicalScopeValue(value) {
727
743
  return value;
728
744
  }
729
745
 
730
- function programFoundationStage(programReadiness, workspace) {
731
- const ready = programReadiness.evidenceReady;
746
+ function programFoundationStage(programReadiness, workspace, audit) {
747
+ const needsOperatingPeriod = audit?.auditKind === "soc-2-type-2";
748
+ const ready = needsOperatingPeriod ? programReadiness.operating : programReadiness.evidenceReady;
732
749
  return stage("program", "Program Readiness", "The management program can be prepared and operated without an audit record or CPA firm.", [
733
750
  item(
734
751
  "evidence-ready",
735
752
  ready ? "complete" : "action",
736
753
  "Reach the Evidence Ready gate",
737
754
  ready
738
- ? `${programReadiness.target.label} is evidence-ready. ${programReadiness.operating ? "Evidence collection is running." : "Management can begin the candidate period."}`
739
- : `${programReadiness.counts.action} program-readiness actions remain across scope, policies, controls, and evidence preparation.`,
755
+ ? needsOperatingPeriod
756
+ ? `${programReadiness.target.label} is operating for the selected engagement.`
757
+ : `${programReadiness.target.label} is evidence-ready.`
758
+ : needsOperatingPeriod && programReadiness.evidenceReady
759
+ ? "Start the candidate period and finish the current operating-readiness work before treating a Type 2 engagement as management-ready."
760
+ : `${programReadiness.counts.action} program-readiness actions remain across scope, policies, controls, and evidence preparation.`,
740
761
  workspace || { type: "workspace" }
741
762
  )
742
763
  ]);
@@ -858,14 +879,23 @@ function engagementStage(audit, byId, programReadiness) {
858
879
  return stage("engagement", "Engage the Auditor", "Record the independent CPA firm and the current management owner who authorizes and coordinates the engagement.", items);
859
880
  }
860
881
 
861
- function fieldworkStage(audit, sections) {
882
+ function fieldworkStage(audit, sections, programReadiness) {
862
883
  if (!audit) {
863
884
  return stage("fieldwork", "Prepare Fieldwork", "Build engagement-specific documents, exact-period evidence, and Type 2 populations after the firm and period are recorded.", [
864
885
  item("fieldwork-later", "later", "Prepare engagement-specific fieldwork", "This work starts after the CPA engagement and formal period exist.", { type: "audit" })
865
886
  ]);
866
887
  }
888
+ const formalPeriodReady = Boolean(coverageStart(audit.coverage) && coverageEnd(audit.coverage));
889
+ const programReady = audit.auditKind === "soc-2-type-2"
890
+ ? programReadiness.operating
891
+ : programReadiness.evidenceReady;
892
+ const available = formalPeriodReady && programReady;
867
893
  const items = sections.flatMap((section) => section.items.map((current) => ({
868
894
  ...current,
895
+ ...(available || ["complete", "info", "external"].includes(current.status) ? {} : {
896
+ status: "later",
897
+ message: `${current.message} Finish ${formalPeriodReady ? "Program Readiness" : "the formal period and Program Readiness"} before starting this fieldwork item.`
898
+ }),
869
899
  id: `${section.id}-${current.id}`,
870
900
  section: section.title
871
901
  })));
@@ -877,6 +907,89 @@ function fieldworkStage(audit, sections) {
877
907
  );
878
908
  }
879
909
 
910
+ function occurrenceContinuityStage(audit, records, model, asOf) {
911
+ if (audit.auditKind !== "soc-2-type-2") {
912
+ return stage(
913
+ "occurrences",
914
+ "Operating Occurrences",
915
+ "Occurrence continuity applies to Type 2 operating-effectiveness periods.",
916
+ [item("type-2-only", "info", "No Type 2 occurrence check required", "A Type 1 report evaluates design and implementation as of one date.")]
917
+ );
918
+ }
919
+ const periodStart = coverageStart(audit.coverage);
920
+ const periodEnd = coverageEnd(audit.coverage);
921
+ if (!periodStart || !periodEnd) {
922
+ return stage(
923
+ "occurrences",
924
+ "Operating Occurrences",
925
+ "Check every expected scheduled occurrence across the exact Type 2 period.",
926
+ [item("period-required", "later", "Select the formal Type 2 period", "The formal period is required before FileGRC can calculate expected occurrences.", audit)]
927
+ );
928
+ }
929
+ const selectedControlIds = new Set(audit.controlIds || []);
930
+ const periodThrough = [periodEnd, asOf].filter(Boolean).sort()[0] || periodEnd;
931
+ if (periodThrough < periodStart) {
932
+ return stage(
933
+ "occurrences",
934
+ "Operating Occurrences",
935
+ "Check every expected scheduled occurrence across the exact Type 2 period.",
936
+ [item("period-not-started", "later", "Wait for the formal period to begin", `The formal Type 2 period starts on ${periodStart}. No operating occurrences are expected yet.`, audit)]
937
+ );
938
+ }
939
+ const periodResources = records.filter((record) => (
940
+ record.type !== "obligation"
941
+ || !(record.controlIds || []).length
942
+ || record.controlIds.some((id) => selectedControlIds.has(id))
943
+ ));
944
+ const plan = planObligations(periodResources, {
945
+ from: periodStart,
946
+ asOf: periodThrough,
947
+ through: periodThrough,
948
+ now: `${periodThrough}T23:59:59.999Z`,
949
+ includeComplete: true,
950
+ model
951
+ });
952
+ const gapStatuses = new Set(["overdue", "blocked", "due", "proposed"]);
953
+ const calendarGaps = plan.calendarItems.filter(({ status }) => gapStatuses.has(status));
954
+ const eventGaps = plan.eventRuns
955
+ .filter((run) => run.occurredOn >= periodStart && run.occurredOn <= periodThrough)
956
+ .flatMap((run) => run.actions)
957
+ .filter((action) => (
958
+ (gapStatuses.has(action.status) || action.lateCompletion)
959
+ && action.controlIds.some((id) => selectedControlIds.has(id))
960
+ ));
961
+ const gaps = [...calendarGaps, ...eventGaps];
962
+ const lateGaps = eventGaps.filter(({ lateCompletion }) => lateCompletion);
963
+ const firstGap = gaps[0];
964
+ return stage(
965
+ "occurrences",
966
+ "Operating Occurrences",
967
+ "Check every expected scheduled occurrence across the exact Type 2 period.",
968
+ [item(
969
+ "period-occurrences",
970
+ gaps.length ? "action" : "complete",
971
+ "Cover every expected operating occurrence",
972
+ gaps.length
973
+ ? lateGaps.length
974
+ ? `${gaps.length} expected ${gaps.length === 1 ? "occurrence needs" : "occurrences need"} review, including ${lateGaps.length} completed after the allowed window. Open the affected Action Item, record a Finding or Exception with management's conclusion, and retain the late completion as historical evidence.`
975
+ : `${gaps.length} expected ${gaps.length === 1 ? "occurrence is" : "occurrences are"} incomplete, blocked, or still proposed within the formal period. Resolve each Work Queue item before treating management fieldwork as ready.`
976
+ : `Every expected occurrence through ${periodThrough} has an accepted completion.`,
977
+ firstGap?.actionItemId
978
+ ? { type: "action-item", id: firstGap.actionItemId }
979
+ : firstGap?.obligationId ? { type: "obligation", id: firstGap.obligationId } : audit,
980
+ {
981
+ affectedActionItemIds: eventGaps.map(({ actionItemId }) => actionItemId).filter(Boolean),
982
+ affectedEventIds: [...new Set(eventGaps.map(({ eventId }) => eventId).filter(Boolean))],
983
+ lateCount: lateGaps.length,
984
+ commands: firstGap?.actionItemId ? [
985
+ `npx filegrc get ${firstGap.actionItemId} --json`,
986
+ `npx filegrc scaffold finding --title "Late operating occurrence review"`
987
+ ] : []
988
+ }
989
+ )]
990
+ );
991
+ }
992
+
880
993
  async function auditDocumentActivationAssessments(loaded, audit, byId, asOf) {
881
994
  const links = new Map();
882
995
  const addLink = (documentId, role, definition = null) => {
@@ -914,14 +1027,14 @@ async function auditDocumentActivationAssessments(loaded, audit, byId, asOf) {
914
1027
  ["approved", "active"].includes(document.status)
915
1028
  && document.approvedOn
916
1029
  && document.approvedContentRevisions
917
- && (document.activationBasis === "legacy-v4"
1030
+ && (["legacy-v4", "historical"].includes(document.activationBasis)
918
1031
  ? (document.ownerIds || []).length
919
1032
  : currentPartyPeople(document.ownerIds || [], byId).size)
920
1033
  && (document.approverIds || []).length
921
1034
  && partiesIndependent(document.ownerIds, document.approverIds, byId)
922
1035
  );
923
1036
  if (!approvalComplete) issues.push("Complete the independent approval and bind the approved Markdown revision first.");
924
- if (document.status === "active" && document.activationBasis !== "legacy-v4") {
1037
+ if (document.status === "active" && !["legacy-v4", "historical"].includes(document.activationBasis)) {
925
1038
  if (document.activationBasis !== "recorded") issues.push("Record the Step 5 activation basis.");
926
1039
  if (!document.activatedOn) issues.push("Record the separate Step 5 activation date.");
927
1040
  else if (document.activatedOn > asOf) issues.push(`The activation date ${document.activatedOn} is after ${asOf}.`);
@@ -1045,8 +1158,8 @@ async function documentsStage(loaded, audit, byId, asOf) {
1045
1158
  complete ? "complete" : representationLater ? "later" : "action",
1046
1159
  definition.title,
1047
1160
  complete
1048
- ? document.activationBasis === "legacy-v4"
1049
- ? "Linked Markdown is complete and effective. Its active state is preserved from model v4, which did not record approval and activation as separate events."
1161
+ ? ["legacy-v4", "historical"].includes(document.activationBasis)
1162
+ ? "Linked Markdown is complete and effective. Its historical lifecycle basis does not claim a separate activation event that was never recorded."
1050
1163
  : modelSupports(loaded.model, "governed-document-activation")
1051
1164
  ? "Linked Markdown is complete, independently approved, separately activated by a named Person, revision-bound at both events, and effective."
1052
1165
  : "Linked Markdown is complete, active, approved, and effective."
@@ -1209,7 +1322,12 @@ function populationsStage(audit, records, byId, model) {
1209
1322
  const populations = audit
1210
1323
  ? records.filter((record) => record.type === "audit-population" && record.auditId === audit.id)
1211
1324
  : [];
1212
- const templates = model.auditReadiness?.populationTemplates || [];
1325
+ const templates = applicablePopulationTemplates(
1326
+ audit,
1327
+ records,
1328
+ model.auditReadiness?.populationTemplates || [],
1329
+ populations
1330
+ );
1213
1331
  const items = templates.map((template) => {
1214
1332
  const population = populations.find((record) => record.populationKind === template.kind);
1215
1333
  const result = populationResult(population, audit, byId);
@@ -1368,11 +1486,26 @@ function initializationNeeded(audit, records, model) {
1368
1486
  const populationKinds = new Set(records
1369
1487
  .filter((record) => record.type === "audit-population" && record.auditId === audit.id)
1370
1488
  .map((record) => record.populationKind));
1371
- const needsPopulation = audit.auditKind === "soc-2-type-2" && (readiness.populationTemplates || [])
1489
+ const needsPopulation = audit.auditKind === "soc-2-type-2"
1490
+ && applicablePopulationTemplates(audit, records, readiness.populationTemplates || [])
1372
1491
  .some((template) => !populationKinds.has(template.kind));
1373
1492
  return needsDocumentLink || needsPopulation;
1374
1493
  }
1375
1494
 
1495
+ function applicablePopulationTemplates(audit, records, templates, existingPopulations = []) {
1496
+ const selectedControls = (audit?.controlIds || [])
1497
+ .map((id) => records.find((record) => record.type === "control" && record.id === id))
1498
+ .filter(Boolean);
1499
+ const selectedCodes = new Set(selectedControls.map(({ code }) => code).filter(Boolean));
1500
+ const existingKinds = new Set(existingPopulations.map(({ populationKind }) => populationKind));
1501
+ const recognizedCodes = new Set(templates.flatMap(({ controlCodes }) => controlCodes || []));
1502
+ if (selectedControls.some(({ code }) => !code || !recognizedCodes.has(code))) return templates;
1503
+ return templates.filter((template) => (
1504
+ existingKinds.has(template.kind)
1505
+ || (template.controlCodes || []).some((code) => selectedCodes.has(code))
1506
+ ));
1507
+ }
1508
+
1376
1509
  function applicableManagementDocuments(audit, readiness) {
1377
1510
  return (readiness.managementDocuments || []).filter((definition) => (
1378
1511
  !audit || !definition.engagementKinds?.length || definition.engagementKinds.includes(audit.auditKind)
@@ -1571,7 +1704,10 @@ function item(id, status, title, message, resource = {}, options = {}) {
1571
1704
  message,
1572
1705
  ...(resource.type ? { resourceType: resource.type } : {}),
1573
1706
  ...(resource.id ? { resourceId: resource.id } : {}),
1574
- ...(options.commands?.length ? { commands: options.commands } : {})
1707
+ ...(options.commands?.length ? { commands: options.commands } : {}),
1708
+ ...(options.affectedActionItemIds?.length ? { affectedActionItemIds: options.affectedActionItemIds } : {}),
1709
+ ...(options.affectedEventIds?.length ? { affectedEventIds: options.affectedEventIds } : {}),
1710
+ ...(options.lateCount ? { lateCount: options.lateCount } : {})
1575
1711
  };
1576
1712
  }
1577
1713
 
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { modelSupports } from "../model/index.js";
4
+ import { applicabilityReviewIsCurrent, applicabilityScopeRevision } from "./applicability-scope.js";
4
5
  import { applyResourceBatch } from "./files.js";
5
6
  import { getWorkspaceRevisionSnapshot } from "./git.js";
6
7
  import { serializeWorkspaceMutation } from "./mutation.js";
@@ -29,15 +30,22 @@ export async function scaffoldApplicabilityReview(input = process.cwd(), options
29
30
  throw new Error(`Applicability review type must be one of ${[...REVIEWABLE_TYPES].join(", ")}.`);
30
31
  }
31
32
  const program = resolveProgram(loaded, options.programId);
33
+ const requirementById = new Map(loaded.resources
34
+ .filter(({ type }) => type === "requirement")
35
+ .map((record) => [record.id, record]));
32
36
  const reviewedRequirementIds = new Set((program.requirementApplicability || [])
33
- .filter(({ decision }) => ["applicable", "not-applicable"].includes(decision))
37
+ .filter((review) => (
38
+ ["applicable", "not-applicable"].includes(review.decision)
39
+ && requirementById.has(review.requirementId)
40
+ && applicabilityReviewIsCurrent(review, requirementById.get(review.requirementId), program, loaded.resources, loaded.model)
41
+ ))
34
42
  .map(({ requirementId }) => requirementId));
35
43
  const records = loaded.resources.filter((record) => (
36
44
  REVIEWABLE_TYPES.has(record.type)
37
45
  && (!requestedType || record.type === requestedType)
38
46
  && (record.type === "requirement" && modelSupports(loaded.model, "program-scope")
39
47
  ? !reviewedRequirementIds.has(record.id)
40
- : !record.applicabilityReview)
48
+ : !applicabilityReviewIsCurrent(record.applicabilityReview, record, program, loaded.resources, loaded.model))
41
49
  && !["retired", "superseded"].includes(record.status)
42
50
  ));
43
51
  return {
@@ -79,8 +87,7 @@ function planApplicabilityReviewWithContext(context, options) {
79
87
  throw new Error("Batch expected revisions must be keyed by resource ID.");
80
88
  }
81
89
  const program = resolveProgram(loaded, options.programId);
82
- const v4RequirementDecisions = [];
83
- const update = options.decisions.flatMap((decision) => {
90
+ const reviewedDecisions = options.decisions.map((decision) => {
84
91
  const record = byId.get(decision.id);
85
92
  if (!record || !REVIEWABLE_TYPES.has(record.type)) {
86
93
  throw new Error(`Resource "${decision.id}" is not an applicability-review record.`);
@@ -99,6 +106,33 @@ function planApplicabilityReviewWithContext(context, options) {
99
106
  if (constraint && !constraint.allowedDecisions.includes(result)) {
100
107
  throw new Error(`${record.reference || record.title} must be applicable because it is required for the selected SOC 2 Security program.`);
101
108
  }
109
+ if (record.type === "requirement" && !["applicable", "not-applicable"].includes(result)) {
110
+ throw new Error(`Requirement "${record.id}" must be applicable or not-applicable.`);
111
+ }
112
+ return { record, result, rationale, reviewedByIds, reviewedOn };
113
+ });
114
+ const v4RequirementDecisions = reviewedDecisions
115
+ .filter(({ record }) => record.type === "requirement" && modelSupports(loaded.model, "program-scope"))
116
+ .map(({ record, result, rationale, reviewedByIds, reviewedOn }) => ({
117
+ requirementId: record.id,
118
+ decision: result,
119
+ rationale,
120
+ reviewedByIds,
121
+ reviewedOn,
122
+ scopeRevision: applicabilityScopeRevision(record, program, loaded.resources, loaded.model)
123
+ }));
124
+ const replacedRequirementIds = new Set(v4RequirementDecisions.map(({ requirementId }) => requirementId));
125
+ const reviewedProgram = v4RequirementDecisions.length
126
+ ? {
127
+ ...program,
128
+ requirementApplicability: [
129
+ ...(program.requirementApplicability || []).filter(({ requirementId }) => !replacedRequirementIds.has(requirementId)),
130
+ ...v4RequirementDecisions
131
+ ]
132
+ }
133
+ : program;
134
+ const update = reviewedDecisions.flatMap(({ record, result, rationale, reviewedByIds, reviewedOn }) => {
135
+ const scopeRevision = applicabilityScopeRevision(record, reviewedProgram, loaded.resources, loaded.model);
102
136
  const next = {
103
137
  ...record,
104
138
  applicabilityReview: {
@@ -106,22 +140,11 @@ function planApplicabilityReviewWithContext(context, options) {
106
140
  rationale,
107
141
  reviewedByIds,
108
142
  reviewedOn,
109
- scopeRevision: basis.scopeRevision
143
+ scopeRevision
110
144
  }
111
145
  };
112
146
  if (record.type === "requirement") {
113
- if (!["applicable", "not-applicable"].includes(result)) {
114
- throw new Error(`Requirement "${record.id}" must be applicable or not-applicable.`);
115
- }
116
147
  if (modelSupports(loaded.model, "program-scope")) {
117
- v4RequirementDecisions.push({
118
- requirementId: record.id,
119
- decision: result,
120
- rationale,
121
- reviewedByIds,
122
- reviewedOn,
123
- scopeRevision: basis.scopeRevision
124
- });
125
148
  return [];
126
149
  }
127
150
  next.applicability = result;
@@ -132,14 +155,7 @@ function planApplicabilityReviewWithContext(context, options) {
132
155
  return [next];
133
156
  });
134
157
  if (v4RequirementDecisions.length) {
135
- const replaced = new Set(v4RequirementDecisions.map(({ requirementId }) => requirementId));
136
- update.push({
137
- ...program,
138
- requirementApplicability: [
139
- ...(program.requirementApplicability || []).filter(({ requirementId }) => !replaced.has(requirementId)),
140
- ...v4RequirementDecisions
141
- ]
142
- });
158
+ update.push(reviewedProgram);
143
159
  }
144
160
  return {
145
161
  operation: "applicability-review",