filegrc 0.13.5 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.13.5",
3
+ "version": "0.14.0",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
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 Items and Requirement Mappings.");
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 },
@@ -11,6 +11,7 @@ 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";
14
15
 
15
16
  export { collectionRevision };
16
17
 
@@ -63,11 +64,19 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
63
64
  currentRevision
64
65
  }
65
66
  );
67
+ const reviewerEligibility = resourceType === "control"
68
+ ? assessControlReviewers(loaded, records)
69
+ : null;
70
+ const reviewersEligible = !reviewerEligibility || Boolean(
71
+ review?.reviewedByIds?.length
72
+ && review.reviewedByIds.every((id) => reviewerEligibility.eligibleReviewerIds.includes(id))
73
+ );
66
74
  const complete = Boolean(
67
75
  review?.status === "active"
68
76
  && allowedDecisions.includes(review.decision)
69
77
  && revisionMatches
70
78
  && temporalReview
79
+ && reviewersEligible
71
80
  );
72
81
  const stale = Boolean(
73
82
  review?.status === "active"
@@ -86,8 +95,11 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
86
95
  collectionRevision: currentRevision,
87
96
  status: complete ? "current" : stale ? "stale" : "review-required",
88
97
  complete,
98
+ ...(reviewerEligibility || {}),
89
99
  message: complete
90
100
  ? `${configuration.title} were reviewed on ${review.reviewedOn}.`
101
+ : revisionMatches && review?.status === "active" && !reviewersEligible
102
+ ? "Review the Control collection again with a reviewer who does not own an included Control or its enabled Obligation."
91
103
  : stale
92
104
  ? `${configuration.title} changed after the last confirmation. Review the current records again.`
93
105
  : !records.length && !allowsEmptyCollection
@@ -101,6 +113,7 @@ export async function scaffoldCollectionReview(input = process.cwd(), options =
101
113
  const program = resolveProgram(loaded, options.programId);
102
114
  const resourceType = requiredType(loaded, options.resourceType);
103
115
  const assessment = assessCollectionReview(loaded, resourceType, { programId: program.id });
116
+ await requireControlReviewReady(loaded, resourceType, program.id);
104
117
  const allowedDecisions = assessment.configuration.decisions || ["complete"];
105
118
  const now = options.now ? new Date(options.now) : new Date();
106
119
  if (Number.isNaN(now.getTime())) throw new Error("A valid Collection Review time is required.");
@@ -141,6 +154,7 @@ export async function planCollectionReview(input = process.cwd(), options = {})
141
154
  const program = resolveProgram(loaded, options.programId);
142
155
  const resourceType = requiredType(loaded, options.resourceType);
143
156
  const assessment = assessCollectionReview(loaded, resourceType, { programId: program.id });
157
+ await requireControlReviewReady(loaded, resourceType, program.id);
144
158
  const configuration = assessment.configuration;
145
159
  const decision = String(options.decision || "").trim();
146
160
  const rationale = String(options.rationale || "").trim();
@@ -178,6 +192,13 @@ export async function planCollectionReview(input = process.cwd(), options = {})
178
192
  if (!rationale || !reviewedByIds.length || !reviewedOn) {
179
193
  throw new Error(`${configuration.title} review needs review notes, a reviewer, and a review date.`);
180
194
  }
195
+ if (resourceType === "control") {
196
+ const eligible = new Set(assessment.eligibleReviewerIds || []);
197
+ const conflicts = reviewedByIds.filter((id) => !eligible.has(id));
198
+ if (conflicts.length) {
199
+ throw new Error(`Control collection review needs a reviewer who does not own an included Control or its enabled Obligation: ${conflicts.join(", ")}.`);
200
+ }
201
+ }
181
202
  if (temporalReviews) {
182
203
  if (reviewedOn !== today) {
183
204
  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 +291,44 @@ export async function planCollectionReview(input = process.cwd(), options = {})
270
291
  };
271
292
  }
272
293
 
294
+ async function requireControlReviewReady(loaded, resourceType, programId) {
295
+ if (resourceType !== "control") return;
296
+ const { assessProgramReadiness } = await import("./program-readiness.js");
297
+ const readiness = await assessProgramReadiness(loaded, { programId });
298
+ const earlierItems = readiness.stages
299
+ .find(({ id }) => id === "controls")
300
+ ?.items.filter(({ id }) => (
301
+ id !== "collection-review-control"
302
+ && !id.startsWith("document-activation-")
303
+ && !id.startsWith("training-activation-")
304
+ && !id.startsWith("policy-activation-")
305
+ )) || [];
306
+ if (!earlierItems.length || earlierItems.some(({ status }) => status !== "complete")) {
307
+ throw new Error("Complete the Step 3 implementation work before recording the Control collection review. Program content activation follows this review.");
308
+ }
309
+ }
310
+
311
+ function assessControlReviewers(loaded, controls) {
312
+ const byId = new Map(loaded.resources.map((record) => [record.id, record]));
313
+ const controlIds = new Set(controls.map(({ id }) => id));
314
+ const conflictIds = new Set();
315
+ for (const control of controls) {
316
+ for (const id of currentPartyPeople(control.ownerIds || [], byId)) conflictIds.add(id);
317
+ }
318
+ for (const obligation of loaded.resources.filter((record) => (
319
+ record.type === "obligation"
320
+ && record.status === "active"
321
+ && (record.controlIds || []).some((id) => controlIds.has(id))
322
+ ))) {
323
+ for (const id of currentPartyPeople(obligation.ownerIds || [], byId)) conflictIds.add(id);
324
+ }
325
+ const people = loaded.resources.filter(({ type, status }) => type === "person" && status === "active");
326
+ return {
327
+ eligibleReviewerIds: people.map(({ id }) => id).filter((id) => !conflictIds.has(id)),
328
+ reviewerConflictIds: people.map(({ id }) => id).filter((id) => conflictIds.has(id))
329
+ };
330
+ }
331
+
273
332
  function sameIds(left, right) {
274
333
  return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
275
334
  }
@@ -1,5 +1,7 @@
1
1
  import { modelSupports } from "../model/index.js";
2
2
  import { programComponents, selectedRequirementIds } from "./program.js";
3
+ import { retentionReviewResourceIds } 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")) {
@@ -35,6 +37,7 @@ export function scopedCollectionRecords(loaded, resourceType, program) {
35
37
  const selected = {
36
38
  system: systemIds,
37
39
  component: componentIds,
40
+ control: controlIds,
38
41
  framework: new Set(scopedProgram.frameworkIds || []),
39
42
  "complementary-control": new Set(loaded.resources.filter((record) => (
40
43
  record.type === "complementary-control"
@@ -51,7 +54,9 @@ export function scopedCollectionRecords(loaded, resourceType, program) {
51
54
  )).map(({ id }) => id))
52
55
  }[resourceType];
53
56
  return loaded.resources.filter((record) => (
54
- record.type === resourceType && (!selected || selected.has(record.id))
57
+ record.type === resourceType
58
+ && (!selected || selected.has(record.id))
59
+ && (resourceType !== "control" || record.status === "implemented")
55
60
  ));
56
61
  }
57
62
 
@@ -114,6 +119,20 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
114
119
  if (record) records.set(record.id, record);
115
120
  }
116
121
  };
122
+ const addPartyIds = (ids) => {
123
+ const pending = [...(ids || [])];
124
+ const visited = new Set();
125
+ while (pending.length) {
126
+ const id = pending.shift();
127
+ if (!id || visited.has(id)) continue;
128
+ visited.add(id);
129
+ const record = byId.get(id);
130
+ if (!record) continue;
131
+ records.set(record.id, record);
132
+ if (record.type === "team") pending.push(...(record.memberIds || []), ...(record.chairIds || []));
133
+ if (record.type === "appointment") pending.push(record.holderId);
134
+ }
135
+ };
117
136
 
118
137
  if (resourceType === "framework") {
119
138
  addIds(program?.systemIds);
@@ -139,6 +158,66 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
139
158
  addIds((record.informationUses || []).map(({ informationTypeId }) => informationTypeId));
140
159
  }
141
160
  }
