filegrc 0.16.11 → 0.16.13

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.16.11",
3
+ "version": "0.16.13",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,10 @@
1
1
  import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionsMatch } from "./revisions.js";
2
- import { getDataCommitHistory } from "./git.js";
3
- import { historicalWorkspace } from "./historical-workspace.js";
2
+ import { reviewCommitsChangingTypes, reviewHistoricalWorkspace, reviewHistoryCommits, reviewHistoryContext } from "./historical-workspace.js";
3
+
4
+ const scopeSourceTypes = new Set([
5
+ "workspace", "program", "commitment", "control", "complementary-control",
6
+ "system", "framework", "component", "vendor", "policy", "requirement"
7
+ ]);
4
8
 
5
9
  const excludedResourceFields = new Set([
6
10
  "applicabilityReview",
@@ -23,6 +27,13 @@ const unorderedStringArrayFields = new Set([
23
27
  "tags"
24
28
  ]);
25
29
 
30
+ const applicabilityDecisionFields = {
31
+ requirement: ["frameworkId", "reference", "description", "parentRequirementId"],
32
+ control: ["statement", "requirementIds", "code", "activity", "controlType", "operationMode", "operationPattern", "systemIds", "policyIds", "componentIds", "evidenceSourceComponentIds"],
33
+ commitment: ["commitmentKind", "statement", "systemIds", "sourceResourceIds", "requirementIds", "controlIds", "customerFacing", "reportingRouteRequirements"],
34
+ "complementary-control": ["responsibleParty", "statement", "systemIds", "vendorId", "requirementIds", "commitmentIds", "relatedControlIds", "sourceDocumentIds", "componentIds"]
35
+ };
36
+
26
37
  export function applicabilityScopeRevision(record, program, resources, model, options = {}) {
27
38
  const scopedResource = ["commitment", "control", "complementary-control"].includes(record?.type)
28
39
  && !options.legacyBroadScope && !options.legacyBroadCommitment;
@@ -79,8 +90,7 @@ export function applicabilityScopeRevision(record, program, resources, model, op
79
90
  .sort((left, right) => left.requirementId.localeCompare(right.requirementId))
80
91
  })
81
92
  },
82
- resource: canonicalObject(model, record.type, Object.fromEntries(Object.entries(record)
83
- .filter(([field]) => !excludedResourceFields.has(field)))),
93
+ resource: applicabilityResourceFacts(record, model, options),
84
94
  systems: resources
85
95
  .filter(({ type, id }) => type === "system" && selectedSystemIds.has(id))
86
96
  .sort(compareRecordIds)
@@ -192,6 +202,17 @@ function compareRecordIds(left, right) {
192
202
  return left.id.localeCompare(right.id);
193
203
  }
194
204
 
205
+ function applicabilityResourceFacts(record, model, options) {
206
+ const fields = Number(model.modelVersion) >= 11 && !options.legacyResourceProjection
207
+ ? applicabilityDecisionFields[record.type] : null;
208
+ if (!fields) return canonicalObject(model, record.type, Object.fromEntries(Object.entries(record)
209
+ .filter(([field]) => !excludedResourceFields.has(field))));
210
+ const value = Object.fromEntries(["id", "type", "title", "externalIds", "extensions", ...fields]
211
+ .filter((field) => record[field] !== undefined).map((field) => [field, record[field]]));
212
+ value.unavailable = ["not-applicable", "retired", "superseded"].includes(record.status);
213
+ return canonicalObject(model, record.type, value);
214
+ }
215
+
195
216
  export function applicabilityReviewIsCurrent(review, record, program, resources, model, root = null) {
196
217
  if (
197
218
  review?.scopeRevision
@@ -200,12 +221,17 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
200
221
  && Number(model?.modelVersion || 0) < 7
201
222
  ) return true;
202
223
  if (!review?.scopeRevision) return false;
224
+ if (Number(model.modelVersion) >= 11 && record?.type === "control"
225
+ && (review.decision === "not-applicable") !== (record.status === "not-applicable")) return false;
226
+ if (Number(model.modelVersion) >= 11 && ["retired", "superseded"].includes(record?.status)) return false;
203
227
  const matches = (revision) => revisionsMatch("applicability-scope", review.scopeRevision, revision);
204
228
  if (matches(applicabilityScopeRevision(record, program, resources, model))) return true;
229
+ if (Number(model.modelVersion) >= 11 && applicabilityDecisionFields[record?.type]
230
+ && matches(applicabilityScopeRevision(record, program, resources, model, { legacyResourceProjection: true }))) return true;
205
231
  if (["commitment", "control", "complementary-control"].includes(record?.type) && matches(applicabilityScopeRevision(
206
232
  record, program, resources, model, { legacyBroadScope: true }
207
233
  ))) return true;
208
- if (["commitment", "control", "complementary-control"].includes(record?.type) && root && historicalScopedReviewMatches(
234
+ if (Number(model.modelVersion) >= 11 && applicabilityDecisionFields[record?.type] && root && historicalScopedReviewMatches(
209
235
  review.scopeRevision, record, program, resources, model, root
210
236
  )) return true;
211
237
  // Older reviews hashed Policy approval metadata alongside scope facts.
@@ -243,8 +269,13 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
243
269
 
244
270
  function historicalScopedReviewMatches(stored, record, program, resources, model, root) {
245
271
  const current = applicabilityScopeRevision(record, program, resources, model);
246
- for (const commit of getDataCommitHistory(root).reverse()) {
247
- const snapshot = historicalWorkspace(root, commit);
272
+ const context = reviewHistoryContext(root, resources);
273
+ const { index } = context;
274
+ const commits = index?.available
275
+ ? reviewCommitsChangingTypes(index, scopeSourceTypes)
276
+ : reviewHistoryCommits(context);
277
+ for (const commit of [...commits].reverse()) {
278
+ const snapshot = reviewHistoricalWorkspace(context, commit);
248
279
  if (!snapshot) continue;
249
280
  const historicalResources = snapshot.resources;
250
281
  const historicalRecord = historicalResources.find(({ id }) => id === record.id);
@@ -254,9 +285,14 @@ function historicalScopedReviewMatches(stored, record, program, resources, model
254
285
  if (!revisionsMatch("applicability-scope", current, applicabilityScopeRevision(
255
286
  historicalRecord, historicalProgram, historicalResources, historicalModel
256
287
  ))) continue;
257
- if (revisionsMatch("applicability-scope", stored, applicabilityScopeRevision(
258
- historicalRecord, historicalProgram, historicalResources, historicalModel, { legacyBroadScope: true }
259
- ))) return true;
288
+ for (const legacyBroadScope of [false, true]) {
289
+ for (const legacyResourceProjection of [false, true]) {
290
+ if (revisionsMatch("applicability-scope", stored, applicabilityScopeRevision(
291
+ historicalRecord, historicalProgram, historicalResources, historicalModel,
292
+ { legacyBroadScope, legacyResourceProjection }
293
+ ))) return true;
294
+ }
295
+ }
260
296
  }
261
297
  return false;
262
298
  }
@@ -142,16 +142,23 @@ function planApplicabilityReviewWithContext(context, options) {
142
142
  }
143
143
  : program;
144
144
  const update = reviewedDecisions.flatMap(({ record, result, rationale, reviewedByIds, reviewedOn }) => {
145
- const scopeRevision = applicabilityScopeRevision(record, reviewedProgram, loaded.resources, loaded.model);
146
- const next = {
147
- ...record,
148
- applicabilityReview: {
149
- decision: result,
150
- rationale,
151
- reviewedByIds,
152
- reviewedOn,
153
- scopeRevision
145
+ const next = { ...record };
146
+ if (record.type === "control" && result === "not-applicable" && record.status !== "not-applicable") {
147
+ next.status = "not-applicable";
148
+ if (loaded.model.resources.control.fields.statusTransition) {
149
+ next.statusTransition = { changedByIds: reviewedByIds, changedOn: reviewedOn, reason: rationale };
154
150
  }
151
+ }
152
+ if (record.type === "control" && result !== "not-applicable" && record.status === "not-applicable") {
153
+ next.status = "planned";
154
+ delete next.statusTransition;
155
+ }
156
+ next.applicabilityReview = {
157
+ decision: result,
158
+ rationale,
159
+ reviewedByIds,
160
+ reviewedOn,
161
+ scopeRevision: applicabilityScopeRevision(next, reviewedProgram, loaded.resources, loaded.model)
155
162
  };
156
163
  if (record.type === "requirement") {
157
164
  if (modelSupports(loaded.model, "program-scope")) {
@@ -160,8 +167,6 @@ function planApplicabilityReviewWithContext(context, options) {
160
167
  next.applicability = result;
161
168
  next.applicabilityRationale = rationale;
162
169
  }
163
- if (record.type === "control" && result === "not-applicable") next.status = "not-applicable";
164
- if (record.type === "control" && result === "applicable" && record.status === "not-applicable") next.status = "planned";
165
170
  return [next];
166
171
  });
167
172
  if (v4RequirementDecisions.length) {
@@ -7,8 +7,8 @@ import {
7
7
  collectionScopeRevisionFacts
8
8
  } from "./collection-scope.js";
9
9
  import { resolveDataPath } from "./paths.js";
10
- import { getDataCommitHistory, getFileAtRevision } from "./git.js";
11
- import { historicalWorkspace } from "./historical-workspace.js";
10
+ import { getFileAtRevision } from "./git.js";
11
+ import { reviewHistoricalWorkspace, reviewHistoryCommits, reviewHistoryContext } from "./historical-workspace.js";
12
12
  import { resolveProgram } from "./program.js";
13
13
  import { markdownEntries } from "./resource-markdown.js";
14
14
  import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionsMatch } from "./revisions.js";
@@ -46,8 +46,9 @@ export function collectionRevisionMatches(loaded, resourceType, storedRevision,
46
46
 
47
47
  function historicallyEquivalentCollection(loaded, resourceType, stored, options, current) {
48
48
  if (!loaded.root) return false;
49
- for (const commit of getDataCommitHistory(loaded.root).reverse()) {
50
- const snapshot = historicalWorkspace(loaded.root, commit);
49
+ const context = reviewHistoryContext(loaded.root, loaded);
50
+ for (const commit of [...reviewHistoryCommits(context)].reverse()) {
51
+ const snapshot = reviewHistoricalWorkspace(context, commit);
51
52
  if (!snapshot) continue;
52
53
  const historicalOptions = {
53
54
  ...options,
@@ -103,7 +104,9 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy, scop
103
104
  contentRevisions: (legacy || includeContent ? markdownEntries(loaded.model, record) : []).flatMap(({ path }) => {
104
105
  try {
105
106
  const content = options.historicalCommit
106
- ? getFileAtRevision(loaded.root, options.historicalCommit, `data/${path}`)
107
+ ? loaded.historicalFiles
108
+ ? loaded.historicalFiles.get(`data/${path}`) ?? null
109
+ : getFileAtRevision(loaded.root, options.historicalCommit, `data/${path}`)
107
110
  : readFileSync(resolveDataPath(loaded.root, path), "utf8");
108
111
  if (content === null) return [];
109
112
  return [{ path, revision: createHash("sha256").update(content).digest("hex") }];
@@ -200,6 +200,7 @@ function scopedProgramPeople(loaded, program = {}) {
200
200
 
201
201
  export function collectionRevisionInputs(loaded, resourceType, program, options = {}) {
202
202
  const legacy = options.legacy === true;
203
+ const currentModel = modelSupports(loaded.model, "retention-schedule-approval");
203
204
  const reviewed = scopedCollectionRecords(loaded, resourceType, program);
204
205
  if (!modelSupports(loaded.model, "program-scope")) {
205
206
  return reviewed.map((record) => ({ record, value: record, includeContent: true }));
@@ -367,11 +368,11 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
367
368
  && record.type === "document"
368
369
  ? retentionScheduleDocumentProposalValue(record)
369
370
  : options.historicalReviewMetadata
370
- ? dependencyRevisionValue(resourceType, record, legacy)
371
- : substantiveRevisionValue(dependencyRevisionValue(resourceType, record, legacy)),
371
+ ? dependencyRevisionValue(resourceType, record, legacy, true, currentModel)
372
+ : substantiveRevisionValue(dependencyRevisionValue(resourceType, record, legacy, false, currentModel)),
372
373
  includeContent: legacy
373
374
  || reviewedIds.has(record.id)
374
- || dependencyContentAffectsRevision(resourceType, record.type)
375
+ || dependencyContentAffectsRevision(resourceType, record.type, options.historicalReviewMetadata, currentModel)
375
376
  }));
376
377
  }
377
378
 
@@ -379,7 +380,7 @@ function retentionScheduleRowProposalValue(record, historicalReviewMetadata = fa
379
380
  return Object.fromEntries(Object.entries(record).filter(([field]) => ![
380
381
  "approvedByIds",
381
382
  "approvedOn",
382
- ...(historicalReviewMetadata ? [] : ["reviewedSourceRevisions"])
383
+ ...(historicalReviewMetadata ? [] : ["reviewedSourceRevisions", "tags", "statusTransition"])
383
384
  ].includes(field)));
384
385
  }
385
386
 
@@ -591,22 +592,31 @@ const dependencyContentTypes = {
591
592
  component: new Set(["system"]),
592
593
  control: new Set(["system", "component", "obligation", "source-coverage", "evidence", "retention-schedule-item", "document", "policy", "training"]),
593
594
  "retention-schedule-item": new Set(["document", "policy"]),
594
- "complementary-control": new Set(["system", "control", "document", "component"])
595
+ "complementary-control": new Set(["system", "document", "component"])
595
596
  };
596
597
 
597
- function dependencyRevisionValue(resourceType, record, legacy) {
598
- const fields = legacy
598
+ function dependencyRevisionValue(resourceType, record, legacy, historicalReviewMetadata = false, currentModel = false) {
599
+ const controlDecision = currentModel && !historicalReviewMetadata && record.type === "control"
600
+ && ["retention-schedule-item", "complementary-control"].includes(resourceType);
601
+ const fields = controlDecision
602
+ ? resourceType === "retention-schedule-item"
603
+ ? ["id", "type", "statement", "activity", "controlType", "operationMode", "operationPattern", "requirementIds", "systemIds", "policyIds", "componentIds", "evidenceSourceComponentIds"]
604
+ : ["id", "type", "statement", "activity", "controlType", "operationMode", "operationPattern", "requirementIds", "systemIds", "policyIds", "componentIds", "evidenceSourceComponentIds"]
605
+ : legacy
599
606
  ? legacyDependencyFieldOverrides[resourceType]?.[record.type]
600
607
  || dependencyFields[resourceType]?.[record.type]
601
608
  : dependencyFields[resourceType]?.[record.type];
602
609
  if (!fields) return { id: record.id, type: record.type };
603
- return Object.fromEntries(fields
610
+ const value = Object.fromEntries(fields
604
611
  .filter((field) => record[field] !== undefined)
605
612
  .map((field) => [field, record[field]]));
613
+ if (controlDecision) value.unavailable = ["not-applicable", "retired", "superseded"].includes(record.status);
614
+ return value;
606
615
  }
607
616
 
608
- function dependencyContentAffectsRevision(resourceType, dependencyType) {
609
- return dependencyContentTypes[resourceType]?.has(dependencyType) || false;
617
+ function dependencyContentAffectsRevision(resourceType, dependencyType, historicalReviewMetadata = false, currentModel = false) {
618
+ return ((!currentModel || historicalReviewMetadata) && resourceType === "complementary-control" && dependencyType === "control")
619
+ || dependencyContentTypes[resourceType]?.has(dependencyType) || false;
610
620
  }
611
621
 
612
622
  function sorted(values) {
@@ -1,10 +1,55 @@
1
1
  import { loadModel } from "../model/index.js";
2
- import { getDataFilesAtRevision, getFileAtRevision } from "./git.js";
2
+ import { getDataCommitHistory, getDataFilesAtRevision, getDataRecordHistoryIndex, getFileAtRevision } from "./git.js";
3
+ import { performance } from "node:perf_hooks";
3
4
 
4
- // Rebuild only the authoritative data needed for a historical revision check.
5
- // The caller must compare current and historical facts before accepting a
6
- // legacy binding; this snapshot never supplies a new approval.
7
- export function historicalWorkspace(root, commit) {
5
+ const reviewContexts = new WeakMap();
6
+
7
+ export function reviewHistoryContext(root, owner) {
8
+ const key = owner.resources || owner;
9
+ let context = reviewContexts.get(key);
10
+ if (context) return context;
11
+ let index = null;
12
+ try {
13
+ // Leave time for the original Git recovery path if this larger index
14
+ // cannot be built within a browser request.
15
+ index = getDataRecordHistoryIndex(root, { deadline: performance.now() + 3_000 });
16
+ } catch (error) {
17
+ if (error.code !== "FILEGRC_HISTORY_DEADLINE") throw error;
18
+ }
19
+ context = { root, index, statesByCommit: new Map(), workspacesByCommit: new Map() };
20
+ reviewContexts.set(key, context);
21
+ return context;
22
+ }
23
+
24
+ export function reviewHistoryCommits(context) {
25
+ if (context.index?.available) return context.index.commits;
26
+ context.fallbackCommits ||= getDataCommitHistory(context.root);
27
+ return context.fallbackCommits;
28
+ }
29
+
30
+ export function reviewCommitsChangingTypes(index, types) {
31
+ const paths = new Set();
32
+ for (const records of index.recordsByCommit.values()) {
33
+ for (const { record, path } of records.values()) {
34
+ if (types.has(record.type)) paths.add(path);
35
+ }
36
+ }
37
+ return index.commits.filter((commit) => [...(index.fileChangesByCommit.get(commit)?.keys() || [])]
38
+ .some((path) => paths.has(path)));
39
+ }
40
+
41
+ export function reviewHistoricalWorkspace(context, commit) {
42
+ if (context.index?.available) return indexedHistoricalWorkspace(context, commit);
43
+ if (context.workspacesByCommit.has(commit)) return context.workspacesByCommit.get(commit);
44
+ const loaded = historicalWorkspace(context.root, commit);
45
+ context.workspacesByCommit.set(commit, loaded);
46
+ if (context.workspacesByCommit.size > 128) context.workspacesByCommit.delete(context.workspacesByCommit.keys().next().value);
47
+ return loaded;
48
+ }
49
+
50
+ // Preserve recovery beyond the reconciliation index's size and build limits.
51
+ // It is slower, but still requires the same substantive comparison.
52
+ function historicalWorkspace(root, commit) {
8
53
  const entries = [];
9
54
  for (const path of getDataFilesAtRevision(root, commit).filter(authoritativeJsonPath)) {
10
55
  const source = getFileAtRevision(root, commit, path);
@@ -25,6 +70,59 @@ export function historicalWorkspace(root, commit) {
25
70
  }
26
71
  }
27
72
 
73
+ // The history index already contains every committed JSON and Markdown source.
74
+ // Materialize its first-parent states once instead of asking Git to list and
75
+ // read the entire workspace for every legacy review binding.
76
+ export function indexedHistoricalWorkspace(context, commit) {
77
+ const { root, index } = context;
78
+ if (!index?.available || !index.parentsByCommit.has(commit)) return null;
79
+ // Reuse a full request-sized history for ordinary workspaces while bounding
80
+ // retained maps when a repository contains many thousands of data files.
81
+ const cacheLimit = Math.max(1, Math.min(512, Math.floor(200_000 / Math.max(1, index.historicalRecordPaths.size))));
82
+ if (context.workspacesByCommit.has(commit)) return context.workspacesByCommit.get(commit);
83
+ let state = context.statesByCommit.get(commit);
84
+ if (!state) {
85
+ const lineage = [];
86
+ let cursor = commit;
87
+ while (cursor && !context.statesByCommit.has(cursor)) {
88
+ lineage.push(cursor);
89
+ cursor = index.parentsByCommit.get(cursor)?.[0] || null;
90
+ }
91
+ state = cursor ? context.statesByCommit.get(cursor) : new Map();
92
+ for (let position = lineage.length - 1; position >= 0; position -= 1) {
93
+ const changedAt = lineage[position];
94
+ const next = new Map(state);
95
+ for (const [path, source] of index.fileChangesByCommit.get(changedAt) || []) {
96
+ if (source === null) next.delete(path);
97
+ else next.set(path, source);
98
+ }
99
+ context.statesByCommit.set(changedAt, next);
100
+ if (context.statesByCommit.size > cacheLimit) context.statesByCommit.delete(context.statesByCommit.keys().next().value);
101
+ state = next;
102
+ }
103
+ }
104
+ const entries = [];
105
+ for (const [path, source] of state) {
106
+ if (!authoritativeJsonPath(path)) continue;
107
+ try {
108
+ entries.push({ record: JSON.parse(source), source, relativePath: path.slice("data/".length) });
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+ const resources = entries.map(({ record }) => record);
114
+ const workspace = resources.find(({ type }) => type === "workspace");
115
+ if (!workspace) return null;
116
+ try {
117
+ const loaded = { root, entries, resources, workspace, model: loadModel(workspace.dataModelVersion), historicalFiles: state };
118
+ context.workspacesByCommit.set(commit, loaded);
119
+ if (context.workspacesByCommit.size > cacheLimit) context.workspacesByCommit.delete(context.workspacesByCommit.keys().next().value);
120
+ return loaded;
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
28
126
  function authoritativeJsonPath(path) {
29
127
  const parts = path.split("/");
30
128
  if (parts[0] !== "data" || !path.endsWith(".json")) return false;
package/src/retention.js CHANGED
@@ -6,8 +6,8 @@ import { programComponents } from "./program.js";
6
6
  import { markdownEntries } from "./resource-markdown.js";
7
7
  import { modelSupports } from "../model/index.js";
8
8
  import { canonicalCalculatedRevisionJson, revisionDigest, revisionsMatch } from "./revisions.js";
9
- import { getDataCommitHistory, getFileAtRevision } from "./git.js";
10
- import { historicalWorkspace } from "./historical-workspace.js";
9
+ import { getFileAtRevision } from "./git.js";
10
+ import { reviewHistoricalWorkspace, reviewHistoryCommits, reviewHistoryContext } from "./historical-workspace.js";
11
11
 
12
12
  export async function assessRetentionReadiness(loaded, program, options = {}) {
13
13
  if (!loaded.model.resources["retention-schedule-item"]) return [];
@@ -167,7 +167,7 @@ export async function resourceReviewRevisions(loaded, ids, scopeHashInput = "leg
167
167
  if (!entry || reviewing.has(id)) return null;
168
168
  reviewing.add(id);
169
169
  const parts = [reviewSource(loaded, entry, scopeHashInput)];
170
- for (const markdown of markdownEntries(loaded.model, entry.record)) {
170
+ for (const markdown of reviewMarkdownEntries(loaded, entry, false)) {
171
171
  try {
172
172
  parts.push(await readFile(resolveDataPath(loaded.root, markdown.path), "utf8"));
173
173
  } catch (error) {
@@ -200,10 +200,12 @@ export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legac
200
200
  if (!entry || reviewing.has(id)) return null;
201
201
  reviewing.add(id);
202
202
  const parts = [reviewSource(loaded, entry, scopeHashInput, legacySource)];
203
- for (const markdown of markdownEntries(loaded.model, entry.record)) {
203
+ for (const markdown of reviewMarkdownEntries(loaded, entry, legacySource)) {
204
204
  try {
205
205
  const source = historicalCommit
206
- ? getFileAtRevision(loaded.root, historicalCommit, `data/${markdown.path}`)
206
+ ? loaded.historicalFiles
207
+ ? loaded.historicalFiles.get(`data/${markdown.path}`) ?? null
208
+ : getFileAtRevision(loaded.root, historicalCommit, `data/${markdown.path}`)
207
209
  : readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8");
208
210
  if (source !== null) parts.push(source);
209
211
  } catch (error) {
@@ -241,8 +243,24 @@ export function resourceReviewRevisionMatches(loaded, revisions, id, stored) {
241
243
  // records and Markdown, before the old approval is treated as current.
242
244
  function historicallyEquivalentReviewSource(loaded, id, stored, current) {
243
245
  if (!loaded.root) return false;
244
- for (const commit of getDataCommitHistory(loaded.root).reverse()) {
245
- const snapshot = historicalWorkspace(loaded.root, commit);
246
+ const context = reviewHistoryContext(loaded.root, loaded);
247
+ const { index } = context;
248
+ const relevantPaths = index?.available ? new Set(["data/workspace.json"]) : null;
249
+ const graphIds = new Set();
250
+ const visit = (resourceId) => {
251
+ if (graphIds.has(resourceId)) return;
252
+ graphIds.add(resourceId);
253
+ const entry = loaded.entries.find(({ record }) => record.id === resourceId);
254
+ if (!entry) return;
255
+ for (const path of (index.historiesById.get(resourceId) || []).map(({ path }) => path)) relevantPaths.add(path);
256
+ for (const markdown of markdownEntries(loaded.model, entry.record)) relevantPaths.add(`data/${markdown.path}`);
257
+ for (const sourceId of entry.record.sourceResourceIds || []) visit(sourceId);
258
+ };
259
+ if (relevantPaths) visit(id);
260
+ for (const commit of [...reviewHistoryCommits(context)].reverse()) {
261
+ const changed = index?.available ? index.fileChangesByCommit.get(commit) : null;
262
+ if (relevantPaths && ![...changed.keys()].some((path) => relevantPaths.has(path) || path.endsWith(".md"))) continue;
263
+ const snapshot = reviewHistoricalWorkspace(context, commit);
246
264
  if (!snapshot?.entries.some(({ record }) => record.id === id)) continue;
247
265
  const semantic = resourceReviewRevisionsSync(snapshot, [id], "legacy", false, commit).get(id);
248
266
  if (!revisionsMatch("content", semantic, current)) continue;
@@ -299,10 +317,52 @@ function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = f
299
317
  }
300
318
  return legacySource
301
319
  ? canonicalCalculatedRevisionJson(entry.source, scopeHashInput)
302
- : canonicalCalculatedRevisionJson(JSON.stringify(substantiveReviewSource(JSON.parse(entry.source))), scopeHashInput);
320
+ : canonicalCalculatedRevisionJson(JSON.stringify(substantiveReviewSource(
321
+ JSON.parse(entry.source), modelSupports(loaded.model, "retention-schedule-approval")
322
+ )), scopeHashInput);
323
+ }
324
+
325
+ function reviewMarkdownEntries(loaded, entry, legacySource) {
326
+ // Control Markdown records the implementation procedure. Retention and
327
+ // mapping decisions depend on the structured Control design instead.
328
+ if (!legacySource && modelSupports(loaded.model, "retention-schedule-approval")
329
+ && entry.record.type === "control") return [];
330
+ return markdownEntries(loaded.model, entry.record);
303
331
  }
304
332
 
305
- function substantiveReviewSource(record) {
333
+ const reviewDecisionFields = {
334
+ control: ["statement", "requirementIds", "code", "activity", "controlType", "operationMode", "operationPattern", "systemIds", "policyIds", "componentIds", "evidenceSourceComponentIds"],
335
+ commitment: ["commitmentKind", "statement", "systemIds", "sourceResourceIds", "requirementIds", "controlIds", "customerFacing", "effectiveOn", "reportingRouteRequirements"],
336
+ "complementary-control": ["responsibleParty", "statement", "systemIds", "vendorId", "requirementIds", "commitmentIds", "relatedControlIds", "sourceDocumentIds", "componentIds"],
337
+ "source-coverage": ["sourceFamilyId", "coverageKind", "scopeResourceIds", "excludedPopulation", "collectionCadence", "reconciliationMethod", "validFrom", "validThrough", "componentId", "retentionScheduleItemIds"],
338
+ system: ["purpose", "servicesProvided", "boundary", "exclusions", "criticality", "informationTypeIds", "classificationId", "internetExposed", "continuityObjectives"],
339
+ component: ["componentKind", "description", "criticality", "environment", "vendorId", "systemUses", "informationUses", "evidenceSourceKinds", "internetExposed", "classificationId", "continuityObjectives"],
340
+ vendor: ["category", "criticality", "description", "standardAgreement", "agreementDocumentId", "startDate", "endDate", "informationTypeIds", "classificationId"],
341
+ "information-type": ["classificationId", "description"],
342
+ framework: ["version", "publisher", "description", "sourceReference", "effectiveOn"],
343
+ requirement: ["frameworkId", "reference", "description", "parentRequirementId"],
344
+ policy: ["policyNumber", "policyKind", "version", "effectiveOn", "supersedesId", "parentPolicyId", "relatedPolicyIds", "relatedDocumentIds", "requirementIds", "audience", "acknowledgementRequired", "programRole", "reportingRouteRequirements"],
345
+ document: ["documentKind", "workflowScope", "template", "version", "effectiveOn", "supersedesId", "systemIds", "controlIds", "relatedDocumentIds", "audience", "acknowledgementRequired", "trainingIds", "classificationId", "programRole", "componentIds", "reportingRouteRequirements"]
346
+ };
347
+
348
+ function substantiveReviewSource(record, currentModel) {
349
+ if (currentModel && reviewDecisionFields[record.type]) {
350
+ const fields = ["id", "type", "title", "extensions", "externalIds", ...reviewDecisionFields[record.type]];
351
+ const decision = Object.fromEntries(fields.filter((field) => record[field] !== undefined)
352
+ .map((field) => [field, record[field]]));
353
+ if (record.type === "control") {
354
+ decision.unavailable = ["not-applicable", "retired", "superseded"].includes(record.status);
355
+ } else if (["policy", "document"].includes(record.type)) {
356
+ decision.governingStatus = ["approved", "active"].includes(record.status) ? "approved" : record.status || null;
357
+ } else {
358
+ decision.status = record.status || null;
359
+ }
360
+ if (["control", "commitment", "complementary-control"].includes(record.type)) {
361
+ decision.nonApplicableDecision = ["not-applicable", "externally-managed", "zero-population"]
362
+ .includes(record.applicabilityReview?.decision) ? record.applicabilityReview.decision : null;
363
+ }
364
+ return decision;
365
+ }
306
366
  const source = record.type === "program" ? {
307
367
  id: record.id,
308
368
  type: record.type,