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 +1 -1
- package/src/applicability-scope.js +17 -8
- package/src/audit-preparation.js +2 -2
- package/src/cli.js +5 -0
- package/src/collection-review-integrity.js +14 -4
- package/src/collection-review.js +13 -8
- package/src/collection-revision.js +14 -10
- package/src/document-activation.js +2 -1
- package/src/files.js +16 -10
- package/src/index.js +2 -0
- package/src/mutation.js +2 -0
- package/src/obligations.js +3 -3
- package/src/policy-activation.js +2 -1
- package/src/program-lifecycle.js +8 -2
- package/src/reporting-route-integrity.js +2 -2
- package/src/requirement-mapping.js +2 -1
- package/src/retention-schedule-approval.js +8 -5
- package/src/retention.js +5 -4
- package/src/revisions.js +101 -0
- package/src/runtime-version.js +66 -0
- package/src/server.js +2 -0
- package/src/state.js +2 -2
- package/src/validate.js +32 -11
- package/src/workflow-history-integrity.js +15 -7
- package/src/workflow.js +5 -2
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
&&
|
|
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
|
|
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) {
|
package/src/audit-preparation.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
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)
|
|
102
|
+
&& revisionsMatch("collection-review", collectionReviewRevision(historical), expected);
|
|
93
103
|
} catch {
|
|
94
104
|
return false;
|
|
95
105
|
}
|
package/src/collection-review.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
?
|
|
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
|
|
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
|
-
:
|
|
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:
|
|
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:
|
|
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
|
|
28
|
+
return revisionsMatch("collection", storedRevision, currentRevision)
|
|
28
29
|
|| (resourceType !== "retention-schedule-item" || !modelSupports(loaded.model, "retention-schedule-approval"))
|
|
29
|
-
&& storedRevision
|
|
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
|
-
|
|
72
|
-
|
|
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 (
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1166
|
-
|
|
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
|
|
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
|
|
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)));
|
package/src/obligations.js
CHANGED
|
@@ -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
|
|
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
|
|
1511
|
+
return calculateRevision("content", source);
|
|
1512
1512
|
}
|
|
1513
1513
|
|
|
1514
1514
|
function calendarWindow(recurrence, configuredWindow, index) {
|
package/src/policy-activation.js
CHANGED
|
@@ -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 (
|
|
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 };
|
package/src/program-lifecycle.js
CHANGED
|
@@ -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
|
|
69
|
-
|
|
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
|
|
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]
|
|
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] =
|
|
129
|
-
|
|
130
|
-
.
|
|
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) =>
|
|
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]
|
|
330
|
+
revisions.get(id) && revisionsMatch("content", rule.reviewedSourceRevisions?.[id], revisions.get(id))
|
|
330
331
|
));
|
|
331
332
|
}
|
|
332
333
|
|
package/src/revisions.js
ADDED
|
@@ -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
|
|
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
|
|
561
|
-
|| record.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]
|
|
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
|
|
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]
|
|
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
|
|
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
|
-
||
|
|
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 (
|
|
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] =
|
|
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
|
-
|
|
2288
|
-
|| expected[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")
|
|
837
|
-
|
|
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:
|
|
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) {
|