filegrc 0.15.0 → 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 +121 -14
- package/src/audit-selection.js +33 -0
- package/src/cli.js +13 -3
- 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 +3 -2
- package/src/program-path.js +1 -1
- package/src/program-readiness.js +287 -34
- package/src/retention-schedule-approval.js +36 -17
- package/src/retention.js +81 -23
- 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 +264 -134
- package/src/workflow.js +5 -1
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 })
|
package/src/collection-scope.js
CHANGED
|
@@ -65,6 +65,77 @@ export function scopedCollectionRecords(loaded, resourceType, program) {
|
|
|
65
65
|
));
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
export function retentionScheduleReviewScope(loaded) {
|
|
69
|
+
const programs = loaded.resources.filter((record) => (
|
|
70
|
+
record.type === "program" && record.status !== "retired"
|
|
71
|
+
));
|
|
72
|
+
return {
|
|
73
|
+
id: "workspace",
|
|
74
|
+
programIds: programs.map(({ id }) => id).sort(),
|
|
75
|
+
systemIds: [...new Set(programs.flatMap((record) => record.systemIds || []))],
|
|
76
|
+
controlIds: [...new Set(programs.flatMap((record) => record.controlIds || []))],
|
|
77
|
+
frameworkIds: [...new Set(programs.flatMap((record) => record.frameworkIds || []))]
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const RECORD_PROPOSAL_COLLECTIONS = new Set([
|
|
82
|
+
"component",
|
|
83
|
+
"vendor",
|
|
84
|
+
"complementary-control",
|
|
85
|
+
"information-type"
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
export function collectionRecordProposals(loaded, resourceType, records, program) {
|
|
89
|
+
if (!RECORD_PROPOSAL_COLLECTIONS.has(resourceType)) return [];
|
|
90
|
+
const currentRecords = records.filter((record) => collectionRecordIsCurrent(record));
|
|
91
|
+
const scopedIds = new Set(currentRecords.map(({ id }) => id));
|
|
92
|
+
const candidates = new Map(currentRecords.map((record) => [record.id, record]));
|
|
93
|
+
const activePrograms = loaded.resources.filter((record) => record.type === "program" && record.status !== "retired");
|
|
94
|
+
if (activePrograms.length === 1 && activePrograms[0].id === program?.id && ["component", "complementary-control"].includes(resourceType)) {
|
|
95
|
+
for (const record of loaded.resources) {
|
|
96
|
+
if (
|
|
97
|
+
record.type !== resourceType
|
|
98
|
+
|| ["retired", "superseded"].includes(record.status)
|
|
99
|
+
|| collectionRecordProposalComplete(record, loaded)
|
|
100
|
+
) continue;
|
|
101
|
+
candidates.set(record.id, record);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return [...candidates.values()].map((record) => ({
|
|
105
|
+
resourceId: record.id,
|
|
106
|
+
title: record.title,
|
|
107
|
+
complete: scopedIds.has(record.id) && collectionRecordProposalComplete(record, loaded)
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function collectionRecordIsCurrent(record) {
|
|
112
|
+
if (record.type === "vendor") return !["deprecated", "terminated"].includes(record.status);
|
|
113
|
+
if (record.type === "information-type") return !["superseded", "retired"].includes(record.status);
|
|
114
|
+
if (record.type === "complementary-control") return !["superseded", "retired"].includes(record.status);
|
|
115
|
+
return record.status !== "retired";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function collectionRecordProposalComplete(record, loaded) {
|
|
119
|
+
if (record.type === "vendor") return record.status !== "evaluating";
|
|
120
|
+
if (record.type === "component") {
|
|
121
|
+
if (record.status === "planned") return false;
|
|
122
|
+
if (record.status === "retired") return true;
|
|
123
|
+
return record.status === "active"
|
|
124
|
+
&& Boolean(String(record.description || "").trim())
|
|
125
|
+
&& (record.ownerIds || []).length > 0
|
|
126
|
+
&& (record.systemUses || []).length > 0
|
|
127
|
+
&& (record.systemUses || []).every(({ roles, rationale }) => (
|
|
128
|
+
(roles || []).length > 0 && Boolean(String(rationale || "").trim())
|
|
129
|
+
));
|
|
130
|
+
}
|
|
131
|
+
if (record.type === "information-type") {
|
|
132
|
+
if (record.status === "retired") return true;
|
|
133
|
+
const classification = loaded.resources.find(({ id, type }) => type === "classification" && id === record.classificationId);
|
|
134
|
+
return record.status === "active" && classification?.status === "active";
|
|
135
|
+
}
|
|
136
|
+
return record.status !== "planned";
|
|
137
|
+
}
|
|
138
|
+
|
|
68
139
|
export function selectScopedCollectionRecords(loaded, selector, program) {
|
|
69
140
|
if (!selector?.resourceType) return [];
|
|
70
141
|
return scopedCollectionRecords(loaded, selector.resourceType, program).filter((record) => (
|
|
@@ -231,7 +302,10 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
|
|
|
231
302
|
.map(({ id }) => id));
|
|
232
303
|
}
|
|
233
304
|
if (resourceType === "retention-schedule-item" && !legacy && modelSupports(loaded.model, "retention-schedule-approval")) {
|
|
234
|
-
const
|
|
305
|
+
const programs = program
|
|
306
|
+
? [program]
|
|
307
|
+
: loaded.resources.filter((record) => record.type === "program" && record.status !== "retired");
|
|
308
|
+
const uses = programs.flatMap((candidate) => retentionUses(loaded, candidate));
|
|
235
309
|
addIds(uses.map(({ resource }) => resource.id));
|
|
236
310
|
addIds(uses.map(({ informationTypeId }) => informationTypeId));
|
|
237
311
|
const schedules = loaded.resources
|
|
@@ -243,14 +317,14 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
|
|
|
243
317
|
))
|
|
244
318
|
addIds(schedules.map(({ id }) => id));
|
|
245
319
|
for (const schedule of schedules) {
|
|
246
|
-
addPartyIds(
|
|
320
|
+
addPartyIds(schedule.ownerIds || []);
|
|
247
321
|
}
|
|
248
322
|
for (const record of reviewed) {
|
|
249
323
|
addIds([record.scheduleDocumentId]);
|
|
250
324
|
addIds(record.informationTypeIds);
|
|
251
325
|
addIds(record.scopeResourceIds);
|
|
252
326
|
addIds(record.sourceResourceIds);
|
|
253
|
-
addPartyIds(
|
|
327
|
+
addPartyIds(record.ownerIds || []);
|
|
254
328
|
}
|
|
255
329
|
}
|
|
256
330
|
if (resourceType === "complementary-control") {
|
|
@@ -267,14 +341,43 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
|
|
|
267
341
|
return [...records.values()].map((record) => ({
|
|
268
342
|
record,
|
|
269
343
|
value: reviewedIds.has(record.id)
|
|
270
|
-
?
|
|
271
|
-
|
|
344
|
+
? resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
|
|
345
|
+
? retentionScheduleRowProposalValue(record)
|
|
346
|
+
: record
|
|
347
|
+
: resourceType === "retention-schedule-item"
|
|
348
|
+
&& modelSupports(loaded.model, "retention-schedule-approval")
|
|
349
|
+
&& record.type === "document"
|
|
350
|
+
? retentionScheduleDocumentProposalValue(record)
|
|
351
|
+
: dependencyRevisionValue(resourceType, record, legacy),
|
|
272
352
|
includeContent: legacy
|
|
273
353
|
|| reviewedIds.has(record.id)
|
|
274
354
|
|| dependencyContentAffectsRevision(resourceType, record.type)
|
|
275
355
|
}));
|
|
276
356
|
}
|
|
277
357
|
|
|
358
|
+
function retentionScheduleRowProposalValue(record) {
|
|
359
|
+
return Object.fromEntries(Object.entries(record).filter(([field]) => ![
|
|
360
|
+
"approvedByIds",
|
|
361
|
+
"approvedOn"
|
|
362
|
+
].includes(field)));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function retentionScheduleDocumentProposalValue(record) {
|
|
366
|
+
return Object.fromEntries(Object.entries(record).filter(([field]) => ![
|
|
367
|
+
"status",
|
|
368
|
+
"approverIds",
|
|
369
|
+
"approvedOn",
|
|
370
|
+
"approvedContentRevisions",
|
|
371
|
+
"activationBasis",
|
|
372
|
+
"activatedByIds",
|
|
373
|
+
"activatedOn",
|
|
374
|
+
"activatedContentRevisions",
|
|
375
|
+
"effectiveOn",
|
|
376
|
+
"proposedEffectiveOn",
|
|
377
|
+
"statusTransition"
|
|
378
|
+
].includes(field)));
|
|
379
|
+
}
|
|
380
|
+
|
|
278
381
|
export function collectionScopeRevisionFacts(loaded, resourceType, program) {
|
|
279
382
|
if (!modelSupports(loaded.model, "program-scope")) {
|
|
280
383
|
const common = { programId: program?.id ?? null };
|
|
@@ -296,6 +399,19 @@ export function collectionScopeRevisionFacts(loaded, resourceType, program) {
|
|
|
296
399
|
}
|
|
297
400
|
return common;
|
|
298
401
|
}
|
|
402
|
+
if (resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")) {
|
|
403
|
+
const programs = loaded.resources
|
|
404
|
+
.filter((record) => record.type === "program" && record.status !== "retired")
|
|
405
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
406
|
+
return {
|
|
407
|
+
programIds: programs.map(({ id }) => id),
|
|
408
|
+
programs: programs.map((record) => ({
|
|
409
|
+
id: record.id,
|
|
410
|
+
systemIds: sorted(record.systemIds),
|
|
411
|
+
controlIds: sorted(record.controlIds)
|
|
412
|
+
}))
|
|
413
|
+
};
|
|
414
|
+
}
|
|
299
415
|
const common = { programId: program?.id ?? null };
|
|
300
416
|
if (resourceType === "framework") {
|
|
301
417
|
return {
|
package/src/files.js
CHANGED
|
@@ -234,6 +234,12 @@ export async function applyGovernedContentActivationBatch(input, changes) {
|
|
|
234
234
|
));
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
export async function applyRetentionScheduleReviewBatch(input, changes) {
|
|
238
|
+
return serializeWorkspaceMutation(input, (root) => (
|
|
239
|
+
applyResourceBatchUnlocked(root, changes, "retention-schedule-approval")
|
|
240
|
+
));
|
|
241
|
+
}
|
|
242
|
+
|
|
237
243
|
export async function applyModelMigrationBatch(input, changes) {
|
|
238
244
|
return serializeWorkspaceMutation(input, (root) => (
|
|
239
245
|
applyResourceBatchUnlocked(root, changes, "model-migration")
|
|
@@ -1231,7 +1237,13 @@ function assertGovernedContentLifecycleMutation(previousRecord, nextRecord, mode
|
|
|
1231
1237
|
const title = getResourceDefinition(model, nextRecord.type).title;
|
|
1232
1238
|
const approvedStatuses = new Set(["approved", "active", "superseded", "retired"]);
|
|
1233
1239
|
const activatedStatuses = new Set(["active", "superseded", "retired"]);
|
|
1234
|
-
|
|
1240
|
+
const retentionScheduleReapproval = lifecycleOperation === "retention-schedule-approval"
|
|
1241
|
+
&& previousRecord.type === "document"
|
|
1242
|
+
&& previousRecord.documentKind === "schedule"
|
|
1243
|
+
&& previousRecord.workflowScope === "program"
|
|
1244
|
+
&& nextRecord.documentKind === "schedule"
|
|
1245
|
+
&& nextRecord.workflowScope === "program";
|
|
1246
|
+
if (approvedStatuses.has(previousRecord.status) && approvedStatuses.has(nextRecord.status) && !retentionScheduleReapproval) {
|
|
1235
1247
|
assertLifecycleFieldsUnchanged(previousRecord, nextRecord, [
|
|
1236
1248
|
"approverIds",
|
|
1237
1249
|
"approvedOn",
|
package/src/git.js
CHANGED
|
@@ -1774,6 +1774,92 @@ export async function prefetchBrowserRemote(
|
|
|
1774
1774
|
return prefetch;
|
|
1775
1775
|
}
|
|
1776
1776
|
|
|
1777
|
+
export async function synchronizeBrowserStartup(
|
|
1778
|
+
input = process.cwd(),
|
|
1779
|
+
options = {},
|
|
1780
|
+
) {
|
|
1781
|
+
return serializeWorkspaceMutation(input, async (root) => {
|
|
1782
|
+
const config = await getRepositoryConfig(root);
|
|
1783
|
+
if (config.mode !== "trunk" || options.allowNonAuthoritativeWrites === true) {
|
|
1784
|
+
return { status: "not-needed", updated: false };
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
const before = await requireTrunkPreconditionsAsync(root, config, {
|
|
1788
|
+
allowAhead: true,
|
|
1789
|
+
});
|
|
1790
|
+
await fetchConfiguredRemote(root, config);
|
|
1791
|
+
const synchronized = inspectTrunkRepository(
|
|
1792
|
+
root,
|
|
1793
|
+
config,
|
|
1794
|
+
await getRepositorySnapshot(root, { fresh: true }),
|
|
1795
|
+
);
|
|
1796
|
+
if (synchronized.ahead > 0 || synchronized.behind === 0) {
|
|
1797
|
+
return {
|
|
1798
|
+
status: synchronized.ahead > 0 ? "blocked" : "current",
|
|
1799
|
+
updated: false,
|
|
1800
|
+
ahead: synchronized.ahead,
|
|
1801
|
+
behind: synchronized.behind,
|
|
1802
|
+
commit: synchronized.currentCommit,
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
const target = synchronized.upstreamCommit;
|
|
1807
|
+
const incomingCommits = lines(
|
|
1808
|
+
git(root, [
|
|
1809
|
+
"rev-list",
|
|
1810
|
+
"--reverse",
|
|
1811
|
+
`${synchronized.currentCommit}..${target}`,
|
|
1812
|
+
]),
|
|
1813
|
+
);
|
|
1814
|
+
assertCommitsInsideWorkspace(root, incomingCommits);
|
|
1815
|
+
for (const commit of incomingCommits) assertNoCommitWorkspaceContentFilters(root, commit);
|
|
1816
|
+
assertExpectedCheckout(
|
|
1817
|
+
root,
|
|
1818
|
+
`refs/heads/${config.authoritativeBranch}`,
|
|
1819
|
+
before.currentCommit,
|
|
1820
|
+
);
|
|
1821
|
+
await gitForWriteAsync(
|
|
1822
|
+
root,
|
|
1823
|
+
[
|
|
1824
|
+
"-c",
|
|
1825
|
+
"core.hooksPath=/dev/null",
|
|
1826
|
+
"merge",
|
|
1827
|
+
"--ff-only",
|
|
1828
|
+
"--no-edit",
|
|
1829
|
+
"--no-stat",
|
|
1830
|
+
target,
|
|
1831
|
+
],
|
|
1832
|
+
`fast-forward ${config.authoritativeBranch} at server startup`,
|
|
1833
|
+
{
|
|
1834
|
+
expectedCheckout: {
|
|
1835
|
+
expectedRef: `refs/heads/${config.authoritativeBranch}`,
|
|
1836
|
+
expectedCommit: before.currentCommit,
|
|
1837
|
+
},
|
|
1838
|
+
expectedNoOperation: true,
|
|
1839
|
+
},
|
|
1840
|
+
);
|
|
1841
|
+
const after = inspectTrunkRepository(
|
|
1842
|
+
root,
|
|
1843
|
+
config,
|
|
1844
|
+
await getRepositorySnapshot(root, { fresh: true }),
|
|
1845
|
+
);
|
|
1846
|
+
if (after.ahead !== 0 || after.behind !== 0 || after.currentCommit !== target) {
|
|
1847
|
+
throw new Error(
|
|
1848
|
+
"The authoritative branch changed while FileGRC fast-forwarded it. Reconcile the repository with Git, then restart FileGRC.",
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
const synchronizedAt = new Date().toISOString();
|
|
1852
|
+
lastSuccessfulSynchronizations.set(root, synchronizedAt);
|
|
1853
|
+
return {
|
|
1854
|
+
status: "updated",
|
|
1855
|
+
updated: true,
|
|
1856
|
+
commit: after.currentCommit,
|
|
1857
|
+
shortCommit: after.currentCommit.slice(0, 8),
|
|
1858
|
+
synchronizedAt,
|
|
1859
|
+
};
|
|
1860
|
+
});
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1777
1863
|
async function prefetchBrowserRemoteCoalesced(root, options) {
|
|
1778
1864
|
const prepared = await serializeWorkspaceMutation(root, async () => {
|
|
1779
1865
|
const config = await getRepositoryConfig(root);
|
package/src/index.js
CHANGED
|
@@ -49,7 +49,8 @@ export {
|
|
|
49
49
|
pullWorkspace,
|
|
50
50
|
pushWorkspace,
|
|
51
51
|
retryBrowserSync,
|
|
52
|
-
runBrowserMutation
|
|
52
|
+
runBrowserMutation,
|
|
53
|
+
synchronizeBrowserStartup
|
|
53
54
|
} from "./git.js";
|
|
54
55
|
export { generateModelDocumentation } from "./model-docs.js";
|
|
55
56
|
export { renderMarkdown } from "./markdown.js";
|
|
@@ -86,7 +87,7 @@ export {
|
|
|
86
87
|
scaffoldExternalReviewerGovernance,
|
|
87
88
|
setupExternalReviewerGovernance
|
|
88
89
|
} from "./external-reviewer.js";
|
|
89
|
-
export { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
|
|
90
|
+
export { assessEvidenceMap, assessProgramReadiness, calculateProgramProgress } from "./program-readiness.js";
|
|
90
91
|
export {
|
|
91
92
|
approveReportingRouteSet,
|
|
92
93
|
assessReportingRoutePeriod,
|
package/src/program-path.js
CHANGED
|
@@ -111,7 +111,7 @@ export const PROGRAM_PATH = [
|
|
|
111
111
|
summary: "Review and independently approve Policies, program Documents, Training content, and the Data Retention Schedule.",
|
|
112
112
|
sections: [
|
|
113
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: "
|
|
114
|
+
{ id: "retention", title: "Data Retention Schedule", description: "Complete each row proposal, then approve the document and all proposed rows once.", steps: ["Review the schedule policy, legal holds, exceptions, and disposal requirements.", "Have the owner finish each row for the Information Types and operational sources the organization actually uses.", "Trigger one independent approval only after the document and every row proposal are complete."], types: [], utility: "retention-schedule", defaultOpen: true }
|
|
115
115
|
],
|
|
116
116
|
resourceTypes: [],
|
|
117
117
|
supportingResourceTypes: ["policy", "document", "training", "retention-schedule-item"],
|