filegrc 0.15.4 → 0.16.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.15.4",
3
+ "version": "0.16.0",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,4 +1,4 @@
1
- import { createHash } from "node:crypto";
1
+ import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionsMatch } from "./revisions.js";
2
2
 
3
3
  const excludedResourceFields = new Set([
4
4
  "applicabilityReview",
@@ -151,7 +151,7 @@ export function applicabilityScopeRevision(record, program, resources, model) {
151
151
  "customerFacing"
152
152
  ], model))
153
153
  };
154
- return `scope:${createHash("sha256").update(stableJson(facts)).digest("hex")}`;
154
+ return calculateRevision("applicability-scope", stableJson(facts));
155
155
  }
156
156
 
157
157
  function compareRecordIds(left, right) {
@@ -162,11 +162,16 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
162
162
  if (
163
163
  review?.scopeRevision
164
164
  && !review.scopeRevision.startsWith("scope:")
165
+ && !review.scopeRevision.startsWith("filegrc:applicability-scope:")
165
166
  && Number(model?.modelVersion || 0) < 7
166
167
  ) return true;
167
168
  return Boolean(
168
169
  review?.scopeRevision
169
- && review.scopeRevision === applicabilityScopeRevision(record, program, resources, model)
170
+ && revisionsMatch(
171
+ "applicability-scope",
172
+ review.scopeRevision,
173
+ applicabilityScopeRevision(record, program, resources, model)
174
+ )
170
175
  );
171
176
  }
172
177
 
@@ -176,17 +181,17 @@ function pick(record, fields, model, objectType = null) {
176
181
  .map((field) => [field, record[field]])));
177
182
  }
178
183
 
