filegrc 0.16.9 → 0.16.11

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.9",
3
+ "version": "0.16.11",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,4 +1,6 @@
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
4
 
3
5
  const excludedResourceFields = new Set([
4
6
  "applicabilityReview",
@@ -22,13 +24,30 @@ const unorderedStringArrayFields = new Set([
22
24
  ]);
23
25
 
24
26
  export function applicabilityScopeRevision(record, program, resources, model, options = {}) {
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 }) => (
27
+ const scopedResource = ["commitment", "control", "complementary-control"].includes(record?.type)
28
+ && !options.legacyBroadScope && !options.legacyBroadCommitment;
29
+ const linkedRequirementIds = new Set(record?.requirementIds || []);
30
+ const linkedControlIds = record?.type === "control"
31
+ ? [record.id]
32
+ : record?.type === "complementary-control"
33
+ ? record.relatedControlIds || []
34
+ : record?.controlIds || [];
35
+ const selectedSystemIds = new Set((program?.systemIds || []).filter((id) => (
36
+ !scopedResource || !(record.systemIds || []).length || record.systemIds.includes(id)
37
+ )));
38
+ const selectedFrameworkIds = new Set((program?.frameworkIds || []).filter((id) => (
39
+ !scopedResource || !linkedRequirementIds.size || resources.some((candidate) => (
40
+ candidate.type === "requirement" && candidate.frameworkId === id && linkedRequirementIds.has(candidate.id)
41
+ ))
42
+ )));
43
+ const selectedControlIds = new Set((program?.controlIds || []).filter((id) => (
44
+ !scopedResource || !linkedControlIds.length || linkedControlIds.includes(id)
45
+ )));
46
+ const selectedComponents = resources.filter(({ type, status, systemUses, id }) => (
29
47
  type === "component"
30
48
  && status !== "retired"
31
49
  && (systemUses || []).some(({ systemId }) => selectedSystemIds.has(systemId))
50
+ && (!scopedResource || !(record.componentIds || []).length || record.componentIds.includes(id))
32
51
  ));
33
52
  const selectedVendorIds = new Set(selectedComponents.map(({ vendorId }) => vendorId).filter(Boolean));
34
53
  const selectedPolicyIds = new Set(resources
@@ -38,17 +57,24 @@ export function applicabilityScopeRevision(record, program, resources, model, op
38
57
  const facts = {
39
58
  modelVersion: model.modelVersion,
40
59
  program: {
41
- ...pick(program || {}, [
60
+ ...pick(scopedResource ? {
61
+ ...program,
62
+ systemIds: [...selectedSystemIds],
63
+ frameworkIds: [...selectedFrameworkIds],
64
+ requirementIds: (program?.requirementIds || []).filter((id) => !linkedRequirementIds.size || linkedRequirementIds.has(id)),
65
+ controlIds: [...selectedControlIds]
66
+ } : program || {}, [
42
67
  "id",
43
68
  "assuranceGoal",
44
69
  "systemIds",
45
70
  "frameworkIds",
46
71
  "requirementIds",
47
72
  "controlIds",
48
- "riskMethodology"
73
+ ...(record?.type === "commitment" && scopedResource ? [] : ["riskMethodology"])
49
74
  ], model),
50
75
  ...(record.type === "requirement" ? {} : {
51
76
  requirementApplicability: (program?.requirementApplicability || [])
77
+ .filter((review) => !scopedResource || !linkedRequirementIds.size || linkedRequirementIds.has(review.requirementId))
52
78
  .map((review) => pick(review, ["requirementId", "decision"], model, "program-applicability"))
53
79
  .sort((left, right) => left.requirementId.localeCompare(right.requirementId))
54
80
  })
@@ -117,13 +143,16 @@ export function applicabilityScopeRevision(record, program, resources, model, op
117
143
  policies: resources
118
144
  .filter(({ type, id }) => type === "policy" && selectedPolicyIds.has(id))
119
145
  .sort(compareRecordIds)
120
- .map((policy) => options.legacyPolicyStatus
146
+ .map((policy) => options.legacyPolicyStatus || options.legacyPolicyOverrides
121
147
  ? pick({
122
148
  ...policy,
123
- status: options.legacyPolicyStatus === "approved" && ["in-review", "active"].includes(policy.status)
124
- ? "approved" : policy.status,
125
- effectiveOn: options.legacyPolicyStatus === "approved" && policy.status === "active"
126
- ? undefined : policy.effectiveOn
149
+ status: (options.legacyPolicyOverrides?.[policy.id]?.status || options.legacyPolicyStatus || "current") === "current"
150
+ ? policy.status : options.legacyPolicyOverrides?.[policy.id]?.status || options.legacyPolicyStatus,
151
+ effectiveOn: (options.legacyPolicyOverrides?.[policy.id]?.withoutEffectiveOn
152
+ ?? options.legacyPolicyWithoutEffectiveOn) ? undefined : policy.effectiveOn,
153
+ approvedContentRevisions: (options.legacyPolicyOverrides?.[policy.id]?.withoutApprovalBinding
154
+ ?? options.legacyPolicyWithoutApprovalBinding)
155
+ ? undefined : policy.approvedContentRevisions
127
156
  }, [
128
157
  "id", "status", "policyKind", "version", "programRole", "effectiveOn",
129
158
  "requirementIds", "audience", "acknowledgementRequired",
@@ -131,14 +160,19 @@ export function applicabilityScopeRevision(record, program, resources, model, op
131
160
  ], model)
132
161
  : pick(policy, ["id", "requirementIds"], model)),
133
162
  requirements: resources
134
- .filter(({ type, frameworkId }) => type === "requirement" && selectedFrameworkIds.has(frameworkId))
163
+ .filter(({ type, frameworkId, id }) => type === "requirement" && selectedFrameworkIds.has(frameworkId)
164
+ && (!scopedResource || !linkedRequirementIds.size || linkedRequirementIds.has(id)))
135
165
  .sort(compareRecordIds)
136
166
  .map((requirement) => pick(requirement, ["id", "frameworkId", "reference", "description", "parentRequirementId"], model)),
137
167
  commitments: resources
138
- .filter(({ type, status, systemIds }) => (
168
+ .filter(({ type, status, systemIds, controlIds, id }) => (
139
169
  type === "commitment"
140
170
  && !["retired", "superseded"].includes(status)
141
171
  && (systemIds || []).some((id) => selectedSystemIds.has(id))
172
+ && (!scopedResource
173
+ || record.type === "commitment" && id === record.id
174
+ || record.type === "control" && (controlIds || []).includes(record.id)
175
+ || record.type === "complementary-control" && (controlIds || []).some((controlId) => selectedControlIds.has(controlId)))
142
176
  ))
143
177
  .sort(compareRecordIds)
144
178
  .map((commitment) => pick(commitment, [
@@ -158,7 +192,7 @@ function compareRecordIds(left, right) {
158
192
  return left.id.localeCompare(right.id);
159
193
  }
160
194
 
161
- export function applicabilityReviewIsCurrent(review, record, program, resources, model) {
195
+ export function applicabilityReviewIsCurrent(review, record, program, resources, model, root = null) {
162
196
  if (
163
197
  review?.scopeRevision
164
198
  && !review.scopeRevision.startsWith("scope:")
@@ -168,11 +202,76 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
168
202
  if (!review?.scopeRevision) return false;
169
203
  const matches = (revision) => revisionsMatch("applicability-scope", review.scopeRevision, revision);
170
204
  if (matches(applicabilityScopeRevision(record, program, resources, model))) return true;
171
- // Reviews written before the scope fix included Policy lifecycle fields. Accept
172
- // that earlier fingerprint only when its material inputs still match.
173
- if (matches(applicabilityScopeRevision(record, program, resources, model, { legacyPolicyStatus: "current" }))) return true;
174
- return resources.some(({ type, status }) => type === "policy" && ["in-review", "active"].includes(status))
175
- && matches(applicabilityScopeRevision(record, program, resources, model, { legacyPolicyStatus: "approved" }));
205
+ if (["commitment", "control", "complementary-control"].includes(record?.type) && matches(applicabilityScopeRevision(
206
+ record, program, resources, model, { legacyBroadScope: true }
207
+ ))) return true;
208
+ if (["commitment", "control", "complementary-control"].includes(record?.type) && root && historicalScopedReviewMatches(
209
+ review.scopeRevision, record, program, resources, model, root
210
+ )) return true;
211
+ // Older reviews hashed Policy approval metadata alongside scope facts.
212
+ // Reconstruct each Policy independently because other Policies may already
213
+ // have been approved when one Policy changes. Material fields stay current.
214
+ for (const legacyPolicyStatus of ["current", "draft", "in-review", "approved", "active"]) {
215
+ for (const legacyPolicyWithoutApprovalBinding of [false, true]) {
216
+ for (const legacyPolicyWithoutEffectiveOn of [false, true]) {
217
+ if (matches(applicabilityScopeRevision(record, program, resources, model, {
218
+ legacyPolicyStatus,
219
+ legacyPolicyWithoutApprovalBinding,
220
+ legacyPolicyWithoutEffectiveOn
221
+ }))) return true;
222
+ }
223
+ }
224
+ }
225
+ const selectedControlIds = new Set(program?.controlIds || []);
226
+ const selectedPolicyIds = new Set(resources
227
+ .filter(({ type, id }) => type === "control" && selectedControlIds.has(id))
228
+ .flatMap(({ policyIds }) => policyIds || []));
229
+ for (const id of record.policyIds || []) selectedPolicyIds.add(id);
230
+ const policies = resources.filter(({ type, id }) => type === "policy" && selectedPolicyIds.has(id));
231
+ // One Policy may advance while the others retain different approval states.
232
+ // Reconstruct that Policy's former lifecycle fields without changing any
233
+ // material scope fact or searching combinations of unrelated Policies.
234
+ for (const policy of policies) {
235
+ for (const value of legacyPolicyAlternatives(policy)) {
236
+ if (matches(applicabilityScopeRevision(record, program, resources, model, {
237
+ legacyPolicyOverrides: { [policy.id]: value }
238
+ }))) return true;
239
+ }
240
+ }
241
+ return false;
242
+ }
243
+
244
+ function historicalScopedReviewMatches(stored, record, program, resources, model, root) {
245
+ const current = applicabilityScopeRevision(record, program, resources, model);
246
+ for (const commit of getDataCommitHistory(root).reverse()) {
247
+ const snapshot = historicalWorkspace(root, commit);
248
+ if (!snapshot) continue;
249
+ const historicalResources = snapshot.resources;
250
+ const historicalRecord = historicalResources.find(({ id }) => id === record.id);
251
+ const historicalProgram = historicalResources.find(({ id }) => id === program?.id);
252
+ if (!historicalRecord || !historicalProgram) continue;
253
+ const historicalModel = snapshot.model;
254
+ if (!revisionsMatch("applicability-scope", current, applicabilityScopeRevision(
255
+ historicalRecord, historicalProgram, historicalResources, historicalModel
256
+ ))) continue;
257
+ if (revisionsMatch("applicability-scope", stored, applicabilityScopeRevision(
258
+ historicalRecord, historicalProgram, historicalResources, historicalModel, { legacyBroadScope: true }
259
+ ))) return true;
260
+ }
261
+ return false;
262
+ }
263
+
264
+ function legacyPolicyAlternatives(policy) {
265
+ const prior = {
266
+ active: ["approved", "in-review", "draft"],
267
+ approved: ["in-review", "draft"],
268
+ "in-review": ["draft"]
269
+ }[policy.status] || [];
270
+ return prior.map((status) => ({
271
+ status,
272
+ withoutApprovalBinding: status === "draft" || status === "in-review",
273
+ withoutEffectiveOn: true
274
+ }));
176
275
  }
177
276
 
178
277
  function pick(record, fields, model, objectType = null) {
@@ -373,7 +373,7 @@ function scopeStage(loaded, audit, records, byId, programReadiness) {
373
373
  const v4Decisions = new Map((program?.requirementApplicability || [])
374
374
  .filter((decision) => (
375
375
  requirementById.has(decision.requirementId)
376
- && applicabilityReviewIsCurrent(decision, requirementById.get(decision.requirementId), program, records, loaded.model)
376
+ && applicabilityReviewIsCurrent(decision, requirementById.get(decision.requirementId), program, records, loaded.model, loaded.root)
377
377
  ))
378
378
  .map((decision) => [decision.requirementId, decision]));
379
379
  const unresolvedRequirements = frameworkRequirementIds
@@ -37,7 +37,7 @@ export async function scaffoldApplicabilityReview(input = process.cwd(), options
37
37
  .filter((review) => (
38
38
  ["applicable", "not-applicable"].includes(review.decision)
39
39
  && requirementById.has(review.requirementId)
40
- && applicabilityReviewIsCurrent(review, requirementById.get(review.requirementId), program, loaded.resources, loaded.model)
40
+ && applicabilityReviewIsCurrent(review, requirementById.get(review.requirementId), program, loaded.resources, loaded.model, loaded.root)
41
41
  ))
42
42
  .map(({ requirementId }) => requirementId));
43
43
  const requirementReviews = new Map((program.requirementApplicability || [])
@@ -47,7 +47,7 @@ export async function scaffoldApplicabilityReview(input = process.cwd(), options
47
47
  && (!requestedType || record.type === requestedType)
48
48
  && (record.type === "requirement" && modelSupports(loaded.model, "program-scope")
49
49
  ? !reviewedRequirementIds.has(record.id)
50
- : !applicabilityReviewIsCurrent(record.applicabilityReview, record, program, loaded.resources, loaded.model))
50
+ : !applicabilityReviewIsCurrent(record.applicabilityReview, record, program, loaded.resources, loaded.model, loaded.root))
51
51
  && !["retired", "superseded"].includes(record.status)
52
52
  ));
53
53
  return {
@@ -7,6 +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
12
  import { resolveProgram } from "./program.js";
11
13
  import { markdownEntries } from "./resource-markdown.js";
12
14
  import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionsMatch } from "./revisions.js";
@@ -39,6 +41,35 @@ export function collectionRevisionMatches(loaded, resourceType, storedRevision,
39
41
  }
40
42
  }
41
43
  }
44
+ return historicallyEquivalentCollection(loaded, resourceType, storedRevision, options, currentRevision);
45
+ }
46
+
47
+ function historicallyEquivalentCollection(loaded, resourceType, stored, options, current) {
48
+ if (!loaded.root) return false;
49
+ for (const commit of getDataCommitHistory(loaded.root).reverse()) {
50
+ const snapshot = historicalWorkspace(loaded.root, commit);
51
+ if (!snapshot) continue;
52
+ const historicalOptions = {
53
+ ...options,
54
+ programId: options.programId || options.program?.id,
55
+ historicalCommit: commit
56
+ };
57
+ delete historicalOptions.program;
58
+ delete historicalOptions.currentRevision;
59
+ // Require the complete reviewed basis to be unchanged under the current
60
+ // semantic calculation before accepting an older byte-sensitive digest.
61
+ if (!revisionsMatch("collection", current, collectionRevision(snapshot, resourceType, historicalOptions))) continue;
62
+ for (const legacy of [false, true]) {
63
+ for (const scopeHashInput of ["legacy", "digest"]) {
64
+ for (const scopeFactsInput of ["legacy", "source"]) {
65
+ if (revisionsMatch("collection", stored, calculateCollectionRevision(
66
+ snapshot, resourceType, { ...historicalOptions, historicalReviewMetadata: true },
67
+ legacy, scopeHashInput, scopeFactsInput
68
+ ))) return true;
69
+ }
70
+ }
71
+ }
72
+ }
42
73
  return false;
43
74
  }
44
75
 
@@ -51,7 +82,7 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy, scop
51
82
  : Object.hasOwn(options, "program")
52
83
  ? options.program
53
84
  : resolveProgram(loaded, options.programId);
54
- const inputs = new Map(collectionRevisionInputs(loaded, resourceType, program, { legacy })
85
+ const inputs = new Map(collectionRevisionInputs(loaded, resourceType, program, { legacy, historicalReviewMetadata: options.historicalReviewMetadata })
55
86
  .map((input) => [input.record.id, input]));
56
87
  const authoritativeSource = loaded.resources.find(({ id }) => id === options.authoritativeSourceId);
57
88
  if (authoritativeSource) {
@@ -71,7 +102,10 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy, scop
71
102
  .digest("hex"),
72
103
  contentRevisions: (legacy || includeContent ? markdownEntries(loaded.model, record) : []).flatMap(({ path }) => {
73
104
  try {
74
- const content = readFileSync(resolveDataPath(loaded.root, path), "utf8");
105
+ const content = options.historicalCommit
106
+ ? getFileAtRevision(loaded.root, options.historicalCommit, `data/${path}`)
107
+ : readFileSync(resolveDataPath(loaded.root, path), "utf8");
108
+ if (content === null) return [];
75
109
  return [{ path, revision: createHash("sha256").update(content).digest("hex") }];
76
110
  } catch (error) {
77
111
  if (error.code === "ENOENT") return [];
@@ -80,7 +114,9 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy, scop
80
114
  })
81
115
  }))
82
116
  .sort((left, right) => left.id.localeCompare(right.id));
83
- const scopeFacts = collectionScopeRevisionFacts(loaded, resourceType, program);
117
+ const scopeFacts = collectionScopeRevisionFacts(loaded, resourceType, program, {
118
+ historicalReviewMetadata: options.historicalReviewMetadata
119
+ });
84
120
  const workspaceScope = scopeFactsInput === "source" ? scopeFacts : canonicalScopeFacts(scopeFacts);
85
121
  const source = JSON.stringify({ resourceType, records, workspaceScope });
86
122
  return legacy
@@ -360,23 +360,26 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
360
360
  record,
361
361
  value: reviewedIds.has(record.id)
362
362
  ? resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
363
- ? retentionScheduleRowProposalValue(record)
364
- : record
363
+ ? retentionScheduleRowProposalValue(record, options.historicalReviewMetadata)
364
+ : options.historicalReviewMetadata ? record : substantiveRevisionValue(record)
365
365
  : resourceType === "retention-schedule-item"
366
366
  && modelSupports(loaded.model, "retention-schedule-approval")
367
367
  && record.type === "document"
368
368
  ? retentionScheduleDocumentProposalValue(record)
369
- : dependencyRevisionValue(resourceType, record, legacy),
369
+ : options.historicalReviewMetadata
370
+ ? dependencyRevisionValue(resourceType, record, legacy)
371
+ : substantiveRevisionValue(dependencyRevisionValue(resourceType, record, legacy)),
370
372
  includeContent: legacy
371
373
  || reviewedIds.has(record.id)
372
374
  || dependencyContentAffectsRevision(resourceType, record.type)
373
375
  }));
374
376
  }
375
377
 
376
- function retentionScheduleRowProposalValue(record) {
378
+ function retentionScheduleRowProposalValue(record, historicalReviewMetadata = false) {
377
379
  return Object.fromEntries(Object.entries(record).filter(([field]) => ![
378
380
  "approvedByIds",
379
- "approvedOn"
381
+ "approvedOn",
382
+ ...(historicalReviewMetadata ? [] : ["reviewedSourceRevisions"])
380
383
  ].includes(field)));
381
384
  }
382
385
 
@@ -396,7 +399,7 @@ function retentionScheduleDocumentProposalValue(record) {
396
399
  ].includes(field)));
397
400
  }
398
401
 
399
- export function collectionScopeRevisionFacts(loaded, resourceType, program) {
402
+ export function collectionScopeRevisionFacts(loaded, resourceType, program, options = {}) {
400
403
  if (!modelSupports(loaded.model, "program-scope")) {
401
404
  const common = { programId: program?.id ?? null };
402
405
  if (resourceType === "framework") {
@@ -426,7 +429,7 @@ export function collectionScopeRevisionFacts(loaded, resourceType, program) {
426
429
  programs: programs.map((record) => ({
427
430
  id: record.id,
428
431
  systemIds: sorted(record.systemIds),
429
- controlIds: sorted(record.controlIds)
432
+ ...(options.historicalReviewMetadata ? { controlIds: sorted(record.controlIds) } : {})
430
433
  }))
431
434
  };
432
435
  }
@@ -454,7 +457,11 @@ export function collectionScopeRevisionFacts(loaded, resourceType, program) {
454
457
  assuranceGoal: program?.assuranceGoal ?? null,
455
458
  frameworkIds: sorted(program?.frameworkIds),
456
459
  requirementIds: sorted(selectedRequirementIds(program || {}, loaded.model)),
457
- requirementApplicability: program?.requirementApplicability || [],
460
+ requirementApplicability: options.historicalReviewMetadata
461
+ ? program?.requirementApplicability || []
462
+ : (program?.requirementApplicability || [])
463
+ .map(({ requirementId, decision }) => ({ requirementId, decision }))
464
+ .sort((left, right) => left.requirementId.localeCompare(right.requirementId)),
458
465
  riskMethodology: program?.riskMethodology || null
459
466
  } : scope;
460
467
  }
@@ -462,9 +469,33 @@ export function collectionScopeRevisionFacts(loaded, resourceType, program) {
462
469
  }
463
470
 
464
471
  export function authoritativeSourceRevisionValue(record) {
465
- return Object.fromEntries(Object.entries(record).filter(([field]) => (
472
+ return substantiveRevisionValue(Object.fromEntries(Object.entries(record).filter(([field]) => (
466
473
  !authoritativeSourceBookkeepingFields.has(field)
467
- )));
474
+ ))));
475
+ }
476
+
477
+ function substantiveRevisionValue(record) {
478
+ if (!record) return record;
479
+ const value = { ...record };
480
+ if (value.type === "retention-schedule-item") {
481
+ delete value.approvedByIds;
482
+ delete value.approvedOn;
483
+ delete value.reviewedSourceRevisions;
484
+ }
485
+ if (value.type === "program" && value.requirementApplicability) {
486
+ value.requirementApplicability = value.requirementApplicability
487
+ .map(({ requirementId, decision }) => ({ requirementId, decision }))
488
+ .sort((left, right) => left.requirementId.localeCompare(right.requirementId));
489
+ }
490
+ if (value.applicabilityReview) value.applicabilityReview = { decision: value.applicabilityReview.decision };
491
+ if (["policy", "document", "training"].includes(value.type)) {
492
+ for (const field of [
493
+ "approverIds", "approvedOn", "activationBasis", "activatedByIds",
494
+ "activatedOn", "activatedContentRevisions", "statusTransition"
495
+ ]) delete value[field];
496
+ if (["approved", "active"].includes(value.status)) value.status = "approved";
497
+ }
498
+ return value;
468
499
  }
469
500
 
470
501
  const authoritativeSourceBookkeepingFields = new Set([
@@ -0,0 +1,33 @@
1
+ import { loadModel } from "../model/index.js";
2
+ import { getDataFilesAtRevision, getFileAtRevision } from "./git.js";
3
+
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) {
8
+ const entries = [];
9
+ for (const path of getDataFilesAtRevision(root, commit).filter(authoritativeJsonPath)) {
10
+ const source = getFileAtRevision(root, commit, path);
11
+ if (!source) return null;
12
+ try {
13
+ entries.push({ record: JSON.parse(source), source, relativePath: path.slice("data/".length) });
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+ const resources = entries.map(({ record }) => record);
19
+ const workspace = resources.find(({ type }) => type === "workspace");
20
+ if (!workspace) return null;
21
+ try {
22
+ return { root, entries, resources, workspace, model: loadModel(workspace.dataModelVersion) };
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function authoritativeJsonPath(path) {
29
+ const parts = path.split("/");
30
+ if (parts[0] !== "data" || !path.endsWith(".json")) return false;
31
+ if (parts.slice(1).some((part) => part.startsWith(".") || part === "content")) return false;
32
+ return !(parts[1] === "evidence" && parts.length > 3 && parts.at(-1) !== "evidence.json");
33
+ }
@@ -155,7 +155,7 @@ export async function assessProgramReadiness(input, options = {}) {
155
155
  };
156
156
 
157
157
  const policyStage = await policiesStage(scope, records, byId, readMarkdown, loaded.model);
158
- const controlStage = await controlsStage(scope, byId, readMarkdown, asOf, loaded.model);
158
+ const controlStage = await controlsStage(scope, byId, readMarkdown, asOf, loaded.model, loaded.root);
159
159
  controlStage.items.unshift(...collectionReviews
160
160
  .filter(({ resourceType }) => resourceType === "complementary-control")
161
161
  .map(collectionReviewReadinessItem));
@@ -218,7 +218,8 @@ export async function assessProgramReadiness(input, options = {}) {
218
218
  byId,
219
219
  loaded.model,
220
220
  collectionReviews.filter(({ resourceType }) => ["person", "framework", "system", "component", "vendor", "classification", "information-type"].includes(resourceType)),
221
- workspace?.timezone || "UTC"
221
+ workspace?.timezone || "UTC",
222
+ loaded.root
222
223
  );
223
224
  if (modelSupports(loaded.model, "reporting-route-sets")) {
224
225
  const routeSets = await assessReportingRouteSets(loaded, {
@@ -373,7 +374,7 @@ function programScope(program, records, byId, model, loaded) {
373
374
  };
374
375
  }
375
376
 
376
- function scopeStage(workspace, scope, records, byId, model, collectionReviews = [], timezone = "UTC") {
377
+ function scopeStage(workspace, scope, records, byId, model, collectionReviews = [], timezone = "UTC", root = null) {
377
378
  const items = [];
378
379
  const goal = workspace?.assuranceGoal || "none";
379
380
  items.push(item(
@@ -459,7 +460,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
459
460
  && record.effectiveOn
460
461
  && (!model.resources.commitment?.fields?.applicabilityReview || (
461
462
  record.applicabilityReview?.decision === "applicable"
462
- && applicabilityReviewIsCurrent(record.applicabilityReview, record, workspace, records, model)
463
+ && applicabilityReviewIsCurrent(record.applicabilityReview, record, workspace, records, model, root)
463
464
  ))
464
465
  && currentPartyPeople(record.ownerIds, byId).size > 0
465
466
  && (record.requirementIds || []).length > 0
@@ -496,7 +497,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
496
497
  const reviewedRequirements = (workspace?.requirementApplicability || [])
497
498
  .filter((decision) => (
498
499
  requirementById.has(decision.requirementId)
499
- && applicabilityReviewIsCurrent(decision, requirementById.get(decision.requirementId), workspace, records, model)
500
+ && applicabilityReviewIsCurrent(decision, requirementById.get(decision.requirementId), workspace, records, model, root)
500
501
  ));
501
502
  const v4Decisions = new Map(reviewedRequirements.map((decision) => [decision.requirementId, decision.decision]));
502
503
  const staleRequirementIds = new Set((workspace?.requirementApplicability || [])
@@ -1617,7 +1618,7 @@ function legacyPolicyLibraryProposals(records) {
1617
1618
  }];
1618
1619
  }
1619
1620
 
1620
- async function controlsStage(scope, byId, readMarkdown, asOf, model) {
1621
+ async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null) {
1621
1622
  const items = [];
1622
1623
  const families = selectedControlFamilies(scope.controls, model);
1623
1624
  if (!scope.controls.length) {
@@ -1651,7 +1652,7 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
1651
1652
  const checks = {
1652
1653
  ...(model.resources.control?.fields?.applicabilityReview ? {
1653
1654
  applicability: control.applicabilityReview?.decision === "applicable"
1654
- && applicabilityReviewIsCurrent(control.applicabilityReview, control, scope.program, [...byId.values()], model)
1655
+ && applicabilityReviewIsCurrent(control.applicabilityReview, control, scope.program, [...byId.values()], model, root)
1655
1656
  } : {}),
1656
1657
  implemented: control.status === "implemented",
1657
1658
  owner: (control.ownerIds || []).length > 0,
package/src/retention.js CHANGED
@@ -6,6 +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
11
 
10
12
  export async function assessRetentionReadiness(loaded, program, options = {}) {
11
13
  if (!loaded.model.resources["retention-schedule-item"]) return [];
@@ -187,7 +189,7 @@ export async function resourceReviewRevisions(loaded, ids, scopeHashInput = "leg
187
189
  return new Map([...revisions].filter(([id]) => wanted.has(id)));
188
190
  }
189
191
 
190
- export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legacy") {
192
+ export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legacy", legacySource = false, historicalCommit = null) {
191
193
  const wanted = new Set(ids);
192
194
  const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
193
195
  const revisions = new Map();
@@ -197,10 +199,13 @@ export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legac
197
199
  const entry = entries.get(id);
198
200
  if (!entry || reviewing.has(id)) return null;
199
201
  reviewing.add(id);
200
- const parts = [reviewSource(loaded, entry, scopeHashInput)];
202
+ const parts = [reviewSource(loaded, entry, scopeHashInput, legacySource)];
201
203
  for (const markdown of markdownEntries(loaded.model, entry.record)) {
202
204
  try {
203
- parts.push(readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8"));
205
+ const source = historicalCommit
206
+ ? getFileAtRevision(loaded.root, historicalCommit, `data/${markdown.path}`)
207
+ : readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8");
208
+ if (source !== null) parts.push(source);
204
209
  } catch (error) {
205
210
  if (error.code !== "ENOENT") throw error;
206
211
  }
@@ -222,10 +227,31 @@ export function resourceReviewRevisionMatches(loaded, revisions, id, stored) {
222
227
  const current = revisions.get(id);
223
228
  if (!current || !revisionDigest("content", stored)) return false;
224
229
  if (revisionsMatch("content", stored, current)) return true;
230
+ const legacy = resourceReviewRevisionsSync(loaded, [id], "legacy", true).get(id);
231
+ if (revisionsMatch("content", stored, legacy)) return true;
225
232
  // Version 0.16.0 used a bare digest as the hash input for scopeRevision.
226
233
  // Read that binding without changing the legacy scope: basis restored here.
227
- const compatible = resourceReviewRevisionsSync(loaded, [id], "digest").get(id);
228
- return revisionsMatch("content", stored, compatible);
234
+ const compatible = resourceReviewRevisionsSync(loaded, [id], "digest", true).get(id);
235
+ if (revisionsMatch("content", stored, compatible)) return true;
236
+ return historicallyEquivalentReviewSource(loaded, id, stored, current);
237
+ }
238
+
239
+ // Recompute the old byte-sensitive binding at each committed state. The
240
+ // semantic source graph must still equal today's graph, including linked
241
+ // records and Markdown, before the old approval is treated as current.
242
+ function historicallyEquivalentReviewSource(loaded, id, stored, current) {
243
+ if (!loaded.root) return false;
244
+ for (const commit of getDataCommitHistory(loaded.root).reverse()) {
245
+ const snapshot = historicalWorkspace(loaded.root, commit);
246
+ if (!snapshot?.entries.some(({ record }) => record.id === id)) continue;
247
+ const semantic = resourceReviewRevisionsSync(snapshot, [id], "legacy", false, commit).get(id);
248
+ if (!revisionsMatch("content", semantic, current)) continue;
249
+ for (const scopeHashInput of ["legacy", "digest"]) {
250
+ const legacy = resourceReviewRevisionsSync(snapshot, [id], scopeHashInput, true, commit).get(id);
251
+ if (revisionsMatch("content", stored, legacy)) return true;
252
+ }
253
+ }
254
+ return false;
229
255
  }
230
256
 
231
257
  export function retentionUses(loaded, program) {
@@ -249,7 +275,7 @@ export function retentionUses(loaded, program) {
249
275
  return [...new Map(uses.map((use) => [`${use.resource.id}:${use.informationTypeId}`, use])).values()];
250
276
  }
251
277
 
252
- function reviewSource(loaded, entry, scopeHashInput = "legacy") {
278
+ function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = false) {
253
279
  if (
254
280
  modelSupports(loaded.model, "retention-schedule-approval")
255
281
  && entry.record.type === "document"
@@ -271,7 +297,34 @@ function reviewSource(loaded, entry, scopeHashInput = "legacy") {
271
297
  ]) delete approved[field];
272
298
  return JSON.stringify(approved);
273
299
  }
274
- return canonicalCalculatedRevisionJson(entry.source, scopeHashInput);
300
+ return legacySource
301
+ ? canonicalCalculatedRevisionJson(entry.source, scopeHashInput)
302
+ : canonicalCalculatedRevisionJson(JSON.stringify(substantiveReviewSource(JSON.parse(entry.source))), scopeHashInput);
303
+ }
304
+
305
+ function substantiveReviewSource(record) {
306
+ const source = record.type === "program" ? {
307
+ id: record.id,
308
+ type: record.type,
309
+ status: record.status,
310
+ assuranceGoal: record.assuranceGoal,
311
+ systemIds: [...(record.systemIds || [])].sort(),
312
+ extensions: record.extensions,
313
+ requirementApplicability: (record.requirementApplicability || [])
314
+ .map(({ requirementId, decision }) => ({ requirementId, decision }))
315
+ .sort((left, right) => left.requirementId.localeCompare(right.requirementId))
316
+ } : { ...record };
317
+ if (source.applicabilityReview) {
318
+ source.applicabilityReview = { decision: source.applicabilityReview.decision };
319
+ }
320
+ if (["policy", "document", "training"].includes(source.type)) {
321
+ for (const field of [
322
+ "approverIds", "approvedOn", "activationBasis", "activatedByIds",
323
+ "activatedOn", "activatedContentRevisions", "statusTransition"
324
+ ]) delete source[field];
325
+ if (["approved", "active"].includes(source.status)) source.status = "approved";
326
+ }
327
+ return source;
275
328
  }
276
329
 
277
330
  export function nearDuplicateInformationTypes(records) {
package/src/state.js CHANGED
@@ -351,7 +351,7 @@ function assessApplicabilityReviewStatuses(loaded, program) {
351
351
  : record.applicabilityReview;
352
352
  return [record.id, !review || !["applicable", "not-applicable", "externally-managed", "zero-population"].includes(review.decision)
353
353
  ? "missing" : applicabilityReviewIsCurrent(
354
- review, record, program, loaded.resources, loaded.model
354
+ review, record, program, loaded.resources, loaded.model, loaded.root
355
355
  ) ? "current" : "stale"];
356
356
  }));
357
357
  }
package/src/workflow.js CHANGED
@@ -397,7 +397,7 @@ function recordFinalizationFindings(loaded, program) {
397
397
  if (incomplete) {
398
398
  findings.push(finalizationFinding(record, incomplete, loaded.model));
399
399
  }
400
- for (const missing of finalizationFields(record, loaded.model, loaded.resources, program)) {
400
+ for (const missing of finalizationFields(record, loaded.model, loaded.resources, program, loaded.root)) {
401
401
  findings.push(fieldFinding(record, missing, loaded.model));
402
402
  }
403
403
  }
@@ -532,11 +532,11 @@ function recordFinalizationRequiredness(record, loaded, program) {
532
532
  return "conditional";
533
533
  }
534
534
 
535
- function finalizationFields(record, model, resources, program) {
535
+ function finalizationFields(record, model, resources, program, root) {
536
536
  const fields = [];
537
537
  const needsReview = ["requirement", "commitment", "complementary-control", "control"].includes(record.type)
538
538
  && model.resources[record.type]?.fields?.applicabilityReview
539
- && !applicabilityReviewIsCurrent(record.applicabilityReview, record, program, resources, model);
539
+ && !applicabilityReviewIsCurrent(record.applicabilityReview, record, program, resources, model, root);
540
540
  if (needsReview) {
541
541
  const currentRevision = applicabilityScopeRevision(record, program, resources, model);
542
542
  fields.push({