filegrc 0.15.1 → 0.15.3
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/v11.json +13 -20
- package/package.json +1 -1
- package/src/audit-preparation.js +111 -10
- package/src/cli.js +7 -0
- package/src/collection-review.js +124 -31
- package/src/collection-revision.js +6 -1
- package/src/collection-scope.js +121 -5
- package/src/files.js +13 -1
- package/src/git.js +86 -0
- package/src/index.js +2 -1
- package/src/obligation-members.js +31 -0
- package/src/obligations.js +39 -6
- package/src/program-path.js +1 -1
- package/src/program-readiness.js +102 -15
- package/src/retention-schedule-approval.js +36 -17
- package/src/retention.js +59 -20
- package/src/server.js +15 -1
- package/src/source-coverage.js +7 -0
- package/src/state.js +2 -0
- package/src/validate.js +35 -15
- package/src/web.js +80 -39
- package/src/workflow.js +3 -1
|
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
|
|
4
4
|
import { markdownEntries } from "./resource-markdown.js";
|
|
5
5
|
import { resolveDataPath } from "./paths.js";
|
|
6
|
-
import { currentPartyPeople
|
|
6
|
+
import { currentPartyPeople } from "./parties.js";
|
|
7
7
|
import {
|
|
8
8
|
nearDuplicateInformationTypes,
|
|
9
9
|
resourceReviewRevisionsSync,
|
|
@@ -26,7 +26,7 @@ export function retentionScheduleApprovalIssues(loaded, program, rows, options =
|
|
|
26
26
|
&& !["superseded", "retired"].includes(record.status)
|
|
27
27
|
));
|
|
28
28
|
if (!schedules.length) {
|
|
29
|
-
issues.push(issue("missing-retention-schedule-document", "Add
|
|
29
|
+
issues.push(issue("missing-retention-schedule-document", "Add the Data Retention Schedule document before requesting approval of the complete schedule."));
|
|
30
30
|
}
|
|
31
31
|
if (schedules.length !== 1) {
|
|
32
32
|
if (schedules.length) {
|
|
@@ -38,17 +38,22 @@ export function retentionScheduleApprovalIssues(loaded, program, rows, options =
|
|
|
38
38
|
const linkedControl = schedule && (schedule.controlIds || []).some((id) => (program.controlIds || []).includes(id));
|
|
39
39
|
if (
|
|
40
40
|
schedule
|
|
41
|
-
&& (
|
|
42
|
-
|| !schedule.approvedOn
|
|
43
|
-
|| !schedule.approvedContentRevisions
|
|
44
|
-
|| currentPartyPeople(schedule.ownerIds || [], byId).size === 0
|
|
45
|
-
|| !partiesIndependent(schedule.ownerIds, schedule.approverIds, byId)
|
|
41
|
+
&& (currentPartyPeople(schedule.ownerIds || [], byId).size === 0
|
|
46
42
|
|| !linkedControl
|
|
47
43
|
|| !substantiveMarkdown(scheduleContent)
|
|
48
|
-
|| openPlaceholderCount(scheduleContent) > 0
|
|
44
|
+
|| openPlaceholderCount(scheduleContent) > 0)
|
|
45
|
+
) {
|
|
46
|
+
issues.push(issue("incomplete-retention-schedule-document", "Complete the Data Retention Schedule document, its owner, linked Control, and Markdown before requesting approval of the complete schedule."));
|
|
47
|
+
}
|
|
48
|
+
if (
|
|
49
|
+
schedule
|
|
50
|
+
&& options.approvalReview
|
|
51
|
+
&& (!["approved", "active"].includes(schedule.status)
|
|
52
|
+
|| schedule.approvedOn !== options.approvalReview.reviewedOn
|
|
53
|
+
|| !sameIds(schedule.approverIds, options.approvalReview.reviewedByIds)
|
|
49
54
|
|| !approvalBindingsMatch(loaded, schedule))
|
|
50
55
|
) {
|
|
51
|
-
issues.push(issue("
|
|
56
|
+
issues.push(issue("invalid-retention-schedule-approval-binding", "The Data Retention Schedule document no longer matches the recorded whole-schedule approval. Review and approve the current complete schedule again."));
|
|
52
57
|
}
|
|
53
58
|
const activeRows = rows.filter(({ status }) => status === "active");
|
|
54
59
|
const mismatched = schedule
|
|
@@ -65,15 +70,20 @@ export function retentionScheduleApprovalIssues(loaded, program, rows, options =
|
|
|
65
70
|
if (incomplete.length) {
|
|
66
71
|
issues.push(issue("incomplete-retention-schedule-row", `Complete the retention decisions before approving the schedule revision: ${incomplete[0].title}.`));
|
|
67
72
|
}
|
|
68
|
-
const
|
|
69
|
-
const
|
|
73
|
+
const proposedRows = rows.filter(rowProposesCoverage);
|
|
74
|
+
const scopedPrograms = program.programIds
|
|
75
|
+
? program.programIds.map((id) => byId.get(id)).filter((record) => record?.type === "program")
|
|
76
|
+
: [program];
|
|
77
|
+
const uncovered = scopedPrograms.flatMap((candidate) => (
|
|
78
|
+
retentionUses(loaded, candidate).map((use) => ({ ...use, programId: candidate.id }))
|
|
79
|
+
)).find((use) => !proposedRows.some((row) => (
|
|
70
80
|
(row.informationTypeIds || []).includes(use.informationTypeId)
|
|
71
|
-
&& ((row.scopeResourceIds || []).includes(use.resource.id) || (row.scopeResourceIds || []).includes(
|
|
81
|
+
&& ((row.scopeResourceIds || []).includes(use.resource.id) || (row.scopeResourceIds || []).includes(use.programId))
|
|
72
82
|
)));
|
|
73
83
|
if (uncovered) {
|
|
74
84
|
issues.push(issue(
|
|
75
85
|
"uncovered-retention-information-use",
|
|
76
|
-
`${uncovered.resource.title} uses ${byId.get(uncovered.informationTypeId)?.title || uncovered.informationTypeId}, but no
|
|
86
|
+
`${uncovered.resource.title} uses ${byId.get(uncovered.informationTypeId)?.title || uncovered.informationTypeId}, but no proposed retention schedule row covers both the type and scope.`
|
|
77
87
|
));
|
|
78
88
|
}
|
|
79
89
|
if (nearDuplicateInformationTypes(loaded.resources).length && !options.informationTypesReviewed) {
|
|
@@ -82,6 +92,10 @@ export function retentionScheduleApprovalIssues(loaded, program, rows, options =
|
|
|
82
92
|
return issues;
|
|
83
93
|
}
|
|
84
94
|
|
|
95
|
+
function sameIds(left = [], right = []) {
|
|
96
|
+
return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
function issue(code, message) {
|
|
86
100
|
return { code, message };
|
|
87
101
|
}
|
|
@@ -99,6 +113,14 @@ function primaryMarkdown(loaded, record) {
|
|
|
99
113
|
}
|
|
100
114
|
}
|
|
101
115
|
|
|
116
|
+
function rowProposesCoverage(row) {
|
|
117
|
+
return Boolean(
|
|
118
|
+
(row.informationTypeIds || []).length
|
|
119
|
+
&& (row.scopeResourceIds || []).length
|
|
120
|
+
&& row.scheduleDocumentId
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
102
124
|
function approvalBindingsMatch(loaded, record) {
|
|
103
125
|
const actual = {};
|
|
104
126
|
for (const entry of markdownEntries(loaded.model, record)) {
|
|
@@ -114,8 +136,5 @@ function approvalBindingsMatch(loaded, record) {
|
|
|
114
136
|
const actualKeys = Object.keys(actual).sort();
|
|
115
137
|
const expectedKeys = Object.keys(expected).sort();
|
|
116
138
|
return actualKeys.length === expectedKeys.length
|
|
117
|
-
&& actualKeys.every((key, index) =>
|
|
118
|
-
key === expectedKeys[index]
|
|
119
|
-
&& actual[key] === expected[key]
|
|
120
|
-
));
|
|
139
|
+
&& actualKeys.every((key, index) => key === expectedKeys[index] && actual[key] === expected[key]);
|
|
121
140
|
}
|
package/src/retention.js
CHANGED
|
@@ -10,18 +10,26 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
|
10
10
|
if (!loaded.model.resources["retention-schedule-item"]) return [];
|
|
11
11
|
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
12
12
|
const rules = loaded.resources.filter((record) => (
|
|
13
|
-
record.type === "retention-schedule-item"
|
|
13
|
+
record.type === "retention-schedule-item"
|
|
14
|
+
&& !["retired", "superseded"].includes(record.status)
|
|
14
15
|
));
|
|
15
|
-
const
|
|
16
|
-
const
|
|
16
|
+
const activeRules = rules.filter(({ status }) => status === "active");
|
|
17
|
+
const revisions = await resourceReviewRevisions(loaded, activeRules.flatMap((rule) => retentionReviewResourceIds(rule, loaded)));
|
|
18
|
+
const usableRules = activeRules.filter((rule) => retentionRuleIsCurrent(rule, revisions, byId, loaded));
|
|
19
|
+
const proposedRules = rules.filter(retentionRuleHasCoverageProposal);
|
|
17
20
|
const uses = retentionUses(loaded, program);
|
|
18
|
-
const items = rules.
|
|
21
|
+
const items = rules.map((rule) => readinessItem(
|
|
19
22
|
`retention-rule-${rule.id}`,
|
|
20
|
-
"action",
|
|
21
|
-
`
|
|
22
|
-
|
|
23
|
+
usableRules.includes(rule) ? "complete" : "action",
|
|
24
|
+
usableRules.includes(rule) ? `${rule.title} proposal is ready` : `Finish ${rule.title}`,
|
|
25
|
+
usableRules.includes(rule)
|
|
26
|
+
? "The owner completed this row and bound it to its current source revisions. It will become part of the authoritative schedule only when the complete schedule is approved."
|
|
27
|
+
: rule.status === "planned"
|
|
28
|
+
? "Finish the type, scope, cutoff, period, disposition, sources, and owner review for this proposed row before requesting schedule approval."
|
|
29
|
+
: "This row is incomplete or is not bound to every current source revision. Review it before requesting schedule approval.",
|
|
23
30
|
rule,
|
|
24
31
|
{
|
|
32
|
+
coverageState: usableRules.includes(rule) ? "proposed" : "incomplete",
|
|
25
33
|
sourceResourceIds: retentionReviewResourceIds(rule, loaded),
|
|
26
34
|
commands: [
|
|
27
35
|
`npx filegrc get ${rule.id} --mutation`,
|
|
@@ -39,31 +47,41 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
|
39
47
|
usesByInformationType.set(use.informationTypeId, grouped);
|
|
40
48
|
}
|
|
41
49
|
items.push(...[...usesByInformationType].map(([informationTypeId, informationTypeUses]) => {
|
|
42
|
-
const
|
|
50
|
+
const absentUses = informationTypeUses.filter((use) => (
|
|
51
|
+
!proposedRules.some((rule) => ruleCoversUse(rule, use, program))
|
|
52
|
+
));
|
|
53
|
+
const pendingUses = informationTypeUses.filter((use) => (
|
|
43
54
|
!usableRules.some((rule) => ruleCoversUse(rule, use, program))
|
|
44
55
|
));
|
|
56
|
+
const approvalPending = !options.scheduleApproved;
|
|
45
57
|
const matches = usableRules.filter((rule) => (
|
|
46
58
|
informationTypeUses.some((use) => ruleCoversUse(rule, use, program))
|
|
47
59
|
));
|
|
48
60
|
const informationTypeTitle = byId.get(informationTypeId)?.title || informationTypeId;
|
|
49
|
-
const affectedTitles =
|
|
61
|
+
const affectedTitles = absentUses.map(({ resource }) => resource.title);
|
|
50
62
|
const affectedSummary = affectedTitles.length > 3
|
|
51
63
|
? `${affectedTitles.slice(0, 3).join(", ")}, and ${affectedTitles.length - 3} more`
|
|
52
64
|
: affectedTitles.join(", ");
|
|
53
65
|
return readinessItem(
|
|
54
66
|
`retention-use-${informationTypeId}`,
|
|
55
|
-
|
|
67
|
+
absentUses.length ? "action" : pendingUses.length || approvalPending ? "info" : "complete",
|
|
56
68
|
`Decide retention for ${informationTypeTitle}`,
|
|
57
|
-
|
|
69
|
+
absentUses.length
|
|
58
70
|
? `${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
|
-
:
|
|
71
|
+
: pendingUses.length
|
|
72
|
+
? `Proposed rows cover every in-scope type-and-scope pair. Finish the row review, then approve the complete schedule once instead of approving these coverage checks separately.`
|
|
73
|
+
: approvalPending
|
|
74
|
+
? "Completed row proposals cover every in-scope type-and-scope pair. Approve the complete schedule once; these derived checks are not separate approval work."
|
|
75
|
+
: `The approved schedule covers all ${informationTypeUses.length} in-scope ${informationTypeUses.length === 1 ? "use" : "uses"}.`,
|
|
60
76
|
{ type: "retention-schedule-item" },
|
|
61
77
|
{
|
|
78
|
+
progressUnit: false,
|
|
79
|
+
coverageState: absentUses.length ? "absent" : pendingUses.length || approvalPending ? "proposed" : "approved",
|
|
62
80
|
informationTypeId,
|
|
63
|
-
affectedResourceIds:
|
|
64
|
-
retentionScopeResourceIds:
|
|
81
|
+
affectedResourceIds: absentUses.map(({ resource }) => resource.id),
|
|
82
|
+
retentionScopeResourceIds: absentUses.length === informationTypeUses.length
|
|
65
83
|
? [program.id]
|
|
66
|
-
:
|
|
84
|
+
: absentUses.map(({ resource }) => resource.id),
|
|
67
85
|
retentionScheduleItemIds: matches.map(({ id }) => id),
|
|
68
86
|
commands: [
|
|
69
87
|
"npx filegrc guide retention-schedule-item --json",
|
|
@@ -75,6 +93,11 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
|
75
93
|
|
|
76
94
|
for (const coverage of loaded.resources.filter((record) => record.type === "source-coverage" && record.status === "active")) {
|
|
77
95
|
const linked = (coverage.retentionScheduleItemIds || []).map((id) => byId.get(id)).filter(Boolean);
|
|
96
|
+
const proposed = linked.filter((rule) => (
|
|
97
|
+
rule.type === "retention-schedule-item"
|
|
98
|
+
&& proposedRules.includes(rule)
|
|
99
|
+
&& (rule.scopeResourceIds || []).includes(coverage.id)
|
|
100
|
+
));
|
|
78
101
|
const matching = linked.filter((rule) => (
|
|
79
102
|
rule.type === "retention-schedule-item"
|
|
80
103
|
&& rule.status === "active"
|
|
@@ -83,13 +106,17 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
|
83
106
|
));
|
|
84
107
|
items.push(readinessItem(
|
|
85
108
|
`retention-source-coverage-${coverage.id}`,
|
|
86
|
-
matching.length ? "complete" : "action",
|
|
109
|
+
matching.length && options.scheduleApproved ? "complete" : proposed.length ? "info" : "action",
|
|
87
110
|
`Confirm retained evidence for ${coverage.title}`,
|
|
88
|
-
matching.length
|
|
111
|
+
matching.length && options.scheduleApproved
|
|
89
112
|
? `The source-coverage record references a current schedule item scoped to this population.`
|
|
90
|
-
:
|
|
113
|
+
: proposed.length
|
|
114
|
+
? "A proposed schedule row covers this evidence source. Finish that row and approve the complete schedule before relying on it."
|
|
115
|
+
: `No proposed schedule row covers ${coverage.id}. Add the genuine type-and-scope decision before schedule approval.`,
|
|
91
116
|
coverage,
|
|
92
117
|
{
|
|
118
|
+
progressUnit: false,
|
|
119
|
+
coverageState: matching.length && options.scheduleApproved ? "approved" : proposed.length ? "proposed" : "absent",
|
|
93
120
|
retentionScheduleItemIds: matching.map(({ id }) => id),
|
|
94
121
|
commands: [
|
|
95
122
|
`npx filegrc get ${coverage.id} --mutation`,
|
|
@@ -110,6 +137,7 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
|
110
137
|
: `${duplicates.length} similar pair${duplicates.length === 1 ? " needs" : "s need"} management review. FileGRC will not merge records or rewrite relationships automatically.`,
|
|
111
138
|
{ type: "information-type" },
|
|
112
139
|
{
|
|
140
|
+
progressUnit: false,
|
|
113
141
|
candidates: duplicates,
|
|
114
142
|
commands: [
|
|
115
143
|
"npx filegrc list information-type --workflow --json",
|
|
@@ -218,8 +246,11 @@ function reviewSource(loaded, entry) {
|
|
|
218
246
|
&& entry.record.workflowScope === "program"
|
|
219
247
|
) {
|
|
220
248
|
const approved = structuredClone(entry.record);
|
|
221
|
-
if (approved.status === "active") approved.status = "approved";
|
|
222
249
|
for (const field of [
|
|
250
|
+
"status",
|
|
251
|
+
"approverIds",
|
|
252
|
+
"approvedOn",
|
|
253
|
+
"approvedContentRevisions",
|
|
223
254
|
"activationBasis",
|
|
224
255
|
"activatedByIds",
|
|
225
256
|
"activatedOn",
|
|
@@ -252,6 +283,14 @@ function ruleCoversUse(rule, use, program) {
|
|
|
252
283
|
return scope.has(use.resource.id) || scope.has(program.id);
|
|
253
284
|
}
|
|
254
285
|
|
|
286
|
+
function retentionRuleHasCoverageProposal(rule) {
|
|
287
|
+
return Boolean(
|
|
288
|
+
(rule.informationTypeIds || []).length
|
|
289
|
+
&& (rule.scopeResourceIds || []).length
|
|
290
|
+
&& rule.scheduleDocumentId
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
255
294
|
export function retentionReviewResourceIds(rule, loaded) {
|
|
256
295
|
const programUseIds = [];
|
|
257
296
|
if (loaded) {
|
|
@@ -279,7 +318,7 @@ export function retentionRuleIsCurrent(rule, revisions, byId = new Map(), loaded
|
|
|
279
318
|
if (!(rule.informationTypeIds || []).length || !(rule.scopeResourceIds || []).length || !rule.scheduleDocumentId) return false;
|
|
280
319
|
const schedule = byId.get(rule.scheduleDocumentId);
|
|
281
320
|
if (schedule?.type !== "document" || schedule.documentKind !== "schedule" || schedule.workflowScope !== "program" || ["superseded", "retired"].includes(schedule.status)) return false;
|
|
282
|
-
if (!(rule.ownerIds || []).length
|
|
321
|
+
if (!(rule.ownerIds || []).length) return false;
|
|
283
322
|
if (!rule.reviewedSourceRevisions || typeof rule.reviewedSourceRevisions !== "object") return false;
|
|
284
323
|
if (!retentionCutoffIsComplete(rule.cutoff) || !retentionPeriodIsComplete(rule.retentionPeriod)) return false;
|
|
285
324
|
if (!["delete", "destroy", "erase", "anonymize", "transfer", "retain-permanently"].includes(rule.dispositionAction)) return false;
|
package/src/server.js
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
pushWorkspace,
|
|
34
34
|
retryBrowserSync,
|
|
35
35
|
runBrowserMutation,
|
|
36
|
+
synchronizeBrowserStartup,
|
|
36
37
|
withGitCommandCache,
|
|
37
38
|
withGitCommandDeadline
|
|
38
39
|
} from "./git.js";
|
|
@@ -777,6 +778,18 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
|
|
|
777
778
|
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
778
779
|
throw new Error("The server port must be an integer from 0 through 65535.");
|
|
779
780
|
}
|
|
781
|
+
let startupSynchronization;
|
|
782
|
+
try {
|
|
783
|
+
startupSynchronization = await synchronizeBrowserStartup(input, {
|
|
784
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true
|
|
785
|
+
});
|
|
786
|
+
} catch (error) {
|
|
787
|
+
startupSynchronization = {
|
|
788
|
+
status: "failed",
|
|
789
|
+
updated: false,
|
|
790
|
+
message: error.message
|
|
791
|
+
};
|
|
792
|
+
}
|
|
780
793
|
const loaded = await loadWorkspace(input);
|
|
781
794
|
getResourceDefinition(loaded.model, "workspace");
|
|
782
795
|
const server = createFilegrcServer(loaded.root, {
|
|
@@ -806,7 +819,8 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
|
|
|
806
819
|
address: server.address(),
|
|
807
820
|
url: `http://${urlHost(host)}:${server.address().port}`,
|
|
808
821
|
requestedPort: port,
|
|
809
|
-
usedFallbackPort
|
|
822
|
+
usedFallbackPort,
|
|
823
|
+
startupSynchronization
|
|
810
824
|
};
|
|
811
825
|
}
|
|
812
826
|
|
package/src/source-coverage.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { resourceReviewRevisions, retentionReviewResourceIds, retentionRuleIsCurrent } from "./retention.js";
|
|
2
|
+
import { retentionScheduleIsAuthoritative } from "./collection-review.js";
|
|
3
|
+
import { modelSupports } from "../model/index.js";
|
|
4
|
+
import { resolveProgram } from "./program.js";
|
|
2
5
|
|
|
3
6
|
export async function sourceCoverageComplete(record, loaded, program = loaded.workspace) {
|
|
4
7
|
const structuredRetention = loaded.model.resources["retention-schedule-item"];
|
|
@@ -6,6 +9,10 @@ export async function sourceCoverageComplete(record, loaded, program = loaded.wo
|
|
|
6
9
|
return false;
|
|
7
10
|
}
|
|
8
11
|
if (structuredRetention) {
|
|
12
|
+
const retentionProgram = modelSupports(loaded.model, "program-scope")
|
|
13
|
+
? resolveProgram(loaded, program?.type === "program" ? program.id : undefined)
|
|
14
|
+
: program;
|
|
15
|
+
if (!retentionScheduleIsAuthoritative(loaded, retentionProgram)) return false;
|
|
9
16
|
const linkedRules = (record.retentionScheduleItemIds || []).map((id) => (
|
|
10
17
|
loaded.resources.find((resource) => resource.id === id && resource.type === "retention-schedule-item")
|
|
11
18
|
));
|
package/src/state.js
CHANGED
|
@@ -347,6 +347,8 @@ function serializeCollectionReviewAssessment(assessment) {
|
|
|
347
347
|
status: assessment.status,
|
|
348
348
|
complete: assessment.complete,
|
|
349
349
|
message: assessment.message,
|
|
350
|
+
...(assessment.recordProposals ? { recordProposals: assessment.recordProposals } : {}),
|
|
351
|
+
...(assessment.incompleteRecordProposals ? { incompleteRecordProposals: assessment.incompleteRecordProposals } : {}),
|
|
350
352
|
...(assessment.eligibleReviewerIds ? { eligibleReviewerIds: assessment.eligibleReviewerIds } : {}),
|
|
351
353
|
...(assessment.reviewerConflictIds ? { reviewerConflictIds: assessment.reviewerConflictIds } : {}),
|
|
352
354
|
...(assessment.approvalIssues ? { approvalIssues: assessment.approvalIssues } : {})
|
package/src/validate.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createReadStream } from "node:fs";
|
|
|
3
3
|
import { readFile, stat } from "node:fs/promises";
|
|
4
4
|
import { performance } from "node:perf_hooks";
|
|
5
5
|
import { getResourceDefinition, modelSupports } from "../model/index.js";
|
|
6
|
-
import { scopedCollectionRecords, selectScopedCollectionRecords } from "./collection-scope.js";
|
|
6
|
+
import { collectionRecordProposals, retentionScheduleReviewScope, scopedCollectionRecords, selectScopedCollectionRecords } from "./collection-scope.js";
|
|
7
7
|
import {
|
|
8
8
|
collectionRevision,
|
|
9
9
|
collectionRevisionMatches
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
recordsAtRevision
|
|
45
45
|
} from "./reporting-route-integrity.js";
|
|
46
46
|
import { validateWorkflowHistoryIntegrity } from "./workflow-history-integrity.js";
|
|
47
|
+
import { occurrenceMemberExceptionIsValid } from "./obligation-members.js";
|
|
47
48
|
|
|
48
49
|
let fingerprintFileReadObserver = null;
|
|
49
50
|
|
|
@@ -667,18 +668,7 @@ function validateObligationOccurrence(record, model, resources, entries, root, w
|
|
|
667
668
|
));
|
|
668
669
|
}
|
|
669
670
|
if (member.disposition === "exception") {
|
|
670
|
-
|
|
671
|
-
const occurrenceStart = record.coverage?.kind === "as-of" ? record.coverage.on : record.coverage?.startsOn;
|
|
672
|
-
const occurrenceEnd = record.coverage?.kind === "as-of" ? record.coverage.on : record.coverage?.endsOn;
|
|
673
|
-
const exceptionCoversMember = exception?.scopeResourceIds?.includes(member.resourceId)
|
|
674
|
-
|| exception?.scopeResourceIds?.includes(record.obligationId);
|
|
675
|
-
if (
|
|
676
|
-
exception?.type !== "exception"
|
|
677
|
-
|| exception.status !== "approved"
|
|
678
|
-
|| !exceptionCoversMember
|
|
679
|
-
|| exception.approval?.approvedOn > occurrenceStart
|
|
680
|
-
|| exception.approval?.expiresOn < occurrenceEnd
|
|
681
|
-
) {
|
|
671
|
+
if (!occurrenceMemberExceptionIsValid(member, record, byId)) {
|
|
682
672
|
diagnostics.push(error(
|
|
683
673
|
"invalid-member-exception",
|
|
684
674
|
path,
|
|
@@ -1224,6 +1214,10 @@ function sameRouteProposal(proposal, finalized) {
|
|
|
1224
1214
|
return [...keys].every((key) => allowed.has(key) || JSON.stringify(proposal[key]) === JSON.stringify(finalized[key]));
|
|
1225
1215
|
}
|
|
1226
1216
|
|
|
1217
|
+
function sameStringIds(left = [], right = []) {
|
|
1218
|
+
return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1227
1221
|
function validateTimestampTimezone(value, timezone, path, diagnostics, label) {
|
|
1228
1222
|
if (!value || !timezone) return;
|
|
1229
1223
|
const source = String(value);
|
|
@@ -1252,10 +1246,19 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1252
1246
|
const program = modelSupports(model, "program-scope")
|
|
1253
1247
|
? (record.scopeResourceIds || []).map((id) => byId.get(id)).find(({ type } = {}) => type === "program")
|
|
1254
1248
|
: null;
|
|
1249
|
+
const workspaceWideRetentionReview = record.resourceType === "retention-schedule-item"
|
|
1250
|
+
&& modelSupports(model, "retention-schedule-approval");
|
|
1251
|
+
const retentionScope = workspaceWideRetentionReview ? retentionScheduleReviewScope(loaded) : null;
|
|
1255
1252
|
const recordCount = scopedCollectionRecords(loaded, record.resourceType, program).length;
|
|
1256
1253
|
const currentPopulationIds = scopedCollectionRecords(loaded, record.resourceType, program)
|
|
1257
1254
|
.map(({ id }) => id)
|
|
1258
1255
|
.sort();
|
|
1256
|
+
const incompleteProposals = collectionRecordProposals(
|
|
1257
|
+
loaded,
|
|
1258
|
+
record.resourceType,
|
|
1259
|
+
scopedCollectionRecords(loaded, record.resourceType, program),
|
|
1260
|
+
program
|
|
1261
|
+
).filter(({ complete }) => !complete);
|
|
1259
1262
|
const currentRevision = collectionRevision(loaded, record.resourceType, {
|
|
1260
1263
|
program,
|
|
1261
1264
|
authoritativeSourceId: record.decision === "externally-managed"
|
|
@@ -1274,6 +1277,20 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1274
1277
|
currentRevision
|
|
1275
1278
|
}
|
|
1276
1279
|
);
|
|
1280
|
+
if (current && workspaceWideRetentionReview && !sameStringIds(record.scopeResourceIds, retentionScope.programIds)) {
|
|
1281
|
+
diagnostics.push(error(
|
|
1282
|
+
"invalid-retention-schedule-review-scope",
|
|
1283
|
+
path,
|
|
1284
|
+
"The current Data Retention Schedule review must cover every active Program as one workspace-wide approval."
|
|
1285
|
+
));
|
|
1286
|
+
}
|
|
1287
|
+
if (current && incompleteProposals.length) {
|
|
1288
|
+
diagnostics.push(error(
|
|
1289
|
+
"incomplete-collection-review-proposals",
|
|
1290
|
+
path,
|
|
1291
|
+
`Complete every ${configuration.title.toLowerCase()} record proposal before the collection review. Remaining: ${incompleteProposals.map(({ title }) => title).join(", ")}.`
|
|
1292
|
+
));
|
|
1293
|
+
}
|
|
1277
1294
|
if (current && record.resourceType === "control") {
|
|
1278
1295
|
const controls = scopedCollectionRecords(loaded, "control", program);
|
|
1279
1296
|
const controlIds = new Set(controls.map(({ id }) => id));
|
|
@@ -1299,8 +1316,11 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1299
1316
|
}
|
|
1300
1317
|
if (current && record.resourceType === "retention-schedule-item" && modelSupports(model, "retention-schedule-approval")) {
|
|
1301
1318
|
const rows = scopedCollectionRecords(loaded, record.resourceType, program);
|
|
1302
|
-
for (const issue of retentionScheduleApprovalIssues(loaded,
|
|
1303
|
-
informationTypesReviewed:
|
|
1319
|
+
for (const issue of retentionScheduleApprovalIssues(loaded, retentionScope, rows, {
|
|
1320
|
+
informationTypesReviewed: retentionScope.programIds.every((id) => (
|
|
1321
|
+
collectionReviewIsCurrent(loaded, "information-type", byId.get(id))
|
|
1322
|
+
)),
|
|
1323
|
+
approvalReview: record
|
|
1304
1324
|
})) {
|
|
1305
1325
|
diagnostics.push(error(issue.code, path, issue.message));
|
|
1306
1326
|
}
|