filegrc 0.15.1 → 0.15.2
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 +105 -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/program-path.js +1 -1
- package/src/program-readiness.js +91 -13
- package/src/retention-schedule-approval.js +36 -17
- package/src/retention.js +58 -20
- package/src/server.js +15 -1
- package/src/source-coverage.js +7 -0
- package/src/state.js +2 -0
- package/src/validate.js +33 -3
- package/src/web.js +72 -38
- package/src/workflow.js +3 -1
package/model/v11.json
CHANGED
|
@@ -567,7 +567,7 @@
|
|
|
567
567
|
},
|
|
568
568
|
"vendor": {
|
|
569
569
|
"title": "Vendor inventory",
|
|
570
|
-
"description": "
|
|
570
|
+
"description": "Finish each material external provider record, then confirm the Vendor inventory once as a complete collection separate from Program System and Component scope.",
|
|
571
571
|
"reviewPoints": [
|
|
572
572
|
"Add Vendors for material external commercial or service relationships.",
|
|
573
573
|
"Confirm contracts, due diligence, criticality, information access, and monitoring.",
|
|
@@ -593,7 +593,7 @@
|
|
|
593
593
|
},
|
|
594
594
|
"complementary-control": {
|
|
595
595
|
"title": "Customer and provider responsibilities",
|
|
596
|
-
"description": "
|
|
596
|
+
"description": "Finish each customer or carved-out provider responsibility record, then confirm the collection once, including when no such dependency exists.",
|
|
597
597
|
"reviewPoints": [
|
|
598
598
|
"Think through the customer and carved-out provider actions that in-scope Controls depend on, and add any missing Complementary Control.",
|
|
599
599
|
"Confirm each statement names a specific action, responsible party, affected System, and dependent Control.",
|
|
@@ -606,7 +606,7 @@
|
|
|
606
606
|
},
|
|
607
607
|
"component": {
|
|
608
608
|
"title": "Scoped Components",
|
|
609
|
-
"description": "
|
|
609
|
+
"description": "Finish each Component proposal, then confirm once that the collection materially participating in the selected Program Systems is complete.",
|
|
610
610
|
"reviewPoints": [
|
|
611
611
|
"Start with each bounded System and include only Components that deliver its service, support Controls, produce authoritative Evidence, or support relevant operations.",
|
|
612
612
|
"Confirm every System use has the right role and a specific rationale.",
|
|
@@ -631,7 +631,7 @@
|
|
|
631
631
|
},
|
|
632
632
|
"information-type": {
|
|
633
633
|
"title": "Information Type inventory",
|
|
634
|
-
"description": "
|
|
634
|
+
"description": "Finish each Information Type proposal, then confirm once that the collection is complete, distinct, and linked to the Systems, Components, and Vendors that use or access it.",
|
|
635
635
|
"reviewPoints": [
|
|
636
636
|
"Review near-duplicate names and choose one canonical Information Type only after confirming they mean the same thing.",
|
|
637
637
|
"Retire superseded records through a reviewed migration that rewrites every relationship.",
|
|
@@ -644,11 +644,11 @@
|
|
|
644
644
|
},
|
|
645
645
|
"retention-schedule-item": {
|
|
646
646
|
"title": "Data Retention Schedule",
|
|
647
|
-
"description": "
|
|
647
|
+
"description": "Complete each structured row proposal, then approve the schedule document and all proposed rows in one independent review. Include only actual information uses and keep undecided organization values planned.",
|
|
648
648
|
"reviewPoints": [
|
|
649
|
-
"
|
|
649
|
+
"Confirm the owner completed every row proposal, then review the Data Retention Schedule document, its Markdown, and every proposed row as one revision.",
|
|
650
650
|
"Confirm schedule coverage across the Systems, Components, Vendors, source families, logs, backups, audit records, and FileGRC records the organization actually uses.",
|
|
651
|
-
"Confirm every
|
|
651
|
+
"Confirm every proposed item has a reviewed cutoff, retention period, disposition action, owner, and exact source revisions; keep undecided behavior planned."
|
|
652
652
|
],
|
|
653
653
|
"decisions": [
|
|
654
654
|
"complete",
|
|
@@ -7699,14 +7699,14 @@
|
|
|
7699
7699
|
"pluralTitle": "Retention Schedule",
|
|
7700
7700
|
"group": "governance",
|
|
7701
7701
|
"collection": "retention-schedule-items",
|
|
7702
|
-
"description": "One
|
|
7702
|
+
"description": "One owner-completed proposal that states which Information Types and operational scope it covers, when retention starts, how long information is kept, and its proposed disposition. It becomes authoritative through the single whole-schedule approval.",
|
|
7703
7703
|
"guidance": {
|
|
7704
7704
|
"policyBasis": "Management must translate legal, contractual, privacy, security, and business needs into explicit retention and disposition instructions without treating starter periods as organization facts.",
|
|
7705
|
-
"cadence": "
|
|
7705
|
+
"cadence": "Complete the proposal before whole-schedule approval and revisit it after a source Policy, Requirement, Commitment, information use, System, Component, Vendor, or legal-hold process changes.",
|
|
7706
7706
|
"reviewPoints": [
|
|
7707
7707
|
"Confirm the Information Types and operational scope covered by this item.",
|
|
7708
|
-
"Confirm the cutoff, retention period, and disposition action from
|
|
7709
|
-
"Keep the item planned until
|
|
7708
|
+
"Confirm the cutoff, retention period, and disposition action from management sources.",
|
|
7709
|
+
"Keep the item planned until the owner completes every organization-specific value and binds the current source revisions."
|
|
7710
7710
|
],
|
|
7711
7711
|
"sourceResourceIds": [
|
|
7712
7712
|
"policy-information-security",
|
|
@@ -7828,16 +7828,11 @@
|
|
|
7828
7828
|
"relation": [
|
|
7829
7829
|
"person"
|
|
7830
7830
|
],
|
|
7831
|
-
"
|
|
7832
|
-
"status": "active"
|
|
7833
|
-
},
|
|
7834
|
-
"label": "Approved by"
|
|
7831
|
+
"label": "Legacy row reviewer"
|
|
7835
7832
|
},
|
|
7836
7833
|
"approvedOn": {
|
|
7837
7834
|
"type": "date",
|
|
7838
|
-
"
|
|
7839
|
-
"status": "active"
|
|
7840
|
-
}
|
|
7835
|
+
"label": "Legacy row review date"
|
|
7841
7836
|
},
|
|
7842
7837
|
"reviewedSourceRevisions": {
|
|
7843
7838
|
"type": "object",
|
|
@@ -7891,8 +7886,6 @@
|
|
|
7891
7886
|
"retentionPeriod",
|
|
7892
7887
|
"dispositionAction",
|
|
7893
7888
|
"dispositionInstructions",
|
|
7894
|
-
"approvedByIds",
|
|
7895
|
-
"approvedOn",
|
|
7896
7889
|
"reviewedSourceRevisions",
|
|
7897
7890
|
"supersedesId",
|
|
7898
7891
|
"statusTransition"
|
package/package.json
CHANGED
package/src/audit-preparation.js
CHANGED
|
@@ -105,17 +105,21 @@ export async function assessAuditPreparation(input, options = {}) {
|
|
|
105
105
|
}
|
|
106
106
|
const items = stages.flatMap((stage) => stage.items);
|
|
107
107
|
const counts = countStatuses(items);
|
|
108
|
-
const
|
|
109
|
-
|
|
108
|
+
const progressUnits = items.flatMap((current) => (
|
|
109
|
+
current.progressUnit === false || ["external", "info", "later"].includes(current.status)
|
|
110
|
+
? []
|
|
111
|
+
: current.progressUnits?.length ? current.progressUnits : [current]
|
|
112
|
+
)).filter((current) => !["external", "info", "later"].includes(current.status));
|
|
113
|
+
const completedProgressUnits = progressUnits.filter((current) => current.status === "complete");
|
|
110
114
|
return {
|
|
111
115
|
schemaVersion: 1,
|
|
112
116
|
generatedAt: options.generatedAt || new Date().toISOString(),
|
|
113
117
|
audit: audit ? auditSummary(audit) : null,
|
|
114
118
|
status: !audit ? "not-started" : counts.action ? "needs-work" : "management-ready",
|
|
115
119
|
progress: {
|
|
116
|
-
complete:
|
|
117
|
-
total:
|
|
118
|
-
percent:
|
|
120
|
+
complete: completedProgressUnits.length,
|
|
121
|
+
total: progressUnits.length,
|
|
122
|
+
percent: progressUnits.length ? Math.round((completedProgressUnits.length / progressUnits.length) * 100) : 0
|
|
119
123
|
},
|
|
120
124
|
counts,
|
|
121
125
|
canInitialize: Boolean(audit
|
|
@@ -965,7 +969,7 @@ function occurrenceContinuityStage(audit, records, model, asOf) {
|
|
|
965
969
|
const gapStatuses = new Set(["overdue", "blocked", "due", "proposed"]);
|
|
966
970
|
const calendarGaps = plan.calendarItems.filter(({ status }) => gapStatuses.has(status));
|
|
967
971
|
const eventGaps = plan.eventRuns
|
|
968
|
-
.filter((run) => run.occurredOn >= periodStart && run.occurredOn <= periodThrough)
|
|
972
|
+
.filter((run) => run.status !== "canceled" && run.occurredOn >= periodStart && run.occurredOn <= periodThrough)
|
|
969
973
|
.flatMap((run) => run.actions)
|
|
970
974
|
.filter((action) => (
|
|
971
975
|
(gapStatuses.has(action.status) || action.lateCompletion)
|
|
@@ -974,6 +978,57 @@ function occurrenceContinuityStage(audit, records, model, asOf) {
|
|
|
974
978
|
const gaps = [...calendarGaps, ...eventGaps];
|
|
975
979
|
const lateGaps = eventGaps.filter(({ lateCompletion }) => lateCompletion);
|
|
976
980
|
const firstGap = gaps[0];
|
|
981
|
+
const progressUnits = occurrenceProgressUnits(plan, {
|
|
982
|
+
selectedControlIds,
|
|
983
|
+
periodStart,
|
|
984
|
+
periodThrough
|
|
985
|
+
});
|
|
986
|
+
return occurrenceContinuityStageResult({
|
|
987
|
+
audit,
|
|
988
|
+
gaps,
|
|
989
|
+
lateGaps,
|
|
990
|
+
firstGap,
|
|
991
|
+
eventGaps,
|
|
992
|
+
periodThrough,
|
|
993
|
+
progressUnits
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
export function occurrenceProgressUnits(plan, { selectedControlIds, periodStart, periodThrough }) {
|
|
998
|
+
return [
|
|
999
|
+
...plan.calendarItems.flatMap((occurrence) => {
|
|
1000
|
+
const completedMemberIds = new Set(occurrence.completedMemberIds || []);
|
|
1001
|
+
const conclusionComplete = occurrence.reconciliationStatus === "reconciled"
|
|
1002
|
+
&& ["complete", "complete-with-exceptions", "zero-population"].includes(occurrence.operatingResult);
|
|
1003
|
+
const occurrenceComplete = conclusionComplete || occurrence.legacySchedule && occurrence.status === "complete";
|
|
1004
|
+
const memberUnits = (occurrence.expectedMemberIds || []).map((resourceId) => ({
|
|
1005
|
+
id: `occurrence-${occurrence.occurrenceKey}-member-${resourceId}`,
|
|
1006
|
+
status: occurrenceComplete || completedMemberIds.has(resourceId) ? "complete" : "action",
|
|
1007
|
+
title: `${occurrence.title} member ${resourceId}`
|
|
1008
|
+
}));
|
|
1009
|
+
const membersComplete = memberUnits.every(({ status }) => status === "complete");
|
|
1010
|
+
return [
|
|
1011
|
+
...memberUnits,
|
|
1012
|
+
{
|
|
1013
|
+
id: `occurrence-${occurrence.occurrenceKey}-reconciliation`,
|
|
1014
|
+
status: occurrenceComplete ? "complete" : membersComplete && occurrence.membershipFinal ? "action" : "blocked",
|
|
1015
|
+
title: `${occurrence.title} reconciliation`
|
|
1016
|
+
}
|
|
1017
|
+
];
|
|
1018
|
+
}),
|
|
1019
|
+
...plan.eventRuns
|
|
1020
|
+
.filter((run) => run.status !== "canceled" && run.occurredOn >= periodStart && run.occurredOn <= periodThrough)
|
|
1021
|
+
.flatMap((run) => run.actions)
|
|
1022
|
+
.filter((action) => action.controlIds.some((id) => selectedControlIds.has(id)))
|
|
1023
|
+
.map((action) => ({
|
|
1024
|
+
id: `event-action-${action.actionItemId || action.key}`,
|
|
1025
|
+
status: action.status === "complete" && !action.lateCompletion ? "complete" : "action",
|
|
1026
|
+
title: action.title
|
|
1027
|
+
}))
|
|
1028
|
+
];
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function occurrenceContinuityStageResult({ audit, gaps, lateGaps, firstGap, eventGaps, periodThrough, progressUnits }) {
|
|
977
1032
|
return stage(
|
|
978
1033
|
"occurrences",
|
|
979
1034
|
"Operating Occurrences",
|
|
@@ -997,7 +1052,8 @@ function occurrenceContinuityStage(audit, records, model, asOf) {
|
|
|
997
1052
|
commands: firstGap?.actionItemId ? [
|
|
998
1053
|
`npx filegrc get ${firstGap.actionItemId} --json`,
|
|
999
1054
|
`npx filegrc scaffold finding --title "Late operating occurrence review"`
|
|
1000
|
-
] : []
|
|
1055
|
+
] : [],
|
|
1056
|
+
progressUnits
|
|
1001
1057
|
}
|
|
1002
1058
|
)]
|
|
1003
1059
|
);
|
|
@@ -1348,12 +1404,33 @@ function populationsStage(audit, records, byId, model) {
|
|
|
1348
1404
|
const items = templates.map((template) => {
|
|
1349
1405
|
const population = populations.find((record) => record.populationKind === template.kind);
|
|
1350
1406
|
const result = populationResult(population, audit, byId);
|
|
1407
|
+
const preparationComplete = populationPreparationComplete(population, audit);
|
|
1351
1408
|
return item(
|
|
1352
1409
|
`population-${template.kind}`,
|
|
1353
1410
|
result.status,
|
|
1354
1411
|
template.title,
|
|
1355
1412
|
result.message || `Use ${template.sourcePrompt} as the starting point, then record the exact authoritative source.`,
|
|
1356
|
-
population || { type: "audit-population" }
|
|
1413
|
+
population || { type: "audit-population" },
|
|
1414
|
+
{
|
|
1415
|
+
progressUnits: population?.status === "not-applicable" ? [
|
|
1416
|
+
{
|
|
1417
|
+
id: `population-${template.kind}-applicability`,
|
|
1418
|
+
status: result.status,
|
|
1419
|
+
title: `${template.title} applicability decision`
|
|
1420
|
+
}
|
|
1421
|
+
] : [
|
|
1422
|
+
{
|
|
1423
|
+
id: `population-${template.kind}-preparation`,
|
|
1424
|
+
status: preparationComplete ? "complete" : "action",
|
|
1425
|
+
title: `${template.title} preparation`
|
|
1426
|
+
},
|
|
1427
|
+
{
|
|
1428
|
+
id: `population-${template.kind}-conclusion`,
|
|
1429
|
+
status: result.status === "complete" ? "complete" : preparationComplete ? "action" : "blocked",
|
|
1430
|
+
title: `${template.title} reconciliation conclusion`
|
|
1431
|
+
}
|
|
1432
|
+
]
|
|
1433
|
+
}
|
|
1357
1434
|
);
|
|
1358
1435
|
});
|
|
1359
1436
|
return stage(
|
|
@@ -1400,7 +1477,7 @@ function auditorStage(audit, byId, modelVersion) {
|
|
|
1400
1477
|
]);
|
|
1401
1478
|
}
|
|
1402
1479
|
|
|
1403
|
-
function populationResult(population, audit, byId) {
|
|
1480
|
+
export function populationResult(population, audit, byId) {
|
|
1404
1481
|
if (!population) return { status: "action", message: "Initialize this population for the engagement." };
|
|
1405
1482
|
if (!coverageMatches(
|
|
1406
1483
|
population.coverage,
|
|
@@ -1433,6 +1510,7 @@ function populationResult(population, audit, byId) {
|
|
|
1433
1510
|
&& evidence.type === "evidence"
|
|
1434
1511
|
&& evidence.artifactKind === "population-export"
|
|
1435
1512
|
&& evidence.status === "verified"
|
|
1513
|
+
&& (population.ownerIds || []).length > 0
|
|
1436
1514
|
&& (population.sourceComponentId || population.sourceSystemId)
|
|
1437
1515
|
&& (evidence.sourceComponentId || evidence.sourceSystemId) === (population.sourceComponentId || population.sourceSystemId)
|
|
1438
1516
|
&& coverageMatches(
|
|
@@ -1459,6 +1537,21 @@ function populationResult(population, audit, byId) {
|
|
|
1459
1537
|
};
|
|
1460
1538
|
}
|
|
1461
1539
|
|
|
1540
|
+
function populationPreparationComplete(population, audit) {
|
|
1541
|
+
if (!population || !coverageMatches(
|
|
1542
|
+
population.coverage,
|
|
1543
|
+
coverageStart(audit?.coverage),
|
|
1544
|
+
coverageEnd(audit?.coverage)
|
|
1545
|
+
)) return false;
|
|
1546
|
+
if (population.status === "not-applicable") {
|
|
1547
|
+
return !(population.controlIds || []).length && Boolean(String(population.notApplicableReason || "").trim());
|
|
1548
|
+
}
|
|
1549
|
+
return Boolean(
|
|
1550
|
+
(population.ownerIds || []).length
|
|
1551
|
+
&& (population.sourceComponentId || population.sourceSystemId)
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1462
1555
|
function timestampDate(value, timezone) {
|
|
1463
1556
|
if (!value || !timezone) return null;
|
|
1464
1557
|
const instant = new Date(value);
|
|
@@ -1730,7 +1823,9 @@ function item(id, status, title, message, resource = {}, options = {}) {
|
|
|
1730
1823
|
...(options.commands?.length ? { commands: options.commands } : {}),
|
|
1731
1824
|
...(options.affectedActionItemIds?.length ? { affectedActionItemIds: options.affectedActionItemIds } : {}),
|
|
1732
1825
|
...(options.affectedEventIds?.length ? { affectedEventIds: options.affectedEventIds } : {}),
|
|
1733
|
-
...(options.lateCount ? { lateCount: options.lateCount } : {})
|
|
1826
|
+
...(options.lateCount ? { lateCount: options.lateCount } : {}),
|
|
1827
|
+
...(options.progressUnits?.length ? { progressUnits: options.progressUnits } : {}),
|
|
1828
|
+
...(options.progressUnit === false ? { progressUnit: false } : {})
|
|
1734
1829
|
};
|
|
1735
1830
|
}
|
|
1736
1831
|
|
package/src/cli.js
CHANGED
|
@@ -136,6 +136,13 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
136
136
|
if (result.usedFallbackPort) {
|
|
137
137
|
console.log(`Port ${result.requestedPort} is already in use. Using ${result.address.port} instead.`);
|
|
138
138
|
}
|
|
139
|
+
if (result.startupSynchronization?.status === "updated") {
|
|
140
|
+
console.log(`Updated the authoritative branch to ${result.startupSynchronization.shortCommit} before serving.`);
|
|
141
|
+
} else if (result.startupSynchronization?.status === "blocked") {
|
|
142
|
+
console.warn(`Remote startup sync is blocked because local and remote history diverged (${result.startupSynchronization.ahead} ahead, ${result.startupSynchronization.behind} behind). Reconcile the branch before relying on this server.`);
|
|
143
|
+
} else if (result.startupSynchronization?.status === "failed") {
|
|
144
|
+
console.warn(`Remote startup sync did not complete: ${result.startupSynchronization.message}`);
|
|
145
|
+
}
|
|
139
146
|
console.log(`filegrc workspace: ${result.url}`);
|
|
140
147
|
console.log(`Data: ${result.root}/data`);
|
|
141
148
|
printGithubStarMessage();
|
package/src/collection-review.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
2
3
|
import { modelSupports } from "../model/index.js";
|
|
3
4
|
import {
|
|
4
5
|
collectionRevision,
|
|
5
6
|
collectionRevisionMatches
|
|
6
7
|
} from "./collection-revision.js";
|
|
7
|
-
import { scopedCollectionRecords } from "./collection-scope.js";
|
|
8
|
-
import { applyResourceBatch, INTERNAL_WORKFLOW_CAPABILITIES } from "./files.js";
|
|
8
|
+
import { collectionRecordProposals, retentionScheduleReviewScope, scopedCollectionRecords } from "./collection-scope.js";
|
|
9
|
+
import { applyResourceBatch, applyRetentionScheduleReviewBatch, contentRevision, INTERNAL_WORKFLOW_CAPABILITIES } from "./files.js";
|
|
9
10
|
import { getGitSummary } from "./git.js";
|
|
10
11
|
import { createResourceId } from "./id.js";
|
|
11
12
|
import { loadWorkspace } from "./workspace.js";
|
|
@@ -13,6 +14,8 @@ import { resolveProgram } from "./program.js";
|
|
|
13
14
|
import { currentCalendarDate } from "./time.js";
|
|
14
15
|
import { currentPartyPeople } from "./parties.js";
|
|
15
16
|
import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js";
|
|
17
|
+
import { markdownEntries } from "./resource-markdown.js";
|
|
18
|
+
import { resolveDataPath } from "./paths.js";
|
|
16
19
|
|
|
17
20
|
export { collectionRevision };
|
|
18
21
|
|
|
@@ -29,13 +32,21 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
29
32
|
const configuration = loaded.model.collectionReviews?.[resourceType];
|
|
30
33
|
if (!configuration) return null;
|
|
31
34
|
const program = resolveProgram(loaded, options.programId);
|
|
35
|
+
const workspaceWideRetentionReview = resourceType === "retention-schedule-item"
|
|
36
|
+
&& modelSupports(loaded.model, "retention-schedule-approval");
|
|
37
|
+
const retentionScope = workspaceWideRetentionReview ? retentionScheduleReviewScope(loaded) : null;
|
|
32
38
|
const records = scopedCollectionRecords(loaded, resourceType, program);
|
|
33
|
-
const
|
|
39
|
+
const reviewEntries = loaded.entries.filter(({ record }) => (
|
|
34
40
|
record.type === "collection-review"
|
|
35
41
|
&& record.resourceType === resourceType
|
|
36
42
|
&& record.status !== "retired"
|
|
37
|
-
&& (
|
|
43
|
+
&& (workspaceWideRetentionReview
|
|
44
|
+
|| !modelSupports(loaded.model, "program-scope")
|
|
45
|
+
|| (record.scopeResourceIds || []).includes(program.id))
|
|
38
46
|
));
|
|
47
|
+
const reviewEntry = workspaceWideRetentionReview
|
|
48
|
+
? reviewEntries.find(({ record }) => sameIds(record.scopeResourceIds, retentionScope.programIds)) || reviewEntries[0]
|
|
49
|
+
: reviewEntries[0];
|
|
39
50
|
const review = reviewEntry?.record || null;
|
|
40
51
|
const authoritativeSourceId = review?.decision === "externally-managed"
|
|
41
52
|
? review.authoritativeComponentId || review.authoritativeSystemId
|
|
@@ -71,26 +82,35 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
71
82
|
? assessRetentionScheduleReviewers(loaded, records)
|
|
72
83
|
: null;
|
|
73
84
|
const approvalIssues = resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
|
|
74
|
-
? retentionScheduleApprovalIssues(loaded,
|
|
75
|
-
informationTypesReviewed:
|
|
85
|
+
? retentionScheduleApprovalIssues(loaded, retentionScope, records, {
|
|
86
|
+
informationTypesReviewed: retentionScope.programIds.every((id) => (
|
|
87
|
+
assessCollectionReview(loaded, "information-type", { programId: id }).complete
|
|
88
|
+
)),
|
|
89
|
+
approvalReview: review?.status === "active" ? review : null
|
|
76
90
|
})
|
|
77
91
|
: [];
|
|
92
|
+
const recordProposals = collectionRecordProposals(loaded, resourceType, records, program);
|
|
93
|
+
const incompleteRecordProposals = recordProposals.filter(({ complete: ready }) => !ready);
|
|
78
94
|
const reviewersEligible = !reviewerEligibility || Boolean(
|
|
79
95
|
review?.reviewedByIds?.length
|
|
80
96
|
&& review.reviewedByIds.every((id) => reviewerEligibility.eligibleReviewerIds.includes(id))
|
|
81
97
|
);
|
|
98
|
+
const reviewScopeMatches = !workspaceWideRetentionReview
|
|
99
|
+
|| sameIds(review?.scopeResourceIds, retentionScope.programIds);
|
|
82
100
|
const complete = Boolean(
|
|
83
101
|
review?.status === "active"
|
|
84
102
|
&& allowedDecisions.includes(review.decision)
|
|
85
103
|
&& revisionMatches
|
|
86
104
|
&& temporalReview
|
|
105
|
+
&& reviewScopeMatches
|
|
87
106
|
&& reviewersEligible
|
|
107
|
+
&& !incompleteRecordProposals.length
|
|
88
108
|
&& !approvalIssues.length
|
|
89
109
|
);
|
|
90
110
|
const stale = Boolean(
|
|
91
111
|
review?.status === "active"
|
|
92
112
|
&& review.collectionRevision
|
|
93
|
-
&& !revisionMatches
|
|
113
|
+
&& (!revisionMatches || !reviewScopeMatches)
|
|
94
114
|
);
|
|
95
115
|
return {
|
|
96
116
|
resourceType,
|
|
@@ -101,15 +121,19 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
101
121
|
reviewRevision: reviewEntry
|
|
102
122
|
? createHash("sha256").update(reviewEntry.source).digest("hex")
|
|
103
123
|
: null,
|
|
124
|
+
reviewEntries,
|
|
104
125
|
collectionRevision: currentRevision,
|
|
105
126
|
status: complete ? "current" : stale ? "stale" : "review-required",
|
|
106
127
|
complete,
|
|
107
128
|
...(reviewerEligibility || {}),
|
|
129
|
+
...(recordProposals.length ? { recordProposals, incompleteRecordProposals } : {}),
|
|
108
130
|
...(approvalIssues.length ? { approvalIssues } : {}),
|
|
109
131
|
message: complete
|
|
110
132
|
? `${configuration.title} were reviewed on ${review.reviewedOn}.`
|
|
111
133
|
: approvalIssues.length
|
|
112
134
|
? approvalIssues[0].message
|
|
135
|
+
: incompleteRecordProposals.length
|
|
136
|
+
? `Complete ${incompleteRecordProposals.length} ${configuration.title.toLowerCase()} ${incompleteRecordProposals.length === 1 ? "record proposal" : "record proposals"} before the collection review.`
|
|
113
137
|
: revisionMatches && review?.status === "active" && !reviewersEligible
|
|
114
138
|
? resourceType === "retention-schedule-item"
|
|
115
139
|
? "Review the Data Retention Schedule again with a reviewer who does not own its governing document or an included schedule row."
|
|
@@ -122,6 +146,11 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
122
146
|
};
|
|
123
147
|
}
|
|
124
148
|
|
|
149
|
+
export function retentionScheduleIsAuthoritative(loaded, program) {
|
|
150
|
+
return !modelSupports(loaded.model, "retention-schedule-approval")
|
|
151
|
+
|| assessCollectionReview(loaded, "retention-schedule-item", { programId: program?.id }).complete;
|
|
152
|
+
}
|
|
153
|
+
|
|
125
154
|
export async function scaffoldCollectionReview(input = process.cwd(), options = {}) {
|
|
126
155
|
const loaded = await loadWorkspace(input);
|
|
127
156
|
const program = resolveProgram(loaded, options.programId);
|
|
@@ -170,6 +199,9 @@ export async function planCollectionReview(input = process.cwd(), options = {})
|
|
|
170
199
|
const loaded = await loadWorkspace(input);
|
|
171
200
|
const program = resolveProgram(loaded, options.programId);
|
|
172
201
|
const resourceType = requiredType(loaded, options.resourceType);
|
|
202
|
+
const workspaceWideRetentionReview = resourceType === "retention-schedule-item"
|
|
203
|
+
&& modelSupports(loaded.model, "retention-schedule-approval");
|
|
204
|
+
const retentionScope = workspaceWideRetentionReview ? retentionScheduleReviewScope(loaded) : null;
|
|
173
205
|
const assessment = assessCollectionReview(loaded, resourceType, { programId: program.id });
|
|
174
206
|
if (options.expectedCollectionRevision && options.expectedCollectionRevision !== assessment.collectionRevision) {
|
|
175
207
|
throw new Error(`${assessment.configuration.title} changed after it was displayed. Reload and review the current revision before approving it.`);
|
|
@@ -178,7 +210,8 @@ export async function planCollectionReview(input = process.cwd(), options = {})
|
|
|
178
210
|
throw new Error("Data Retention Schedule approval requires the displayed collection revision. Reload or scaffold the current review before approving it.");
|
|
179
211
|
}
|
|
180
212
|
await requireControlReviewReady(loaded, resourceType, program.id);
|
|
181
|
-
await requireRetentionScheduleReady(loaded, resourceType, program);
|
|
213
|
+
await requireRetentionScheduleReady(loaded, resourceType, program, retentionScope);
|
|
214
|
+
requireCollectionRecordProposalsReady(assessment);
|
|
182
215
|
const configuration = assessment.configuration;
|
|
183
216
|
const decision = String(options.decision || "").trim();
|
|
184
217
|
const rationale = String(options.rationale || "").trim();
|
|
@@ -251,6 +284,9 @@ export async function planCollectionReview(input = process.cwd(), options = {})
|
|
|
251
284
|
authoritativeSourceId: decision === "externally-managed" ? authoritativeSourceId : null
|
|
252
285
|
});
|
|
253
286
|
const existing = assessment.review;
|
|
287
|
+
const existingEntries = workspaceWideRetentionReview
|
|
288
|
+
? assessment.reviewEntries
|
|
289
|
+
: assessment.reviewEntries.filter(({ record }) => record.id === existing?.id);
|
|
254
290
|
const preservesReviewHistory = temporalReviews;
|
|
255
291
|
const record = {
|
|
256
292
|
...(!existing ? {
|
|
@@ -264,7 +300,7 @@ export async function planCollectionReview(input = process.cwd(), options = {})
|
|
|
264
300
|
type: "collection-review",
|
|
265
301
|
title: `${configuration.title} review`,
|
|
266
302
|
resourceType,
|
|
267
|
-
scopeResourceIds: [program.id]
|
|
303
|
+
scopeResourceIds: workspaceWideRetentionReview ? retentionScope.programIds : [program.id]
|
|
268
304
|
} : preservesReviewHistory ? {
|
|
269
305
|
...existing,
|
|
270
306
|
id: createResourceId(
|
|
@@ -281,6 +317,7 @@ export async function planCollectionReview(input = process.cwd(), options = {})
|
|
|
281
317
|
reviewedOn,
|
|
282
318
|
collectionRevision: currentRevision,
|
|
283
319
|
scopeRevision,
|
|
320
|
+
...(workspaceWideRetentionReview ? { scopeResourceIds: retentionScope.programIds } : {}),
|
|
284
321
|
...(temporalReviews ? {
|
|
285
322
|
coverage: { kind: "as-of", on: reviewedOn },
|
|
286
323
|
knowledgeCutoffAt: now.toISOString(),
|
|
@@ -294,34 +331,86 @@ export async function planCollectionReview(input = process.cwd(), options = {})
|
|
|
294
331
|
delete record.authoritativeSystemId;
|
|
295
332
|
delete record.authoritativeComponentId;
|
|
296
333
|
}
|
|
334
|
+
const creates = !existing || preservesReviewHistory ? [record] : [];
|
|
335
|
+
const updates = !existing || preservesReviewHistory ? [] : [record];
|
|
336
|
+
const expectedRevisions = Object.fromEntries(existingEntries.map((entry) => [
|
|
337
|
+
entry.record.id,
|
|
338
|
+
entry.record.id === existing?.id && options.expectedRevision
|
|
339
|
+
? options.expectedRevision
|
|
340
|
+
: createHash("sha256").update(entry.source).digest("hex")
|
|
341
|
+
]));
|
|
342
|
+
if (existingEntries.length && preservesReviewHistory) {
|
|
343
|
+
for (const entry of existingEntries) {
|
|
344
|
+
updates.push({
|
|
345
|
+
...entry.record,
|
|
346
|
+
status: "retired",
|
|
347
|
+
statusTransition: {
|
|
348
|
+
changedByIds: reviewedByIds,
|
|
349
|
+
changedOn: reviewedOn,
|
|
350
|
+
reason: `Superseded by ${record.id}.`
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")) {
|
|
356
|
+
const scheduleEntry = loaded.entries.find(({ record: candidate }) => (
|
|
357
|
+
candidate.type === "document"
|
|
358
|
+
&& candidate.documentKind === "schedule"
|
|
359
|
+
&& candidate.workflowScope === "program"
|
|
360
|
+
&& !["superseded", "retired"].includes(candidate.status)
|
|
361
|
+
));
|
|
362
|
+
if (scheduleEntry && ["draft", "approved", "active"].includes(scheduleEntry.record.status)) {
|
|
363
|
+
const currentContentRevisions = await contentRevisions(loaded, scheduleEntry.record);
|
|
364
|
+
const approvedSchedule = {
|
|
365
|
+
...scheduleEntry.record,
|
|
366
|
+
status: "approved",
|
|
367
|
+
approverIds: reviewedByIds,
|
|
368
|
+
approvedOn: reviewedOn,
|
|
369
|
+
approvedContentRevisions: currentContentRevisions
|
|
370
|
+
};
|
|
371
|
+
for (const field of [
|
|
372
|
+
"activationBasis",
|
|
373
|
+
"activatedByIds",
|
|
374
|
+
"activatedOn",
|
|
375
|
+
"activatedContentRevisions",
|
|
376
|
+
"effectiveOn",
|
|
377
|
+
"statusTransition"
|
|
378
|
+
]) delete approvedSchedule[field];
|
|
379
|
+
updates.push(approvedSchedule);
|
|
380
|
+
expectedRevisions[scheduleEntry.record.id] = scheduleEntry.revision;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
297
383
|
return {
|
|
298
384
|
operation: "collection-review",
|
|
299
385
|
resourceType,
|
|
300
386
|
assessment,
|
|
301
387
|
changes: {
|
|
302
|
-
...(
|
|
303
|
-
...(
|
|
304
|
-
|
|
305
|
-
...existing,
|
|
306
|
-
status: "retired",
|
|
307
|
-
statusTransition: {
|
|
308
|
-
changedByIds: reviewedByIds,
|
|
309
|
-
changedOn: reviewedOn,
|
|
310
|
-
reason: `Superseded by ${record.id}.`
|
|
311
|
-
}
|
|
312
|
-
}]
|
|
313
|
-
} : {}),
|
|
314
|
-
...(existing ? {
|
|
315
|
-
expectedRevisions: {
|
|
316
|
-
[existing.id]: options.expectedRevision || assessment.reviewRevision
|
|
317
|
-
}
|
|
318
|
-
} : {}),
|
|
388
|
+
...(creates.length ? { create: creates } : {}),
|
|
389
|
+
...(updates.length ? { update: updates } : {}),
|
|
390
|
+
...(Object.keys(expectedRevisions).length ? { expectedRevisions } : {}),
|
|
319
391
|
validateWholeWorkspace: true,
|
|
320
392
|
workflowCapability: INTERNAL_WORKFLOW_CAPABILITIES.collectionReviewReassessment
|
|
321
393
|
}
|
|
322
394
|
};
|
|
323
395
|
}
|
|
324
396
|
|
|
397
|
+
function requireCollectionRecordProposalsReady(assessment) {
|
|
398
|
+
const incomplete = assessment.incompleteRecordProposals || [];
|
|
399
|
+
if (!incomplete.length) return;
|
|
400
|
+
throw new Error(
|
|
401
|
+
`Complete every ${assessment.configuration.title.toLowerCase()} record proposal before the collection review. Remaining: ${incomplete.map(({ title }) => title).join(", ")}.`
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function contentRevisions(loaded, record) {
|
|
406
|
+
const revisions = {};
|
|
407
|
+
for (const markdown of markdownEntries(loaded.model, record)) {
|
|
408
|
+
const source = await readFile(resolveDataPath(loaded.root, markdown.path), "utf8");
|
|
409
|
+
revisions[markdown.path] = contentRevision(source);
|
|
410
|
+
}
|
|
411
|
+
return revisions;
|
|
412
|
+
}
|
|
413
|
+
|
|
325
414
|
async function requireControlReviewReady(loaded, resourceType, programId) {
|
|
326
415
|
if (resourceType !== "control") return;
|
|
327
416
|
const { assessProgramReadiness } = await import("./program-readiness.js");
|
|
@@ -339,11 +428,13 @@ async function requireControlReviewReady(loaded, resourceType, programId) {
|
|
|
339
428
|
}
|
|
340
429
|
}
|
|
341
430
|
|
|
342
|
-
async function requireRetentionScheduleReady(loaded, resourceType, program) {
|
|
431
|
+
async function requireRetentionScheduleReady(loaded, resourceType, program, retentionScope) {
|
|
343
432
|
if (resourceType !== "retention-schedule-item" || !modelSupports(loaded.model, "retention-schedule-approval")) return;
|
|
344
433
|
const rows = scopedCollectionRecords(loaded, resourceType, program);
|
|
345
|
-
const approvalIssues = retentionScheduleApprovalIssues(loaded, program, rows, {
|
|
346
|
-
informationTypesReviewed:
|
|
434
|
+
const approvalIssues = retentionScheduleApprovalIssues(loaded, retentionScope || program, rows, {
|
|
435
|
+
informationTypesReviewed: (retentionScope?.programIds || [program.id]).every((id) => (
|
|
436
|
+
assessCollectionReview(loaded, "information-type", { programId: id }).complete
|
|
437
|
+
))
|
|
347
438
|
});
|
|
348
439
|
if (approvalIssues.length) throw new Error(approvalIssues[0].message);
|
|
349
440
|
}
|
|
@@ -388,7 +479,7 @@ function assessRetentionScheduleReviewers(loaded, rows) {
|
|
|
388
479
|
};
|
|
389
480
|
}
|
|
390
481
|
|
|
391
|
-
function sameIds(left, right) {
|
|
482
|
+
function sameIds(left = [], right = []) {
|
|
392
483
|
return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
|
|
393
484
|
}
|
|
394
485
|
|
|
@@ -397,7 +488,9 @@ export async function applyCollectionReview(input = process.cwd(), options = {})
|
|
|
397
488
|
throw new Error("Preview the collection review and confirm the write.");
|
|
398
489
|
}
|
|
399
490
|
const plan = await planCollectionReview(input, options);
|
|
400
|
-
const result =
|
|
491
|
+
const result = plan.resourceType === "retention-schedule-item"
|
|
492
|
+
? await applyRetentionScheduleReviewBatch(input, plan.changes)
|
|
493
|
+
: await applyResourceBatch(input, plan.changes);
|
|
401
494
|
const loaded = await loadWorkspace(input);
|
|
402
495
|
return {
|
|
403
496
|
...plan,
|
|
@@ -30,7 +30,12 @@ export function collectionRevisionMatches(loaded, resourceType, storedRevision,
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function calculateCollectionRevision(loaded, resourceType, options, legacy) {
|
|
33
|
-
const
|
|
33
|
+
const workspaceWideRetentionReview = resourceType === "retention-schedule-item"
|
|
34
|
+
&& modelSupports(loaded.model, "retention-schedule-approval")
|
|
35
|
+
&& !legacy;
|
|
36
|
+
const program = workspaceWideRetentionReview
|
|
37
|
+
? null
|
|
38
|
+
: Object.hasOwn(options, "program")
|
|
34
39
|
? options.program
|
|
35
40
|
: resolveProgram(loaded, options.programId);
|
|
36
41
|
const inputs = new Map(collectionRevisionInputs(loaded, resourceType, program, { legacy })
|