filegrc 0.13.5 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/model/index.js +7 -5
- package/model/v11.json +12200 -0
- package/package.json +1 -1
- package/src/agent.js +1 -1
- package/src/cli.js +1 -1
- package/src/collection-review.js +118 -0
- package/src/collection-revision.js +3 -1
- package/src/collection-scope.js +150 -3
- package/src/document-activation.js +28 -4
- package/src/model-migration.js +70 -2
- package/src/policy-activation.js +16 -2
- package/src/program-path.js +64 -14
- package/src/program-readiness.js +37 -14
- package/src/retention-schedule-approval.js +121 -0
- package/src/retention.js +56 -1
- package/src/state.js +19 -22
- package/src/validate.js +70 -1
- package/src/web.js +425 -71
- package/src/workflow-history-integrity.js +5 -1
- package/src/workflow.js +1 -3
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -82,7 +82,7 @@ export function buildAgentGuide(loaded, type, options = {}) {
|
|
|
82
82
|
instructions: RESOURCE_INSTRUCTIONS[type] || definition.description,
|
|
83
83
|
use: definition.description,
|
|
84
84
|
purpose: definition.description,
|
|
85
|
-
programStep: resourceProgramContext(type),
|
|
85
|
+
programStep: resourceProgramContext(type, loaded.model),
|
|
86
86
|
policyBasis: definition.guidance.policyBasis,
|
|
87
87
|
cadence: definition.guidance.cadence,
|
|
88
88
|
emptyState: definition.guidance.emptyState ?? null,
|
package/src/cli.js
CHANGED
|
@@ -553,7 +553,7 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
553
553
|
: record.type === "requirement-mapping"
|
|
554
554
|
? [...new Set([...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])])]
|
|
555
555
|
: null;
|
|
556
|
-
if (!dependencyIds) throw new Error("Review bindings are available for Retention Schedule
|
|
556
|
+
if (!dependencyIds) throw new Error("Review bindings are available for Retention Schedule records and Requirement Mappings.");
|
|
557
557
|
const revisions = await resourceReviewRevisions(loaded, dependencyIds);
|
|
558
558
|
const result = {
|
|
559
559
|
resource: { type: record.type, id: record.id, title: record.title },
|
package/src/collection-review.js
CHANGED
|
@@ -11,6 +11,8 @@ import { createResourceId } from "./id.js";
|
|
|
11
11
|
import { loadWorkspace } from "./workspace.js";
|
|
12
12
|
import { resolveProgram } from "./program.js";
|
|
13
13
|
import { currentCalendarDate } from "./time.js";
|
|
14
|
+
import { currentPartyPeople } from "./parties.js";
|
|
15
|
+
import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js";
|
|
14
16
|
|
|
15
17
|
export { collectionRevision };
|
|
16
18
|
|
|
@@ -63,11 +65,27 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
63
65
|
currentRevision
|
|
64
66
|
}
|
|
65
67
|
);
|
|
68
|
+
const reviewerEligibility = resourceType === "control"
|
|
69
|
+
? assessControlReviewers(loaded, records)
|
|
70
|
+
: resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
|
|
71
|
+
? assessRetentionScheduleReviewers(loaded, records)
|
|
72
|
+
: null;
|
|
73
|
+
const approvalIssues = resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
|
|
74
|
+
? retentionScheduleApprovalIssues(loaded, program, records, {
|
|
75
|
+
informationTypesReviewed: assessCollectionReview(loaded, "information-type", options).complete
|
|
76
|
+
})
|
|
77
|
+
: [];
|
|
78
|
+
const reviewersEligible = !reviewerEligibility || Boolean(
|
|
79
|
+
review?.reviewedByIds?.length
|
|
80
|
+
&& review.reviewedByIds.every((id) => reviewerEligibility.eligibleReviewerIds.includes(id))
|
|
81
|
+
);
|
|
66
82
|
const complete = Boolean(
|
|
67
83
|
review?.status === "active"
|
|
68
84
|
&& allowedDecisions.includes(review.decision)
|
|
69
85
|
&& revisionMatches
|
|
70
86
|
&& temporalReview
|
|
87
|
+
&& reviewersEligible
|
|
88
|
+
&& !approvalIssues.length
|
|
71
89
|
);
|
|
72
90
|
const stale = Boolean(
|
|
73
91
|
review?.status === "active"
|
|
@@ -86,8 +104,16 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
86
104
|
collectionRevision: currentRevision,
|
|
87
105
|
status: complete ? "current" : stale ? "stale" : "review-required",
|
|
88
106
|
complete,
|
|
107
|
+
...(reviewerEligibility || {}),
|
|
108
|
+
...(approvalIssues.length ? { approvalIssues } : {}),
|
|
89
109
|
message: complete
|
|
90
110
|
? `${configuration.title} were reviewed on ${review.reviewedOn}.`
|
|
111
|
+
: approvalIssues.length
|
|
112
|
+
? approvalIssues[0].message
|
|
113
|
+
: revisionMatches && review?.status === "active" && !reviewersEligible
|
|
114
|
+
? resourceType === "retention-schedule-item"
|
|
115
|
+
? "Review the Data Retention Schedule again with a reviewer who does not own its governing document or an included schedule row."
|
|
116
|
+
: "Review the Control collection again with a reviewer who does not own an included Control or its enabled Obligation."
|
|
91
117
|
: stale
|
|
92
118
|
? `${configuration.title} changed after the last confirmation. Review the current records again.`
|
|
93
119
|
: !records.length && !allowsEmptyCollection
|
|
@@ -101,6 +127,7 @@ export async function scaffoldCollectionReview(input = process.cwd(), options =
|
|
|
101
127
|
const program = resolveProgram(loaded, options.programId);
|
|
102
128
|
const resourceType = requiredType(loaded, options.resourceType);
|
|
103
129
|
const assessment = assessCollectionReview(loaded, resourceType, { programId: program.id });
|
|
130
|
+
await requireControlReviewReady(loaded, resourceType, program.id);
|
|
104
131
|
const allowedDecisions = assessment.configuration.decisions || ["complete"];
|
|
105
132
|
const now = options.now ? new Date(options.now) : new Date();
|
|
106
133
|
if (Number.isNaN(now.getTime())) throw new Error("A valid Collection Review time is required.");
|
|
@@ -130,6 +157,9 @@ export async function scaffoldCollectionReview(input = process.cwd(), options =
|
|
|
130
157
|
reviewedOn: priorReview?.reviewedOn
|
|
131
158
|
? currentCalendarDate(loaded.workspace.timezone, now)
|
|
132
159
|
: null,
|
|
160
|
+
...(resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
|
|
161
|
+
? { expectedCollectionRevision: assessment.collectionRevision }
|
|
162
|
+
: {}),
|
|
133
163
|
...(v4
|
|
134
164
|
? { authoritativeComponentId: preservedAuthoritativeSourceId }
|
|
135
165
|
: { authoritativeSystemId: preservedAuthoritativeSourceId })
|
|
@@ -141,6 +171,14 @@ export async function planCollectionReview(input = process.cwd(), options = {})
|
|
|
141
171
|
const program = resolveProgram(loaded, options.programId);
|
|
142
172
|
const resourceType = requiredType(loaded, options.resourceType);
|
|
143
173
|
const assessment = assessCollectionReview(loaded, resourceType, { programId: program.id });
|
|
174
|
+
if (options.expectedCollectionRevision && options.expectedCollectionRevision !== assessment.collectionRevision) {
|
|
175
|
+
throw new Error(`${assessment.configuration.title} changed after it was displayed. Reload and review the current revision before approving it.`);
|
|
176
|
+
}
|
|
177
|
+
if (resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval") && !options.expectedCollectionRevision) {
|
|
178
|
+
throw new Error("Data Retention Schedule approval requires the displayed collection revision. Reload or scaffold the current review before approving it.");
|
|
179
|
+
}
|
|
180
|
+
await requireControlReviewReady(loaded, resourceType, program.id);
|
|
181
|
+
await requireRetentionScheduleReady(loaded, resourceType, program);
|
|
144
182
|
const configuration = assessment.configuration;
|
|
145
183
|
const decision = String(options.decision || "").trim();
|
|
146
184
|
const rationale = String(options.rationale || "").trim();
|
|
@@ -178,6 +216,20 @@ export async function planCollectionReview(input = process.cwd(), options = {})
|
|
|
178
216
|
if (!rationale || !reviewedByIds.length || !reviewedOn) {
|
|
179
217
|
throw new Error(`${configuration.title} review needs review notes, a reviewer, and a review date.`);
|
|
180
218
|
}
|
|
219
|
+
if (resourceType === "control") {
|
|
220
|
+
const eligible = new Set(assessment.eligibleReviewerIds || []);
|
|
221
|
+
const conflicts = reviewedByIds.filter((id) => !eligible.has(id));
|
|
222
|
+
if (conflicts.length) {
|
|
223
|
+
throw new Error(`Control collection review needs a reviewer who does not own an included Control or its enabled Obligation: ${conflicts.join(", ")}.`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")) {
|
|
227
|
+
const eligible = new Set(assessment.eligibleReviewerIds || []);
|
|
228
|
+
const conflicts = reviewedByIds.filter((id) => !eligible.has(id));
|
|
229
|
+
if (conflicts.length) {
|
|
230
|
+
throw new Error(`Data Retention Schedule approval needs a reviewer who does not own its governing document or an included schedule row: ${conflicts.join(", ")}.`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
181
233
|
if (temporalReviews) {
|
|
182
234
|
if (reviewedOn !== today) {
|
|
183
235
|
throw new Error("A current Collection Review must use today's workspace-local review date. Reconstruct an older population from Git history instead of backdating today's files.");
|
|
@@ -270,6 +322,72 @@ export async function planCollectionReview(input = process.cwd(), options = {})
|
|
|
270
322
|
};
|
|
271
323
|
}
|
|
272
324
|
|
|
325
|
+
async function requireControlReviewReady(loaded, resourceType, programId) {
|
|
326
|
+
if (resourceType !== "control") return;
|
|
327
|
+
const { assessProgramReadiness } = await import("./program-readiness.js");
|
|
328
|
+
const readiness = await assessProgramReadiness(loaded, { programId });
|
|
329
|
+
const earlierItems = readiness.stages
|
|
330
|
+
.find(({ id }) => id === "controls")
|
|
331
|
+
?.items.filter(({ id }) => (
|
|
332
|
+
id !== "collection-review-control"
|
|
333
|
+
&& !id.startsWith("document-activation-")
|
|
334
|
+
&& !id.startsWith("training-activation-")
|
|
335
|
+
&& !id.startsWith("policy-activation-")
|
|
336
|
+
)) || [];
|
|
337
|
+
if (!earlierItems.length || earlierItems.some(({ status }) => status !== "complete")) {
|
|
338
|
+
throw new Error("Complete the Step 3 implementation work before recording the Control collection review. Program content activation follows this review.");
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function requireRetentionScheduleReady(loaded, resourceType, program) {
|
|
343
|
+
if (resourceType !== "retention-schedule-item" || !modelSupports(loaded.model, "retention-schedule-approval")) return;
|
|
344
|
+
const rows = scopedCollectionRecords(loaded, resourceType, program);
|
|
345
|
+
const approvalIssues = retentionScheduleApprovalIssues(loaded, program, rows, {
|
|
346
|
+
informationTypesReviewed: assessCollectionReview(loaded, "information-type", { programId: program.id }).complete
|
|
347
|
+
});
|
|
348
|
+
if (approvalIssues.length) throw new Error(approvalIssues[0].message);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function assessControlReviewers(loaded, controls) {
|
|
352
|
+
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
353
|
+
const controlIds = new Set(controls.map(({ id }) => id));
|
|
354
|
+
const conflictIds = new Set();
|
|
355
|
+
for (const control of controls) {
|
|
356
|
+
for (const id of currentPartyPeople(control.ownerIds || [], byId)) conflictIds.add(id);
|
|
357
|
+
}
|
|
358
|
+
for (const obligation of loaded.resources.filter((record) => (
|
|
359
|
+
record.type === "obligation"
|
|
360
|
+
&& record.status === "active"
|
|
361
|
+
&& (record.controlIds || []).some((id) => controlIds.has(id))
|
|
362
|
+
))) {
|
|
363
|
+
for (const id of currentPartyPeople(obligation.ownerIds || [], byId)) conflictIds.add(id);
|
|
364
|
+
}
|
|
365
|
+
const people = loaded.resources.filter(({ type, status }) => type === "person" && status === "active");
|
|
366
|
+
return {
|
|
367
|
+
eligibleReviewerIds: people.map(({ id }) => id).filter((id) => !conflictIds.has(id)),
|
|
368
|
+
reviewerConflictIds: people.map(({ id }) => id).filter((id) => conflictIds.has(id))
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function assessRetentionScheduleReviewers(loaded, rows) {
|
|
373
|
+
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
374
|
+
const conflictIds = new Set();
|
|
375
|
+
const schedules = loaded.resources.filter((record) => (
|
|
376
|
+
record.type === "document"
|
|
377
|
+
&& record.documentKind === "schedule"
|
|
378
|
+
&& record.workflowScope === "program"
|
|
379
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
380
|
+
));
|
|
381
|
+
for (const record of [...schedules, ...rows]) {
|
|
382
|
+
for (const id of currentPartyPeople(record.ownerIds || [], byId)) conflictIds.add(id);
|
|
383
|
+
}
|
|
384
|
+
const people = loaded.resources.filter(({ type, status }) => type === "person" && status === "active");
|
|
385
|
+
return {
|
|
386
|
+
eligibleReviewerIds: people.map(({ id }) => id).filter((id) => !conflictIds.has(id)),
|
|
387
|
+
reviewerConflictIds: people.map(({ id }) => id).filter((id) => conflictIds.has(id))
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
273
391
|
function sameIds(left, right) {
|
|
274
392
|
return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
|
|
275
393
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { modelSupports } from "../model/index.js";
|
|
3
4
|
import {
|
|
4
5
|
authoritativeSourceRevisionValue,
|
|
5
6
|
collectionRevisionInputs,
|
|
@@ -24,7 +25,8 @@ export function collectionRevisionMatches(loaded, resourceType, storedRevision,
|
|
|
24
25
|
// Version 0.9.2 narrowed this hash basis. Keep unchanged 0.9.1 reviews valid
|
|
25
26
|
// until management records a new review on the current basis.
|
|
26
27
|
return storedRevision === currentRevision
|
|
27
|
-
||
|
|
28
|
+
|| (resourceType !== "retention-schedule-item" || !modelSupports(loaded.model, "retention-schedule-approval"))
|
|
29
|
+
&& storedRevision === legacyCollectionRevision(loaded, resourceType, options);
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
function calculateCollectionRevision(loaded, resourceType, options, legacy) {
|
package/src/collection-scope.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { modelSupports } from "../model/index.js";
|
|
2
2
|
import { programComponents, selectedRequirementIds } from "./program.js";
|
|
3
|
+
import { retentionReviewResourceIds, retentionUses } from "./retention.js";
|
|
4
|
+
import { documentIsAuditSpecific } from "./program-lifecycle.js";
|
|
3
5
|
|
|
4
6
|
export function scopedCollectionRecords(loaded, resourceType, program) {
|
|
5
7
|
if (!modelSupports(loaded.model, "program-scope")) {
|
|
6
8
|
return loaded.resources.filter((record) => record.type === resourceType);
|
|
7
9
|
}
|
|
10
|
+
if (resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")) {
|
|
11
|
+
return loaded.resources.filter((record) => (
|
|
12
|
+
record.type === resourceType && !["retired", "superseded"].includes(record.status)
|
|
13
|
+
));
|
|
14
|
+
}
|
|
8
15
|
if (resourceType === "person") {
|
|
9
16
|
return scopedProgramPeople(loaded, program);
|
|
10
17
|
}
|
|
@@ -35,6 +42,7 @@ export function scopedCollectionRecords(loaded, resourceType, program) {
|
|
|
35
42
|
const selected = {
|
|
36
43
|
system: systemIds,
|
|
37
44
|
component: componentIds,
|
|
45
|
+
control: controlIds,
|
|
38
46
|
framework: new Set(scopedProgram.frameworkIds || []),
|
|
39
47
|
"complementary-control": new Set(loaded.resources.filter((record) => (
|
|
40
48
|
record.type === "complementary-control"
|
|
@@ -51,7 +59,9 @@ export function scopedCollectionRecords(loaded, resourceType, program) {
|
|
|
51
59
|
)).map(({ id }) => id))
|
|
52
60
|
}[resourceType];
|
|
53
61
|
return loaded.resources.filter((record) => (
|
|
54
|
-
record.type === resourceType
|
|
62
|
+
record.type === resourceType
|
|
63
|
+
&& (!selected || selected.has(record.id))
|
|
64
|
+
&& (resourceType !== "control" || record.status === "implemented")
|
|
55
65
|
));
|
|
56
66
|
}
|
|
57
67
|
|
|
@@ -114,6 +124,20 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
|
|
|
114
124
|
if (record) records.set(record.id, record);
|
|
115
125
|
}
|
|
116
126
|
};
|
|
127
|
+
const addPartyIds = (ids) => {
|
|
128
|
+
const pending = [...(ids || [])];
|
|
129
|
+
const visited = new Set();
|
|
130
|
+
while (pending.length) {
|
|
131
|
+
const id = pending.shift();
|
|
132
|
+
if (!id || visited.has(id)) continue;
|
|
133
|
+
visited.add(id);
|
|
134
|
+
const record = byId.get(id);
|
|
135
|
+
if (!record) continue;
|
|
136
|
+
records.set(record.id, record);
|
|
137
|
+
if (record.type === "team") pending.push(...(record.memberIds || []), ...(record.chairIds || []));
|
|
138
|
+
if (record.type === "appointment") pending.push(record.holderId);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
117
141
|
|
|
118
142
|
if (resourceType === "framework") {
|
|
119
143
|
addIds(program?.systemIds);
|
|
@@ -139,6 +163,66 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
|
|
|
139
163
|
addIds((record.informationUses || []).map(({ informationTypeId }) => informationTypeId));
|
|
140
164
|
}
|
|
141
165
|
}
|
|
166
|
+
if (resourceType === "control") {
|
|
167
|
+
const obligations = loaded.resources.filter((record) => (
|
|
168
|
+
record.type === "obligation"
|
|
169
|
+
&& record.status === "active"
|
|
170
|
+
&& (record.controlIds || []).some((id) => reviewedIds.has(id))
|
|
171
|
+
));
|
|
172
|
+
const controlCodes = new Set(reviewed.map(({ code }) => code).filter(Boolean));
|
|
173
|
+
const sourceFamilyIds = new Set((loaded.model.evidenceSourceFamilies || [])
|
|
174
|
+
.filter((family) => (family.controlCodes || []).some((code) => controlCodes.has(code)))
|
|
175
|
+
.map(({ id }) => id));
|
|
176
|
+
const sourceCoverage = loaded.resources.filter((record) => (
|
|
177
|
+
record.type === "source-coverage"
|
|
178
|
+
&& record.status !== "retired"
|
|
179
|
+
&& sourceFamilyIds.has(record.sourceFamilyId)
|
|
180
|
+
));
|
|
181
|
+
const policyIds = new Set(reviewed.flatMap((record) => record.policyIds || []));
|
|
182
|
+
const policies = loaded.resources.filter((record) => (
|
|
183
|
+
record.type === "policy"
|
|
184
|
+
&& policyIds.has(record.id)
|
|
185
|
+
&& record.programRole !== "conditional"
|
|
186
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
187
|
+
));
|
|
188
|
+
const governedIds = new Set([
|
|
189
|
+
...policies.flatMap((record) => record.relatedDocumentIds || []),
|
|
190
|
+
...obligations.flatMap((record) => [
|
|
191
|
+
...(record.scopeResourceIds || []),
|
|
192
|
+
...(record.templateResourceId ? [record.templateResourceId] : [])
|
|
193
|
+
])
|
|
194
|
+
]);
|
|
195
|
+
const governedContent = loaded.resources.filter((record) => (
|
|
196
|
+
["document", "training"].includes(record.type)
|
|
197
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
198
|
+
&& (governedIds.has(record.id) || (record.controlIds || []).some((id) => reviewedIds.has(id)))
|
|
199
|
+
&& (record.type !== "document" || !documentIsAuditSpecific(record, loaded.model))
|
|
200
|
+
));
|
|
201
|
+
addIds(program?.systemIds);
|
|
202
|
+
addIds(obligations.map(({ id }) => id));
|
|
203
|
+
addIds(sourceCoverage.map(({ id }) => id));
|
|
204
|
+
addIds(policies.map(({ id }) => id));
|
|
205
|
+
addIds(governedContent.map(({ id }) => id));
|
|
206
|
+
for (const record of reviewed) {
|
|
207
|
+
addIds(record.componentIds);
|
|
208
|
+
addIds(record.evidenceSourceComponentIds);
|
|
209
|
+
addPartyIds(record.ownerIds);
|
|
210
|
+
}
|
|
211
|
+
for (const obligation of obligations) addPartyIds(obligation.ownerIds);
|
|
212
|
+
for (const record of [...policies, ...governedContent]) {
|
|
213
|
+
addPartyIds([...(record.ownerIds || []), ...(record.approverIds || []), ...(record.approvedByIds || [])]);
|
|
214
|
+
}
|
|
215
|
+
for (const coverage of sourceCoverage) {
|
|
216
|
+
addIds([coverage.componentId]);
|
|
217
|
+
addIds(coverage.readinessTestEvidenceIds);
|
|
218
|
+
addIds(coverage.retentionScheduleItemIds);
|
|
219
|
+
addPartyIds([...(coverage.ownerIds || []), ...(coverage.retrieverIds || [])]);
|
|
220
|
+
for (const rule of (coverage.retentionScheduleItemIds || []).map((id) => byId.get(id)).filter(Boolean)) {
|
|
221
|
+
addIds(retentionReviewResourceIds(rule, loaded));
|
|
222
|
+
addPartyIds([...(rule.ownerIds || []), ...(rule.approvedByIds || [])]);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
142
226
|
if (resourceType === "information-type" && !legacy) {
|
|
143
227
|
addIds(program?.systemIds);
|
|
144
228
|
addIds(programComponents(loaded, program || {}).map(({ id }) => id));
|
|
@@ -146,6 +230,29 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
|
|
|
146
230
|
.filter((record) => record.type === "vendor" && record.status !== "retired")
|
|
147
231
|
.map(({ id }) => id));
|
|
148
232
|
}
|
|
233
|
+
if (resourceType === "retention-schedule-item" && !legacy && modelSupports(loaded.model, "retention-schedule-approval")) {
|
|
234
|
+
const uses = retentionUses(loaded, program || {});
|
|
235
|
+
addIds(uses.map(({ resource }) => resource.id));
|
|
236
|
+
addIds(uses.map(({ informationTypeId }) => informationTypeId));
|
|
237
|
+
const schedules = loaded.resources
|
|
238
|
+
.filter((record) => (
|
|
239
|
+
record.type === "document"
|
|
240
|
+
&& record.documentKind === "schedule"
|
|
241
|
+
&& record.workflowScope === "program"
|
|
242
|
+
&& !["superseded", "retired"].includes(record.status)
|
|
243
|
+
))
|
|
244
|
+
addIds(schedules.map(({ id }) => id));
|
|
245
|
+
for (const schedule of schedules) {
|
|
246
|
+
addPartyIds([...(schedule.ownerIds || []), ...(schedule.approverIds || [])]);
|
|
247
|
+
}
|
|
248
|
+
for (const record of reviewed) {
|
|
249
|
+
addIds([record.scheduleDocumentId]);
|
|
250
|
+
addIds(record.informationTypeIds);
|
|
251
|
+
addIds(record.scopeResourceIds);
|
|
252
|
+
addIds(record.sourceResourceIds);
|
|
253
|
+
addPartyIds([...(record.ownerIds || []), ...(record.approvedByIds || [])]);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
149
256
|
if (resourceType === "complementary-control") {
|
|
150
257
|
addIds(program?.systemIds);
|
|
151
258
|
addIds(program?.controlIds);
|
|
@@ -202,12 +309,20 @@ export function collectionScopeRevisionFacts(loaded, resourceType, program) {
|
|
|
202
309
|
if (resourceType === "system") {
|
|
203
310
|
return { ...common, systemIds: sorted(program?.systemIds) };
|
|
204
311
|
}
|
|
205
|
-
if (resourceType === "component" || resourceType === "complementary-control") {
|
|
206
|
-
|
|
312
|
+
if (resourceType === "component" || resourceType === "complementary-control" || resourceType === "control") {
|
|
313
|
+
const scope = {
|
|
207
314
|
...common,
|
|
208
315
|
systemIds: sorted(program?.systemIds),
|
|
209
316
|
controlIds: sorted(program?.controlIds)
|
|
210
317
|
};
|
|
318
|
+
return resourceType === "control" ? {
|
|
319
|
+
...scope,
|
|
320
|
+
assuranceGoal: program?.assuranceGoal ?? null,
|
|
321
|
+
frameworkIds: sorted(program?.frameworkIds),
|
|
322
|
+
requirementIds: sorted(selectedRequirementIds(program || {}, loaded.model)),
|
|
323
|
+
requirementApplicability: program?.requirementApplicability || [],
|
|
324
|
+
riskMethodology: program?.riskMethodology || null
|
|
325
|
+
} : scope;
|
|
211
326
|
}
|
|
212
327
|
return common;
|
|
213
328
|
}
|
|
@@ -244,11 +359,41 @@ const dependencyFields = {
|
|
|
244
359
|
classification: ["id", "type", "status", "rank", "description", "handlingRequirements"],
|
|
245
360
|
"information-type": ["id", "type", "status", "classificationId", "description"]
|
|
246
361
|
},
|
|
362
|
+
control: {
|
|
363
|
+
system: ["id", "type", "status", "purpose", "servicesProvided", "boundary", "exclusions", "criticality", "informationTypeIds", "classificationId", "internetExposed", "continuityObjectives"],
|
|
364
|
+
component: ["id", "type", "status", "componentKind", "description", "ownerIds", "systemUses", "evidenceSourceKinds", "evidenceOwnerIds"],
|
|
365
|
+
obligation: ["id", "type", "status", "activityType", "scheduleMode", "ownerIds", "activeRuleId", "policyIds", "controlIds", "ruleIds"],
|
|
366
|
+
"source-coverage": ["id", "type", "status", "sourceFamilyId", "coverageKind", "scopeResourceIds", "excludedPopulation", "retrieverIds", "collectionCadence", "retentionScheduleItemIds", "reconciliationMethod", "validFrom", "validThrough", "applicabilityReview", "readinessTestEvidenceIds", "ownerIds", "componentId"],
|
|
367
|
+
evidence: ["id", "type", "status", "artifactKind", "readinessTest", "retrievalResult", "accessConfirmed", "coveredSourceFamilyIds", "sourceComponentId", "componentIds", "systemIds", "collectedOn", "collectorIds", "verifierIds", "verifiedOn"],
|
|
368
|
+
"retention-schedule-item": ["id", "type", "status", "description", "informationTypeIds", "scopeResourceIds", "scheduleDocumentId", "sourceResourceIds", "ownerIds", "approvedByIds", "approvedOn", "reviewedSourceRevisions", "cutoff", "retentionPeriod", "dispositionAction", "dispositionInstructions"],
|
|
369
|
+
document: ["id", "type", "documentKind", "workflowScope", "ownerIds", "approverIds", "approvedOn", "approvedContentRevisions", "controlIds"],
|
|
370
|
+
policy: ["id", "type", "programRole", "ownerIds", "approverIds", "approvedOn", "approvedContentRevisions", "relatedDocumentIds", "controlIds"],
|
|
371
|
+
training: ["id", "type", "ownerIds", "approverIds", "approvedOn", "approvedContentRevisions", "controlIds"],
|
|
372
|
+
"information-type": ["id", "type", "status", "classificationId", "description"],
|
|
373
|
+
person: ["id", "type", "status", "affiliation", "jobTitle"],
|
|
374
|
+
team: ["id", "type", "status", "memberIds", "chairIds"],
|
|
375
|
+
appointment: ["id", "type", "status", "appointmentKind", "scopeResourceIds", "holderId", "startsOn", "endsOn"]
|
|
376
|
+
},
|
|
247
377
|
"information-type": {
|
|
248
378
|
system: ["id", "type", "status", "informationTypeIds"],
|
|
249
379
|
component: ["id", "type", "status", "systemUses", "informationUses"],
|
|
250
380
|
vendor: ["id", "type", "status", "informationTypeIds"]
|
|
251
381
|
},
|
|
382
|
+
"retention-schedule-item": {
|
|
383
|
+
document: ["id", "type", "documentKind", "workflowScope", "ownerIds", "approverIds", "approvedOn", "approvedContentRevisions", "controlIds"],
|
|
384
|
+
policy: ["id", "type", "status", "programRole", "ownerIds", "approverIds", "approvedOn", "approvedContentRevisions", "controlIds"],
|
|
385
|
+
requirement: ["id", "type", "title", "frameworkId", "reference", "description"],
|
|
386
|
+
commitment: ["id", "type", "status", "commitmentKind", "statement", "effectiveOn"],
|
|
387
|
+
control: ["id", "type", "status", "statement", "systemIds", "componentIds"],
|
|
388
|
+
"information-type": ["id", "type", "status", "classificationId", "description"],
|
|
389
|
+
system: ["id", "type", "status", "informationTypeIds"],
|
|
390
|
+
component: ["id", "type", "status", "systemUses", "informationUses"],
|
|
391
|
+
vendor: ["id", "type", "status", "informationTypeIds"],
|
|
392
|
+
"source-coverage": ["id", "type", "status", "sourceFamilyId", "coverageKind", "scopeResourceIds", "retentionScheduleItemIds", "componentId"],
|
|
393
|
+
person: ["id", "type", "status", "affiliation", "jobTitle"],
|
|
394
|
+
team: ["id", "type", "status", "memberIds", "chairIds"],
|
|
395
|
+
appointment: ["id", "type", "status", "appointmentKind", "scopeResourceIds", "holderId", "startsOn", "endsOn"]
|
|
396
|
+
},
|
|
252
397
|
"complementary-control": {
|
|
253
398
|
system: ["id", "type", "status", "purpose", "servicesProvided", "boundary", "exclusions", "criticality", "informationTypeIds", "classificationId", "internetExposed", "continuityObjectives"],
|
|
254
399
|
control: ["id", "type", "status", "statement", "activity", "systemIds", "componentIds", "evidenceSourceComponentIds"],
|
|
@@ -279,6 +424,8 @@ const dependencyContentTypes = {
|
|
|
279
424
|
framework: new Set(["system"]),
|
|
280
425
|
vendor: new Set(["document"]),
|
|
281
426
|
component: new Set(["system"]),
|
|
427
|
+
control: new Set(["system", "component", "obligation", "source-coverage", "evidence", "retention-schedule-item", "document", "policy", "training"]),
|
|
428
|
+
"retention-schedule-item": new Set(["document", "policy"]),
|
|
282
429
|
"complementary-control": new Set(["system", "control", "document", "component"])
|
|
283
430
|
};
|
|
284
431
|
|
|
@@ -10,12 +10,18 @@ import { loadWorkspace } from "./workspace.js";
|
|
|
10
10
|
export async function scaffoldDocumentActivation(input = process.cwd(), options = {}) {
|
|
11
11
|
const loaded = await loadWorkspace(input);
|
|
12
12
|
requireDocumentLifecycle(loaded);
|
|
13
|
-
const
|
|
13
|
+
const readiness = options.auditId ? null : await assessProgramReadiness(loaded, { programId: options.programId });
|
|
14
|
+
const oversightReady = options.auditId || controlOversightComplete(loaded, readiness);
|
|
15
|
+
const candidates = oversightReady
|
|
16
|
+
? await activationCandidates(loaded, { ...options, documentsOnly: true })
|
|
17
|
+
: [];
|
|
14
18
|
const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
|
|
15
19
|
const today = currentCalendarDate(loaded.workspace.timezone);
|
|
16
20
|
return {
|
|
17
21
|
available: candidates.length > 0,
|
|
18
|
-
message:
|
|
22
|
+
message: !oversightReady
|
|
23
|
+
? "Complete the current Control collection review before Program Content Activation."
|
|
24
|
+
: candidates.length
|
|
19
25
|
? `${candidates.length} approved ${candidates.length === 1 ? "Document is" : "Documents are"} ready to activate.`
|
|
20
26
|
: `No ${options.auditId ? "engagement Document" : "program Document"} is ready to activate. Review the current readiness actions first.`,
|
|
21
27
|
nextCommand: candidates.length ? null : options.auditId
|
|
@@ -34,6 +40,12 @@ export async function scaffoldDocumentActivation(input = process.cwd(), options
|
|
|
34
40
|
export async function planDocumentActivation(input = process.cwd(), options = {}) {
|
|
35
41
|
const loaded = await loadWorkspace(input);
|
|
36
42
|
requireDocumentLifecycle(loaded);
|
|
43
|
+
if (!options.auditId && modelSupports(loaded.model, "control-collection-oversight")) {
|
|
44
|
+
const readiness = await assessProgramReadiness(loaded, { programId: options.programId });
|
|
45
|
+
if (!controlOversightComplete(loaded, readiness)) {
|
|
46
|
+
throw new Error("Complete the current Control collection review before Program Content Activation.");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
37
49
|
const resourceIds = [...new Set((options.resourceIds || options.documentIds || []).map(String))];
|
|
38
50
|
if (!resourceIds.length) throw new Error("Governed-content activation needs at least one approved program Document or Training record.");
|
|
39
51
|
const activatedByIds = [...new Set((options.activatedByIds || []).map(String))];
|
|
@@ -118,6 +130,12 @@ export async function planDocumentActivation(input = process.cwd(), options = {}
|
|
|
118
130
|
};
|
|
119
131
|
}
|
|
120
132
|
|
|
133
|
+
function controlOversightComplete(loaded, readiness) {
|
|
134
|
+
if (!modelSupports(loaded.model, "control-collection-oversight") || !readiness.scope.controlIds.length) return true;
|
|
135
|
+
return readiness.stages.find(({ id }) => id === "controls")?.items
|
|
136
|
+
.find(({ id }) => id === "collection-review-control")?.status === "complete";
|
|
137
|
+
}
|
|
138
|
+
|
|
121
139
|
async function activationCandidates(loaded, options) {
|
|
122
140
|
if (options.auditId) {
|
|
123
141
|
return (await assessAuditDocumentActivations(loaded, {
|
|
@@ -150,12 +168,18 @@ export async function scaffoldGovernedContentActivation(input = process.cwd(), o
|
|
|
150
168
|
if (!modelSupports(loaded.model, "governed-training-activation")) {
|
|
151
169
|
throw new Error("Unified governed-content activation requires a model v6 or later workspace.");
|
|
152
170
|
}
|
|
153
|
-
const
|
|
171
|
+
const readiness = await assessProgramReadiness(loaded, { programId: options.programId });
|
|
172
|
+
const oversightReady = controlOversightComplete(loaded, readiness);
|
|
173
|
+
const candidates = oversightReady
|
|
174
|
+
? await activationCandidates(loaded, { ...options, auditId: undefined })
|
|
175
|
+
: [];
|
|
154
176
|
const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
|
|
155
177
|
const today = currentCalendarDate(loaded.workspace.timezone);
|
|
156
178
|
return {
|
|
157
179
|
available: candidates.length > 0,
|
|
158
|
-
message:
|
|
180
|
+
message: !oversightReady
|
|
181
|
+
? "Complete the current Control collection review before Program Content Activation."
|
|
182
|
+
: candidates.length
|
|
159
183
|
? `${candidates.length} approved governed-content ${candidates.length === 1 ? "record is" : "records are"} ready to activate.`
|
|
160
184
|
: "No program Document or Training record is ready to activate. Review the current readiness actions first.",
|
|
161
185
|
nextCommand: candidates.length ? null : "npx filegrc program-path --next --json",
|
package/src/model-migration.js
CHANGED
|
@@ -901,7 +901,8 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
|
|
|
901
901
|
: sourceVersion === "6" ? "7"
|
|
902
902
|
: sourceVersion === "7" ? "8"
|
|
903
903
|
: sourceVersion === "8" ? "9"
|
|
904
|
-
:
|
|
904
|
+
: sourceVersion === "9" ? "10"
|
|
905
|
+
: ACTIVE_MODEL_VERSION;
|
|
905
906
|
if (sourceVersion === requestedTarget) return emptyPlan(sourceVersion, requestedTarget);
|
|
906
907
|
if (sourceVersion === "1" && requestedTarget === "2") {
|
|
907
908
|
return planV1ToV2Migration(input, options);
|
|
@@ -927,9 +928,12 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
|
|
|
927
928
|
if (sourceVersion === "8" && requestedTarget === "9") {
|
|
928
929
|
return planV8ToV9Migration(loaded);
|
|
929
930
|
}
|
|
930
|
-
if (sourceVersion === "9" && requestedTarget ===
|
|
931
|
+
if (sourceVersion === "9" && requestedTarget === "10") {
|
|
931
932
|
return planV9ToV10Migration(loaded);
|
|
932
933
|
}
|
|
934
|
+
if (sourceVersion === "10" && requestedTarget === ACTIVE_MODEL_VERSION) {
|
|
935
|
+
return planV10ToV11Migration(loaded);
|
|
936
|
+
}
|
|
933
937
|
if (sourceVersion === "1" && requestedTarget === ACTIVE_MODEL_VERSION) {
|
|
934
938
|
throw new Error(
|
|
935
939
|
"Model v1 workspaces must migrate to model v2 first. "
|
|
@@ -2406,6 +2410,70 @@ async function planV9ToV10Migration(loaded) {
|
|
|
2406
2410
|
};
|
|
2407
2411
|
}
|
|
2408
2412
|
|
|
2413
|
+
async function planV10ToV11Migration(loaded) {
|
|
2414
|
+
if (!loaded.workspace?.id) throw new Error("Model migration requires a valid Workspace record.");
|
|
2415
|
+
const targetModel = loadModel("11");
|
|
2416
|
+
const revisions = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
|
|
2417
|
+
const automatic = [];
|
|
2418
|
+
const reviewRequired = [];
|
|
2419
|
+
const unsupported = [];
|
|
2420
|
+
const missing = [];
|
|
2421
|
+
const manualActions = [];
|
|
2422
|
+
const updates = [];
|
|
2423
|
+
for (const original of loaded.resources) {
|
|
2424
|
+
const record = structuredClone(original);
|
|
2425
|
+
let changed = false;
|
|
2426
|
+
if (record.type === "workspace") {
|
|
2427
|
+
record.dataModelVersion = "11";
|
|
2428
|
+
automatic.push(classifiedChange("automatic", record.id, "dataModelVersion", "Select model v11."));
|
|
2429
|
+
changed = true;
|
|
2430
|
+
}
|
|
2431
|
+
if (record.type === "control") {
|
|
2432
|
+
for (const field of ["implementationReviewedByIds", "implementationReviewedOn"]) {
|
|
2433
|
+
if (!Object.hasOwn(record, field)) continue;
|
|
2434
|
+
delete record[field];
|
|
2435
|
+
automatic.push(classifiedChange("automatic", record.id, field, "Remove the obsolete per-Control implementation review field. Control oversight now uses a periodic Collection Review."));
|
|
2436
|
+
changed = true;
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
if (changed) updates.push(record);
|
|
2440
|
+
}
|
|
2441
|
+
const updatedById = new Map(updates.map((record) => [record.id, record]));
|
|
2442
|
+
const migratedRecords = loaded.resources.map((record) => updatedById.get(record.id) || record);
|
|
2443
|
+
await collectTargetValidationActions(loaded, migratedRecords, targetModel, missing, manualActions);
|
|
2444
|
+
for (const item of [...missing, ...manualActions]) {
|
|
2445
|
+
unsupported.push(classifiedChange("unsupported", item.resourceId, item.field, item.message));
|
|
2446
|
+
}
|
|
2447
|
+
return {
|
|
2448
|
+
schemaVersion: 2,
|
|
2449
|
+
sourceModelVersion: "10",
|
|
2450
|
+
targetModelVersion: "11",
|
|
2451
|
+
ready: unsupported.length === 0,
|
|
2452
|
+
missing,
|
|
2453
|
+
conflicts: [],
|
|
2454
|
+
manualActions,
|
|
2455
|
+
classifications: { automatic, reviewRequired, unsupported },
|
|
2456
|
+
notes: [
|
|
2457
|
+
"Control owners can mark Controls implemented without a separate per-Control implementation reviewer.",
|
|
2458
|
+
"Periodic Control oversight is recorded as a Git-bound Collection Review over the Program's selected Controls.",
|
|
2459
|
+
"Policy, Document, and Training approval independence remains unchanged."
|
|
2460
|
+
],
|
|
2461
|
+
migrationReport: {},
|
|
2462
|
+
summary: { create: 0, update: updates.length, automatic: automatic.length, reviewRequired: 0, unsupported: unsupported.length },
|
|
2463
|
+
fileDiff: {
|
|
2464
|
+
create: [],
|
|
2465
|
+
update: updates.map((record) => ({ type: record.type, id: record.id, before: loaded.resources.find(({ id }) => id === record.id), after: record }))
|
|
2466
|
+
},
|
|
2467
|
+
changes: {
|
|
2468
|
+
create: [],
|
|
2469
|
+
update: updates,
|
|
2470
|
+
expectedRevisions: Object.fromEntries(updates.map(({ id }) => [id, revisions.get(id)])),
|
|
2471
|
+
validateWholeWorkspace: true,
|
|
2472
|
+
targetModelVersion: "11"
|
|
2473
|
+
}
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2409
2477
|
function normalizeDocumentScopeDecision(value) {
|
|
2410
2478
|
const candidate = typeof value === "object" && value
|
|
2411
2479
|
? value.workflowScope || value.scope
|