161
+ if (resourceType === "control") {
162
+ const obligations = loaded.resources.filter((record) => (
163
+ record.type === "obligation"
164
+ && record.status === "active"
165
+ && (record.controlIds || []).some((id) => reviewedIds.has(id))
166
+ ));
167
+ const controlCodes = new Set(reviewed.map(({ code }) => code).filter(Boolean));
168
+ const sourceFamilyIds = new Set((loaded.model.evidenceSourceFamilies || [])
169
+ .filter((family) => (family.controlCodes || []).some((code) => controlCodes.has(code)))
170
+ .map(({ id }) => id));
171
+ const sourceCoverage = loaded.resources.filter((record) => (
172
+ record.type === "source-coverage"
173
+ && record.status !== "retired"
174
+ && sourceFamilyIds.has(record.sourceFamilyId)
175
+ ));
176
+ const policyIds = new Set(reviewed.flatMap((record) => record.policyIds || []));
177
+ const policies = loaded.resources.filter((record) => (
178
+ record.type === "policy"
179
+ && policyIds.has(record.id)
180
+ && record.programRole !== "conditional"
181
+ && !["superseded", "retired"].includes(record.status)
182
+ ));
183
+ const governedIds = new Set([
184
+ ...policies.flatMap((record) => record.relatedDocumentIds || []),
185
+ ...obligations.flatMap((record) => [
186
+ ...(record.scopeResourceIds || []),
187
+ ...(record.templateResourceId ? [record.templateResourceId] : [])
188
+ ])
189
+ ]);
190
+ const governedContent = loaded.resources.filter((record) => (
191
+ ["document", "training"].includes(record.type)
192
+ && !["superseded", "retired"].includes(record.status)
193
+ && (governedIds.has(record.id) || (record.controlIds || []).some((id) => reviewedIds.has(id)))
194
+ && (record.type !== "document" || !documentIsAuditSpecific(record, loaded.model))
195
+ ));
196
+ addIds(program?.systemIds);
197
+ addIds(obligations.map(({ id }) => id));
198
+ addIds(sourceCoverage.map(({ id }) => id));
199
+ addIds(policies.map(({ id }) => id));
200
+ addIds(governedContent.map(({ id }) => id));
201
+ for (const record of reviewed) {
202
+ addIds(record.componentIds);
203
+ addIds(record.evidenceSourceComponentIds);
204
+ addPartyIds(record.ownerIds);
205
+ }
206
+ for (const obligation of obligations) addPartyIds(obligation.ownerIds);
207
+ for (const record of [...policies, ...governedContent]) {
208
+ addPartyIds([...(record.ownerIds || []), ...(record.approverIds || []), ...(record.approvedByIds || [])]);
209
+ }
210
+ for (const coverage of sourceCoverage) {
211
+ addIds([coverage.componentId]);
212
+ addIds(coverage.readinessTestEvidenceIds);
213
+ addIds(coverage.retentionScheduleItemIds);
214
+ addPartyIds([...(coverage.ownerIds || []), ...(coverage.retrieverIds || [])]);
215
+ for (const rule of (coverage.retentionScheduleItemIds || []).map((id) => byId.get(id)).filter(Boolean)) {
216
+ addIds(retentionReviewResourceIds(rule, loaded));
217
+ addPartyIds([...(rule.ownerIds || []), ...(rule.approvedByIds || [])]);
218
+ }
219
+ }
220
+ }
142
221
  if (resourceType === "information-type" && !legacy) {
143
222
  addIds(program?.systemIds);
144
223
  addIds(programComponents(loaded, program || {}).map(({ id }) => id));
@@ -202,12 +281,20 @@ export function collectionScopeRevisionFacts(loaded, resourceType, program) {
202
281
  if (resourceType === "system") {
203
282
  return { ...common, systemIds: sorted(program?.systemIds) };
204
283
  }
205
- if (resourceType === "component" || resourceType === "complementary-control") {
206
- return {
284
+ if (resourceType === "component" || resourceType === "complementary-control" || resourceType === "control") {
285
+ const scope = {
207
286
  ...common,
208
287
  systemIds: sorted(program?.systemIds),
209
288
  controlIds: sorted(program?.controlIds)
210
289
  };
290
+ return resourceType === "control" ? {
291
+ ...scope,
292
+ assuranceGoal: program?.assuranceGoal ?? null,
293
+ frameworkIds: sorted(program?.frameworkIds),
294
+ requirementIds: sorted(selectedRequirementIds(program || {}, loaded.model)),
295
+ requirementApplicability: program?.requirementApplicability || [],
296
+ riskMethodology: program?.riskMethodology || null
297
+ } : scope;
211
298
  }
212
299
  return common;
213
300
  }
@@ -244,6 +331,21 @@ const dependencyFields = {
244
331
  classification: ["id", "type", "status", "rank", "description", "handlingRequirements"],
245
332
  "information-type": ["id", "type", "status", "classificationId", "description"]
246
333
  },
334
+ control: {
335
+ system: ["id", "type", "status", "purpose", "servicesProvided", "boundary", "exclusions", "criticality", "informationTypeIds", "classificationId", "internetExposed", "continuityObjectives"],
336
+ component: ["id", "type", "status", "componentKind", "description", "ownerIds", "systemUses", "evidenceSourceKinds", "evidenceOwnerIds"],
337
+ obligation: ["id", "type", "status", "activityType", "scheduleMode", "ownerIds", "activeRuleId", "policyIds", "controlIds", "ruleIds"],
338
+ "source-coverage": ["id", "type", "status", "sourceFamilyId", "coverageKind", "scopeResourceIds", "excludedPopulation", "retrieverIds", "collectionCadence", "retentionScheduleItemIds", "reconciliationMethod", "validFrom", "validThrough", "applicabilityReview", "readinessTestEvidenceIds", "ownerIds", "componentId"],
339
+ evidence: ["id", "type", "status", "artifactKind", "readinessTest", "retrievalResult", "accessConfirmed", "coveredSourceFamilyIds", "sourceComponentId", "componentIds", "systemIds", "collectedOn", "collectorIds", "verifierIds", "verifiedOn"],
340
+ "retention-schedule-item": ["id", "type", "status", "description", "informationTypeIds", "scopeResourceIds", "scheduleDocumentId", "sourceResourceIds", "ownerIds", "approvedByIds", "approvedOn", "reviewedSourceRevisions", "cutoff", "retentionPeriod", "dispositionAction", "dispositionInstructions"],
341
+ document: ["id", "type", "documentKind", "workflowScope", "ownerIds", "approverIds", "approvedOn", "approvedContentRevisions", "controlIds"],
342
+ policy: ["id", "type", "programRole", "ownerIds", "approverIds", "approvedOn", "approvedContentRevisions", "relatedDocumentIds", "controlIds"],
343
+ training: ["id", "type", "ownerIds", "approverIds", "approvedOn", "approvedContentRevisions", "controlIds"],
344
+ "information-type": ["id", "type", "status", "classificationId", "description"],
345
+ person: ["id", "type", "status", "affiliation", "jobTitle"],
346
+ team: ["id", "type", "status", "memberIds", "chairIds"],
347
+ appointment: ["id", "type", "status", "appointmentKind", "scopeResourceIds", "holderId", "startsOn", "endsOn"]
348
+ },
247
349
  "information-type": {
248
350
  system: ["id", "type", "status", "informationTypeIds"],
249
351
  component: ["id", "type", "status", "systemUses", "informationUses"],
@@ -279,6 +381,7 @@ const dependencyContentTypes = {
279
381
  framework: new Set(["system"]),
280
382
  vendor: new Set(["document"]),
281
383
  component: new Set(["system"]),
384
+ control: new Set(["system", "component", "obligation", "source-coverage", "evidence", "retention-schedule-item", "document", "policy", "training"]),
282
385
  "complementary-control": new Set(["system", "control", "document", "component"])
283
386
  };
284
387
 
@@ -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 candidates = await activationCandidates(loaded, { ...options, documentsOnly: true });
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: candidates.length
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 candidates = await activationCandidates(loaded, { ...options, auditId: undefined });
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: candidates.length
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",
@@ -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
- : ACTIVE_MODEL_VERSION;
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 === ACTIVE_MODEL_VERSION) {
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
@@ -3,17 +3,21 @@ import { serializeWorkspaceMutation } from "./mutation.js";
3
3
  import { assessProgramReadiness } from "./program-readiness.js";
4
4
  import { currentCalendarDate } from "./time.js";
5
5
  import { loadWorkspace } from "./workspace.js";
6
+ import { modelSupports } from "../model/index.js";
6
7
 
7
8
  export async function scaffoldPolicyActivation(input = process.cwd(), options = {}) {
8
9
  const loaded = await loadWorkspace(input);
9
10
  const readiness = await assessProgramReadiness(loaded, { programId: options.programId });
10
- const approved = readiness.policyActivations.filter(({ state }) => (
11
+ const oversightReady = controlOversightComplete(loaded, readiness);
12
+ const approved = (oversightReady ? readiness.policyActivations : []).filter(({ state }) => (
11
13
  ["approved-implementation-pending", "ready-to-activate"].includes(state)
12
14
  ));
13
15
  const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
14
16
  return {
15
17
  available: approved.length > 0,
16
- message: approved.length
18
+ message: !oversightReady
19
+ ? "Complete the current Control collection review before Program Content Activation."
20
+ : approved.length
17
21
  ? `${approved.length} approved ${approved.length === 1 ? "Policy is" : "Policies are"} available for the Step 3 cutover.`
18
22
  : "No Policy is ready for activation. Finish Step 2 approval, then resolve its Step 3 implementation gaps.",
19
23
  nextCommand: approved.length ? null : "npx filegrc program-path --next --json",
@@ -26,6 +30,10 @@ export async function scaffoldPolicyActivation(input = process.cwd(), options =
26
30
 
27
31
  export async function planPolicyActivation(input = process.cwd(), options = {}) {
28
32
  const loaded = await loadWorkspace(input);
33
+ const readiness = await assessProgramReadiness(loaded, { programId: options.programId });
34
+ if (!controlOversightComplete(loaded, readiness)) {
35
+ throw new Error("Complete the current Control collection review before Program Content Activation.");
36
+ }
29
37
  const policyIds = [...new Set((options.policyIds || []).map(String))];
30
38
  if (!policyIds.length) throw new Error("Policy activation needs at least one approved Policy.");
31
39
  const effectiveOn = String(options.effectiveOn || "").trim();
@@ -67,6 +75,12 @@ export async function planPolicyActivation(input = process.cwd(), options = {})
67
75
  };
68
76
  }
69
77
 
78
+ function controlOversightComplete(loaded, readiness) {
79
+ if (!modelSupports(loaded.model, "control-collection-oversight") || !readiness.scope.controlIds.length) return true;
80
+ return readiness.stages.find(({ id }) => id === "controls")?.items
81
+ .find(({ id }) => id === "collection-review-control")?.status === "complete";
82
+ }
83
+
70
84
  export async function activatePolicies(input = process.cwd(), options = {}) {
71
85
  if (options.confirmed !== true) throw new Error("Review the Policy activation cutover and confirm the write.");
72
86
  return serializeWorkspaceMutation(input, async (root) => {
@@ -129,7 +129,11 @@ export const PROGRAM_PATH = [
129
129
  description: "Finish controls and their evidence sources",
130
130
  summary: "Describe each Control and connect its evidence source.",
131
131
  sections: [
132
- { id: "catalog", title: "Control Catalog", description: "Implement approved requirements, configure retention and Obligations, activate approved program content, finish authoritative evidence sources, then activate the Policies at cutover.", steps: ["Open every planned Control and confirm its mappings and operation pattern.", "Write the real procedure in Record Markdown, add bounded System scope, and map the operating and authoritative evidence-source Components.", "Review every Retention Schedule Item against current information uses and approved sources. Do not infer periods or disposition behavior.", "Review and enable every calendar and event schedule as an Obligation. Enabled work remains dormant until its governing Policy is active.", "Confirm each source Component is active, has an evidence-source role and rationale in the Control's System scope, has current access owners, and includes repeatable retrieval instructions in Record Markdown.", "Implement every requirement linked from an approved program Document or Training record, then activate the unchanged approved revisions with separate activation dates and bindings.", "Use the activation review to inspect planned or partial Controls, inactive governed content, missing Components or sources, missing schedules, and unresolved Exceptions.", "Choose the approved Policies that should take effect, set the real effective date, and confirm the Step 3 cutover."], types: ["control", "complementary-control", "retention-schedule-item", "obligation"], defaultOpen: true }
132
+ { id: "controls", title: "Controls", description: "Implement the selected Controls and connect their scope, procedures, mappings, and operation patterns.", steps: ["Open every planned Control and confirm its mappings and operation pattern.", "Write the real procedure in Record Markdown, add bounded System scope, and map the operating and authoritative evidence-source Components."], types: ["control"], defaultOpen: true },
133
+ { id: "complementary-controls", title: "Complementary Controls", description: "Record customer or provider responsibilities that form part of the implementation boundary, or confirm there are none.", steps: ["Review whether an in-scope Control depends on a customer or carved-out provider action."], types: ["complementary-control"], defaultOpen: true },
134
+ { id: "evidence-sources", title: "Evidence Sources", description: "Check that every Control points to an authoritative source that can produce its expected evidence.", steps: ["Confirm each source Component is active, has the right evidence-source role, names current access owners, and includes repeatable retrieval instructions."], types: [], utility: "evidence-sources", defaultOpen: true },
135
+ { id: "retention", title: "Retention Schedule", description: "Review each retention rule against current information uses and approved sources.", steps: ["Confirm scope, cutoff, period, disposition, sources, owner, and approval for each rule."], types: ["retention-schedule-item"], defaultOpen: true },
136
+ { id: "obligations", title: "Obligations", description: "Configure the calendar and event schedules that operate the Controls.", steps: ["Review and enable every required calendar and event schedule."], types: ["obligation"], defaultOpen: true }
133
137
  ],
134
138
  resourceTypes: ["control", "complementary-control", "retention-schedule-item", "obligation"],
135
139
  commands: [
@@ -142,6 +146,7 @@ export const PROGRAM_PATH = [
142
146
  "filegrc list obligation --json",
143
147
  "filegrc review-collection component --scaffold",
144
148
  "filegrc review-collection complementary-control --scaffold",
149
+ "filegrc review-collection control --scaffold",
145
150
  "filegrc activate-content --scaffold",
146
151
  "filegrc activate-policies --scaffold",
147
152
  "filegrc evidence-map --json",
@@ -64,7 +64,6 @@ export async function assessProgramReadiness(input, options = {}) {
64
64
  .filter(({ resourceType }) => resourceType === "retention-schedule-item")
65
65
  .map(collectionReviewReadinessItem));
66
66
  const governedContent = await governedContentItems(scope, records, byId, readMarkdown, asOf, loaded.model);
67
- controlStage.items.push(...governedContent.items);
68
67
  const policyActivations = await assessPolicyActivations(
69
68
  requiredPolicies(scope, byId),
70
69
  scope.controls,
@@ -74,8 +73,22 @@ export async function assessProgramReadiness(input, options = {}) {
74
73
  asOf,
75
74
  loaded.model
76
75
  );
77
- controlStage.items.push(...policyActivations.map(policyActivationItem));
78
- controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, enabled Obligations, and complete authoritative source ${modelSupports(loaded.model, "component-sources") ? "Components" : "Systems"}. Activate unchanged approved program Documents and Training after their requirements are implemented, then activate approved Policies at the implementation cutover.`;
76
+ const controlOversight = collectionReviews.find(({ resourceType }) => resourceType === "control");
77
+ const oversightEligible = Boolean(controlOversight && controlOversightEligible(controlStage.items));
78
+ if (oversightEligible) {
79
+ controlStage.items.push(collectionReviewReadinessItem(controlOversight));
80
+ }
81
+ const oversightCurrent = oversightEligible && controlOversight.complete === true;
82
+ controlStage.items.push(...governedContent.items.map((item) => oversightCurrent || item.status === "complete"
83
+ ? item
84
+ : { ...item, status: "blocked", message: `Complete the Control collection review before activation. ${item.message}` }));
85
+ controlStage.items.push(...policyActivations.map((assessment) => {
86
+ const item = policyActivationItem(assessment);
87
+ return oversightCurrent || item.status === "complete"
88
+ ? item
89
+ : { ...item, status: "blocked", message: `Complete the Control collection review before activation. ${item.message}` };
90
+ }));
91
+ controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, enabled Obligations, and complete authoritative source ${modelSupports(loaded.model, "component-sources") ? "Components" : "Systems"}. When implementation is ready, perform one review of the implemented Control collection. Management then activates the unchanged approved program content as the Step 3 cutover.`;
79
92
  const scopeReadinessStage = scopeStage(
80
93
  program,
81
94
  scope,
@@ -166,6 +179,10 @@ export async function assessProgramReadiness(input, options = {}) {
166
179
  };
167
180
  }
168
181
 
182
+ export function controlOversightEligible(items) {
183
+ return items.length > 0 && items.every(({ status }) => status === "complete");
184
+ }
185
+
169
186
  export async function assessEvidenceMap(input, options = {}) {
170
187
  const readiness = await assessProgramReadiness(input, options);
171
188
  const evidenceItems = readiness.stages
@@ -1300,12 +1317,7 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
1300
1317
  implementationDate: Boolean(control.effectiveOn && control.effectiveOn <= asOf),
1301
1318
  ...(model.resources.control?.fields?.procedureRevision ? {
1302
1319
  procedureRevision: Boolean(control.procedureRevision),
1303
- procedureEffective: Boolean(control.procedureEffectiveOn && control.procedureEffectiveOn <= asOf),
1304
- implementationReview: Boolean(
1305
- control.implementationReviewedOn
1306
- && control.implementationReviewedOn <= asOf
1307
- && partiesIndependent(control.ownerIds, control.implementationReviewedByIds, byId)
1308
- )
1320
+ procedureEffective: Boolean(control.procedureEffectiveOn && control.procedureEffectiveOn <= asOf)
1309
1321
  } : {}),
1310
1322
  policyMapping: (control.policyIds || []).length > 0,
1311
1323
  criteriaMapping: (control.requirementIds || []).length > 0,
@@ -1699,7 +1711,6 @@ function controlCheckLabel(name) {
1699
1711
  implementationDate: "implementation date",
1700
1712
  procedureRevision: "effective procedure revision",
1701
1713
  procedureEffective: "procedure effective date",
1702
- implementationReview: "independent implementation review",
1703
1714
  policyMapping: "policy mapping",
1704
1715
  criteriaMapping: "criteria mapping",
1705
1716
  workQueue: "running Obligation schedules"
package/src/validate.js CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  } from "./recurrence.js";
21
21
  import { resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
22
22
  import { obligationIsEnabled } from "./program-lifecycle.js";
23
- import { partyPeople } from "./parties.js";
23
+ import { currentPartyPeople, partyPeople } from "./parties.js";
24
24
  import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
25
25
  import { currentCalendarDate, isRfc3339Timestamp, localDateTimeValue, timestampFromLocalDateTime } from "./time.js";
26
26
  import { recordTiming } from "./timing.js";
@@ -1273,6 +1273,29 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
1273
1273
  currentRevision
1274
1274
  }
1275
1275
  );
1276
+ if (current && record.resourceType === "control") {
1277
+ const controls = scopedCollectionRecords(loaded, "control", program);
1278
+ const controlIds = new Set(controls.map(({ id }) => id));
1279
+ const conflictIds = new Set();
1280
+ for (const control of controls) {
1281
+ for (const id of currentPartyPeople(control.ownerIds || [], byId)) conflictIds.add(id);
1282
+ }
1283
+ for (const obligation of loaded.resources.filter((candidate) => (
1284
+ candidate.type === "obligation"
1285
+ && candidate.status === "active"
1286
+ && (candidate.controlIds || []).some((id) => controlIds.has(id))
1287
+ ))) {
1288
+ for (const id of currentPartyPeople(obligation.ownerIds || [], byId)) conflictIds.add(id);
1289
+ }
1290
+ const conflictedReviewers = (record.reviewedByIds || []).filter((id) => conflictIds.has(id));
1291
+ if (conflictedReviewers.length) {
1292
+ diagnostics.push(error(
1293
+ "conflicted-control-collection-reviewer",
1294
+ path,
1295
+ `A Control collection reviewer cannot own an included Control or its enabled Obligation: ${conflictedReviewers.join(", ")}.`
1296
+ ));
1297
+ }
1298
+ }
1276
1299
  if (modelSupports(model, "temporal-collection-reviews")) {
1277
1300
  const temporalValues = [record.coverage, record.knowledgeCutoffAt, record.populationResourceIds];
1278
1301
  const hasTemporalBinding = temporalValues.every((value) => value !== undefined && value !== null);