filegrc 0.13.5 → 0.15.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/README.md +4 -4
- package/model/index.js +7 -5
- package/model/v11.json +12200 -0
- package/package.json +1 -1
- package/src/agent.js +1 -1
- package/src/cli.js +1 -1
- package/src/collection-review.js +118 -0
- package/src/collection-revision.js +3 -1
- package/src/collection-scope.js +150 -3
- package/src/document-activation.js +28 -4
- package/src/model-migration.js +70 -2
- package/src/policy-activation.js +16 -2
- package/src/program-path.js +64 -14
- package/src/program-readiness.js +37 -14
- package/src/retention-schedule-approval.js +121 -0
- package/src/retention.js +56 -1
- package/src/state.js +19 -22
- package/src/validate.js +70 -1
- package/src/web.js +425 -71
- package/src/workflow-history-integrity.js +5 -1
- package/src/workflow.js +1 -3
package/src/policy-activation.js
CHANGED
|
@@ -3,17 +3,21 @@ import { serializeWorkspaceMutation } from "./mutation.js";
|
|
|
3
3
|
import { assessProgramReadiness } from "./program-readiness.js";
|
|
4
4
|
import { currentCalendarDate } from "./time.js";
|
|
5
5
|
import { loadWorkspace } from "./workspace.js";
|
|
6
|
+
import { modelSupports } from "../model/index.js";
|
|
6
7
|
|
|
7
8
|
export async function scaffoldPolicyActivation(input = process.cwd(), options = {}) {
|
|
8
9
|
const loaded = await loadWorkspace(input);
|
|
9
10
|
const readiness = await assessProgramReadiness(loaded, { programId: options.programId });
|
|
10
|
-
const
|
|
11
|
+
const oversightReady = controlOversightComplete(loaded, readiness);
|
|
12
|
+
const approved = (oversightReady ? readiness.policyActivations : []).filter(({ state }) => (
|
|
11
13
|
["approved-implementation-pending", "ready-to-activate"].includes(state)
|
|
12
14
|
));
|
|
13
15
|
const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
|
|
14
16
|
return {
|
|
15
17
|
available: approved.length > 0,
|
|
16
|
-
message:
|
|
18
|
+
message: !oversightReady
|
|
19
|
+
? "Complete the current Control collection review before Program Content Activation."
|
|
20
|
+
: approved.length
|
|
17
21
|
? `${approved.length} approved ${approved.length === 1 ? "Policy is" : "Policies are"} available for the Step 3 cutover.`
|
|
18
22
|
: "No Policy is ready for activation. Finish Step 2 approval, then resolve its Step 3 implementation gaps.",
|
|
19
23
|
nextCommand: approved.length ? null : "npx filegrc program-path --next --json",
|
|
@@ -26,6 +30,10 @@ export async function scaffoldPolicyActivation(input = process.cwd(), options =
|
|
|
26
30
|
|
|
27
31
|
export async function planPolicyActivation(input = process.cwd(), options = {}) {
|
|
28
32
|
const loaded = await loadWorkspace(input);
|
|
33
|
+
const readiness = await assessProgramReadiness(loaded, { programId: options.programId });
|
|
34
|
+
if (!controlOversightComplete(loaded, readiness)) {
|
|
35
|
+
throw new Error("Complete the current Control collection review before Program Content Activation.");
|
|
36
|
+
}
|
|
29
37
|
const policyIds = [...new Set((options.policyIds || []).map(String))];
|
|
30
38
|
if (!policyIds.length) throw new Error("Policy activation needs at least one approved Policy.");
|
|
31
39
|
const effectiveOn = String(options.effectiveOn || "").trim();
|
|
@@ -67,6 +75,12 @@ export async function planPolicyActivation(input = process.cwd(), options = {})
|
|
|
67
75
|
};
|
|
68
76
|
}
|
|
69
77
|
|
|
78
|
+
function controlOversightComplete(loaded, readiness) {
|
|
79
|
+
if (!modelSupports(loaded.model, "control-collection-oversight") || !readiness.scope.controlIds.length) return true;
|
|
80
|
+
return readiness.stages.find(({ id }) => id === "controls")?.items
|
|
81
|
+
.find(({ id }) => id === "collection-review-control")?.status === "complete";
|
|
82
|
+
}
|
|
83
|
+
|
|
70
84
|
export async function activatePolicies(input = process.cwd(), options = {}) {
|
|
71
85
|
if (options.confirmed !== true) throw new Error("Review the Policy activation cutover and confirm the write.");
|
|
72
86
|
return serializeWorkspaceMutation(input, async (root) => {
|
package/src/program-path.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ACTIVE_MODEL_VERSION, modelSupports } from "../model/index.js";
|
|
2
|
+
|
|
1
3
|
export const RESOURCE_INSTRUCTIONS = {
|
|
2
4
|
program: "Define one management compliance or assurance Program with its goal, bounded Systems, selected Frameworks, Requirement applicability decisions, Controls, owners, risk method, and candidate period.",
|
|
3
5
|
person: "Record each person’s actual organizational job title. Keep named program authority, such as CISO, DPO, Policy Owner, or team chair, in dated Appointment records.",
|
|
@@ -105,13 +107,14 @@ export const PROGRAM_PATH = [
|
|
|
105
107
|
id: "policies",
|
|
106
108
|
number: 2,
|
|
107
109
|
title: "Approve Policies",
|
|
108
|
-
description: "
|
|
109
|
-
summary: "Review and independently approve Policies, program Documents, and
|
|
110
|
+
description: "Approve governed content and retention decisions",
|
|
111
|
+
summary: "Review and independently approve Policies, program Documents, Training content, and the Data Retention Schedule.",
|
|
110
112
|
sections: [
|
|
111
|
-
{ id: "policy-content", title: "Policies", description: "Review every program Policy, Document, and Training record in one table, then bind independent approval to each exact revision.", steps: ["Review each governed Markdown artifact and replace every organization placeholder.", "Confirm the owner, separate approver, linked Controls, audience, and intended values that apply to each artifact.", "Record approval and its date against the exact revision. Leave approved content inactive until its Step 3 implementation cutover."], types: [], relatedLinks: [{ type: "policy", label: "Policies", href: "#/
|
|
113
|
+
{ id: "policy-content", title: "Policies", description: "Review every program Policy, Document, and Training record in one table, then bind independent approval to each exact revision.", steps: ["Review each governed Markdown artifact and replace every organization placeholder.", "Confirm the owner, separate approver, linked Controls, audience, and intended values that apply to each artifact.", "Record approval and its date against the exact revision. Leave approved content inactive until its Step 3 implementation cutover."], types: [], relatedLinks: [{ type: "policy", label: "Policies", href: "#/policies" }], defaultOpen: true },
|
|
114
|
+
{ id: "retention", title: "Data Retention Schedule", description: "Approve the schedule document and its structured rows as one governed schedule.", steps: ["Review the schedule policy, legal holds, exceptions, and disposal requirements.", "Define rows only for Information Types and operational sources the organization actually uses.", "Confirm the complete document-and-row revision with one schedule review."], types: [], utility: "retention-schedule", defaultOpen: true }
|
|
112
115
|
],
|
|
113
116
|
resourceTypes: [],
|
|
114
|
-
supportingResourceTypes: ["policy", "document", "training"],
|
|
117
|
+
supportingResourceTypes: ["policy", "document", "training", "retention-schedule-item"],
|
|
115
118
|
commands: [
|
|
116
119
|
"filegrc guide policy --json",
|
|
117
120
|
"filegrc guide document --json",
|
|
@@ -119,29 +122,33 @@ export const PROGRAM_PATH = [
|
|
|
119
122
|
"filegrc list policy --json",
|
|
120
123
|
"filegrc list document --json",
|
|
121
124
|
"filegrc list training --json",
|
|
122
|
-
"filegrc get POLICY_ID --mutation"
|
|
125
|
+
"filegrc get POLICY_ID --mutation",
|
|
126
|
+
"filegrc guide retention-schedule-item --json",
|
|
127
|
+
"filegrc review-collection retention-schedule-item --scaffold"
|
|
123
128
|
]
|
|
124
129
|
},
|
|
125
130
|
{
|
|
126
131
|
id: "controls",
|
|
127
132
|
number: 3,
|
|
128
133
|
title: "Implement Controls",
|
|
129
|
-
description: "
|
|
130
|
-
summary: "
|
|
134
|
+
description: "Implement and prepare to operate",
|
|
135
|
+
summary: "Implement Controls, configure their operating schedules and evidence sources, then activate the approved program.",
|
|
131
136
|
sections: [
|
|
132
|
-
{ id: "
|
|
137
|
+
{ id: "controls", title: "Controls", description: "Implement the selected Controls and connect their scope, procedures, mappings, and operation patterns.", steps: ["Open every planned Control and confirm its mappings and operation pattern.", "Write the real procedure in Record Markdown, add bounded System scope, and map the operating and authoritative evidence-source Components."], types: ["control"], defaultOpen: true },
|
|
138
|
+
{ id: "complementary-controls", title: "Complementary Controls", description: "Record customer or provider responsibilities that form part of the implementation boundary, or confirm there are none.", steps: ["Review whether an in-scope Control depends on a customer or carved-out provider action."], types: ["complementary-control"], defaultOpen: true },
|
|
139
|
+
{ id: "evidence-sources", title: "Evidence Sources", description: "Check that every Control points to an authoritative source that can produce its expected evidence.", steps: ["Confirm each source Component is active, has the right evidence-source role, names current access owners, and includes repeatable retrieval instructions."], types: [], utility: "evidence-sources", defaultOpen: true },
|
|
140
|
+
{ id: "obligations", title: "Obligations", description: "Configure the calendar and event schedules that operate the Controls.", steps: ["Review and enable every required calendar and event schedule."], types: ["obligation"], defaultOpen: true }
|
|
133
141
|
],
|
|
134
|
-
resourceTypes: ["control", "complementary-control", "
|
|
142
|
+
resourceTypes: ["control", "complementary-control", "obligation"],
|
|
135
143
|
commands: [
|
|
136
144
|
"filegrc guide control --json",
|
|
137
145
|
"filegrc list control --json",
|
|
138
146
|
"filegrc get CONTROL_ID --mutation",
|
|
139
147
|
"filegrc guide obligation --json",
|
|
140
|
-
"filegrc guide retention-schedule-item --json",
|
|
141
|
-
"filegrc review-collection retention-schedule-item --scaffold",
|
|
142
148
|
"filegrc list obligation --json",
|
|
143
149
|
"filegrc review-collection component --scaffold",
|
|
144
150
|
"filegrc review-collection complementary-control --scaffold",
|
|
151
|
+
"filegrc review-collection control --scaffold",
|
|
145
152
|
"filegrc activate-content --scaffold",
|
|
146
153
|
"filegrc activate-policies --scaffold",
|
|
147
154
|
"filegrc evidence-map --json",
|
|
@@ -259,8 +266,51 @@ export const PROGRAM_PATH = [
|
|
|
259
266
|
}
|
|
260
267
|
];
|
|
261
268
|
|
|
262
|
-
export function
|
|
269
|
+
export function programPathForModel(model = ACTIVE_MODEL_VERSION) {
|
|
270
|
+
if (modelSupports(model, "retention-schedule-approval")) return PROGRAM_PATH;
|
|
263
271
|
return PROGRAM_PATH.map((stage) => {
|
|
272
|
+
if (stage.id === "policies") {
|
|
273
|
+
return {
|
|
274
|
+
...stage,
|
|
275
|
+
description: "Review governed content and approvals",
|
|
276
|
+
summary: "Review and independently approve Policies, program Documents, and Training content.",
|
|
277
|
+
sections: [{
|
|
278
|
+
...stage.sections[0],
|
|
279
|
+
relatedLinks: [{ type: "policy", label: "Policies", href: "#/stage/policies" }]
|
|
280
|
+
}],
|
|
281
|
+
supportingResourceTypes: stage.supportingResourceTypes.filter((type) => type !== "retention-schedule-item"),
|
|
282
|
+
commands: stage.commands.filter((command) => !command.includes("retention-schedule-item"))
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (stage.id === "controls") {
|
|
286
|
+
const retention = {
|
|
287
|
+
id: "retention",
|
|
288
|
+
title: "Retention Schedule",
|
|
289
|
+
description: "Review each retention rule against current information uses and approved sources.",
|
|
290
|
+
steps: ["Confirm scope, cutoff, period, disposition, sources, owner, and approval for each rule."],
|
|
291
|
+
types: ["retention-schedule-item"],
|
|
292
|
+
defaultOpen: true
|
|
293
|
+
};
|
|
294
|
+
return {
|
|
295
|
+
...stage,
|
|
296
|
+
description: "Finish controls and their evidence sources",
|
|
297
|
+
summary: "Describe each Control and connect its evidence source.",
|
|
298
|
+
sections: [...stage.sections.slice(0, 3), retention, ...stage.sections.slice(3)],
|
|
299
|
+
resourceTypes: [...stage.resourceTypes.slice(0, 2), "retention-schedule-item", ...stage.resourceTypes.slice(2)],
|
|
300
|
+
commands: [
|
|
301
|
+
...stage.commands.slice(0, 5),
|
|
302
|
+
"filegrc guide retention-schedule-item --json",
|
|
303
|
+
"filegrc review-collection retention-schedule-item --scaffold",
|
|
304
|
+
...stage.commands.slice(5)
|
|
305
|
+
]
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
return stage;
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function buildAgentProgramPath(model) {
|
|
313
|
+
return programPathForModel(model).map((stage) => {
|
|
264
314
|
const programResourceTypes = [...stage.resourceTypes, ...(stage.supportingResourceTypes || [])]
|
|
265
315
|
.filter((type) => model.resources[type]);
|
|
266
316
|
const resourcePages = programResourceTypes.map((type, index) => {
|
|
@@ -300,8 +350,8 @@ function agentCommand(command) {
|
|
|
300
350
|
return command.startsWith("filegrc ") ? `npx ${command}` : command;
|
|
301
351
|
}
|
|
302
352
|
|
|
303
|
-
export function resourceProgramContext(type) {
|
|
304
|
-
const stage =
|
|
353
|
+
export function resourceProgramContext(type, model) {
|
|
354
|
+
const stage = programPathForModel(model).find((candidate) => (
|
|
305
355
|
candidate.resourceTypes.includes(type) || (candidate.supportingResourceTypes || []).includes(type)
|
|
306
356
|
));
|
|
307
357
|
if (!stage) return null;
|
package/src/program-readiness.js
CHANGED
|
@@ -57,14 +57,20 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
57
57
|
.map(collectionReviewReadinessItem));
|
|
58
58
|
const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
|
|
59
59
|
controlStage.items.push(...sourceStage.items);
|
|
60
|
-
|
|
60
|
+
const retentionItems = await assessRetentionReadiness(loaded, program, {
|
|
61
61
|
informationTypesReviewed: collectionReviews.find(({ resourceType }) => resourceType === "information-type")?.complete === true
|
|
62
|
-
})
|
|
63
|
-
|
|
62
|
+
});
|
|
63
|
+
const retentionReviewItems = collectionReviews
|
|
64
64
|
.filter(({ resourceType }) => resourceType === "retention-schedule-item")
|
|
65
|
-
.map(collectionReviewReadinessItem)
|
|
65
|
+
.map(collectionReviewReadinessItem);
|
|
66
|
+
if (modelSupports(loaded.model, "retention-schedule-approval")) {
|
|
67
|
+
policyStage.items.push(...retentionItems.filter(({ id }) => !id.startsWith("retention-source-coverage-")));
|
|
68
|
+
controlStage.items.push(...retentionItems.filter(({ id }) => id.startsWith("retention-source-coverage-")));
|
|
69
|
+
policyStage.items.push(...retentionReviewItems);
|
|
70
|
+
} else {
|
|
71
|
+
controlStage.items.push(...retentionItems, ...retentionReviewItems);
|
|
72
|
+
}
|
|
66
73
|
const governedContent = await governedContentItems(scope, records, byId, readMarkdown, asOf, loaded.model);
|
|
67
|
-
controlStage.items.push(...governedContent.items);
|
|
68
74
|
const policyActivations = await assessPolicyActivations(
|
|
69
75
|
requiredPolicies(scope, byId),
|
|
70
76
|
scope.controls,
|
|
@@ -74,8 +80,22 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
74
80
|
asOf,
|
|
75
81
|
loaded.model
|
|
76
82
|
);
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
const controlOversight = collectionReviews.find(({ resourceType }) => resourceType === "control");
|
|
84
|
+
const oversightEligible = Boolean(controlOversight && controlOversightEligible(controlStage.items));
|
|
85
|
+
if (oversightEligible) {
|
|
86
|
+
controlStage.items.push(collectionReviewReadinessItem(controlOversight));
|
|
87
|
+
}
|
|
88
|
+
const oversightCurrent = oversightEligible && controlOversight.complete === true;
|
|
89
|
+
controlStage.items.push(...governedContent.items.map((item) => oversightCurrent || item.status === "complete"
|
|
90
|
+
? item
|
|
91
|
+
: { ...item, status: "blocked", message: `Complete the Control collection review before activation. ${item.message}` }));
|
|
92
|
+
controlStage.items.push(...policyActivations.map((assessment) => {
|
|
93
|
+
const item = policyActivationItem(assessment);
|
|
94
|
+
return oversightCurrent || item.status === "complete"
|
|
95
|
+
? item
|
|
96
|
+
: { ...item, status: "blocked", message: `Complete the Control collection review before activation. ${item.message}` };
|
|
97
|
+
}));
|
|
98
|
+
controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, enabled Obligations, and complete authoritative source ${modelSupports(loaded.model, "component-sources") ? "Components" : "Systems"}. When implementation is ready, perform one review of the implemented Control collection. Management then activates the unchanged approved program content as the Step 3 cutover.`;
|
|
79
99
|
const scopeReadinessStage = scopeStage(
|
|
80
100
|
program,
|
|
81
101
|
scope,
|
|
@@ -166,6 +186,10 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
166
186
|
};
|
|
167
187
|
}
|
|
168
188
|
|
|
189
|
+
export function controlOversightEligible(items) {
|
|
190
|
+
return items.length > 0 && items.every(({ status }) => status === "complete");
|
|
191
|
+
}
|
|
192
|
+
|
|
169
193
|
export async function assessEvidenceMap(input, options = {}) {
|
|
170
194
|
const readiness = await assessProgramReadiness(input, options);
|
|
171
195
|
const evidenceItems = readiness.stages
|
|
@@ -883,6 +907,11 @@ function requiredGovernedDocuments(scope, records, byId, model) {
|
|
|
883
907
|
&& (
|
|
884
908
|
linkedDocumentIds.has(record.id)
|
|
885
909
|
|| obligationDocumentIds.has(record.id)
|
|
910
|
+
|| (
|
|
911
|
+
modelSupports(model, "retention-schedule-approval")
|
|
912
|
+
&& record.documentKind === "schedule"
|
|
913
|
+
&& record.workflowScope === "program"
|
|
914
|
+
)
|
|
886
915
|
|| (
|
|
887
916
|
record.programRole === "required"
|
|
888
917
|
&& (record.controlIds || []).some((id) => selectedControlIds.has(id))
|
|
@@ -1300,12 +1329,7 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
|
|
|
1300
1329
|
implementationDate: Boolean(control.effectiveOn && control.effectiveOn <= asOf),
|
|
1301
1330
|
...(model.resources.control?.fields?.procedureRevision ? {
|
|
1302
1331
|
procedureRevision: Boolean(control.procedureRevision),
|
|
1303
|
-
procedureEffective: Boolean(control.procedureEffectiveOn && control.procedureEffectiveOn <= asOf)
|
|
1304
|
-
implementationReview: Boolean(
|
|
1305
|
-
control.implementationReviewedOn
|
|
1306
|
-
&& control.implementationReviewedOn <= asOf
|
|
1307
|
-
&& partiesIndependent(control.ownerIds, control.implementationReviewedByIds, byId)
|
|
1308
|
-
)
|
|
1332
|
+
procedureEffective: Boolean(control.procedureEffectiveOn && control.procedureEffectiveOn <= asOf)
|
|
1309
1333
|
} : {}),
|
|
1310
1334
|
policyMapping: (control.policyIds || []).length > 0,
|
|
1311
1335
|
criteriaMapping: (control.requirementIds || []).length > 0,
|
|
@@ -1699,7 +1723,6 @@ function controlCheckLabel(name) {
|
|
|
1699
1723
|
implementationDate: "implementation date",
|
|
1700
1724
|
procedureRevision: "effective procedure revision",
|
|
1701
1725
|
procedureEffective: "procedure effective date",
|
|
1702
|
-
implementationReview: "independent implementation review",
|
|
1703
1726
|
policyMapping: "policy mapping",
|
|
1704
1727
|
criteriaMapping: "criteria mapping",
|
|
1705
1728
|
workQueue: "running Obligation schedules"
|
|
@@ -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 [];
|
|
@@ -113,7 +115,7 @@ export async function resourceReviewRevisions(loaded, ids) {
|
|
|
113
115
|
const entry = entries.get(id);
|
|
114
116
|
if (!entry || reviewing.has(id)) return null;
|
|
115
117
|
reviewing.add(id);
|
|
116
|
-
const parts = [entry
|
|
118
|
+
const parts = [reviewSource(loaded, entry)];
|
|
117
119
|
for (const markdown of markdownEntries(loaded.model, entry.record)) {
|
|
118
120
|
try {
|
|
119
121
|
parts.push(await readFile(resolveDataPath(loaded.root, markdown.path), "utf8"));
|
|
@@ -136,6 +138,37 @@ export async function resourceReviewRevisions(loaded, ids) {
|
|
|
136
138
|
return new Map([...revisions].filter(([id]) => wanted.has(id)));
|
|
137
139
|
}
|
|
138
140
|
|
|
141
|
+
export function resourceReviewRevisionsSync(loaded, ids) {
|
|
142
|
+
const wanted = new Set(ids);
|
|
143
|
+
const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
|
|
144
|
+
const revisions = new Map();
|
|
145
|
+
const reviewing = new Set();
|
|
146
|
+
const review = (id) => {
|
|
147
|
+
if (revisions.has(id)) return revisions.get(id);
|
|
148
|
+
const entry = entries.get(id);
|
|
149
|
+
if (!entry || reviewing.has(id)) return null;
|
|
150
|
+
reviewing.add(id);
|
|
151
|
+
const parts = [reviewSource(loaded, entry)];
|
|
152
|
+
for (const markdown of markdownEntries(loaded.model, entry.record)) {
|
|
153
|
+
try {
|
|
154
|
+
parts.push(readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8"));
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (error.code !== "ENOENT") throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const sourceId of [...new Set(entry.record.sourceResourceIds || [])].sort()) {
|
|
160
|
+
const revision = review(sourceId);
|
|
161
|
+
if (revision) parts.push(`${sourceId}:${revision}`);
|
|
162
|
+
}
|
|
163
|
+
reviewing.delete(id);
|
|
164
|
+
const revision = contentRevision(parts.join("\n"));
|
|
165
|
+
revisions.set(id, revision);
|
|
166
|
+
return revision;
|
|
167
|
+
};
|
|
168
|
+
for (const id of wanted) review(id);
|
|
169
|
+
return new Map([...revisions].filter(([id]) => wanted.has(id)));
|
|
170
|
+
}
|
|
171
|
+
|
|
139
172
|
export function retentionUses(loaded, program) {
|
|
140
173
|
const systemIds = new Set(program.systemIds || []);
|
|
141
174
|
const componentIds = new Set(programComponents(loaded, program).map(({ id }) => id));
|
|
@@ -157,6 +190,28 @@ export function retentionUses(loaded, program) {
|
|
|
157
190
|
return [...new Map(uses.map((use) => [`${use.resource.id}:${use.informationTypeId}`, use])).values()];
|
|
158
191
|
}
|
|
159
192
|
|
|
193
|
+
function reviewSource(loaded, entry) {
|
|
194
|
+
if (
|
|
195
|
+
modelSupports(loaded.model, "retention-schedule-approval")
|
|
196
|
+
&& entry.record.type === "document"
|
|
197
|
+
&& entry.record.documentKind === "schedule"
|
|
198
|
+
&& entry.record.workflowScope === "program"
|
|
199
|
+
) {
|
|
200
|
+
const approved = structuredClone(entry.record);
|
|
201
|
+
if (approved.status === "active") approved.status = "approved";
|
|
202
|
+
for (const field of [
|
|
203
|
+
"activationBasis",
|
|
204
|
+
"activatedByIds",
|
|
205
|
+
"activatedOn",
|
|
206
|
+
"activatedContentRevisions",
|
|
207
|
+
"effectiveOn",
|
|
208
|
+
"proposedEffectiveOn"
|
|
209
|
+
]) delete approved[field];
|
|
210
|
+
return JSON.stringify(approved);
|
|
211
|
+
}
|
|
212
|
+
return entry.source;
|
|
213
|
+
}
|
|
214
|
+
|
|
160
215
|
export function nearDuplicateInformationTypes(records) {
|
|
161
216
|
const types = records.filter((record) => record.type === "information-type" && !["retired", "superseded"].includes(record.status));
|
|
162
217
|
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) => {
|
package/src/validate.js
CHANGED
|
@@ -19,8 +19,9 @@ import {
|
|
|
19
19
|
validCalendarRecurrence
|
|
20
20
|
} from "./recurrence.js";
|
|
21
21
|
import { resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
|
|
22
|
+
import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js";
|
|
22
23
|
import { obligationIsEnabled } from "./program-lifecycle.js";
|
|
23
|
-
import { partyPeople } from "./parties.js";
|
|
24
|
+
import { currentPartyPeople, partyPeople } from "./parties.js";
|
|
24
25
|
import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
|
|
25
26
|
import { currentCalendarDate, isRfc3339Timestamp, localDateTimeValue, timestampFromLocalDateTime } from "./time.js";
|
|
26
27
|
import { recordTiming } from "./timing.js";
|
|
@@ -1273,6 +1274,55 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1273
1274
|
currentRevision
|
|
1274
1275
|
}
|
|
1275
1276
|
);
|
|
1277
|
+
if (current && record.resourceType === "control") {
|
|
1278
|
+
const controls = scopedCollectionRecords(loaded, "control", program);
|
|
1279
|
+
const controlIds = new Set(controls.map(({ id }) => id));
|
|
1280
|
+
const conflictIds = new Set();
|
|
1281
|
+
for (const control of controls) {
|
|
1282
|
+
for (const id of currentPartyPeople(control.ownerIds || [], byId)) conflictIds.add(id);
|
|
1283
|
+
}
|
|
1284
|
+
for (const obligation of loaded.resources.filter((candidate) => (
|
|
1285
|
+
candidate.type === "obligation"
|
|
1286
|
+
&& candidate.status === "active"
|
|
1287
|
+
&& (candidate.controlIds || []).some((id) => controlIds.has(id))
|
|
1288
|
+
))) {
|
|
1289
|
+
for (const id of currentPartyPeople(obligation.ownerIds || [], byId)) conflictIds.add(id);
|
|
1290
|
+
}
|
|
1291
|
+
const conflictedReviewers = (record.reviewedByIds || []).filter((id) => conflictIds.has(id));
|
|
1292
|
+
if (conflictedReviewers.length) {
|
|
1293
|
+
diagnostics.push(error(
|
|
1294
|
+
"conflicted-control-collection-reviewer",
|
|
1295
|
+
path,
|
|
1296
|
+
`A Control collection reviewer cannot own an included Control or its enabled Obligation: ${conflictedReviewers.join(", ")}.`
|
|
1297
|
+
));
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
if (current && record.resourceType === "retention-schedule-item" && modelSupports(model, "retention-schedule-approval")) {
|
|
1301
|
+
const rows = scopedCollectionRecords(loaded, record.resourceType, program);
|
|
1302
|
+
for (const issue of retentionScheduleApprovalIssues(loaded, program || {}, rows, {
|
|
1303
|
+
informationTypesReviewed: collectionReviewIsCurrent(loaded, "information-type", program)
|
|
1304
|
+
})) {
|
|
1305
|
+
diagnostics.push(error(issue.code, path, issue.message));
|
|
1306
|
+
}
|
|
1307
|
+
const owners = new Set();
|
|
1308
|
+
for (const candidate of loaded.resources.filter((candidate) => (
|
|
1309
|
+
candidate.type === "document"
|
|
1310
|
+
&& candidate.documentKind === "schedule"
|
|
1311
|
+
&& candidate.workflowScope === "program"
|
|
1312
|
+
&& !["superseded", "retired"].includes(candidate.status)
|
|
1313
|
+
|| rows.includes(candidate)
|
|
1314
|
+
))) {
|
|
1315
|
+
for (const id of currentPartyPeople(candidate.ownerIds || [], byId)) owners.add(id);
|
|
1316
|
+
}
|
|
1317
|
+
const conflictedReviewers = (record.reviewedByIds || []).filter((id) => owners.has(id));
|
|
1318
|
+
if (conflictedReviewers.length) {
|
|
1319
|
+
diagnostics.push(error(
|
|
1320
|
+
"conflicted-retention-schedule-reviewer",
|
|
1321
|
+
path,
|
|
1322
|
+
`A Data Retention Schedule reviewer cannot own its governing document or an included schedule row: ${conflictedReviewers.join(", ")}.`
|
|
1323
|
+
));
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1276
1326
|
if (modelSupports(model, "temporal-collection-reviews")) {
|
|
1277
1327
|
const temporalValues = [record.coverage, record.knowledgeCutoffAt, record.populationResourceIds];
|
|
1278
1328
|
const hasTemporalBinding = temporalValues.every((value) => value !== undefined && value !== null);
|
|
@@ -1341,6 +1391,25 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1341
1391
|
}
|
|
1342
1392
|
}
|
|
1343
1393
|
|
|
1394
|
+
function collectionReviewIsCurrent(loaded, resourceType, program) {
|
|
1395
|
+
const review = loaded.resources.find((record) => (
|
|
1396
|
+
record.type === "collection-review"
|
|
1397
|
+
&& record.resourceType === resourceType
|
|
1398
|
+
&& record.status === "active"
|
|
1399
|
+
&& (!modelSupports(loaded.model, "program-scope") || (record.scopeResourceIds || []).includes(program?.id))
|
|
1400
|
+
));
|
|
1401
|
+
if (!review || !(loaded.model.collectionReviews?.[resourceType]?.decisions || ["complete"]).includes(review.decision)) return false;
|
|
1402
|
+
const records = scopedCollectionRecords(loaded, resourceType, program);
|
|
1403
|
+
const revisionMatches = collectionRevisionMatches(loaded, resourceType, review.collectionRevision, { program });
|
|
1404
|
+
if (!modelSupports(loaded.model, "temporal-collection-reviews")) return revisionMatches;
|
|
1405
|
+
return revisionMatches
|
|
1406
|
+
&& review.coverage?.kind === "as-of"
|
|
1407
|
+
&& review.coverage.on === review.reviewedOn
|
|
1408
|
+
&& Boolean(review.knowledgeCutoffAt)
|
|
1409
|
+
&& Array.isArray(review.populationResourceIds)
|
|
1410
|
+
&& JSON.stringify([...review.populationResourceIds].sort()) === JSON.stringify(records.map(({ id }) => id).sort());
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1344
1413
|
function validateCollectionReviewSet(resources, pathById, diagnostics) {
|
|
1345
1414
|
const current = new Map();
|
|
1346
1415
|
for (const review of resources.filter((record) => record.type === "collection-review" && record.status !== "retired")) {
|