179
- function canonicalObject(model, type, value) {
184
+ function canonicalObject(model, type, value, revisionMap = false) {
180
185
  const fields = model.resources?.[type]
181
186
  ? { ...model.commonFields, ...model.resources[type].fields }
182
187
  : model.objectTypes?.[type]?.properties || {};
183
188
  return Object.fromEntries(Object.keys(value).sort().map((name) => [
184
189
  name,
185
- canonicalFieldValue(model, value[name], fields[name], name)
190
+ canonicalFieldValue(model, value[name], fields[name], name, revisionMap)
186
191
  ]));
187
192
  }
188
193
 
189
- function canonicalFieldValue(model, value, field, name) {
194
+ function canonicalFieldValue(model, value, field, name, revisionMap = false) {
190
195
  if (Array.isArray(value)) {
191
196
  const items = value.map((item) => field?.itemObjectType && item && typeof item === "object"
192
197
  ? canonicalObject(model, field.itemObjectType, item)
@@ -197,9 +202,13 @@ function canonicalFieldValue(model, value, field, name) {
197
202
  return items;
198
203
  }
199
204
  if (value && typeof value === "object") {
200
- return field?.objectType ? canonicalObject(model, field.objectType, value) : value;
205
+ return field?.objectType
206
+ ? canonicalObject(model, field.objectType, value, CALCULATED_REVISION_MAP_FIELDS.has(name))
207
+ : value;
201
208
  }
202
- return value;
209
+ return typeof value === "string" && (revisionMap || CALCULATED_REVISION_FIELDS.has(name))
210
+ ? canonicalCalculatedRevision(value)
211
+ : value;
203
212
  }
204
213
 
205
214
  function stableJson(value) {
@@ -1,4 +1,3 @@
1
- import { createHash } from "node:crypto";
2
1
  import { readFile } from "node:fs/promises";
3
2
  import { modelSupports } from "../model/index.js";
4
3
  import { applicabilityReviewIsCurrent } from "./applicability-scope.js";
@@ -33,6 +32,7 @@ import {
33
32
  } from "./soc2.js";
34
33
  import { currentCalendarDate } from "./time.js";
35
34
  import { loadWorkspace } from "./workspace.js";
35
+ import { calculateRevision } from "./revisions.js";
36
36
 
37
37
  const NON_EVIDENCE_RECORD_TYPES = new Set([
38
38
  "audit",
@@ -160,7 +160,7 @@ export async function prepareAuditWorkspace(input, options = {}) {
160
160
 
161
161
  const model = loaded.model.auditReadiness || {};
162
162
  const auditEntry = loaded.entries.find((entry) => entry.record.id === audit.id);
163
- const auditRevision = createHash("sha256").update(auditEntry.source).digest("hex");
163
+ const auditRevision = calculateRevision("content", auditEntry.source);
164
164
  const documents = loaded.resources.filter((record) => record.type === "document");
165
165
  const nextAudit = { ...audit };
166
166
  const linkedDocuments = [];
package/src/cli.js CHANGED
@@ -75,6 +75,7 @@ import { searchResources } from "./search.js";
75
75
  import { serveWorkspace } from "./server.js";
76
76
  import { planWorkspaceSetup, setupWorkspace, summarizeSetupResult } from "./setup.js";
77
77
  import { printGithubStarMessage } from "./startup.js";
78
+ import { assertWorkspaceEngineVersion } from "./runtime-version.js";
78
79
  import { createAppState } from "./state.js";
79
80
  import { currentCalendarDate } from "./time.js";
80
81
  import { validateWorkspace } from "./validate.js";
@@ -116,6 +117,10 @@ export async function runCli(argv = process.argv.slice(2)) {
116
117
  if (["help", "--help", "-h"].includes(command)) return printHelp();
117
118
  if (["version", "--version", "-v"].includes(command)) return printVersion();
118
119
  if (flags.help || args.includes("-h")) return printCommandHelp(command);
120
+ const targetRoot = ["serve", "build", "validate"].includes(command)
121
+ ? positionals[0] ?? root
122
+ : root;
123
+ assertWorkspaceEngineVersion(targetRoot);
119
124
 
120
125
  if (command === "serve") {
121
126
  const result = await serveWorkspace(positionals[0] ?? root, {
@@ -1,9 +1,9 @@
1
- import { createHash } from "node:crypto";
2
1
  import { loadModel } from "../model/index.js";
3
2
  import { collectionRevision } from "./collection-revision.js";
4
3
  import { scopedCollectionRecords } from "./collection-scope.js";
5
4
  import { getDataFilesAtRevision, getFileAtRevision, getRecordIdentityHistory, hasGitRevision } from "./git.js";
6
5
  import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
6
+ import { calculateRevision, canonicalCalculatedRevision, revisionsMatch } from "./revisions.js";
7
7
 
8
8
  export function collectionReviewRevision(record) {
9
9
  const reviewedFacts = {
@@ -24,7 +24,17 @@ export function collectionReviewRevision(record) {
24
24
  authoritativeComponentId: record.authoritativeComponentId,
25
25
  supersedesId: record.supersedesId
26
26
  };
27
- return createHash("sha256").update(JSON.stringify(reviewedFacts)).digest("hex");
27
+ return calculateRevision("collection-review", JSON.stringify(canonicalRevisionValue(reviewedFacts)));
28
+ }
29
+
30
+ function canonicalRevisionValue(value, name = null) {
31
+ if (Array.isArray(value)) return value.map((item) => canonicalRevisionValue(item));
32
+ if (value && typeof value === "object") {
33
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, canonicalRevisionValue(item, key)]));
34
+ }
35
+ return name === "collectionRevision" && typeof value === "string"
36
+ ? canonicalCalculatedRevision(value)
37
+ : value;
28
38
  }
29
39
 
30
40
  export function historicalCollectionReviewSnapshot(root, record, model, timezone, resourceType, cutoff, selector = null, relativePath = null, expectedCommit = null) {
@@ -60,7 +70,7 @@ export function historicalCollectionReviewSnapshot(root, record, model, timezone
60
70
  authoritativeSourceId: record.authoritativeComponentId
61
71
  });
62
72
  if (
63
- record.collectionRevision !== currentRevision
73
+ !revisionsMatch("collection", record.collectionRevision, currentRevision)
64
74
  || JSON.stringify([...(record.populationResourceIds || [])].sort()) !== JSON.stringify(collectionIds)
65
75
  ) return null;
66
76
  const selectedIds = selector
@@ -89,7 +99,7 @@ function committedCollectionReviewMatch(root, record, relativePath, expectedComm
89
99
  return historical.type === "collection-review"
90
100
  && historical.id === record.id
91
101
  && ["active", "retired"].includes(historical.status)
92
- && collectionReviewRevision(historical) === expected;
102
+ && revisionsMatch("collection-review", collectionReviewRevision(historical), expected);
93
103
  } catch {
94
104
  return false;
95
105
  }
@@ -1,4 +1,3 @@
1
- import { createHash } from "node:crypto";
2
1
  import { readFile } from "node:fs/promises";
3
2
  import { modelSupports } from "../model/index.js";
4
3
  import {
@@ -16,6 +15,8 @@ import { currentPartyPeople } from "./parties.js";
16
15
  import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js";
17
16
  import { markdownEntries } from "./resource-markdown.js";
18
17
  import { resolveDataPath } from "./paths.js";
18
+ import { calculateRevision, calculatedRevisionDiagnostic, revisionsMatch } from "./revisions.js";
19
+ import { personWasActiveOn } from "./soc2.js";
19
20
 
20
21
  export { collectionRevision };
21
22
 
@@ -93,7 +94,10 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
93
94
  const incompleteRecordProposals = recordProposals.filter(({ complete: ready }) => !ready);
94
95
  const reviewersEligible = !reviewerEligibility || Boolean(
95
96
  review?.reviewedByIds?.length
96
- && review.reviewedByIds.every((id) => reviewerEligibility.eligibleReviewerIds.includes(id))
97
+ && review.reviewedByIds.every((id) => (
98
+ personWasActiveOn(loaded.resources.find((record) => record.id === id), review.reviewedOn)
99
+ && !reviewerEligibility.reviewerConflictIds.includes(id)
100
+ ))
97
101
  );
98
102
  const reviewScopeMatches = !workspaceWideRetentionReview
99
103
  || sameIds(review?.scopeResourceIds, retentionScope.programIds);
@@ -119,10 +123,11 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
119
123
  recordCount: records.length,
120
124
  review,
121
125
  reviewRevision: reviewEntry
122
- ? createHash("sha256").update(reviewEntry.source).digest("hex")
126
+ ? calculateRevision("content", reviewEntry.source)
123
127
  : null,
124
128
  reviewEntries,
125
129
  collectionRevision: currentRevision,
130
+ revisionDiagnostic: calculatedRevisionDiagnostic("collection", review?.collectionRevision, currentRevision),
126
131
  status: complete ? "current" : stale ? "stale" : "review-required",
127
132
  complete,
128
133
  ...(reviewerEligibility || {}),
@@ -139,7 +144,7 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
139
144
  ? "Review the Data Retention Schedule again with a reviewer who does not own its governing document or an included schedule row."
140
145
  : "Review the Control collection again with a reviewer who does not own an included Control or its enabled Obligation."
141
146
  : stale
142
- ? `${configuration.title} changed after the last confirmation. Review the current records again.`
147
+ ? `${configuration.title} changed after the last confirmation. Stored revision: ${review.collectionRevision}. Current revision: ${currentRevision}. Review the current records again.`
143
148
  : !records.length && !allowsEmptyCollection
144
149
  ? `Add at least one ${loaded.model.resources[resourceType].title.toLowerCase()} before confirming this collection.`
145
150
  : `Review ${configuration.title.toLowerCase()} before this page can be ready.`
@@ -203,7 +208,7 @@ export async function planCollectionReview(input = process.cwd(), options = {})
203
208
  && modelSupports(loaded.model, "retention-schedule-approval");
204
209
  const retentionScope = workspaceWideRetentionReview ? retentionScheduleReviewScope(loaded) : null;
205
210
  const assessment = assessCollectionReview(loaded, resourceType, { programId: program.id });
206
- if (options.expectedCollectionRevision && options.expectedCollectionRevision !== assessment.collectionRevision) {
211
+ if (options.expectedCollectionRevision && !revisionsMatch("collection", options.expectedCollectionRevision, assessment.collectionRevision)) {
207
212
  throw new Error(`${assessment.configuration.title} changed after it was displayed. Reload and review the current revision before approving it.`);
208
213
  }
209
214
  if (resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval") && !options.expectedCollectionRevision) {
@@ -337,7 +342,7 @@ export async function planCollectionReview(input = process.cwd(), options = {})
337
342
  entry.record.id,
338
343
  entry.record.id === existing?.id && options.expectedRevision
339
344
  ? options.expectedRevision
340
- : createHash("sha256").update(entry.source).digest("hex")
345
+ : calculateRevision("content", entry.source)
341
346
  ]));
342
347
  if (existingEntries.length && preservesReviewHistory) {
343
348
  for (const entry of existingEntries) {
@@ -456,7 +461,7 @@ function assessControlReviewers(loaded, controls) {
456
461
  const people = loaded.resources.filter(({ type, status }) => type === "person" && status === "active");
457
462
  return {
458
463
  eligibleReviewerIds: people.map(({ id }) => id).filter((id) => !conflictIds.has(id)),
459
- reviewerConflictIds: people.map(({ id }) => id).filter((id) => conflictIds.has(id))
464
+ reviewerConflictIds: [...conflictIds].sort()
460
465
  };
461
466
  }
462
467
 
@@ -475,7 +480,7 @@ function assessRetentionScheduleReviewers(loaded, rows) {
475
480
  const people = loaded.resources.filter(({ type, status }) => type === "person" && status === "active");
476
481
  return {
477
482
  eligibleReviewerIds: people.map(({ id }) => id).filter((id) => !conflictIds.has(id)),
478
- reviewerConflictIds: people.map(({ id }) => id).filter((id) => conflictIds.has(id))
483
+ reviewerConflictIds: [...conflictIds].sort()
479
484
  };
480
485
  }
481
486
 
@@ -9,6 +9,7 @@ import {
9
9
  import { resolveDataPath } from "./paths.js";
10
10
  import { resolveProgram } from "./program.js";
11
11
  import { markdownEntries } from "./resource-markdown.js";
12
+ import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionsMatch } from "./revisions.js";
12
13
 
13
14
  export function collectionRevision(loaded, resourceType, options = {}) {
14
15
  return calculateCollectionRevision(loaded, resourceType, options, false);
@@ -24,9 +25,9 @@ export function collectionRevisionMatches(loaded, resourceType, storedRevision,
24
25
  || collectionRevision(loaded, resourceType, options);
25
26
  // Version 0.9.2 narrowed this hash basis. Keep unchanged 0.9.1 reviews valid
26
27
  // until management records a new review on the current basis.
27
- return storedRevision === currentRevision
28
+ return revisionsMatch("collection", storedRevision, currentRevision)
28
29
  || (resourceType !== "retention-schedule-item" || !modelSupports(loaded.model, "retention-schedule-approval"))
29
- && storedRevision === legacyCollectionRevision(loaded, resourceType, options);
30
+ && revisionsMatch("collection", storedRevision, legacyCollectionRevision(loaded, resourceType, options));
30
31
  }
31
32
 
32
33
  function calculateCollectionRevision(loaded, resourceType, options, legacy) {
@@ -68,9 +69,10 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy) {
68
69
  }))
69
70
  .sort((left, right) => left.id.localeCompare(right.id));
70
71
  const workspaceScope = collectionScopeRevisionFacts(loaded, resourceType, program);
71
- return createHash("sha256")
72
- .update(JSON.stringify({ resourceType, records, workspaceScope }))
73
- .digest("hex");
72
+ const source = JSON.stringify({ resourceType, records, workspaceScope });
73
+ return legacy
74
+ ? createHash("sha256").update(source).digest("hex")
75
+ : calculateRevision("collection", source);
74
76
  }
75
77
 
76
78
  function canonicalRecordValue(model, resourceType, value) {
@@ -81,14 +83,14 @@ function canonicalRecordValue(model, resourceType, value) {
81
83
  return canonicalObject(model, value, fields);
82
84
  }
83
85
 
84
- function canonicalObject(model, value, fields = {}) {
86
+ function canonicalObject(model, value, fields = {}, revisionMap = false) {
85
87
  return Object.fromEntries(Object.keys(value).sort().map((name) => [
86
88
  name,
87
- canonicalFieldValue(model, value[name], fields[name])
89
+ canonicalFieldValue(model, value[name], fields[name], name, revisionMap)
88
90
  ]));
89
91
  }
90
92
 
91
- function canonicalFieldValue(model, value, field) {
93
+ function canonicalFieldValue(model, value, field, name, revisionMap = false) {
92
94
  if (Array.isArray(value)) {
93
95
  const objectType = field?.itemObjectType;
94
96
  const itemFields = objectType ? model.objectTypes?.[objectType]?.properties : undefined;
@@ -103,9 +105,11 @@ function canonicalFieldValue(model, value, field) {
103
105
  }
104
106
  if (value && typeof value === "object") {
105
107
  const objectType = field?.objectType;
106
- return canonicalObject(model, value, objectType ? model.objectTypes?.[objectType]?.properties : undefined);
108
+ return canonicalObject(model, value, objectType ? model.objectTypes?.[objectType]?.properties : undefined, CALCULATED_REVISION_MAP_FIELDS.has(name));
107
109
  }
108
- return value;
110
+ return typeof value === "string" && (revisionMap || CALCULATED_REVISION_FIELDS.has(name))
111
+ ? canonicalCalculatedRevision(value)
112
+ : value;
109
113
  }
110
114
 
111
115
  function compareCanonicalValues(left, right) {
@@ -6,6 +6,7 @@ import { assessProgramReadiness } from "./program-readiness.js";
6
6
  import { personWasActiveOn } from "./soc2.js";
7
7
  import { currentCalendarDate } from "./time.js";
8
8
  import { loadWorkspace } from "./workspace.js";
9
+ import { revisionDigest } from "./revisions.js";
9
10
 
10
11
  export async function scaffoldDocumentActivation(input = process.cwd(), options = {}) {
11
12
  const loaded = await loadWorkspace(input);
@@ -97,7 +98,7 @@ export async function planDocumentActivation(input = process.cwd(), options = {}
97
98
  : `${resourceTitle} "${resourceId}" is not ready for ${workflowScope === "engagement" ? "Step 5" : "Step 3"} activation.`
98
99
  );
99
100
  }
100
- if (!/^[a-f0-9]{64}$/.test(expectedRevisions[resourceId] || "")) {
101
+ if (!revisionDigest("content", expectedRevisions[resourceId])) {
101
102
  throw new Error(`Governed-content activation needs the current record revision for "${resourceId}". Regenerate the activation review and try again.`);
102
103
  }
103
104
  const record = {
package/src/files.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createHash, randomUUID } from "node:crypto";
1
+ import { randomUUID } from "node:crypto";
2
2
  import { constants, link, lstat, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
4
  import { getResourceDefinition, loadModel, modelSupports } from "../model/index.js";
@@ -12,6 +12,7 @@ import { measureTiming } from "./timing.js";
12
12
  import { currentCalendarDate, timestampFromLocalDateTime } from "./time.js";
13
13
  import { loadWorkspace } from "./workspace.js";
14
14
  import { validateWorkspace } from "./validate.js";
15
+ import { calculateRevision, revisionsMatch } from "./revisions.js";
15
16
 
16
17
  export const INTERNAL_WORKFLOW_CAPABILITIES = Object.freeze({
17
18
  auditManagementReconciliation: Symbol("audit-management-reconciliation"),
@@ -692,11 +693,11 @@ function assertImmutableWorkflowRecord(existing, next, options = {}, loaded = nu
692
693
  const legacyReplacement = options.workflowCapability === INTERNAL_WORKFLOW_CAPABILITIES.collectionReviewReassessment
693
694
  && existing.status === "active"
694
695
  && next.status === "active";
695
- if (!retirement && !legacyReplacement && options.lifecycleOperation !== "model-migration") {
696
+ if (!retirement && !legacyReplacement && !isMigrationOperation(options.lifecycleOperation)) {
696
697
  throw new Error(`Finalized Collection Review "${existing.id}" is immutable. Record a superseding review instead.`);
697
698
  }
698
699
  }
699
- if (options.lifecycleOperation === "model-migration") return;
700
+ if (isMigrationOperation(options.lifecycleOperation)) return;
700
701
  if (
701
702
  modelSupports(loaded?.model || 0, "reporting-route-sets")
702
703
  && ["policy", "document", "commitment", "risk"].includes(existing.type)
@@ -921,21 +922,21 @@ function assertSpecializedWorkflowCreate(record, options = {}, loaded = null) {
921
922
  if (
922
923
  record?.type === "reconciliation-dismissal"
923
924
  && options.workflowCapability !== INTERNAL_WORKFLOW_CAPABILITIES.reconciliationDismissal
924
- && options.lifecycleOperation !== "model-migration"
925
+ && !isMigrationOperation(options.lifecycleOperation)
925
926
  ) {
926
927
  throw new Error(`Reconciliation dismissal "${record.id || ""}" is workflow-managed. Dismiss the current transition candidate instead of creating it directly.`);
927
928
  }
928
929
  if (
929
930
  record?.type === "collection-review"
930
931
  && options.workflowCapability !== INTERNAL_WORKFLOW_CAPABILITIES.collectionReviewReassessment
931
- && options.lifecycleOperation !== "model-migration"
932
+ && !isMigrationOperation(options.lifecycleOperation)
932
933
  ) {
933
934
  throw new Error(`Collection Review "${record.id || ""}" is workflow-managed. Preview and confirm the collection review instead of creating it directly.`);
934
935
  }
935
936
  if (
936
937
  record?.type === "reporting-route-set"
937
938
  && record.status !== "draft"
938
- && options.lifecycleOperation !== "model-migration"
939
+ && !isMigrationOperation(options.lifecycleOperation)
939
940
  ) {
940
941
  throw new Error(`Reporting Route Set "${record.id || ""}" must be created as a draft and advanced through managed actions.`);
941
942
  }
@@ -1162,13 +1163,14 @@ async function prepareContentWrites(loaded, record, content, options = {}) {
1162
1163
  }
1163
1164
 
1164
1165
  function assertRevision(source, expected, label) {
1165
- if (expected && contentRevision(source) !== expected) {
1166
- throw new Error(`${label} changed after you opened it. Reload the workspace and apply your change again.`);
1166
+ const current = contentRevision(source);
1167
+ if (expected && !revisionsMatch("content", expected, current)) {
1168
+ throw new Error(`${label} changed after you opened it. Stored revision: ${expected}. Current revision: ${current}. Reload the workspace and apply your change again.`);
1167
1169
  }
1168
1170
  }
1169
1171
 
1170
1172
  export function contentRevision(source) {
1171
- return createHash("sha256").update(source).digest("hex");
1173
+ return calculateRevision("content", source);
1172
1174
  }
1173
1175
 
1174
1176
  async function prepareApprovalBinding(loaded, record, contentWrites, previousRecord = null) {
@@ -1223,7 +1225,7 @@ async function prepareApprovalBinding(loaded, record, contentWrites, previousRec
1223
1225
  }
1224
1226
 
1225
1227
  function assertGovernedContentLifecycleMutation(previousRecord, nextRecord, model, lifecycleOperation) {
1226
- if (lifecycleOperation === "model-migration") return;
1228
+ if (isMigrationOperation(lifecycleOperation)) return;
1227
1229
  const governedTraining = previousRecord?.type === "training"
1228
1230
  && nextRecord?.type === "training"
1229
1231
  && modelSupports(model, "governed-training-activation");
@@ -1269,6 +1271,10 @@ function assertGovernedContentLifecycleMutation(previousRecord, nextRecord, mode
1269
1271
  }
1270
1272
  }
1271
1273
 
1274
+ function isMigrationOperation(operation) {
1275
+ return operation === "model-migration";
1276
+ }
1277
+
1272
1278
  function assertLifecycleFieldsUnchanged(previousRecord, nextRecord, fields, eventLabel, resourceTitle) {
1273
1279
  const changed = fields.filter((field) => (
1274
1280
  JSON.stringify(previousRecord[field] ?? null) !== JSON.stringify(nextRecord[field] ?? null)
package/src/index.js CHANGED
@@ -123,6 +123,8 @@ export { createFilegrcServer, serveWorkspace } from "./server.js";
123
123
  export { normalizeSetupPayload, planWorkspaceSetup, setupWorkspace, summarizeSetupResult } from "./setup.js";
124
124
  export { createAppState, createResourceDetail } from "./state.js";
125
125
  export { currentCalendarDate, formatCalendarDate, formatLocalDateTime } from "./time.js";
126
+ export { assertWorkspaceEngineVersion, workspaceEngineVersion } from "./runtime-version.js";
127
+ export { calculatedRevisionDiagnostic, REVISION_SCHEME_VERSION } from "./revisions.js";
126
128
  export { validateWorkspace } from "./validate.js";
127
129
  export {
128
130
  assessWorkflow,
package/src/mutation.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { resolveWorkspaceRoot } from "./paths.js";
3
+ import { assertWorkspaceEngineVersion } from "./runtime-version.js";
3
4
 
4
5
  const mutationQueues = new Map();
5
6
  const activeMutation = new AsyncLocalStorage();
@@ -7,6 +8,7 @@ const deferredValidation = new AsyncLocalStorage();
7
8
 
8
9
  export function serializeWorkspaceMutation(input, task) {
9
10
  const root = resolveWorkspaceRoot(input);
11
+ assertWorkspaceEngineVersion(root);
10
12
  if (activeMutation.getStore() === root) return task(root);
11
13
  const previous = mutationQueues.get(root) ?? Promise.resolve();
12
14
  const run = previous.catch(() => {}).then(() => activeMutation.run(root, () => task(root)));
@@ -1,4 +1,3 @@
1
- import { createHash } from "node:crypto";
2
1
  import { scaffoldResourceMutation } from "./agent.js";
3
2
  import { createResourceId } from "./id.js";
4
3
  import {
@@ -30,6 +29,7 @@ import { collectionReviewRevision, historicalCollectionReviewSnapshot } from "./
30
29
  import { bindAttestationReportingRouteSet, reportingRouteRevision } from "./reporting-route-integrity.js";
31
30
  import { selectScopedCollectionRecords } from "./collection-scope.js";
32
31
  import { occurrenceMemberIsResolved } from "./obligation-members.js";
32
+ import { calculateRevision, revisionsMatch } from "./revisions.js";
33
33
 
34
34
  const COMPLETION_DATE_FIELDS = [
35
35
  "completedOn",
@@ -993,7 +993,7 @@ export async function scaffoldObligationRuleActivation(input, options = {}) {
993
993
  export async function activateObligationRule(input, options = {}) {
994
994
  const scaffold = await scaffoldObligationRuleActivation(input, options);
995
995
  if (!(options.approvedByIds || []).length) throw new Error("Select at least one person who approved this rule.");
996
- if (options.confirmedRevision !== scaffold.review.revision) {
996
+ if (!revisionsMatch("content", options.confirmedRevision, scaffold.review.revision)) {
997
997
  throw new Error("Confirm the current Obligation rule revision before activation.");
998
998
  }
999
999
  const loaded = await loadWorkspace(input);
@@ -1508,7 +1508,7 @@ function bindEffectiveReportingRoute(loaded, record) {
1508
1508
  }
1509
1509
 
1510
1510
  function contentRevision(source) {
1511
- return createHash("sha256").update(source).digest("hex");
1511
+ return calculateRevision("content", source);
1512
1512
  }
1513
1513
 
1514
1514
  function calendarWindow(recurrence, configuredWindow, index) {
@@ -4,6 +4,7 @@ import { assessProgramReadiness } from "./program-readiness.js";
4
4
  import { currentCalendarDate } from "./time.js";
5
5
  import { loadWorkspace } from "./workspace.js";
6
6
  import { modelSupports } from "../model/index.js";
7
+ import { revisionDigest } from "./revisions.js";
7
8
 
8
9
  export async function scaffoldPolicyActivation(input = process.cwd(), options = {}) {
9
10
  const loaded = await loadWorkspace(input);
@@ -53,7 +54,7 @@ export async function planPolicyActivation(input = process.cwd(), options = {})
53
54
  if (entry.record.status !== "approved") {
54
55
  throw new Error(`Policy "${policyId}" must be approved and inactive before the Step 3 cutover.`);
55
56
  }
56
- if (!/^[a-f0-9]{64}$/.test(expectedRevisions[policyId] || "")) {
57
+ if (!revisionDigest("content", expectedRevisions[policyId])) {
57
58
  throw new Error(`Policy activation needs the current revision for "${policyId}". Regenerate the cutover review and try again.`);
58
59
  }
59
60
  const record = { ...entry.record, status: "active", effectiveOn };
@@ -1,5 +1,6 @@
1
1
  import { currentPartyPeople } from "./parties.js";
2
2
  import { modelSupports } from "../model/index.js";
3
+ import { revisionsMatch } from "./revisions.js";
3
4
 
4
5
  const requiredDocumentsByControlCache = new WeakMap();
5
6
 
@@ -65,8 +66,13 @@ export function governedContentIsOperating(record, asOf, model) {
65
66
 
66
67
  export function contentRevisionBindingsMatch(left, right) {
67
68
  if (!left || !right || Array.isArray(left) || Array.isArray(right)) return false;
68
- const normalize = (value) => Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
69
- return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right));
69
+ const leftEntries = Object.entries(left).sort(([a], [b]) => a.localeCompare(b));
70
+ const rightEntries = Object.entries(right).sort(([a], [b]) => a.localeCompare(b));
71
+ return leftEntries.length === rightEntries.length
72
+ && leftEntries.every(([path, revision], index) => (
73
+ rightEntries[index][0] === path
74
+ && (revision === rightEntries[index][1] || revisionsMatch("content", revision, rightEntries[index][1]))
75
+ ));
70
76
  }
71
77
 
72
78
  export function obligationGovernedDocuments(obligation, byId, model) {
@@ -1,4 +1,3 @@
1
- import { createHash } from "node:crypto";
2
1
  import {
3
2
  getDataRecordHistoryIndex,
4
3
  getFileAtRevision,
@@ -12,6 +11,7 @@ import { addCalendarDays } from "./recurrence.js";
12
11
  import { coverageContains } from "./coverage.js";
13
12
  import { appointmentWasAuthorizedOn } from "./soc2.js";
14
13
  import { isRfc3339Timestamp, localDateTimeValue, timestampFromLocalDateTime } from "./time.js";
14
+ import { calculateRevision } from "./revisions.js";
15
15
 
16
16
  const CONTEMPORANEOUS_COMMIT_WINDOW_MS = 86_400_000;
17
17
  const REPORTING_ROUTE_REQUIREMENT_SOURCE_TYPES = new Set(["policy", "document", "commitment", "risk"]);
@@ -32,7 +32,7 @@ export function reportingRouteRevision(record) {
32
32
  sourceResourceIds: record.sourceResourceIds,
33
33
  ownerIds: record.ownerIds
34
34
  };
35
- return createHash("sha256").update(JSON.stringify(effectiveFacts)).digest("hex");
35
+ return calculateRevision("reporting-route", JSON.stringify(effectiveFacts));
36
36
  }
37
37
 
38
38
  export function reportingRouteEventAuthorityIssue(records, routeSet, options = {}) {
@@ -1,4 +1,5 @@
1
1
  import { resourceReviewRevisions } from "./retention.js";
2
+ import { revisionsMatch } from "./revisions.js";
2
3
 
3
4
  export async function assessRequirementMappingReadiness(loaded) {
4
5
  if (!loaded.model.resources["requirement-mapping"]) return [];
@@ -17,7 +18,7 @@ export async function assessRequirementMappingReadiness(loaded) {
17
18
  ...(mapping.targetResourceIds || [])
18
19
  ])];
19
20
  const staleIds = mappedIds.filter((id) => (
20
- !revisions.get(id) || mapping.reviewedSourceRevisions?.[id] !== revisions.get(id)
21
+ !revisions.get(id) || !revisionsMatch("content", mapping.reviewedSourceRevisions?.[id], revisions.get(id))
21
22
  )).concat(Object.keys(mapping.reviewedSourceRevisions || {}).filter((id) => !mappedIds.includes(id)));
22
23
  const structurallyComplete = mappingStructureIsComplete(mapping);
23
24
  const complete = mapping.status === "active" && structurallyComplete && staleIds.length === 0;
@@ -1,4 +1,3 @@
1
- import { createHash } from "node:crypto";
2
1
  import { readFileSync } from "node:fs";
3
2
  import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
4
3
  import { markdownEntries } from "./resource-markdown.js";
@@ -11,6 +10,7 @@ import {
11
10
  retentionRuleIsCurrent,
12
11
  retentionUses
13
12
  } from "./retention.js";
13
+ import { calculateRevision, revisionsMatch } from "./revisions.js";
14
14
 
15
15
  export function retentionScheduleApprovalIssues(loaded, program, rows, options = {}) {
16
16
  const byId = new Map(loaded.resources.map((record) => [record.id, record]));
@@ -125,9 +125,10 @@ function approvalBindingsMatch(loaded, record) {
125
125
  const actual = {};
126
126
  for (const entry of markdownEntries(loaded.model, record)) {
127
127
  try {
128
- actual[entry.path] = createHash("sha256")
129
- .update(readFileSync(resolveDataPath(loaded.root, entry.path), "utf8"))
130
- .digest("hex");
128
+ actual[entry.path] = calculateRevision(
129
+ "content",
130
+ readFileSync(resolveDataPath(loaded.root, entry.path), "utf8")
131
+ );
131
132
  } catch (error) {
132
133
  if (error.code !== "ENOENT") throw error;
133
134
  }
@@ -136,5 +137,7 @@ function approvalBindingsMatch(loaded, record) {
136
137
  const actualKeys = Object.keys(actual).sort();
137
138
  const expectedKeys = Object.keys(expected).sort();
138
139
  return actualKeys.length === expectedKeys.length
139
- && actualKeys.every((key, index) => key === expectedKeys[index] && actual[key] === expected[key]);
140
+ && actualKeys.every((key, index) => (
141
+ key === expectedKeys[index] && revisionsMatch("content", actual[key], expected[key])
142
+ ));
140
143
  }
package/src/retention.js CHANGED
@@ -5,6 +5,7 @@ import { resolveDataPath } from "./paths.js";
5
5
  import { programComponents } from "./program.js";
6
6
  import { markdownEntries } from "./resource-markdown.js";
7
7
  import { modelSupports } from "../model/index.js";
8
+ import { canonicalCalculatedRevisionJson, revisionDigest, revisionsMatch } from "./revisions.js";
8
9
 
9
10
  export async function assessRetentionReadiness(loaded, program, options = {}) {
10
11
  if (!loaded.model.resources["retention-schedule-item"]) return [];
@@ -173,7 +174,7 @@ export async function resourceReviewRevisions(loaded, ids) {
173
174
  }
174
175
  for (const sourceId of [...new Set(entry.record.sourceResourceIds || [])].sort()) {
175
176
  const revision = await review(sourceId);
176
- if (revision) parts.push(`${sourceId}:${revision}`);
177
+ if (revision) parts.push(`${sourceId}:${revisionDigest("content", revision) || revision}`);
177
178
  }
178
179
  reviewing.delete(id);
179
180
  const revision = contentRevision(parts.join("\n"));
@@ -206,7 +207,7 @@ export function resourceReviewRevisionsSync(loaded, ids) {
206
207
  }
207
208
  for (const sourceId of [...new Set(entry.record.sourceResourceIds || [])].sort()) {
208
209
  const revision = review(sourceId);
209
- if (revision) parts.push(`${sourceId}:${revision}`);
210
+ if (revision) parts.push(`${sourceId}:${revisionDigest("content", revision) || revision}`);
210
211
  }
211
212
  reviewing.delete(id);
212
213
  const revision = contentRevision(parts.join("\n"));
@@ -260,7 +261,7 @@ function reviewSource(loaded, entry) {
260
261
  ]) delete approved[field];
261
262
  return JSON.stringify(approved);
262
263
  }
263
- return entry.source;
264
+ return canonicalCalculatedRevisionJson(entry.source);
264
265
  }
265
266
 
266
267
  export function nearDuplicateInformationTypes(records) {
@@ -326,7 +327,7 @@ export function retentionRuleIsCurrent(rule, revisions, byId = new Map(), loaded
326
327
  const dependencyIds = retentionReviewResourceIds(rule, loaded);
327
328
  if (Object.keys(rule.reviewedSourceRevisions).length !== dependencyIds.length) return false;
328
329
  return dependencyIds.every((id) => (
329
- revisions.get(id) && rule.reviewedSourceRevisions?.[id] === revisions.get(id)
330
+ revisions.get(id) && revisionsMatch("content", rule.reviewedSourceRevisions?.[id], revisions.get(id))
330
331
  ));
331
332
  }
332
333
 
@@ -0,0 +1,101 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export const REVISION_SCHEME_VERSION = 1;
4
+
5
+ export const CALCULATED_REVISION_FIELDS = new Set([
6
+ "collectionRevision",
7
+ "collectionReviewRevision",
8
+ "reportingRouteRevision",
9
+ "scopeRevision"
10
+ ]);
11
+
12
+ export const CALCULATED_REVISION_MAP_FIELDS = new Set([
13
+ "approvedContentRevisions",
14
+ "activatedContentRevisions",
15
+ "effectiveContentRevisions",
16
+ "contentRevisions",
17
+ "reviewedSourceRevisions"
18
+ ]);
19
+
20
+ const PREFIXES = Object.freeze({
21
+ content: `filegrc:content:v${REVISION_SCHEME_VERSION}:sha256:`,
22
+ collection: `filegrc:collection:v${REVISION_SCHEME_VERSION}:sha256:`,
23
+ "applicability-scope": `filegrc:applicability-scope:v${REVISION_SCHEME_VERSION}:sha256:`,
24
+ "collection-review": `filegrc:collection-review:v${REVISION_SCHEME_VERSION}:sha256:`,
25
+ "reporting-route": `filegrc:reporting-route:v${REVISION_SCHEME_VERSION}:sha256:`
26
+ });
27
+
28
+ export function calculateRevision(kind, source) {
29
+ const prefix = PREFIXES[kind];
30
+ if (!prefix) throw new Error(`Unknown calculated revision kind "${kind}".`);
31
+ return `${prefix}${createHash("sha256").update(source).digest("hex")}`;
32
+ }
33
+
34
+ export function revisionDigest(kind, value) {
35
+ const text = String(value || "");
36
+ const prefix = PREFIXES[kind];
37
+ if (prefix && text.startsWith(prefix) && /^[a-f0-9]{64}$/.test(text.slice(prefix.length))) {
38
+ return text.slice(prefix.length);
39
+ }
40
+ if (kind === "applicability-scope" && /^scope:[a-f0-9]{64}$/.test(text)) return text.slice(6);
41
+ if (/^[a-f0-9]{64}$/.test(text)) return text;
42
+ return null;
43
+ }
44
+
45
+ export function revisionsMatch(kind, stored, current) {
46
+ const storedDigest = revisionDigest(kind, stored);
47
+ const currentDigest = revisionDigest(kind, current);
48
+ return Boolean(storedDigest && currentDigest && storedDigest === currentDigest);
49
+ }
50
+
51
+ export function calculatedRevisionDiagnostic(kind, stored, current) {
52
+ return {
53
+ kind,
54
+ storedRevision: stored || null,
55
+ currentRevision: current || null,
56
+ storedScheme: revisionScheme(kind, stored),
57
+ currentScheme: revisionScheme(kind, current),
58
+ matches: revisionsMatch(kind, stored, current)
59
+ };
60
+ }
61
+
62
+ export function canonicalCalculatedRevision(value) {
63
+ const text = String(value || "");
64
+ // Keep the original digest as the calculation input. Merely installing a
65
+ // scheme-aware engine must not stale a decision made over unchanged facts.
66
+ if (/^[a-f0-9]{64}$/.test(text)) return text;
67
+ if (/^scope:[a-f0-9]{64}$/.test(text)) return text.slice(6);
68
+ const match = /^filegrc:[a-z-]+:v\d+:sha256:([a-f0-9]{64})$/.exec(text);
69
+ return match ? match[1] : value;
70
+ }
71
+
72
+ // Preserve JSON layout because older resource-review bindings hashed the exact
73
+ // source bytes. Only scheme labels in model-defined revision slots may change.
74
+ export function canonicalCalculatedRevisionJson(source) {
75
+ const fieldNames = [...CALCULATED_REVISION_FIELDS].join("|");
76
+ const mapNames = [...CALCULATED_REVISION_MAP_FIELDS].join("|");
77
+ const stringValue = /("(?:\\.|[^"\\])*")(\s*:\s*)("(?:\\.|[^"\\])*")/g;
78
+ const canonicalValue = (quoted) => {
79
+ const value = JSON.parse(quoted);
80
+ const canonical = canonicalCalculatedRevision(value);
81
+ return canonical === value ? quoted : JSON.stringify(canonical);
82
+ };
83
+ return source
84
+ .replace(new RegExp(`("(?:${fieldNames})")(\\s*:\\s*)("(?:\\\\.|[^"\\\\])*")`, "g"),
85
+ (_match, key, separator, value) => `${key}${separator}${canonicalValue(value)}`)
86
+ .replace(new RegExp(`("(?:${mapNames})")(\\s*:\\s*\\{)([^{}]*)(\\})`, "g"),
87
+ (_match, key, opening, body, closing) => (
88
+ `${key}${opening}${body.replace(stringValue, (_pair, name, separator, value) => (
89
+ `${name}${separator}${canonicalValue(value)}`
90
+ ))}${closing}`
91
+ ));
92
+ }
93
+
94
+ function revisionScheme(kind, value) {
95
+ const text = String(value || "");
96
+ if (!text) return null;
97
+ if (text.startsWith(PREFIXES[kind] || "\0")) return `filegrc:${kind}:v${REVISION_SCHEME_VERSION}:sha256`;
98
+ if (kind === "applicability-scope" && /^scope:[a-f0-9]{64}$/.test(text)) return "legacy:applicability-scope:sha256";
99
+ if (/^[a-f0-9]{64}$/.test(text)) return `legacy:${kind}:sha256`;
100
+ return "unknown";
101
+ }
@@ -0,0 +1,66 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { resolveWorkspaceRoot } from "./paths.js";
4
+
5
+ const installedVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
6
+
7
+ export function workspaceEngineVersion(input = process.cwd()) {
8
+ let root;
9
+ try {
10
+ root = resolveWorkspaceRoot(input);
11
+ } catch (error) {
12
+ if (/No filegrc workspace was found/.test(error.message)) {
13
+ return { installedVersion, lockedVersion: null, lockfilePath: null };
14
+ }
15
+ throw error;
16
+ }
17
+ const manifestPath = join(root, "package.json");
18
+ let packageManaged = false;
19
+ try {
20
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
21
+ packageManaged = Boolean(manifest.dependencies?.filegrc || manifest.devDependencies?.filegrc);
22
+ } catch (error) {
23
+ if (error.code !== "ENOENT") throw new Error(`Cannot read the workspace package.json: ${error.message}`);
24
+ }
25
+ let lock;
26
+ try {
27
+ lock = JSON.parse(readFileSync(join(root, "package-lock.json"), "utf8"));
28
+ } catch (error) {
29
+ if (error.code === "ENOENT" && !packageManaged) return { installedVersion, lockedVersion: null, lockfilePath: null };
30
+ if (error.code === "ENOENT") throw versionDriftError("The workspace package-lock.json is missing. Run npm install to create it, then npm ci.");
31
+ throw new Error(`Cannot read the workspace package-lock.json: ${error.message}`);
32
+ }
33
+ const installedEntry = lock.packages?.["node_modules/filegrc"];
34
+ const linkedEntry = installedEntry?.link && installedEntry.resolved
35
+ ? lock.packages?.[installedEntry.resolved]
36
+ : null;
37
+ const lockedVersion = linkedEntry?.version
38
+ || installedEntry?.version
39
+ || lock.dependencies?.filegrc?.version
40
+ || null;
41
+ if (packageManaged && !lockedVersion) {
42
+ throw versionDriftError("The workspace package-lock.json has no filegrc version. Run npm install to repair it, then npm ci.");
43
+ }
44
+ return { installedVersion, lockedVersion, lockfilePath: join(root, "package-lock.json") };
45
+ }
46
+
47
+ function versionDriftError(message) {
48
+ const error = new Error(message);
49
+ error.code = "FILEGRC_VERSION_DRIFT";
50
+ return error;
51
+ }
52
+
53
+ export function assertWorkspaceEngineVersion(input = process.cwd()) {
54
+ const result = workspaceEngineVersion(input);
55
+ if (result.lockedVersion && result.lockedVersion !== result.installedVersion) {
56
+ const error = new Error(
57
+ `Installed filegrc ${result.installedVersion} does not match package-lock.json (${result.lockedVersion}). `
58
+ + "Run npm ci in the workspace, then retry."
59
+ );
60
+ error.code = "FILEGRC_VERSION_DRIFT";
61
+ error.installedVersion = result.installedVersion;
62
+ error.lockedVersion = result.lockedVersion;
63
+ throw error;
64
+ }
65
+ return result;
66
+ }
package/src/server.js CHANGED
@@ -78,6 +78,7 @@ import {
78
78
  } from "./workflow.js";
79
79
  import { loadWorkspace } from "./workspace.js";
80
80
  import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js";
81
+ import { assertWorkspaceEngineVersion } from "./runtime-version.js";
81
82
 
82
83
  const STATE_SESSION_MAX_AGE_MS = 5 * 60_000;
83
84
  const MAX_STATE_SESSIONS = 8;
@@ -86,6 +87,7 @@ const RESOURCE_DETAIL_GIT_DEADLINE_MS = 10_000;
86
87
  const STATE_SECTION_GIT_DEADLINE_MS = 10_000;
87
88
 
88
89
  export function createFilegrcServer(input = process.cwd(), options = {}) {
90
+ assertWorkspaceEngineVersion(input);
89
91
  const stateSessions = new Map();
90
92
  const fileDigestCache = new Map();
91
93
  let bootstrapSnapshotPromise = null;
package/src/state.js CHANGED
@@ -1,4 +1,3 @@
1
- import { createHash } from "node:crypto";
2
1
  import { readFile } from "node:fs/promises";
3
2
  import { assessAuditPreparation } from "./audit-preparation.js";
4
3
  import { assessCollectionReviews } from "./collection-review.js";
@@ -17,6 +16,7 @@ import { assessWorkflow } from "./workflow.js";
17
16
  import { measureTiming } from "./timing.js";
18
17
  import { soc2RequirementApplicabilityConstraint } from "./soc2.js";
19
18
  import { modelSupports } from "../model/index.js";
19
+ import { calculateRevision } from "./revisions.js";
20
20
 
21
21
  const renderedMarkdownCache = new Map();
22
22
  const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000;
@@ -447,5 +447,5 @@ function renderMarkdownCached(source) {
447
447
  }
448
448
 
449
449
  function contentRevision(source) {
450
- return createHash("sha256").update(source).digest("hex");
450
+ return calculateRevision("content", source);
451
451
  }
package/src/validate.js CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  } from "./reporting-route-integrity.js";
46
46
  import { validateWorkflowHistoryIntegrity } from "./workflow-history-integrity.js";
47
47
  import { occurrenceMemberExceptionIsValid } from "./obligation-members.js";
48
+ import { calculateRevision, revisionDigest, revisionsMatch } from "./revisions.js";
48
49
 
49
50
  let fingerprintFileReadObserver = null;
50
51
 
@@ -557,8 +558,8 @@ function validateObligationOccurrence(record, model, resources, entries, root, w
557
558
  || !(review.scopeResourceIds || []).includes(record.programId)
558
559
  || !historicalSnapshot
559
560
  || record.collectionReviewCommit !== historicalSnapshot.reviewCommit
560
- || record.collectionReviewRevision !== collectionReviewRevision(review)
561
- || record.collectionRevision !== review.collectionRevision
561
+ || !revisionsMatch("collection-review", record.collectionReviewRevision, collectionReviewRevision(review))
562
+ || !revisionsMatch("collection", record.collectionRevision, review.collectionRevision)
562
563
  || record.scopeRevision !== review.scopeRevision
563
564
  || !reviewCoversCutoff
564
565
  || JSON.stringify(memberIds) !== JSON.stringify(reviewedIds)
@@ -1231,6 +1232,16 @@ function validateTimestampTimezone(value, timezone, path, diagnostics, label) {
1231
1232
 
1232
1233
  function validateCollectionReview(record, loaded, byId, path, diagnostics) {
1233
1234
  if (record.status !== "active") return;
1235
+ const inactiveReviewers = (record.reviewedByIds || []).filter((id) => (
1236
+ !personWasActiveOn(byId.get(id), record.reviewedOn)
1237
+ ));
1238
+ if (inactiveReviewers.length) {
1239
+ diagnostics.push(error(
1240
+ "inactive-collection-reviewer-on-review-date",
1241
+ path,
1242
+ `Collection Review reviewers must have been active on ${record.reviewedOn}: ${inactiveReviewers.join(", ")}.`
1243
+ ));
1244
+ }
1234
1245
  const { model } = loaded;
1235
1246
  const configuration = model.collectionReviews?.[record.resourceType];
1236
1247
  if (!configuration) return;
@@ -1987,13 +1998,13 @@ function validateRetentionScheduleItem(record, loaded, byId, currentReviewRevisi
1987
1998
  const sources = retentionReviewResourceIds(record, loaded);
1988
1999
  const revisions = record.reviewedSourceRevisions || {};
1989
2000
  const missing = sources.filter((id) => (
1990
- !currentReviewRevisions.get(id) || revisions[id] !== currentReviewRevisions.get(id)
2001
+ !currentReviewRevisions.get(id) || !revisionsMatch("content", revisions[id], currentReviewRevisions.get(id))
1991
2002
  )).concat(Object.keys(revisions).filter((id) => !sources.includes(id)));
1992
2003
  if (missing.length) {
1993
2004
  diagnostics.push(error(
1994
2005
  "stale-retention-review",
1995
2006
  path,
1996
- `reviewedSourceRevisions must bind the schedule Document, Information Types, operational scope, and authority or source records before activation: ${missing.join(", ")}.`
2007
+ `reviewedSourceRevisions must bind the schedule Document, Information Types, operational scope, and authority or source records before activation: ${revisionMismatchDetails(missing, revisions, currentReviewRevisions)}.`
1997
2008
  ));
1998
2009
  }
1999
2010
  }
@@ -2011,17 +2022,23 @@ function validateRequirementMapping(record, currentReviewRevisions, path, diagno
2011
2022
  const mappedIds = [...new Set([...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])])];
2012
2023
  const revisions = record.reviewedSourceRevisions || {};
2013
2024
  const missing = mappedIds.filter((id) => (
2014
- !currentReviewRevisions.get(id) || revisions[id] !== currentReviewRevisions.get(id)
2025
+ !currentReviewRevisions.get(id) || !revisionsMatch("content", revisions[id], currentReviewRevisions.get(id))
2015
2026
  )).concat(Object.keys(revisions).filter((id) => !mappedIds.includes(id)));
2016
2027
  if (missing.length) {
2017
2028
  diagnostics.push(error(
2018
2029
  "stale-requirement-mapping",
2019
2030
  path,
2020
- `reviewedSourceRevisions must bind every mapped resource before activation: ${missing.join(", ")}.`
2031
+ `reviewedSourceRevisions must bind every mapped resource before activation: ${revisionMismatchDetails(missing, revisions, currentReviewRevisions)}.`
2021
2032
  ));
2022
2033
  }
2023
2034
  }
2024
2035
 
2036
+ function revisionMismatchDetails(ids, stored, current) {
2037
+ return ids.map((id) => (
2038
+ `${id} (stored: ${stored[id] || "missing"}; current: ${current.get(id) || "missing"})`
2039
+ )).join(", ");
2040
+ }
2041
+
2025
2042
  function obligationCompletionTypes(model, obligation) {
2026
2043
  if (obligation.activityType === "custom") return obligation.customActivity?.completionResourceTypes || [];
2027
2044
  return model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || [];
@@ -2170,7 +2187,7 @@ async function validateAttestationBinding(record, model, root, byId, path, diagn
2170
2187
  }
2171
2188
  const invalid = actualPaths.filter((item) => (
2172
2189
  !expectedPaths.has(item)
2173
- || !/^[a-f0-9]{64}$/.test(String(record.contentRevisions[item] || ""))
2190
+ || !revisionDigest("content", record.contentRevisions[item])
2174
2191
  ));
2175
2192
  const unboundSubjects = [...subjectPaths].filter(([, paths]) => (
2176
2193
  paths.length && !paths.some((item) => actualPaths.includes(item))
@@ -2251,7 +2268,11 @@ function validateReportingRouteBinding(record, loaded, path, diagnostics) {
2251
2268
  .sort((left, right) => right.record.effectiveAt.localeCompare(left.record.effectiveAt))[0] || null;
2252
2269
  const expectedId = route?.record.id;
2253
2270
  const expectedRevision = route ? reportingRouteRevision(route.record) : undefined;
2254
- if (record.reportingRouteId !== expectedId || record.reportingRouteRevision !== expectedRevision) {
2271
+ if (
2272
+ record.reportingRouteId !== expectedId
2273
+ || (expectedRevision && !revisionsMatch("reporting-route", record.reportingRouteRevision, expectedRevision))
2274
+ || (!expectedRevision && record.reportingRouteRevision !== expectedRevision)
2275
+ ) {
2255
2276
  diagnostics.push(error(
2256
2277
  "invalid-reporting-route-binding",
2257
2278
  path,
@@ -2276,7 +2297,7 @@ async function validateContentBinding(record, model, root, path, diagnostics, bi
2276
2297
  for (const item of markdownEntries(model, record)) {
2277
2298
  try {
2278
2299
  const source = await readFile(resolveDataPath(root, item.path), "utf8");
2279
- actual[item.path] = createHash("sha256").update(source).digest("hex");
2300
+ actual[item.path] = calculateRevision("content", source);
2280
2301
  } catch (error) {
2281
2302
  if (error.code !== "ENOENT") throw error;
2282
2303
  }
@@ -2284,8 +2305,8 @@ async function validateContentBinding(record, model, root, path, diagnostics, bi
2284
2305
  const expected = record[bindingField];
2285
2306
  const paths = [...new Set([...Object.keys(actual), ...Object.keys(expected)])].sort();
2286
2307
  const invalid = paths.filter((item) => (
2287
- !/^[a-f0-9]{64}$/.test(String(expected[item] || ""))
2288
- || expected[item] !== actual[item]
2308
+ !revisionDigest("content", expected[item])
2309
+ || !revisionsMatch("content", expected[item], actual[item])
2289
2310
  ));
2290
2311
  if (invalid.length) {
2291
2312
  diagnostics.push(error(
@@ -13,6 +13,7 @@ import { resolveDataPath } from "./paths.js";
13
13
  import { markdownEntries } from "./resource-markdown.js";
14
14
  import { currentCalendarDate, isRfc3339Timestamp, timestampFromLocalDateTime } from "./time.js";
15
15
  import { recordsAtRevision, reportingRouteFixedEvidence } from "./reporting-route-integrity.js";
16
+ import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, canonicalCalculatedRevision } from "./revisions.js";
16
17
 
17
18
  const FINAL_STATUSES = new Map([
18
19
  ["reconciliation-dismissal", new Set([undefined])],
@@ -823,18 +824,25 @@ function permittedFinalizedTransition(previous, current) {
823
824
  && (previous.evidenceIds || []).every((id) => (current.evidenceIds || []).includes(id))
824
825
  ) allowed = ["evidenceIds"];
825
826
  return [...new Set([...Object.keys(previous), ...Object.keys(current)])].every((key) => (
826
- allowed.includes(key) || sameJson(previous[key], current[key])
827
+ allowed.includes(key) || sameJson(previous[key], current[key], key)
827
828
  ));
828
829
  }
829
830
 
830
- function sameJson(left, right) {
831
- return JSON.stringify(canonicalJson(left)) === JSON.stringify(canonicalJson(right));
831
+ function sameJson(left, right, field = null) {
832
+ return JSON.stringify(canonicalJson(left, field)) === JSON.stringify(canonicalJson(right, field));
832
833
  }
833
834
 
834
- function canonicalJson(value) {
835
- if (Array.isArray(value)) return value.map(canonicalJson);
836
- if (!value || typeof value !== "object") return value;
837
- return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalJson(value[key])]));
835
+ function canonicalJson(value, field = null, revisionMap = false) {
836
+ if (Array.isArray(value)) return value.map((item) => canonicalJson(item));
837
+ if (!value || typeof value !== "object") {
838
+ return typeof value === "string" && (revisionMap || CALCULATED_REVISION_FIELDS.has(field))
839
+ ? canonicalCalculatedRevision(value)
840
+ : value;
841
+ }
842
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [
843
+ key,
844
+ canonicalJson(value[key], key, revisionMap || CALCULATED_REVISION_MAP_FIELDS.has(field))
845
+ ]));
838
846
  }
839
847
 
840
848
  function proofIds(occurrence, byId) {
package/src/workflow.js CHANGED
@@ -2,7 +2,7 @@ import { cp, mkdtemp, rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { modelSupports } from "../model/index.js";
5
- import { applicabilityReviewIsCurrent } from "./applicability-scope.js";
5
+ import { applicabilityReviewIsCurrent, applicabilityScopeRevision } from "./applicability-scope.js";
6
6
  import { retentionScheduleIsAuthoritative } from "./collection-review.js";
7
7
  import { assessRequiredAppointments } from "./appointments.js";
8
8
  import { assessSourceCoverageReadiness } from "./source-coverage.js";
@@ -533,10 +533,13 @@ function finalizationFields(record, model, resources, program) {
533
533
  && model.resources[record.type]?.fields?.applicabilityReview
534
534
  && !applicabilityReviewIsCurrent(record.applicabilityReview, record, program, resources, model);
535
535
  if (needsReview) {
536
+ const currentRevision = applicabilityScopeRevision(record, program, resources, model);
536
537
  fields.push({
537
538
  field: "applicabilityReview",
538
539
  requiredness: "required",
539
- message: "Review applicability against the current service scope, then record the decision, rationale, reviewer, and date."
540
+ message: record.applicabilityReview?.scopeRevision
541
+ ? `Review applicability against the current service scope. Stored revision: ${record.applicabilityReview.scopeRevision}. Current revision: ${currentRevision}. Then record the decision, rationale, reviewer, and date.`
542
+ : `Review applicability against the current service scope. Current revision: ${currentRevision}. Then record the decision, rationale, reviewer, and date.`
540
543
  });
541
544
  }
542
545
  if (record.type === "policy" && model.resources.policy?.fields?.programRole && !record.programRole) {