filegrc 0.15.3 → 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/web.js +5 -3
- package/src/workflow-history-integrity.js +15 -7
- package/src/workflow.js +30 -20
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(
|
package/src/web.js
CHANGED
|
@@ -1772,7 +1772,7 @@ function stagePageCard(stage, destination, index) {
|
|
|
1772
1772
|
return '<a href="' + href + '"><span class="workflow-finding-status ' + esc(item.state) + '">' + esc(properCase(item.state)) + '</span><span><strong>' + esc(item.title) + '</strong><small>' + esc(stagePageItemDetail(item)) + '</small></span></a>';
|
|
1773
1773
|
}).join("") + (items.length > 3 ? '<small class="stage-page-tasks-more">+' + (items.length - 3) + ' more on this page</small>' : "") + '</div>'
|
|
1774
1774
|
: "";
|
|
1775
|
-
return '<article class="stage-page-card ' + (complete ? "complete" : "") + '"><
|
|
1775
|
+
return '<article class="stage-page-card ' + (complete ? "complete" : "") + '"><div class="stage-page-card-head"><div><small>' + esc(stepLabel) + '</small><h3>' + esc(destination.label) + '</h3></div><div class="stage-page-card-actions">' + completionState + '<a class="stage-page-open" href="' + destination.href + '" aria-label="Open ' + esc(destination.label) + '"><span>Open ›</span></a></div></div><p>' + esc(summary) + '</p>' + taskPreview + '</article>';
|
|
1776
1776
|
}
|
|
1777
1777
|
|
|
1778
1778
|
function stagePageItemDetail(item) {
|
|
@@ -1898,7 +1898,7 @@ function stagePageItems(stage, destination) {
|
|
|
1898
1898
|
}
|
|
1899
1899
|
|
|
1900
1900
|
function workflowUiStage(stageId) {
|
|
1901
|
-
return
|
|
1901
|
+
return ["operate", "operation"].includes(stageId) ? "run" : stageId;
|
|
1902
1902
|
}
|
|
1903
1903
|
|
|
1904
1904
|
function operationProgress() {
|
|
@@ -6490,11 +6490,13 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
6490
6490
|
.nav-stage>.nav-items>a.nav-direct{display:grid;grid-template-columns:minmax(0,1fr) var(--nav-control-width);gap:6px;align-items:center;width:100%;padding:6px 6px 6px 7px;font-size:13.2px}
|
|
6491
6491
|
.nav-heading-row{display:grid;grid-template-columns:minmax(0,1fr) 24px;gap:2px;align-items:stretch}.nav-heading-row>.nav-heading{display:grid;grid-template-columns:24px minmax(0,1fr);gap:6px;align-items:center;width:100%;padding:7px 6px;border-radius:7px;color:#d5d9ed;text-align:left;text-transform:none;letter-spacing:0;text-decoration:none}.nav-heading-row>.nav-heading:hover,.nav-heading-row>.nav-heading.current{background:rgba(255,255,255,.1);color:#fff}.nav-toggle{display:grid;place-items:center;width:24px;height:auto;min-height:100%;padding:0;border:0;border-radius:6px;background:none;color:#aeb6d8;cursor:pointer}.nav-toggle:hover{background:rgba(255,255,255,.1);color:#fff}.nav-chevron{display:block;width:12px;height:12px;place-self:center;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;transition:transform .15s}.nav-subheading-row{display:grid;grid-template-columns:minmax(0,1fr) 22px;gap:2px;align-items:center;width:100%;padding:0;border:0;border-radius:6px;background:none;color:#919bc4;cursor:pointer}.nav-subheading-row:hover{background:rgba(255,255,255,.1);color:#fff}.nav-subheading-row>.nav-subheading{display:flex;align-items:center;min-width:0;padding:7px;color:inherit;text-align:left;text-transform:uppercase;letter-spacing:.09em;font-size:9.6px;font-weight:780}.nav-group.open>.nav-heading-row .nav-chevron,.nav-group.open>.nav-subheading-row .nav-chevron{transform:rotate(90deg)}
|
|
6492
6492
|
.stage-overview-hero{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:28px;align-items:center;padding:26px 28px;background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.stage-overview-hero h2,.group-overview-head h2{font:500 37.2px Georgia,serif;margin:7px 0}.stage-overview-hero>div>p:not(.kicker),.group-overview-head>div>p:not(.kicker){max-width:760px;color:var(--muted);font-size:14.4px;line-height:1.55;margin:0}.stage-progress-card{padding:15px 17px;background:var(--paper);border:1px solid var(--line);border-radius:9px}.stage-progress-card>div:first-child{display:flex;align-items:center;justify-content:space-between;margin-bottom:11px}.stage-progress-card>div:first-child>strong{font:500 33.6px Georgia,serif}.stage-progress-card p{color:var(--muted);font-size:10.8px;line-height:1.45;margin:9px 0 0}.badge.neutral{background:#e5e8f2;color:#555e73}.stage-overview-layout{display:grid;grid-template-columns:320px minmax(0,1fr);gap:15px;margin-top:15px;align-items:start}.stage-plan ol,.group-plan ol{display:grid;gap:12px;padding-left:20px;margin:0}.stage-plan li,.group-plan li{padding-left:4px;color:var(--ink);font-size:13.2px;line-height:1.5}.stage-groups{min-width:0}.stage-groups>.section-head{margin:4px 0 13px}.stage-group-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.stage-group-card{position:relative;display:block;min-height:138px;padding:18px 38px 17px 18px;background:var(--panel);border:1px solid var(--line);border-radius:10px;text-decoration:none;box-shadow:0 2px 8px rgba(21,40,33,.025)}.stage-group-card:hover{border-color:var(--accent-light)}.stage-group-card h3{font-size:15.6px;margin:0 0 7px}.stage-group-card p{color:var(--muted);font-size:12px;line-height:1.5;margin:0}.stage-group-card small{display:block;color:var(--accent);font-size:9.6px;font-weight:700;margin-top:12px}.stage-group-arrow{position:absolute;right:16px;top:16px;color:var(--accent);font-size:24px}.group-overview-head{display:flex;justify-content:space-between;align-items:end;gap:25px;margin-bottom:15px}.stage-status-link{display:grid;grid-template-columns:auto auto;align-items:center;gap:4px 12px;min-width:155px;padding:12px 14px;background:var(--panel);border:1px solid var(--line);border-radius:9px;text-decoration:none}.stage-status-link>strong{font:500 28.8px Georgia,serif;text-align:right}.stage-status-link>small{grid-column:1/-1;color:var(--muted);font-size:9.6px;text-align:right}.relationship-note{display:grid;grid-template-columns:250px minmax(0,1fr);gap:20px;align-items:center;margin-bottom:15px;padding:17px 20px;background:var(--accent-soft);border:1px solid #cbd3ff;border-radius:10px}.relationship-note h3{font-size:15.6px;margin:5px 0 0}.relationship-note>p{color:var(--muted);font-size:12px;line-height:1.55;margin:0}.relationship-note code{font-size:10.8px}.group-plan{margin-bottom:24px}.group-related-links{display:flex;align-items:center;gap:8px;margin-top:18px;padding-top:14px;border-top:1px solid var(--line)}.group-related-links>span{color:var(--muted);font-size:10.8px;margin-right:auto}.group-destinations>.section-head{margin-bottom:12px}.group-destination-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px}.group-destination-card{display:grid;grid-template-columns:minmax(0,1fr) 100px;gap:16px;min-height:150px;padding:18px;background:var(--panel);border:1px solid var(--line);border-radius:10px;text-decoration:none;box-shadow:0 2px 8px rgba(21,40,33,.025)}.group-destination-card:hover{border-color:var(--accent-light)}.group-destination-card h3{font-size:15.6px;margin:5px 0 7px}.group-destination-card p:not(.kicker){color:var(--muted);font-size:10.8px;line-height:1.5;margin:0}.destination-rollup{align-self:center;text-align:right}.destination-rollup strong,.destination-rollup small{display:block}.destination-rollup strong{font:500 32.4px Georgia,serif}.destination-rollup small{color:var(--muted);font-size:9.6px;line-height:1.35;margin-top:3px}
|
|
6493
|
-
.collection-review-panel{margin:16px 0 22px}.collection-review-panel.required{border-color:#d8bd78}.collection-review-panel.current{border-color:#b9dac6}.collection-review-head{display:flex;align-items:flex-start;justify-content:space-between;gap:24px}.collection-review-head h3{margin:4px 0 6px}.collection-review-head p:not(.kicker){max-width:900px;margin:0;color:var(--muted);font-size:11px;line-height:1.5}.collection-review-panel details{margin-top:13px;padding:10px 12px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-panel summary{cursor:pointer;font-size:11px;font-weight:750}.collection-review-panel ul,.collection-review-checks ul{margin:9px 0 0;padding-left:20px;color:var(--muted);font-size:10.4px;line-height:1.55}.collection-review-foot{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:13px}.collection-review-result{display:flex;align-items:baseline;gap:8px;margin:0}.collection-review-result strong{font-size:11px}.collection-review-result span{color:var(--muted);font-size:10.4px}.collection-review-checks{margin:13px 0;padding:11px 13px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-checks>strong{font-size:11px}.event-dialog-steps.collection-review-checks{margin:15px 0 0;padding:10px;border:0}.collection-review-dialog textarea{box-sizing:border-box;width:100%;resize:vertical}.review-save-status{margin-right:auto;color:var(--muted);font-size:11px}.resource-review-criteria{margin:16px 0 22px;padding:13px 15px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}.resource-review-criteria>strong{font-size:11px}.resource-review-criteria ul{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px 28px;margin:9px 0 0;padding-left:20px;color:var(--muted);font-size:10.4px;line-height:1.5}.commit-dialog .resource-review-criteria{margin:12px 0;background:var(--surface-soft)}.record-workflow-action{display:grid;grid-template-columns:auto minmax(140px,1fr);gap:7px;align-items:start;min-width:240px;text-decoration:none}.record-workflow-action strong,.record-workflow-action small{display:block}.record-workflow-action strong{font-size:10.4px}.record-workflow-action small{margin-top:2px;color:var(--muted);font-size:9.2px;line-height:1.35}.record-workflow-clear{color:var(--muted);font-size:10px;white-space:nowrap}.workflow-guidance{margin:16px 0 22px}.workflow-guidance .panel-head{align-items:flex-start;margin-bottom:12px}.workflow-guidance .panel-head h3{margin:4px 0}.workflow-guidance .panel-head p:not(.kicker){margin:4px 0 0;color:var(--muted);font-size:11px}.workflow-findings{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.workflow-findings>a,.workflow-findings>div{display:grid;grid-template-columns:auto minmax(0,1fr);gap:9px;align-items:start;padding:10px 11px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft);text-decoration:none}.workflow-findings strong,.workflow-findings small{display:block}.workflow-findings strong{font-size:11px}.workflow-findings small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.4}.workflow-finding-status{min-width:62px;padding:3px 5px;border-radius:99px;background:var(--accent-soft);color:var(--accent);font-size:8.4px;font-weight:750;text-align:center;text-transform:uppercase;letter-spacing:.04em}.workflow-finding-status.overdue,.workflow-finding-status.blocked{background:#f5ded9;color:#8d352c}.workflow-finding-status.ready,.workflow-finding-status.due,.workflow-finding-status.open{background:#f7e9cf;color:#855717}.workflow-finding-status.complete{background:#ddefe5;color:#176143}.workflow-guidance-more{margin:10px 0 0;color:var(--muted);font-size:9.6px}.workflow-guidance-more>summary{cursor:pointer;font-weight:750}.workflow-findings-more{margin-top:9px}.workflow-guidance-more>p{margin:8px 0 0}.context-workflow{display:flex;align-items:center;justify-content:space-between;gap:24px;margin:-8px 0 18px;padding:17px 19px;background:var(--accent-soft);border:1px solid #cbd3ff;border-radius:10px}.context-workflow h3{font-size:16px;margin:4px 0 5px}.context-workflow p:not(.kicker){color:var(--muted);font-size:12px;line-height:1.5;margin:0;max-width:850px}.context-workflow .button{white-space:nowrap}.policy-lifecycle-note{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(260px,1fr);gap:24px;margin-top:18px}.policy-lifecycle-note h3{margin:5px 0 8px}.policy-lifecycle-note p:not(.kicker),.policy-library-proposals p{color:var(--muted);font-size:11px;line-height:1.55}.policy-library-proposals{display:grid;gap:8px}.policy-library-proposals article{padding:11px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.policy-library-proposals p{margin:5px 0 8px}.policy-activation{display:grid;gap:12px;margin-top:22px}.policy-activation-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.policy-activation-card{padding:17px 18px;border:1px solid #d8bd78;border-radius:10px;background:var(--panel)}.policy-activation-card.active-and-operating{border-color:#b9dac6}.policy-activation-card h3{margin:7px 0 0}.policy-activation-gaps{display:grid;gap:9px;margin-top:13px}.policy-activation-gaps>div{padding-top:8px;border-top:1px solid var(--line)}.policy-activation-gaps small{display:block;margin-bottom:6px;color:var(--muted);font-size:9.6px;font-weight:700;text-transform:uppercase;letter-spacing:.05em}.policy-activation-warning{margin:11px 0 0;padding:9px 10px;border-radius:7px;background:#f7e9cf;color:#6d4917;font-size:10.4px;line-height:1.45}.evidence-map{display:grid;gap:12px;margin-top:18px}.evidence-map-head{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:20px 22px;background:var(--accent-soft);border:1px solid #cbd3ff;border-radius:11px}.evidence-map-head>div:first-child{max-width:760px}.evidence-map-head h2{font:500 24px Georgia,serif;margin:5px 0 7px}.evidence-map-head p:not(.kicker){color:var(--muted);font-size:12px;line-height:1.55;margin:0}.evidence-map-actions{display:flex;gap:8px}.evidence-map-card{padding:18px 20px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.evidence-map-card.complete{border-color:#b9dac6}.evidence-map-card-head{display:flex;align-items:start;justify-content:space-between;gap:20px}.evidence-map-card-head h3{font-size:16px;margin:7px 0 0}.evidence-map-card-head>small{color:var(--muted);font-size:10px;text-align:right}.evidence-map-card>p{color:var(--muted);font-size:12px;line-height:1.55;margin:12px 0}.evidence-map-expectation{display:grid;grid-template-columns:120px minmax(0,1fr);gap:12px;padding:8px 0;border-top:1px solid var(--line);font-size:11px;line-height:1.5}.evidence-map-expectation strong{color:var(--muted);font-size:9.6px;text-transform:uppercase;letter-spacing:.06em}.evidence-map-expectation code{background:var(--paper);border-radius:4px;padding:2px 5px}.evidence-map-links{display:grid;grid-template-columns:minmax(220px,1fr) minmax(280px,1.2fr);gap:20px;margin-top:13px;padding-top:13px;border-top:1px solid var(--line)}.evidence-map-links>div>small{display:block;color:var(--muted);font-size:9.6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin-bottom:7px}.evidence-map-references,.evidence-map-sources{display:flex;flex-wrap:wrap;gap:5px}.evidence-map-source{display:flex;align-items:center;gap:7px;padding:7px 9px;background:var(--paper);border:1px solid var(--line);border-radius:7px;font-size:11px;text-decoration:none}.evidence-map-source.complete{border-color:#b9dac6;background:#edf7f1}.evidence-map-source small{color:var(--muted);font-size:9px}.evidence-map-status{padding:9px 11px;background:var(--paper);border-radius:7px}.evidence-map-empty{padding:24px;background:var(--panel);border:1px solid var(--line);border-radius:10px}.evidence-map-empty h3{margin:6px 0}.evidence-map-empty p:not(.kicker){color:var(--muted);font-size:12px;margin:0 0 14px}.stage-pages{margin-top:24px}.stage-page-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.stage-page-card{position:relative;display:flex;flex-direction:column;min-width:0;padding:18px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025);transition:border-color .15s,box-shadow .15s}.stage-page-card:hover{border-color:var(--accent-light);box-shadow:0 5px 16px rgba(21,40,33,.07)}.stage-page-card.complete{border-color:#b9dac6}.stage-page-card-link{position:absolute;inset:0;z-index:1;border-radius:10px}.stage-page-card-link:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.stage-page-card-head{display:flex;align-items:center;justify-content:space-between;gap:18px}.stage-page-card-head h3{font-size:15.6px;line-height:1.35;margin:4px 0 0}.stage-page-card-head>div>small{display:block;color:var(--accent);font-size:9.6px;font-weight:700}.stage-page-card>p{color:var(--muted);font-size:12px;line-height:1.5;margin:13px 0 0}.stage-page-tasks{position:relative;z-index:2;display:grid;gap:6px;margin-top:13px}.stage-page-tasks>a{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px;align-items:start;padding:9px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft);text-decoration:none}.stage-page-tasks>a:hover{border-color:var(--accent-light)}.stage-page-tasks strong,.stage-page-tasks small{display:block}.stage-page-tasks strong{font-size:10.8px}.stage-page-tasks small{margin-top:2px;color:var(--muted);font-size:9.4px;line-height:1.35}.stage-page-tasks-more{color:var(--muted);font-size:9.4px}.stage-page-rollup{display:flex;flex:0 0 104px;flex-direction:column;justify-content:center;text-align:right}.stage-page-rollup strong,.stage-page-rollup small{display:block}.stage-page-rollup strong{font:500 24px Georgia,serif}.stage-page-rollup small{color:var(--muted);font-size:9.6px;line-height:1.35;margin-top:2px}.stage-page-card-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:auto;padding-top:15px}.stage-page-completion-state{color:var(--muted);font-size:10.8px}.stage-page-completion-state.complete{color:#176143;font-weight:700}.stage-page-open{color:var(--accent);font-size:12px;font-weight:700}.work-queue-section{margin-top:28px}.work-queue-section>.section-head{align-items:end}
|
|
6493
|
+
.collection-review-panel{margin:16px 0 22px}.collection-review-panel.required{border-color:#d8bd78}.collection-review-panel.current{border-color:#b9dac6}.collection-review-head{display:flex;align-items:flex-start;justify-content:space-between;gap:24px}.collection-review-head h3{margin:4px 0 6px}.collection-review-head p:not(.kicker){max-width:900px;margin:0;color:var(--muted);font-size:11px;line-height:1.5}.collection-review-panel details{margin-top:13px;padding:10px 12px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-panel summary{cursor:pointer;font-size:11px;font-weight:750}.collection-review-panel ul,.collection-review-checks ul{margin:9px 0 0;padding-left:20px;color:var(--muted);font-size:10.4px;line-height:1.55}.collection-review-foot{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:13px}.collection-review-result{display:flex;align-items:baseline;gap:8px;margin:0}.collection-review-result strong{font-size:11px}.collection-review-result span{color:var(--muted);font-size:10.4px}.collection-review-checks{margin:13px 0;padding:11px 13px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-checks>strong{font-size:11px}.event-dialog-steps.collection-review-checks{margin:15px 0 0;padding:10px;border:0}.collection-review-dialog textarea{box-sizing:border-box;width:100%;resize:vertical}.review-save-status{margin-right:auto;color:var(--muted);font-size:11px}.resource-review-criteria{margin:16px 0 22px;padding:13px 15px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}.resource-review-criteria>strong{font-size:11px}.resource-review-criteria ul{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px 28px;margin:9px 0 0;padding-left:20px;color:var(--muted);font-size:10.4px;line-height:1.5}.commit-dialog .resource-review-criteria{margin:12px 0;background:var(--surface-soft)}.record-workflow-action{display:grid;grid-template-columns:auto minmax(140px,1fr);gap:7px;align-items:start;min-width:240px;text-decoration:none}.record-workflow-action strong,.record-workflow-action small{display:block}.record-workflow-action strong{font-size:10.4px}.record-workflow-action small{margin-top:2px;color:var(--muted);font-size:9.2px;line-height:1.35}.record-workflow-clear{color:var(--muted);font-size:10px;white-space:nowrap}.workflow-guidance{margin:16px 0 22px}.workflow-guidance .panel-head{align-items:flex-start;margin-bottom:12px}.workflow-guidance .panel-head h3{margin:4px 0}.workflow-guidance .panel-head p:not(.kicker){margin:4px 0 0;color:var(--muted);font-size:11px}.workflow-findings{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.workflow-findings>a,.workflow-findings>div{display:grid;grid-template-columns:auto minmax(0,1fr);gap:9px;align-items:start;padding:10px 11px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft);text-decoration:none}.workflow-findings strong,.workflow-findings small{display:block}.workflow-findings strong{font-size:11px}.workflow-findings small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.4}.workflow-finding-status{min-width:62px;padding:3px 5px;border-radius:99px;background:var(--accent-soft);color:var(--accent);font-size:8.4px;font-weight:750;text-align:center;text-transform:uppercase;letter-spacing:.04em}.workflow-finding-status.overdue,.workflow-finding-status.blocked{background:#f5ded9;color:#8d352c}.workflow-finding-status.ready,.workflow-finding-status.due,.workflow-finding-status.open{background:#f7e9cf;color:#855717}.workflow-finding-status.complete{background:#ddefe5;color:#176143}.workflow-guidance-more{margin:10px 0 0;color:var(--muted);font-size:9.6px}.workflow-guidance-more>summary{cursor:pointer;font-weight:750}.workflow-findings-more{margin-top:9px}.workflow-guidance-more>p{margin:8px 0 0}.context-workflow{display:flex;align-items:center;justify-content:space-between;gap:24px;margin:-8px 0 18px;padding:17px 19px;background:var(--accent-soft);border:1px solid #cbd3ff;border-radius:10px}.context-workflow h3{font-size:16px;margin:4px 0 5px}.context-workflow p:not(.kicker){color:var(--muted);font-size:12px;line-height:1.5;margin:0;max-width:850px}.context-workflow .button{white-space:nowrap}.policy-lifecycle-note{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(260px,1fr);gap:24px;margin-top:18px}.policy-lifecycle-note h3{margin:5px 0 8px}.policy-lifecycle-note p:not(.kicker),.policy-library-proposals p{color:var(--muted);font-size:11px;line-height:1.55}.policy-library-proposals{display:grid;gap:8px}.policy-library-proposals article{padding:11px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.policy-library-proposals p{margin:5px 0 8px}.policy-activation{display:grid;gap:12px;margin-top:22px}.policy-activation-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.policy-activation-card{padding:17px 18px;border:1px solid #d8bd78;border-radius:10px;background:var(--panel)}.policy-activation-card.active-and-operating{border-color:#b9dac6}.policy-activation-card h3{margin:7px 0 0}.policy-activation-gaps{display:grid;gap:9px;margin-top:13px}.policy-activation-gaps>div{padding-top:8px;border-top:1px solid var(--line)}.policy-activation-gaps small{display:block;margin-bottom:6px;color:var(--muted);font-size:9.6px;font-weight:700;text-transform:uppercase;letter-spacing:.05em}.policy-activation-warning{margin:11px 0 0;padding:9px 10px;border-radius:7px;background:#f7e9cf;color:#6d4917;font-size:10.4px;line-height:1.45}.evidence-map{display:grid;gap:12px;margin-top:18px}.evidence-map-head{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:20px 22px;background:var(--accent-soft);border:1px solid #cbd3ff;border-radius:11px}.evidence-map-head>div:first-child{max-width:760px}.evidence-map-head h2{font:500 24px Georgia,serif;margin:5px 0 7px}.evidence-map-head p:not(.kicker){color:var(--muted);font-size:12px;line-height:1.55;margin:0}.evidence-map-actions{display:flex;gap:8px}.evidence-map-card{padding:18px 20px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.evidence-map-card.complete{border-color:#b9dac6}.evidence-map-card-head{display:flex;align-items:start;justify-content:space-between;gap:20px}.evidence-map-card-head h3{font-size:16px;margin:7px 0 0}.evidence-map-card-head>small{color:var(--muted);font-size:10px;text-align:right}.evidence-map-card>p{color:var(--muted);font-size:12px;line-height:1.55;margin:12px 0}.evidence-map-expectation{display:grid;grid-template-columns:120px minmax(0,1fr);gap:12px;padding:8px 0;border-top:1px solid var(--line);font-size:11px;line-height:1.5}.evidence-map-expectation strong{color:var(--muted);font-size:9.6px;text-transform:uppercase;letter-spacing:.06em}.evidence-map-expectation code{background:var(--paper);border-radius:4px;padding:2px 5px}.evidence-map-links{display:grid;grid-template-columns:minmax(220px,1fr) minmax(280px,1.2fr);gap:20px;margin-top:13px;padding-top:13px;border-top:1px solid var(--line)}.evidence-map-links>div>small{display:block;color:var(--muted);font-size:9.6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin-bottom:7px}.evidence-map-references,.evidence-map-sources{display:flex;flex-wrap:wrap;gap:5px}.evidence-map-source{display:flex;align-items:center;gap:7px;padding:7px 9px;background:var(--paper);border:1px solid var(--line);border-radius:7px;font-size:11px;text-decoration:none}.evidence-map-source.complete{border-color:#b9dac6;background:#edf7f1}.evidence-map-source small{color:var(--muted);font-size:9px}.evidence-map-status{padding:9px 11px;background:var(--paper);border-radius:7px}.evidence-map-empty{padding:24px;background:var(--panel);border:1px solid var(--line);border-radius:10px}.evidence-map-empty h3{margin:6px 0}.evidence-map-empty p:not(.kicker){color:var(--muted);font-size:12px;margin:0 0 14px}.stage-pages{margin-top:24px}.stage-page-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.stage-page-card{position:relative;display:flex;flex-direction:column;min-width:0;padding:18px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025);transition:border-color .15s,box-shadow .15s}.stage-page-card:hover{border-color:var(--accent-light);box-shadow:0 5px 16px rgba(21,40,33,.07)}.stage-page-card.complete{border-color:#b9dac6}.stage-page-card-head{display:flex;align-items:center;justify-content:space-between;gap:18px}.stage-page-card-head h3{font-size:15.6px;line-height:1.35;margin:4px 0 0}.stage-page-card-head>div>small{display:block;color:var(--accent);font-size:9.6px;font-weight:700}.stage-page-card>p{color:var(--muted);font-size:12px;line-height:1.5;margin:13px 0 0}.stage-page-tasks{position:relative;z-index:2;display:grid;gap:6px;margin-top:13px}.stage-page-tasks>a{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px;align-items:start;padding:9px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft);text-decoration:none}.stage-page-tasks>a:hover{border-color:var(--accent-light)}.stage-page-tasks strong,.stage-page-tasks small{display:block}.stage-page-tasks strong{font-size:10.8px}.stage-page-tasks small{margin-top:2px;color:var(--muted);font-size:9.4px;line-height:1.35}.stage-page-tasks-more{color:var(--muted);font-size:9.4px}.stage-page-rollup{display:flex;flex:0 0 104px;flex-direction:column;justify-content:center;text-align:right}.stage-page-rollup strong,.stage-page-rollup small{display:block}.stage-page-rollup strong{font:500 24px Georgia,serif}.stage-page-rollup small{color:var(--muted);font-size:9.6px;line-height:1.35;margin-top:2px}.stage-page-card-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:auto;padding-top:15px}.stage-page-completion-state{color:var(--muted);font-size:10.8px}.stage-page-completion-state.complete{color:#176143;font-weight:700}.stage-page-open{color:var(--accent);font-size:12px;font-weight:700}.work-queue-section{margin-top:28px}.work-queue-section>.section-head{align-items:end}
|
|
6494
|
+
.stage-page-card-actions{display:flex;align-items:center;gap:10px}.stage-page-card-actions .stage-page-open{white-space:nowrap}.stage-page-open::after{content:"";position:absolute;inset:0;z-index:1;border-radius:10px}.stage-page-open:focus-visible::after{outline:2px solid var(--focus);outline-offset:2px}.stage-page-open>span{position:relative;z-index:2}
|
|
6494
6495
|
.workflow-findings>a:hover{border-color:var(--accent-light);box-shadow:0 3px 9px rgba(21,40,33,.05)}.workflow-findings>a:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.stage-page-card-head{align-items:flex-start}.stage-page-completion-state{flex:0 0 auto;max-width:150px;padding:5px 8px;border-radius:99px;background:#f7e9cf;color:#855717;font-size:9.6px;font-weight:750;line-height:1.25;text-align:right}.stage-page-completion-state.complete{background:#ddefe5;color:#176143}.obligation-card.workflow-target{outline:2px solid var(--focus);outline-offset:3px}.obligation-card-foot{flex-wrap:wrap}.obligation-card-foot .obligation-links{flex:1 1 120px}
|
|
6495
6496
|
.collection-review-panel.current details{margin-top:8px}.collection-review-detail-content{margin-top:10px}.collection-review-complete-summary{display:flex;align-items:baseline;gap:10px}.collection-review-complete-summary>.kicker{margin:0;white-space:nowrap}.collection-review-panel.current .collection-review-foot{justify-content:flex-end;margin-top:8px}
|
|
6496
6497
|
.collection-review-current-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.collection-review-panel.current .collection-review-details{flex:0 0 auto;margin:0;padding:0;border:0;background:transparent}.collection-review-panel.current .collection-review-details>summary{color:var(--accent);white-space:nowrap}.collection-review-panel.current .collection-review-details[open]{flex-basis:100%;order:3;padding:10px 12px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-panel.current .collection-review-details[open]>summary{margin-bottom:10px}.collection-review-panel.current .collection-review-details[open] .collection-review-detail-content{margin-top:0}.collection-review-panel.current .button{margin-left:auto}
|
|
6497
6498
|
@media(max-width:520px){.collection-review-complete-summary{flex:1 1 180px;min-width:0;flex-wrap:wrap}.collection-review-complete-summary .collection-review-result{min-width:0;flex-wrap:wrap}.collection-review-complete-summary .collection-review-result span{overflow-wrap:anywhere}.collection-review-panel.current .button{margin-left:0}}
|
|
6499
|
+
@media(max-width:520px){.stage-page-card-head{align-items:stretch;flex-direction:column;gap:10px}.stage-page-card-actions{justify-content:space-between}}
|
|
6498
6500
|
.evidence-attachments .panel-head{align-items:flex-start}.evidence-attachments .panel-head h3{margin:0}.evidence-attachments .panel-head p{margin:4px 0 0;color:var(--muted);font-size:10px}.evidence-attachments ul{list-style:none;margin:0;padding:0}.evidence-attachments li{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 0;border-top:1px solid var(--line)}.evidence-attachments li:first-child{border-top:0}.evidence-attachments strong,.evidence-attachments small{display:block}.evidence-attachments strong{font-size:11px}.evidence-attachments small{margin-top:2px;color:var(--muted);font-size:9px;overflow-wrap:anywhere}.evidence-attachments .attachment-empty{display:block;color:var(--muted);font-size:10px;line-height:1.45}.danger-text{color:var(--red)}
|
|
6499
6501
|
.policy-lifecycle-note.single{grid-template-columns:1fr}
|
|
6500
6502
|
.policy-content-table{margin-top:22px}.policy-content-table>.section-head{align-items:end}.policy-content-table>.section-head h2{font:500 27.6px Georgia,serif;margin:5px 0 6px}.policy-content-table>.section-head p:not(.kicker){max-width:720px;margin:0;color:var(--muted);font-size:12px;line-height:1.5}.policy-content-tools{display:flex;align-items:center;justify-content:flex-end;gap:9px;flex-wrap:wrap}.policy-content-tools input[type="search"]{min-width:210px;border:1px solid var(--line);border-radius:7px;background:var(--field);padding:9px 11px}.history-toggle{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:11px}.policy-content-add{position:relative}.policy-content-add>summary{list-style:none}.policy-content-add>summary::-webkit-details-marker{display:none}.policy-content-add>div{position:absolute;right:0;z-index:5;display:grid;min-width:150px;margin-top:6px;padding:5px;border:1px solid var(--line);border-radius:8px;background:var(--panel);box-shadow:var(--shadow)}.policy-content-add button{border:0;border-radius:5px;background:transparent;padding:8px 10px;color:var(--ink);text-align:left;cursor:pointer}.policy-content-add button:hover{background:var(--accent-soft)}.policy-content-type span,.policy-content-approval span{display:block}.policy-content-type small,.policy-content-approval small{display:block;margin-top:3px;color:var(--muted);font-size:9.6px}.policy-content-filter-empty{margin:10px 0 0;padding:16px;border:1px dashed var(--line);border-radius:8px;color:var(--muted);font-size:12px;text-align:center}.policy-content-count{margin:9px 2px 0;color:var(--muted);font-size:10.8px;text-align:right}
|
|
@@ -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";
|
|
@@ -27,6 +27,8 @@ import { resolveProgram } from "./program.js";
|
|
|
27
27
|
import { resourceReviewRevisions, retentionReviewResourceIds, retentionRuleIsCurrent } from "./retention.js";
|
|
28
28
|
import { signatoryAppointmentIssue, soc2ReportEvidenceIssue, subsequentEventsReviewIssue } from "./soc2.js";
|
|
29
29
|
import { planReconciliation } from "./reconciliation.js";
|
|
30
|
+
import { documentIsAuditSpecific } from "./program-lifecycle.js";
|
|
31
|
+
import { resourceProgramContext } from "./program-path.js";
|
|
30
32
|
import { currentCalendarDate } from "./time.js";
|
|
31
33
|
import { measureTiming } from "./timing.js";
|
|
32
34
|
import { validateWorkspace } from "./validate.js";
|
|
@@ -388,10 +390,10 @@ function recordFinalizationFindings(loaded, program) {
|
|
|
388
390
|
if (["workspace", "renderer-settings"].includes(record.type)) continue;
|
|
389
391
|
const incomplete = recordIncompleteReason(record, loaded, program);
|
|
390
392
|
if (incomplete) {
|
|
391
|
-
findings.push(finalizationFinding(record, incomplete));
|
|
393
|
+
findings.push(finalizationFinding(record, incomplete, loaded.model));
|
|
392
394
|
}
|
|
393
395
|
for (const missing of finalizationFields(record, loaded.model, loaded.resources, program)) {
|
|
394
|
-
findings.push(fieldFinding(record, missing));
|
|
396
|
+
findings.push(fieldFinding(record, missing, loaded.model));
|
|
395
397
|
}
|
|
396
398
|
}
|
|
397
399
|
return findings;
|
|
@@ -531,10 +533,13 @@ function finalizationFields(record, model, resources, program) {
|
|
|
531
533
|
&& model.resources[record.type]?.fields?.applicabilityReview
|
|
532
534
|
&& !applicabilityReviewIsCurrent(record.applicabilityReview, record, program, resources, model);
|
|
533
535
|
if (needsReview) {
|
|
536
|
+
const currentRevision = applicabilityScopeRevision(record, program, resources, model);
|
|
534
537
|
fields.push({
|
|
535
538
|
field: "applicabilityReview",
|
|
536
539
|
requiredness: "required",
|
|
537
|
-
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.`
|
|
538
543
|
});
|
|
539
544
|
}
|
|
540
545
|
if (record.type === "policy" && model.resources.policy?.fields?.programRole && !record.programRole) {
|
|
@@ -591,13 +596,13 @@ function finalizationFields(record, model, resources, program) {
|
|
|
591
596
|
return fields;
|
|
592
597
|
}
|
|
593
598
|
|
|
594
|
-
function finalizationFinding(record, details) {
|
|
599
|
+
function finalizationFinding(record, details, model) {
|
|
595
600
|
const code = `record.${record.type}.${record.id}.finalize`;
|
|
596
601
|
return {
|
|
597
602
|
key: code,
|
|
598
603
|
code,
|
|
599
|
-
assessment: recordAssessment(record
|
|
600
|
-
stage: recordStage(record
|
|
604
|
+
assessment: recordAssessment(record, model),
|
|
605
|
+
stage: recordStage(record, model),
|
|
601
606
|
state: details.state,
|
|
602
607
|
severity: details.state === "blocked" ? "error" : "warning",
|
|
603
608
|
requiredness: details.requiredness,
|
|
@@ -609,13 +614,13 @@ function finalizationFinding(record, details) {
|
|
|
609
614
|
};
|
|
610
615
|
}
|
|
611
616
|
|
|
612
|
-
function fieldFinding(record, missing) {
|
|
617
|
+
function fieldFinding(record, missing, model) {
|
|
613
618
|
const code = `record.${record.type}.${record.id}.field.${missing.field}`;
|
|
614
619
|
return {
|
|
615
620
|
key: code,
|
|
616
621
|
code,
|
|
617
|
-
assessment: recordAssessment(record
|
|
618
|
-
stage: recordStage(record
|
|
622
|
+
assessment: recordAssessment(record, model),
|
|
623
|
+
stage: recordStage(record, model),
|
|
619
624
|
state: "ready",
|
|
620
625
|
severity: "warning",
|
|
621
626
|
requiredness: missing.requiredness,
|
|
@@ -1732,22 +1737,27 @@ function shellArgument(value) {
|
|
|
1732
1737
|
: `'${text.replaceAll("'", "'\\''")}'`;
|
|
1733
1738
|
}
|
|
1734
1739
|
|
|
1735
|
-
function recordAssessment(
|
|
1736
|
-
|
|
1737
|
-
if (
|
|
1740
|
+
function recordAssessment(record, model) {
|
|
1741
|
+
const stage = workflowStageForRecord(record, model);
|
|
1742
|
+
if (stage === "audit") return "audit-readiness";
|
|
1743
|
+
if (stage === "operate") return "period-health";
|
|
1738
1744
|
return "program-configuration";
|
|
1739
1745
|
}
|
|
1740
1746
|
|
|
1741
|
-
function
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
if (
|
|
1747
|
-
if (
|
|
1747
|
+
export function workflowStageForRecord(record, model) {
|
|
1748
|
+
const type = record.type;
|
|
1749
|
+
if (documentIsAuditSpecific(record, model)) return "audit";
|
|
1750
|
+
if (type === "source-coverage") return "controls";
|
|
1751
|
+
const stage = resourceProgramContext(type, model)?.id;
|
|
1752
|
+
if (stage === "run") return "operate";
|
|
1753
|
+
if (stage) return stage;
|
|
1748
1754
|
return "operate";
|
|
1749
1755
|
}
|
|
1750
1756
|
|
|
1757
|
+
function recordStage(record, model) {
|
|
1758
|
+
return workflowStageForRecord(record, model);
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1751
1761
|
function appointmentRequiredness(record, model) {
|
|
1752
1762
|
return model.appointmentTemplates?.[record.appointmentKind]?.requiredness || "conditional";
|
|
1753
1763
|
}
|