filegrc 0.14.0 → 0.15.1
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/model/index.js +2 -1
- package/model/v11.json +5 -5
- package/package.json +1 -1
- package/src/agent.js +1 -1
- package/src/audit-preparation.js +16 -4
- package/src/audit-selection.js +33 -0
- package/src/cli.js +6 -3
- package/src/collection-review.js +61 -2
- package/src/collection-revision.js +3 -1
- package/src/collection-scope.js +45 -1
- package/src/index.js +1 -1
- package/src/program-path.js +59 -14
- package/src/program-readiness.js +217 -30
- package/src/retention-schedule-approval.js +121 -0
- package/src/retention.js +87 -12
- package/src/state.js +19 -22
- package/src/validate.js +46 -0
- package/src/web.js +509 -139
- package/src/workflow-history-integrity.js +5 -1
- package/src/workflow.js +2 -0
package/src/program-readiness.js
CHANGED
|
@@ -32,6 +32,94 @@ import { assessSourceCoverageReadiness } from "./source-coverage.js";
|
|
|
32
32
|
import { currentCalendarDate, timestampFromLocalDateTime } from "./time.js";
|
|
33
33
|
import { loadWorkspace } from "./workspace.js";
|
|
34
34
|
|
|
35
|
+
export function calculateProgramProgress({
|
|
36
|
+
stages = [],
|
|
37
|
+
target = {},
|
|
38
|
+
evidenceReady = false,
|
|
39
|
+
operating = false,
|
|
40
|
+
asOf,
|
|
41
|
+
window = null
|
|
42
|
+
} = {}) {
|
|
43
|
+
const typeTwo = target.goal === "soc-2-type-2";
|
|
44
|
+
const trackedWindow = normalizeProgressWindow(window || target.candidateCoverage, window ? "audit" : "candidate");
|
|
45
|
+
const windowStarted = Boolean(
|
|
46
|
+
typeTwo
|
|
47
|
+
&& trackedWindow
|
|
48
|
+
&& asOf
|
|
49
|
+
&& trackedWindow.start <= asOf
|
|
50
|
+
);
|
|
51
|
+
const setupProgress = calculateSetupActionProgress(stages, target, Boolean(trackedWindow));
|
|
52
|
+
if (windowStarted) {
|
|
53
|
+
const totalDays = calendarDayDistance(trackedWindow.start, trackedWindow.end) + 1;
|
|
54
|
+
const elapsedDays = Math.min(
|
|
55
|
+
totalDays,
|
|
56
|
+
Math.max(0, calendarDayDistance(trackedWindow.start, asOf) + 1)
|
|
57
|
+
);
|
|
58
|
+
const percent = totalDays ? Math.round((elapsedDays / totalDays) * 100) : 0;
|
|
59
|
+
const complete = elapsedDays >= totalDays;
|
|
60
|
+
const healthy = Boolean(operating);
|
|
61
|
+
return {
|
|
62
|
+
mode: "operating-window",
|
|
63
|
+
label: trackedWindow.source === "audit" ? "Audit window" : "Operating window",
|
|
64
|
+
status: healthy ? complete ? "Window complete" : "Operating" : "Needs attention",
|
|
65
|
+
complete: elapsedDays,
|
|
66
|
+
total: totalDays,
|
|
67
|
+
remaining: Math.max(0, totalDays - elapsedDays),
|
|
68
|
+
percent,
|
|
69
|
+
unit: "day",
|
|
70
|
+
start: trackedWindow.start,
|
|
71
|
+
end: trackedWindow.end,
|
|
72
|
+
source: trackedWindow.source,
|
|
73
|
+
detail: complete
|
|
74
|
+
? `${totalDays} of ${totalDays} days elapsed.`
|
|
75
|
+
: `Day ${elapsedDays} of ${totalDays}; ${Math.max(0, totalDays - elapsedDays)} days remain.`,
|
|
76
|
+
operating
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
mode: "setup-progress",
|
|
82
|
+
label: "Program setup",
|
|
83
|
+
status: !setupProgress.remaining
|
|
84
|
+
? evidenceReady && typeTwo && trackedWindow?.start > asOf
|
|
85
|
+
? "Scheduled"
|
|
86
|
+
: evidenceReady ? "Evidence ready" : "Ready"
|
|
87
|
+
: evidenceReady && typeTwo ? "Ready to start" : "Needs work",
|
|
88
|
+
...setupProgress,
|
|
89
|
+
unit: "action",
|
|
90
|
+
detail: setupProgress.remaining
|
|
91
|
+
? `${setupProgress.complete} of ${setupProgress.total} required actions complete.`
|
|
92
|
+
: `All ${setupProgress.total} required actions are complete.`,
|
|
93
|
+
operating
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function calculateSetupActionProgress(stages, target, windowMilestoneComplete) {
|
|
98
|
+
const units = new Map();
|
|
99
|
+
for (const currentStage of stages.filter(({ id }) => id !== "operation")) {
|
|
100
|
+
for (const current of currentStage.items || []) {
|
|
101
|
+
if (["info", "later"].includes(current.status) || current.progressUnit === false) continue;
|
|
102
|
+
const key = `${currentStage.id}:${current.progressUnitId || current.id}`;
|
|
103
|
+
const existing = units.get(key);
|
|
104
|
+
if (!existing || existing.status === "complete" && current.status !== "complete") {
|
|
105
|
+
units.set(key, current);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const setupUnits = [...units.values()];
|
|
111
|
+
const complete = setupUnits.filter(({ status }) => status === "complete").length;
|
|
112
|
+
const total = setupUnits.length + (target.goal === "soc-2-type-2" ? 1 : 0);
|
|
113
|
+
const completed = complete + (windowMilestoneComplete ? 1 : 0);
|
|
114
|
+
const remaining = Math.max(0, total - completed);
|
|
115
|
+
return {
|
|
116
|
+
complete: completed,
|
|
117
|
+
total,
|
|
118
|
+
remaining,
|
|
119
|
+
percent: total ? Math.round((completed / total) * 100) : 0
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
35
123
|
export async function assessProgramReadiness(input, options = {}) {
|
|
36
124
|
const loaded = input?.resources && input?.model && input?.entries
|
|
37
125
|
? input
|
|
@@ -41,6 +129,15 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
41
129
|
const workspace = loaded.workspace || records.find((record) => record.type === "workspace");
|
|
42
130
|
const program = resolveProgram(loaded, options.programId);
|
|
43
131
|
const asOf = options.asOf || currentCalendarDate(workspace?.timezone || "UTC");
|
|
132
|
+
const requestedAudit = options.auditId
|
|
133
|
+
? records.find((record) => record.type === "audit" && record.id === options.auditId)
|
|
134
|
+
: null;
|
|
135
|
+
const auditWindow = requestedAudit?.auditKind === "soc-2-type-2"
|
|
136
|
+
? requestedAudit.coverage
|
|
137
|
+
: selectedAuditWindow(records, program, asOf);
|
|
138
|
+
const progressWindow = requestedAudit?.auditKind === "soc-2-type-2"
|
|
139
|
+
? normalizeProgressWindow(auditWindow, "audit")
|
|
140
|
+
: selectedProgressWindow(program?.candidateCoverage, auditWindow, asOf);
|
|
44
141
|
const scope = programScope(program, records, byId, loaded.model, loaded);
|
|
45
142
|
const collectionReviews = assessCollectionReviews(loaded, { programId: program.id });
|
|
46
143
|
const markdown = new Map();
|
|
@@ -57,12 +154,19 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
57
154
|
.map(collectionReviewReadinessItem));
|
|
58
155
|
const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
|
|
59
156
|
controlStage.items.push(...sourceStage.items);
|
|
60
|
-
|
|
157
|
+
const retentionItems = await assessRetentionReadiness(loaded, program, {
|
|
61
158
|
informationTypesReviewed: collectionReviews.find(({ resourceType }) => resourceType === "information-type")?.complete === true
|
|
62
|
-
})
|
|
63
|
-
|
|
159
|
+
});
|
|
160
|
+
const retentionReviewItems = collectionReviews
|
|
64
161
|
.filter(({ resourceType }) => resourceType === "retention-schedule-item")
|
|
65
|
-
.map(collectionReviewReadinessItem)
|
|
162
|
+
.map(collectionReviewReadinessItem);
|
|
163
|
+
if (modelSupports(loaded.model, "retention-schedule-approval")) {
|
|
164
|
+
policyStage.items.push(...retentionItems.filter(({ id }) => !id.startsWith("retention-source-coverage-")));
|
|
165
|
+
controlStage.items.push(...retentionItems.filter(({ id }) => id.startsWith("retention-source-coverage-")));
|
|
166
|
+
policyStage.items.push(...retentionReviewItems);
|
|
167
|
+
} else {
|
|
168
|
+
controlStage.items.push(...retentionItems, ...retentionReviewItems);
|
|
169
|
+
}
|
|
66
170
|
const governedContent = await governedContentItems(scope, records, byId, readMarkdown, asOf, loaded.model);
|
|
67
171
|
const policyActivations = await assessPolicyActivations(
|
|
68
172
|
requiredPolicies(scope, byId),
|
|
@@ -116,26 +220,54 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
116
220
|
const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0);
|
|
117
221
|
const stages = [
|
|
118
222
|
...evidenceGateStages,
|
|
119
|
-
await operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model)
|
|
223
|
+
await operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model, progressWindow)
|
|
120
224
|
];
|
|
121
225
|
finalizeStage(stages.at(-1));
|
|
122
|
-
const
|
|
226
|
+
const periodStarted = Boolean(
|
|
123
227
|
program?.assuranceGoal === "soc-2-type-2"
|
|
124
|
-
&&
|
|
125
|
-
&& coverageStart(program.candidateCoverage) <= asOf
|
|
228
|
+
&& progressWindow?.start <= asOf
|
|
126
229
|
);
|
|
127
230
|
const obligations = planObligations(records, { programId: program.id, asOf, through: asOf, model: loaded.model });
|
|
128
231
|
const policyLibrary = await assessPolicyLibraryUpgrades(loaded);
|
|
129
|
-
const operating =
|
|
232
|
+
const operating = Boolean(
|
|
233
|
+
evidenceReady
|
|
234
|
+
&& periodStarted
|
|
235
|
+
&& stages.at(-1).counts.action === 0
|
|
236
|
+
);
|
|
130
237
|
const canStartCandidatePeriod = Boolean(
|
|
131
238
|
evidenceReady
|
|
132
239
|
&& program?.assuranceGoal === "soc-2-type-2"
|
|
133
240
|
&& !program.candidateCoverage
|
|
241
|
+
&& !periodStarted
|
|
134
242
|
);
|
|
135
243
|
const items = stages.flatMap((current) => current.items);
|
|
136
244
|
const managedItems = items.filter((current) => !["info", "later"].includes(current.status));
|
|
137
245
|
const complete = managedItems.filter((current) => current.status === "complete").length;
|
|
138
246
|
const firstAction = items.find((current) => current.status === "action") || null;
|
|
247
|
+
const target = {
|
|
248
|
+
programId: program?.id || null,
|
|
249
|
+
goal: program?.assuranceGoal || "none",
|
|
250
|
+
label: assuranceGoalLabel(program?.assuranceGoal),
|
|
251
|
+
candidateCoverage: program?.candidateCoverage || null
|
|
252
|
+
};
|
|
253
|
+
const checkProgress = {
|
|
254
|
+
complete,
|
|
255
|
+
total: managedItems.length,
|
|
256
|
+
percent: managedItems.length ? Math.round((complete / managedItems.length) * 100) : 0
|
|
257
|
+
};
|
|
258
|
+
const progress = calculateProgramProgress({
|
|
259
|
+
stages,
|
|
260
|
+
target,
|
|
261
|
+
evidenceReady,
|
|
262
|
+
operating,
|
|
263
|
+
asOf,
|
|
264
|
+
window: progressWindow
|
|
265
|
+
});
|
|
266
|
+
const setupProgress = calculateSetupActionProgress(
|
|
267
|
+
stages,
|
|
268
|
+
target,
|
|
269
|
+
Boolean(progressWindow)
|
|
270
|
+
);
|
|
139
271
|
|
|
140
272
|
return {
|
|
141
273
|
program,
|
|
@@ -143,12 +275,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
143
275
|
dataModelVersion: String(loaded.model.modelVersion),
|
|
144
276
|
generatedAt: options.generatedAt || new Date().toISOString(),
|
|
145
277
|
asOf,
|
|
146
|
-
target
|
|
147
|
-
programId: program?.id || null,
|
|
148
|
-
goal: program?.assuranceGoal || "none",
|
|
149
|
-
label: assuranceGoalLabel(program?.assuranceGoal),
|
|
150
|
-
candidateCoverage: program?.candidateCoverage || null
|
|
151
|
-
},
|
|
278
|
+
target,
|
|
152
279
|
status: operating ? "operating" : evidenceReady ? "evidence-ready" : "needs-work",
|
|
153
280
|
evidenceReady,
|
|
154
281
|
operating,
|
|
@@ -161,11 +288,9 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
161
288
|
...policyLibrary.proposals,
|
|
162
289
|
...legacyPolicyLibraryProposals(records)
|
|
163
290
|
],
|
|
164
|
-
progress
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
percent: managedItems.length ? Math.round((complete / managedItems.length) * 100) : 0
|
|
168
|
-
},
|
|
291
|
+
progress,
|
|
292
|
+
setupProgress,
|
|
293
|
+
checkProgress,
|
|
169
294
|
counts: countStatuses(items),
|
|
170
295
|
firstAction,
|
|
171
296
|
scope: {
|
|
@@ -717,6 +842,7 @@ async function policiesStage(scope, records, byId, readMarkdown, model) {
|
|
|
717
842
|
: "Appoint a reviewer who is separate from the policy owner. The reviewer may be another person in the organization or an external person, and is separate from the CPA firm that may later perform the audit.",
|
|
718
843
|
appointedReviewer || (reviewerNeedsAssignment ? governedRecords[0] : { type: "person" }),
|
|
719
844
|
{
|
|
845
|
+
progressUnit: false,
|
|
720
846
|
commands: [
|
|
721
847
|
"npx filegrc list appointment --workflow --json",
|
|
722
848
|
"npx filegrc guide appointment --json",
|
|
@@ -900,6 +1026,11 @@ function requiredGovernedDocuments(scope, records, byId, model) {
|
|
|
900
1026
|
&& (
|
|
901
1027
|
linkedDocumentIds.has(record.id)
|
|
902
1028
|
|| obligationDocumentIds.has(record.id)
|
|
1029
|
+
|| (
|
|
1030
|
+
modelSupports(model, "retention-schedule-approval")
|
|
1031
|
+
&& record.documentKind === "schedule"
|
|
1032
|
+
&& record.workflowScope === "program"
|
|
1033
|
+
)
|
|
903
1034
|
|| (
|
|
904
1035
|
record.programRole === "required"
|
|
905
1036
|
&& (record.controlIds || []).some((id) => selectedControlIds.has(id))
|
|
@@ -1456,7 +1587,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
|
1456
1587
|
return stage("sources", "Control Evidence Sources", `Complete the authoritative ${modelSupports(model, "component-sources") ? "Components" : "Systems"} for every selected control family before marking the Controls implemented.`, items);
|
|
1457
1588
|
}
|
|
1458
1589
|
|
|
1459
|
-
async function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
|
|
1590
|
+
async function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model, progressWindow = null) {
|
|
1460
1591
|
const goal = workspace?.assuranceGoal || "none";
|
|
1461
1592
|
if (!evidenceReady) {
|
|
1462
1593
|
return stage("operation", "Operate the Program", "Run the controls and preserve dated evidence after the Evidence Ready gate passes.", [
|
|
@@ -1488,12 +1619,9 @@ async function operationStage(loaded, workspace, scope, records, byId, asOf, evi
|
|
|
1488
1619
|
}
|
|
1489
1620
|
|
|
1490
1621
|
const obligations = planObligations(records, { programId: workspace.id, asOf, through: asOf, model });
|
|
1491
|
-
const start =
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
const end = workspace.candidateCoverage?.kind === "range"
|
|
1495
|
-
? coverageEnd(workspace.candidateCoverage)
|
|
1496
|
-
: null;
|
|
1622
|
+
const start = progressWindow?.start || null;
|
|
1623
|
+
const end = progressWindow?.end || null;
|
|
1624
|
+
const formalWindow = progressWindow?.source === "audit";
|
|
1497
1625
|
const startStatus = !start ? "action" : start <= asOf ? "complete" : "later";
|
|
1498
1626
|
const sourceCoverage = await assessSourceCoverageReadiness(loaded, scope.controls.map(({ id }) => id), workspace);
|
|
1499
1627
|
const incompleteSourceCoverage = sourceCoverage.filter(({ complete }) => !complete);
|
|
@@ -1505,16 +1633,20 @@ async function operationStage(loaded, workspace, scope, records, byId, asOf, evi
|
|
|
1505
1633
|
!start
|
|
1506
1634
|
? "Set the management candidate period start when the Evidence Ready gate passes. Do not backdate it."
|
|
1507
1635
|
: start <= asOf
|
|
1508
|
-
?
|
|
1636
|
+
? formalWindow
|
|
1637
|
+
? `The auditor-agreed Type 2 period began on ${start}. Keep evidence collection and operating work current through the period.`
|
|
1638
|
+
: `Management began the candidate Type 2 evidence period on ${start}. This is not the auditor-agreed report period.`
|
|
1509
1639
|
: `Evidence collection is scheduled to begin on ${start}.`,
|
|
1510
1640
|
workspace
|
|
1511
1641
|
),
|
|
1512
1642
|
item(
|
|
1513
1643
|
"candidate-period-end",
|
|
1514
1644
|
end ? "complete" : start ? "later" : "info",
|
|
1515
|
-
"Plan the candidate period end",
|
|
1645
|
+
formalWindow ? "Confirm the auditor-agreed period end" : "Plan the candidate period end",
|
|
1516
1646
|
end
|
|
1517
|
-
?
|
|
1647
|
+
? formalWindow
|
|
1648
|
+
? `Auditor-agreed period: ${start || "start not set"} through ${end}.`
|
|
1649
|
+
: `Management candidate period: ${start || "start not set"} through ${end}. The CPA firm may agree to different dates.`
|
|
1518
1650
|
: "Add the management target end when useful. Starting reliable evidence collection is the immediate milestone.",
|
|
1519
1651
|
workspace
|
|
1520
1652
|
),
|
|
@@ -1772,6 +1904,61 @@ function countStatuses(items) {
|
|
|
1772
1904
|
return counts;
|
|
1773
1905
|
}
|
|
1774
1906
|
|
|
1907
|
+
export function selectedAuditWindow(records, program, asOf) {
|
|
1908
|
+
if (program?.assuranceGoal !== "soc-2-type-2") return null;
|
|
1909
|
+
const candidates = records.filter((record) => (
|
|
1910
|
+
record.type === "audit"
|
|
1911
|
+
&& record.auditKind === "soc-2-type-2"
|
|
1912
|
+
&& (!record.programId || record.programId === program.id)
|
|
1913
|
+
&& !["canceled", "closed", "complete", "delivered"].includes(record.status)
|
|
1914
|
+
&& normalizeProgressWindow(record.coverage)
|
|
1915
|
+
&& coverageStart(record.coverage) <= asOf
|
|
1916
|
+
));
|
|
1917
|
+
const statusOrder = new Map([
|
|
1918
|
+
["planned", 0],
|
|
1919
|
+
["in-progress", 1],
|
|
1920
|
+
["fieldwork", 2],
|
|
1921
|
+
["report-draft", 3],
|
|
1922
|
+
["issued", 4]
|
|
1923
|
+
]);
|
|
1924
|
+
candidates.sort((left, right) => (
|
|
1925
|
+
coverageStart(right.coverage).localeCompare(coverageStart(left.coverage))
|
|
1926
|
+
|| (statusOrder.get(right.status) ?? -1) - (statusOrder.get(left.status) ?? -1)
|
|
1927
|
+
|| coverageEnd(right.coverage).localeCompare(coverageEnd(left.coverage))
|
|
1928
|
+
|| left.id.localeCompare(right.id)
|
|
1929
|
+
));
|
|
1930
|
+
return candidates[0]?.coverage || null;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
export function selectedProgressWindow(candidateCoverage, auditCoverage, asOf) {
|
|
1934
|
+
const candidates = [
|
|
1935
|
+
normalizeProgressWindow(candidateCoverage, "candidate"),
|
|
1936
|
+
normalizeProgressWindow(auditCoverage, "audit")
|
|
1937
|
+
].filter((window) => window && (!asOf || window.start <= asOf));
|
|
1938
|
+
candidates.sort((left, right) => (
|
|
1939
|
+
right.start.localeCompare(left.start)
|
|
1940
|
+
|| Number(right.source === "audit") - Number(left.source === "audit")
|
|
1941
|
+
|| right.end.localeCompare(left.end)
|
|
1942
|
+
));
|
|
1943
|
+
if (candidates.length) return candidates[0];
|
|
1944
|
+
return normalizeProgressWindow(candidateCoverage, "candidate")
|
|
1945
|
+
|| normalizeProgressWindow(auditCoverage, "audit");
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
function normalizeProgressWindow(coverage, source = "candidate") {
|
|
1949
|
+
const start = coverage?.start || coverageStart(coverage);
|
|
1950
|
+
const end = coverage?.end || coverageEnd(coverage);
|
|
1951
|
+
if (!start || !end || end < start) return null;
|
|
1952
|
+
return { start, end, source: coverage?.source || source };
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
function calendarDayDistance(start, end) {
|
|
1956
|
+
const startTime = Date.parse(`${start}T00:00:00Z`);
|
|
1957
|
+
const endTime = Date.parse(`${end}T00:00:00Z`);
|
|
1958
|
+
if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) return 0;
|
|
1959
|
+
return Math.round((endTime - startTime) / 86_400_000);
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1775
1962
|
function shiftYear(value, offset) {
|
|
1776
1963
|
const date = new Date(`${value}T00:00:00Z`);
|
|
1777
1964
|
date.setUTCFullYear(date.getUTCFullYear() + offset);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
|
|
4
|
+
import { markdownEntries } from "./resource-markdown.js";
|
|
5
|
+
import { resolveDataPath } from "./paths.js";
|
|
6
|
+
import { currentPartyPeople, partiesIndependent } from "./parties.js";
|
|
7
|
+
import {
|
|
8
|
+
nearDuplicateInformationTypes,
|
|
9
|
+
resourceReviewRevisionsSync,
|
|
10
|
+
retentionReviewResourceIds,
|
|
11
|
+
retentionRuleIsCurrent,
|
|
12
|
+
retentionUses
|
|
13
|
+
} from "./retention.js";
|
|
14
|
+
|
|
15
|
+
export function retentionScheduleApprovalIssues(loaded, program, rows, options = {}) {
|
|
16
|
+
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
17
|
+
const issues = [];
|
|
18
|
+
const planned = rows.filter(({ status }) => status === "planned");
|
|
19
|
+
if (planned.length) {
|
|
20
|
+
issues.push(issue("planned-retention-schedule-row", `Complete or retire every planned retention row before approving the schedule revision: ${planned[0].title}.`));
|
|
21
|
+
}
|
|
22
|
+
const schedules = loaded.resources.filter((record) => (
|
|
23
|
+
record.type === "document"
|
|
24
|
+
&& record.documentKind === "schedule"
|
|
25
|
+
&& record.workflowScope === "program"
|
|
26
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
27
|
+
));
|
|
28
|
+
if (!schedules.length) {
|
|
29
|
+
issues.push(issue("missing-retention-schedule-document", "Add and approve the Data Retention Schedule document before approving the schedule revision."));
|
|
30
|
+
}
|
|
31
|
+
if (schedules.length !== 1) {
|
|
32
|
+
if (schedules.length) {
|
|
33
|
+
issues.push(issue("multiple-retention-schedule-documents", "Keep exactly one current program Data Retention Schedule document before approving the schedule revision."));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const schedule = schedules.length === 1 ? schedules[0] : null;
|
|
37
|
+
const scheduleContent = schedule ? primaryMarkdown(loaded, schedule) : "";
|
|
38
|
+
const linkedControl = schedule && (schedule.controlIds || []).some((id) => (program.controlIds || []).includes(id));
|
|
39
|
+
if (
|
|
40
|
+
schedule
|
|
41
|
+
&& (!["approved", "active"].includes(schedule.status)
|
|
42
|
+
|| !schedule.approvedOn
|
|
43
|
+
|| !schedule.approvedContentRevisions
|
|
44
|
+
|| currentPartyPeople(schedule.ownerIds || [], byId).size === 0
|
|
45
|
+
|| !partiesIndependent(schedule.ownerIds, schedule.approverIds, byId)
|
|
46
|
+
|| !linkedControl
|
|
47
|
+
|| !substantiveMarkdown(scheduleContent)
|
|
48
|
+
|| openPlaceholderCount(scheduleContent) > 0
|
|
49
|
+
|| !approvalBindingsMatch(loaded, schedule))
|
|
50
|
+
) {
|
|
51
|
+
issues.push(issue("unapproved-retention-schedule-document", "Complete and independently approve the Data Retention Schedule document before approving the complete schedule."));
|
|
52
|
+
}
|
|
53
|
+
const activeRows = rows.filter(({ status }) => status === "active");
|
|
54
|
+
const mismatched = schedule
|
|
55
|
+
? activeRows.filter(({ scheduleDocumentId }) => scheduleDocumentId !== schedule.id)
|
|
56
|
+
: [];
|
|
57
|
+
if (mismatched.length) {
|
|
58
|
+
issues.push(issue("retention-row-wrong-schedule-document", `Link every active retention row to the current Data Retention Schedule document before approval: ${mismatched[0].title}.`));
|
|
59
|
+
}
|
|
60
|
+
const revisions = resourceReviewRevisionsSync(
|
|
61
|
+
loaded,
|
|
62
|
+
activeRows.flatMap((row) => retentionReviewResourceIds(row, loaded))
|
|
63
|
+
);
|
|
64
|
+
const incomplete = activeRows.filter((row) => !retentionRuleIsCurrent(row, revisions, byId, loaded));
|
|
65
|
+
if (incomplete.length) {
|
|
66
|
+
issues.push(issue("incomplete-retention-schedule-row", `Complete the retention decisions before approving the schedule revision: ${incomplete[0].title}.`));
|
|
67
|
+
}
|
|
68
|
+
const usableRows = activeRows.filter((row) => !incomplete.includes(row));
|
|
69
|
+
const uncovered = retentionUses(loaded, program).find((use) => !usableRows.some((row) => (
|
|
70
|
+
(row.informationTypeIds || []).includes(use.informationTypeId)
|
|
71
|
+
&& ((row.scopeResourceIds || []).includes(use.resource.id) || (row.scopeResourceIds || []).includes(program.id))
|
|
72
|
+
)));
|
|
73
|
+
if (uncovered) {
|
|
74
|
+
issues.push(issue(
|
|
75
|
+
"uncovered-retention-information-use",
|
|
76
|
+
`${uncovered.resource.title} uses ${byId.get(uncovered.informationTypeId)?.title || uncovered.informationTypeId}, but no active, current retention schedule row covers both the type and scope.`
|
|
77
|
+
));
|
|
78
|
+
}
|
|
79
|
+
if (nearDuplicateInformationTypes(loaded.resources).length && !options.informationTypesReviewed) {
|
|
80
|
+
issues.push(issue("unreviewed-similar-information-types", "Review similar Information Types before approving the Data Retention Schedule."));
|
|
81
|
+
}
|
|
82
|
+
return issues;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function issue(code, message) {
|
|
86
|
+
return { code, message };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function primaryMarkdown(loaded, record) {
|
|
90
|
+
const definition = loaded.model.resources[record.type];
|
|
91
|
+
const selected = markdownEntries(loaded.model, record).find((entry) => (
|
|
92
|
+
definition.markdown?.[entry.name]?.primary || entry.name === loaded.model.recordContent?.slot
|
|
93
|
+
));
|
|
94
|
+
if (!selected) return "";
|
|
95
|
+
try {
|
|
96
|
+
return readFileSync(resolveDataPath(loaded.root, selected.path), "utf8");
|
|
97
|
+
} catch {
|
|
98
|
+
return "";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function approvalBindingsMatch(loaded, record) {
|
|
103
|
+
const actual = {};
|
|
104
|
+
for (const entry of markdownEntries(loaded.model, record)) {
|
|
105
|
+
try {
|
|
106
|
+
actual[entry.path] = createHash("sha256")
|
|
107
|
+
.update(readFileSync(resolveDataPath(loaded.root, entry.path), "utf8"))
|
|
108
|
+
.digest("hex");
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error.code !== "ENOENT") throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const expected = record.approvedContentRevisions || {};
|
|
114
|
+
const actualKeys = Object.keys(actual).sort();
|
|
115
|
+
const expectedKeys = Object.keys(expected).sort();
|
|
116
|
+
return actualKeys.length === expectedKeys.length
|
|
117
|
+
&& actualKeys.every((key, index) => (
|
|
118
|
+
key === expectedKeys[index]
|
|
119
|
+
&& actual[key] === expected[key]
|
|
120
|
+
));
|
|
121
|
+
}
|
package/src/retention.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import { readFile } from "node:fs/promises";
|
|
2
3
|
import { contentRevision } from "./files.js";
|
|
3
4
|
import { resolveDataPath } from "./paths.js";
|
|
4
5
|
import { programComponents } from "./program.js";
|
|
5
6
|
import { markdownEntries } from "./resource-markdown.js";
|
|
7
|
+
import { modelSupports } from "../model/index.js";
|
|
6
8
|
|
|
7
9
|
export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
8
10
|
if (!loaded.model.resources["retention-schedule-item"]) return [];
|
|
@@ -30,22 +32,42 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
|
30
32
|
]
|
|
31
33
|
}
|
|
32
34
|
));
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
const usesByInformationType = new Map();
|
|
36
|
+
for (const use of uses) {
|
|
37
|
+
const grouped = usesByInformationType.get(use.informationTypeId) || [];
|
|
38
|
+
grouped.push(use);
|
|
39
|
+
usesByInformationType.set(use.informationTypeId, grouped);
|
|
40
|
+
}
|
|
41
|
+
items.push(...[...usesByInformationType].map(([informationTypeId, informationTypeUses]) => {
|
|
42
|
+
const uncoveredUses = informationTypeUses.filter((use) => (
|
|
43
|
+
!usableRules.some((rule) => ruleCoversUse(rule, use, program))
|
|
44
|
+
));
|
|
45
|
+
const matches = usableRules.filter((rule) => (
|
|
46
|
+
informationTypeUses.some((use) => ruleCoversUse(rule, use, program))
|
|
47
|
+
));
|
|
48
|
+
const informationTypeTitle = byId.get(informationTypeId)?.title || informationTypeId;
|
|
49
|
+
const affectedTitles = uncoveredUses.map(({ resource }) => resource.title);
|
|
50
|
+
const affectedSummary = affectedTitles.length > 3
|
|
51
|
+
? `${affectedTitles.slice(0, 3).join(", ")}, and ${affectedTitles.length - 3} more`
|
|
52
|
+
: affectedTitles.join(", ");
|
|
35
53
|
return readinessItem(
|
|
36
|
-
`retention-use-${
|
|
37
|
-
|
|
38
|
-
`Decide retention for ${
|
|
39
|
-
|
|
40
|
-
? `${
|
|
41
|
-
:
|
|
42
|
-
|
|
54
|
+
`retention-use-${informationTypeId}`,
|
|
55
|
+
uncoveredUses.length ? "action" : "complete",
|
|
56
|
+
`Decide retention for ${informationTypeTitle}`,
|
|
57
|
+
uncoveredUses.length
|
|
58
|
+
? `${affectedTitles.length} in-scope ${affectedTitles.length === 1 ? "resource uses" : "resources use"} this Information Type without an active, current retention decision, including ${affectedSummary}. Management must choose the cutoff, period, disposition, and whether one program-wide rule or separate scoped rules apply.`
|
|
59
|
+
: `All ${informationTypeUses.length} in-scope ${informationTypeUses.length === 1 ? "use is" : "uses are"} covered by ${matches.map(({ title }) => title).join(", ")}.`,
|
|
60
|
+
{ type: "retention-schedule-item" },
|
|
43
61
|
{
|
|
44
|
-
informationTypeId
|
|
62
|
+
informationTypeId,
|
|
63
|
+
affectedResourceIds: uncoveredUses.map(({ resource }) => resource.id),
|
|
64
|
+
retentionScopeResourceIds: uncoveredUses.length === informationTypeUses.length
|
|
65
|
+
? [program.id]
|
|
66
|
+
: uncoveredUses.map(({ resource }) => resource.id),
|
|
45
67
|
retentionScheduleItemIds: matches.map(({ id }) => id),
|
|
46
68
|
commands: [
|
|
47
69
|
"npx filegrc guide retention-schedule-item --json",
|
|
48
|
-
`npx filegrc scaffold retention-schedule-item --title ${shellArgument(`Retention for ${
|
|
70
|
+
`npx filegrc scaffold retention-schedule-item --title ${shellArgument(`Retention for ${informationTypeTitle}`)}`
|
|
49
71
|
]
|
|
50
72
|
}
|
|
51
73
|
);
|
|
@@ -113,7 +135,7 @@ export async function resourceReviewRevisions(loaded, ids) {
|
|
|
113
135
|
const entry = entries.get(id);
|
|
114
136
|
if (!entry || reviewing.has(id)) return null;
|
|
115
137
|
reviewing.add(id);
|
|
116
|
-
const parts = [entry
|
|
138
|
+
const parts = [reviewSource(loaded, entry)];
|
|
117
139
|
for (const markdown of markdownEntries(loaded.model, entry.record)) {
|
|
118
140
|
try {
|
|
119
141
|
parts.push(await readFile(resolveDataPath(loaded.root, markdown.path), "utf8"));
|
|
@@ -136,6 +158,37 @@ export async function resourceReviewRevisions(loaded, ids) {
|
|
|
136
158
|
return new Map([...revisions].filter(([id]) => wanted.has(id)));
|
|
137
159
|
}
|
|
138
160
|
|
|
161
|
+
export function resourceReviewRevisionsSync(loaded, ids) {
|
|
162
|
+
const wanted = new Set(ids);
|
|
163
|
+
const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
|
|
164
|
+
const revisions = new Map();
|
|
165
|
+
const reviewing = new Set();
|
|
166
|
+
const review = (id) => {
|
|
167
|
+
if (revisions.has(id)) return revisions.get(id);
|
|
168
|
+
const entry = entries.get(id);
|
|
169
|
+
if (!entry || reviewing.has(id)) return null;
|
|
170
|
+
reviewing.add(id);
|
|
171
|
+
const parts = [reviewSource(loaded, entry)];
|
|
172
|
+
for (const markdown of markdownEntries(loaded.model, entry.record)) {
|
|
173
|
+
try {
|
|
174
|
+
parts.push(readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8"));
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error.code !== "ENOENT") throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const sourceId of [...new Set(entry.record.sourceResourceIds || [])].sort()) {
|
|
180
|
+
const revision = review(sourceId);
|
|
181
|
+
if (revision) parts.push(`${sourceId}:${revision}`);
|
|
182
|
+
}
|
|
183
|
+
reviewing.delete(id);
|
|
184
|
+
const revision = contentRevision(parts.join("\n"));
|
|
185
|
+
revisions.set(id, revision);
|
|
186
|
+
return revision;
|
|
187
|
+
};
|
|
188
|
+
for (const id of wanted) review(id);
|
|
189
|
+
return new Map([...revisions].filter(([id]) => wanted.has(id)));
|
|
190
|
+
}
|
|
191
|
+
|
|
139
192
|
export function retentionUses(loaded, program) {
|
|
140
193
|
const systemIds = new Set(program.systemIds || []);
|
|
141
194
|
const componentIds = new Set(programComponents(loaded, program).map(({ id }) => id));
|
|
@@ -157,6 +210,28 @@ export function retentionUses(loaded, program) {
|
|
|
157
210
|
return [...new Map(uses.map((use) => [`${use.resource.id}:${use.informationTypeId}`, use])).values()];
|
|
158
211
|
}
|
|
159
212
|
|
|
213
|
+
function reviewSource(loaded, entry) {
|
|
214
|
+
if (
|
|
215
|
+
modelSupports(loaded.model, "retention-schedule-approval")
|
|
216
|
+
&& entry.record.type === "document"
|
|
217
|
+
&& entry.record.documentKind === "schedule"
|
|
218
|
+
&& entry.record.workflowScope === "program"
|
|
219
|
+
) {
|
|
220
|
+
const approved = structuredClone(entry.record);
|
|
221
|
+
if (approved.status === "active") approved.status = "approved";
|
|
222
|
+
for (const field of [
|
|
223
|
+
"activationBasis",
|
|
224
|
+
"activatedByIds",
|
|
225
|
+
"activatedOn",
|
|
226
|
+
"activatedContentRevisions",
|
|
227
|
+
"effectiveOn",
|
|
228
|
+
"proposedEffectiveOn"
|
|
229
|
+
]) delete approved[field];
|
|
230
|
+
return JSON.stringify(approved);
|
|
231
|
+
}
|
|
232
|
+
return entry.source;
|
|
233
|
+
}
|
|
234
|
+
|
|
160
235
|
export function nearDuplicateInformationTypes(records) {
|
|
161
236
|
const types = records.filter((record) => record.type === "information-type" && !["retired", "superseded"].includes(record.status));
|
|
162
237
|
const pairs = [];
|
package/src/state.js
CHANGED
|
@@ -150,17 +150,7 @@ export async function createAppStateSection(input, section, options = {}) {
|
|
|
150
150
|
collectionReviews: Object.fromEntries(
|
|
151
151
|
assessCollectionReviews(loaded, { programId: activeProgram.id }).map((assessment) => [
|
|
152
152
|
assessment.resourceType,
|
|
153
|
-
|
|
154
|
-
resourceType: assessment.resourceType,
|
|
155
|
-
configuration: assessment.configuration,
|
|
156
|
-
recordCount: assessment.recordCount,
|
|
157
|
-
review: assessment.review,
|
|
158
|
-
reviewRevision: assessment.reviewRevision,
|
|
159
|
-
collectionRevision: assessment.collectionRevision,
|
|
160
|
-
status: assessment.status,
|
|
161
|
-
complete: assessment.complete,
|
|
162
|
-
message: assessment.message
|
|
163
|
-
}
|
|
153
|
+
serializeCollectionReviewAssessment(assessment)
|
|
164
154
|
])
|
|
165
155
|
),
|
|
166
156
|
applicabilityConstraints: Object.fromEntries(loaded.resources.flatMap((record) => {
|
|
@@ -318,17 +308,7 @@ async function createAppStateUnlocked(input, options) {
|
|
|
318
308
|
const collectionReviews = Object.fromEntries(
|
|
319
309
|
assessCollectionReviews(loaded, { programId: activeProgram.id }).map((assessment) => [
|
|
320
310
|
assessment.resourceType,
|
|
321
|
-
|
|
322
|
-
resourceType: assessment.resourceType,
|
|
323
|
-
configuration: assessment.configuration,
|
|
324
|
-
recordCount: assessment.recordCount,
|
|
325
|
-
review: assessment.review,
|
|
326
|
-
reviewRevision: assessment.reviewRevision,
|
|
327
|
-
collectionRevision: assessment.collectionRevision,
|
|
328
|
-
status: assessment.status,
|
|
329
|
-
complete: assessment.complete,
|
|
330
|
-
message: assessment.message
|
|
331
|
-
}
|
|
311
|
+
serializeCollectionReviewAssessment(assessment)
|
|
332
312
|
])
|
|
333
313
|
);
|
|
334
314
|
return {
|
|
@@ -356,6 +336,23 @@ async function createAppStateUnlocked(input, options) {
|
|
|
356
336
|
};
|
|
357
337
|
}
|
|
358
338
|
|
|
339
|
+
function serializeCollectionReviewAssessment(assessment) {
|
|
340
|
+
return {
|
|
341
|
+
resourceType: assessment.resourceType,
|
|
342
|
+
configuration: assessment.configuration,
|
|
343
|
+
recordCount: assessment.recordCount,
|
|
344
|
+
review: assessment.review,
|
|
345
|
+
reviewRevision: assessment.reviewRevision,
|
|
346
|
+
collectionRevision: assessment.collectionRevision,
|
|
347
|
+
status: assessment.status,
|
|
348
|
+
complete: assessment.complete,
|
|
349
|
+
message: assessment.message,
|
|
350
|
+
...(assessment.eligibleReviewerIds ? { eligibleReviewerIds: assessment.eligibleReviewerIds } : {}),
|
|
351
|
+
...(assessment.reviewerConflictIds ? { reviewerConflictIds: assessment.reviewerConflictIds } : {}),
|
|
352
|
+
...(assessment.approvalIssues ? { approvalIssues: assessment.approvalIssues } : {})
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
359
356
|
export async function createResourceDetail(input, type, id, options = {}) {
|
|
360
357
|
if (input?.entries && input?.root) return createResourceDetailFromLoaded(input, type, id, options);
|
|
361
358
|
return serializeWorkspaceMutation(input, async (root) => {
|