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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.13.5",
3
+ "version": "0.15.0",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
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 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,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
- || storedRevision === legacyCollectionRevision(loaded, resourceType, options);
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) {
@@ -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 && (!selected || selected.has(record.id))
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
- return {
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 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