filegrc 0.14.0 → 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/model/index.js CHANGED
@@ -17,7 +17,8 @@ export const MODEL_CAPABILITY_VERSIONS = Object.freeze({
17
17
  "temporal-collection-reviews": 9,
18
18
  "reporting-routes": 9,
19
19
  "reporting-route-sets": 10,
20
- "control-collection-oversight": 11
20
+ "control-collection-oversight": 11,
21
+ "retention-schedule-approval": 11
21
22
  });
22
23
 
23
24
  export function modelSupports(modelOrVersion, capability) {
package/model/v11.json CHANGED
@@ -643,12 +643,12 @@
643
643
  ]
644
644
  },
645
645
  "retention-schedule-item": {
646
- "title": "Retention schedule",
647
- "description": "Confirm that every active information use has an approved or explicitly planned retention schedule item and that no schedule item invents an organization-specific period or disposition.",
646
+ "title": "Data Retention Schedule",
647
+ "description": "Approve the schedule document and its structured rows as one governed schedule. Include only actual information uses and keep undecided organization values planned.",
648
648
  "reviewPoints": [
649
- "Confirm schedule coverage across Systems, Components, Vendors, source families, logs, backups, audit records, and the FileGRC repository.",
650
- "Confirm every active item has an approved cutoff, retention period, disposition action, owner, approver, and exact reviewed source revisions.",
651
- "Keep undecided periods and disposition behavior planned for management review."
649
+ "Review the Data Retention Schedule document, its Markdown, and every included structured row as one revision.",
650
+ "Confirm schedule coverage across the Systems, Components, Vendors, source families, logs, backups, audit records, and FileGRC records the organization actually uses.",
651
+ "Confirm every active item has an approved cutoff, retention period, disposition action, owner, approver, and exact reviewed source revisions; keep undecided behavior planned."
652
652
  ],
653
653
  "decisions": [
654
654
  "complete",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.14.0",
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,
@@ -12,6 +12,7 @@ import { loadWorkspace } from "./workspace.js";
12
12
  import { resolveProgram } from "./program.js";
13
13
  import { currentCalendarDate } from "./time.js";
14
14
  import { currentPartyPeople } from "./parties.js";
15
+ import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js";
15
16
 
16
17
  export { collectionRevision };
17
18
 
@@ -66,7 +67,14 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
66
67
  );
67
68
  const reviewerEligibility = resourceType === "control"
68
69
  ? assessControlReviewers(loaded, records)
69
- : null;
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
+ : [];
70
78
  const reviewersEligible = !reviewerEligibility || Boolean(
71
79
  review?.reviewedByIds?.length
72
80
  && review.reviewedByIds.every((id) => reviewerEligibility.eligibleReviewerIds.includes(id))
@@ -77,6 +85,7 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
77
85
  && revisionMatches
78
86
  && temporalReview
79
87
  && reviewersEligible
88
+ && !approvalIssues.length
80
89
  );
81
90
  const stale = Boolean(
82
91
  review?.status === "active"
@@ -96,10 +105,15 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
96
105
  status: complete ? "current" : stale ? "stale" : "review-required",
97
106
  complete,
98
107
  ...(reviewerEligibility || {}),
108
+ ...(approvalIssues.length ? { approvalIssues } : {}),
99
109
  message: complete
100
110
  ? `${configuration.title} were reviewed on ${review.reviewedOn}.`
111
+ : approvalIssues.length
112
+ ? approvalIssues[0].message
101
113
  : 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."
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."
103
117
  : stale
104
118
  ? `${configuration.title} changed after the last confirmation. Review the current records again.`
105
119
  : !records.length && !allowsEmptyCollection
@@ -143,6 +157,9 @@ export async function scaffoldCollectionReview(input = process.cwd(), options =
143
157
  reviewedOn: priorReview?.reviewedOn
144
158
  ? currentCalendarDate(loaded.workspace.timezone, now)
145
159
  : null,
160
+ ...(resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
161
+ ? { expectedCollectionRevision: assessment.collectionRevision }
162
+ : {}),
146
163
  ...(v4
147
164
  ? { authoritativeComponentId: preservedAuthoritativeSourceId }
148
165
  : { authoritativeSystemId: preservedAuthoritativeSourceId })
@@ -154,7 +171,14 @@ export async function planCollectionReview(input = process.cwd(), options = {})
154
171
  const program = resolveProgram(loaded, options.programId);
155
172
  const resourceType = requiredType(loaded, options.resourceType);
156
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
+ }
157
180
  await requireControlReviewReady(loaded, resourceType, program.id);
181
+ await requireRetentionScheduleReady(loaded, resourceType, program);
158
182
  const configuration = assessment.configuration;
159
183
  const decision = String(options.decision || "").trim();
160
184
  const rationale = String(options.rationale || "").trim();
@@ -199,6 +223,13 @@ export async function planCollectionReview(input = process.cwd(), options = {})
199
223
  throw new Error(`Control collection review needs a reviewer who does not own an included Control or its enabled Obligation: ${conflicts.join(", ")}.`);
200
224
  }
201
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
+ }
202
233
  if (temporalReviews) {
203
234
  if (reviewedOn !== today) {
204
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.");
@@ -308,6 +339,15 @@ async function requireControlReviewReady(loaded, resourceType, programId) {
308
339
  }
309
340
  }
310
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
+
311
351
  function assessControlReviewers(loaded, controls) {
312
352
  const byId = new Map(loaded.resources.map((record) => [record.id, record]));
313
353
  const controlIds = new Set(controls.map(({ id }) => id));
@@ -329,6 +369,25 @@ function assessControlReviewers(loaded, controls) {
329
369
  };
330
370
  }
331
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
+
332
391
  function sameIds(left, right) {
333
392
  return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
334
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,12 +1,17 @@
1
1
  import { modelSupports } from "../model/index.js";
2
2
  import { programComponents, selectedRequirementIds } from "./program.js";
3
- import { retentionReviewResourceIds } from "./retention.js";
3
+ import { retentionReviewResourceIds, retentionUses } from "./retention.js";
4
4
  import { documentIsAuditSpecific } from "./program-lifecycle.js";
5
5
 
6
6
  export function scopedCollectionRecords(loaded, resourceType, program) {
7
7
  if (!modelSupports(loaded.model, "program-scope")) {
8
8
  return loaded.resources.filter((record) => record.type === resourceType);
9
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
+ }
10
15
  if (resourceType === "person") {
11
16
  return scopedProgramPeople(loaded, program);
12
17
  }
@@ -225,6 +230,29 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
225
230
  .filter((record) => record.type === "vendor" && record.status !== "retired")
226
231
  .map(({ id }) => id));
227
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
+ }
228
256
  if (resourceType === "complementary-control") {
229
257
  addIds(program?.systemIds);
230
258
  addIds(program?.controlIds);
@@ -351,6 +379,21 @@ const dependencyFields = {
351
379
  component: ["id", "type", "status", "systemUses", "informationUses"],
352
380
  vendor: ["id", "type", "status", "informationTypeIds"]
353
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
+ },
354
397
  "complementary-control": {
355
398
  system: ["id", "type", "status", "purpose", "servicesProvided", "boundary", "exclusions", "criticality", "informationTypeIds", "classificationId", "internetExposed", "continuityObjectives"],
356
399
  control: ["id", "type", "status", "statement", "activity", "systemIds", "componentIds", "evidenceSourceComponentIds"],
@@ -382,6 +425,7 @@ const dependencyContentTypes = {
382
425
  vendor: new Set(["document"]),
383
426
  component: new Set(["system"]),
384
427
  control: new Set(["system", "component", "obligation", "source-coverage", "evidence", "retention-schedule-item", "document", "policy", "training"]),
428
+ "retention-schedule-item": new Set(["document", "policy"]),
385
429
  "complementary-control": new Set(["system", "control", "document", "component"])
386
430
  };
387
431
 
@@ -1,3 +1,5 @@
1
+ import { ACTIVE_MODEL_VERSION, modelSupports } from "../model/index.js";
2
+
1
3
  export const RESOURCE_INSTRUCTIONS = {
2
4
  program: "Define one management compliance or assurance Program with its goal, bounded Systems, selected Frameworks, Requirement applicability decisions, Controls, owners, risk method, and candidate period.",
3
5
  person: "Record each person’s actual organizational job title. Keep named program authority, such as CISO, DPO, Policy Owner, or team chair, in dated Appointment records.",
@@ -105,13 +107,14 @@ export const PROGRAM_PATH = [
105
107
  id: "policies",
106
108
  number: 2,
107
109
  title: "Approve Policies",
108
- description: "Review governed content and approvals",
109
- summary: "Review and independently approve Policies, program Documents, and Training content.",
110
+ description: "Approve governed content and retention decisions",
111
+ summary: "Review and independently approve Policies, program Documents, Training content, and the Data Retention Schedule.",
110
112
  sections: [
111
- { id: "policy-content", title: "Policies", description: "Review every program Policy, Document, and Training record in one table, then bind independent approval to each exact revision.", steps: ["Review each governed Markdown artifact and replace every organization placeholder.", "Confirm the owner, separate approver, linked Controls, audience, and intended values that apply to each artifact.", "Record approval and its date against the exact revision. Leave approved content inactive until its Step 3 implementation cutover."], types: [], relatedLinks: [{ type: "policy", label: "Policies", href: "#/stage/policies" }], defaultOpen: true }
113
+ { id: "policy-content", title: "Policies", description: "Review every program Policy, Document, and Training record in one table, then bind independent approval to each exact revision.", steps: ["Review each governed Markdown artifact and replace every organization placeholder.", "Confirm the owner, separate approver, linked Controls, audience, and intended values that apply to each artifact.", "Record approval and its date against the exact revision. Leave approved content inactive until its Step 3 implementation cutover."], types: [], relatedLinks: [{ type: "policy", label: "Policies", href: "#/policies" }], defaultOpen: true },
114
+ { id: "retention", title: "Data Retention Schedule", description: "Approve the schedule document and its structured rows as one governed schedule.", steps: ["Review the schedule policy, legal holds, exceptions, and disposal requirements.", "Define rows only for Information Types and operational sources the organization actually uses.", "Confirm the complete document-and-row revision with one schedule review."], types: [], utility: "retention-schedule", defaultOpen: true }
112
115
  ],
113
116
  resourceTypes: [],
114
- supportingResourceTypes: ["policy", "document", "training"],
117
+ supportingResourceTypes: ["policy", "document", "training", "retention-schedule-item"],
115
118
  commands: [
116
119
  "filegrc guide policy --json",
117
120
  "filegrc guide document --json",
@@ -119,30 +122,29 @@ export const PROGRAM_PATH = [
119
122
  "filegrc list policy --json",
120
123
  "filegrc list document --json",
121
124
  "filegrc list training --json",
122
- "filegrc get POLICY_ID --mutation"
125
+ "filegrc get POLICY_ID --mutation",
126
+ "filegrc guide retention-schedule-item --json",
127
+ "filegrc review-collection retention-schedule-item --scaffold"
123
128
  ]
124
129
  },
125
130
  {
126
131
  id: "controls",
127
132
  number: 3,
128
133
  title: "Implement Controls",
129
- description: "Finish controls and their evidence sources",
130
- summary: "Describe each Control and connect its evidence source.",
134
+ description: "Implement and prepare to operate",
135
+ summary: "Implement Controls, configure their operating schedules and evidence sources, then activate the approved program.",
131
136
  sections: [
132
137
  { 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
138
  { 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
139
  { 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
140
  { 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 }
137
141
  ],
138
- resourceTypes: ["control", "complementary-control", "retention-schedule-item", "obligation"],
142
+ resourceTypes: ["control", "complementary-control", "obligation"],
139
143
  commands: [
140
144
  "filegrc guide control --json",
141
145
  "filegrc list control --json",
142
146
  "filegrc get CONTROL_ID --mutation",
143
147
  "filegrc guide obligation --json",
144
- "filegrc guide retention-schedule-item --json",
145
- "filegrc review-collection retention-schedule-item --scaffold",
146
148
  "filegrc list obligation --json",
147
149
  "filegrc review-collection component --scaffold",
148
150
  "filegrc review-collection complementary-control --scaffold",
@@ -264,8 +266,51 @@ export const PROGRAM_PATH = [
264
266
  }
265
267
  ];
266
268
 
267
- export function buildAgentProgramPath(model) {
269
+ export function programPathForModel(model = ACTIVE_MODEL_VERSION) {
270
+ if (modelSupports(model, "retention-schedule-approval")) return PROGRAM_PATH;
268
271
  return PROGRAM_PATH.map((stage) => {
272
+ if (stage.id === "policies") {
273
+ return {
274
+ ...stage,
275
+ description: "Review governed content and approvals",
276
+ summary: "Review and independently approve Policies, program Documents, and Training content.",
277
+ sections: [{
278
+ ...stage.sections[0],
279
+ relatedLinks: [{ type: "policy", label: "Policies", href: "#/stage/policies" }]
280
+ }],
281
+ supportingResourceTypes: stage.supportingResourceTypes.filter((type) => type !== "retention-schedule-item"),
282
+ commands: stage.commands.filter((command) => !command.includes("retention-schedule-item"))
283
+ };
284
+ }
285
+ if (stage.id === "controls") {
286
+ const retention = {
287
+ id: "retention",
288
+ title: "Retention Schedule",
289
+ description: "Review each retention rule against current information uses and approved sources.",
290
+ steps: ["Confirm scope, cutoff, period, disposition, sources, owner, and approval for each rule."],
291
+ types: ["retention-schedule-item"],
292
+ defaultOpen: true
293
+ };
294
+ return {
295
+ ...stage,
296
+ description: "Finish controls and their evidence sources",
297
+ summary: "Describe each Control and connect its evidence source.",
298
+ sections: [...stage.sections.slice(0, 3), retention, ...stage.sections.slice(3)],
299
+ resourceTypes: [...stage.resourceTypes.slice(0, 2), "retention-schedule-item", ...stage.resourceTypes.slice(2)],
300
+ commands: [
301
+ ...stage.commands.slice(0, 5),
302
+ "filegrc guide retention-schedule-item --json",
303
+ "filegrc review-collection retention-schedule-item --scaffold",
304
+ ...stage.commands.slice(5)
305
+ ]
306
+ };
307
+ }
308
+ return stage;
309
+ });
310
+ }
311
+
312
+ export function buildAgentProgramPath(model) {
313
+ return programPathForModel(model).map((stage) => {
269
314
  const programResourceTypes = [...stage.resourceTypes, ...(stage.supportingResourceTypes || [])]
270
315
  .filter((type) => model.resources[type]);
271
316
  const resourcePages = programResourceTypes.map((type, index) => {
@@ -305,8 +350,8 @@ function agentCommand(command) {
305
350
  return command.startsWith("filegrc ") ? `npx ${command}` : command;
306
351
  }
307
352
 
308
- export function resourceProgramContext(type) {
309
- const stage = PROGRAM_PATH.find((candidate) => (
353
+ export function resourceProgramContext(type, model) {
354
+ const stage = programPathForModel(model).find((candidate) => (
310
355
  candidate.resourceTypes.includes(type) || (candidate.supportingResourceTypes || []).includes(type)
311
356
  ));
312
357
  if (!stage) return null;
@@ -57,12 +57,19 @@ export async function assessProgramReadiness(input, options = {}) {
57
57
  .map(collectionReviewReadinessItem));
58
58
  const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
59
59
  controlStage.items.push(...sourceStage.items);
60
- controlStage.items.push(...await assessRetentionReadiness(loaded, program, {
60
+ const retentionItems = await assessRetentionReadiness(loaded, program, {
61
61
  informationTypesReviewed: collectionReviews.find(({ resourceType }) => resourceType === "information-type")?.complete === true
62
- }));
63
- controlStage.items.push(...collectionReviews
62
+ });
63
+ const retentionReviewItems = collectionReviews
64
64
  .filter(({ resourceType }) => resourceType === "retention-schedule-item")
65
- .map(collectionReviewReadinessItem));
65
+ .map(collectionReviewReadinessItem);
66
+ if (modelSupports(loaded.model, "retention-schedule-approval")) {
67
+ policyStage.items.push(...retentionItems.filter(({ id }) => !id.startsWith("retention-source-coverage-")));
68
+ controlStage.items.push(...retentionItems.filter(({ id }) => id.startsWith("retention-source-coverage-")));
69
+ policyStage.items.push(...retentionReviewItems);
70
+ } else {
71
+ controlStage.items.push(...retentionItems, ...retentionReviewItems);
72
+ }
66
73
  const governedContent = await governedContentItems(scope, records, byId, readMarkdown, asOf, loaded.model);
67
74
  const policyActivations = await assessPolicyActivations(
68
75
  requiredPolicies(scope, byId),
@@ -900,6 +907,11 @@ function requiredGovernedDocuments(scope, records, byId, model) {
900
907
  && (
901
908
  linkedDocumentIds.has(record.id)
902
909
  || obligationDocumentIds.has(record.id)
910
+ || (
911
+ modelSupports(model, "retention-schedule-approval")
912
+ && record.documentKind === "schedule"
913
+ && record.workflowScope === "program"
914
+ )
903
915
  || (
904
916
  record.programRole === "required"
905
917
  && (record.controlIds || []).some((id) => selectedControlIds.has(id))
@@ -0,0 +1,121 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
4
+ import { markdownEntries } from "./resource-markdown.js";
5
+ import { resolveDataPath } from "./paths.js";
6
+ import { currentPartyPeople, partiesIndependent } from "./parties.js";
7
+ import {
8
+ nearDuplicateInformationTypes,
9
+ resourceReviewRevisionsSync,
10
+ retentionReviewResourceIds,
11
+ retentionRuleIsCurrent,
12
+ retentionUses
13
+ } from "./retention.js";
14
+
15
+ export function retentionScheduleApprovalIssues(loaded, program, rows, options = {}) {
16
+ const byId = new Map(loaded.resources.map((record) => [record.id, record]));
17
+ const issues = [];
18
+ const planned = rows.filter(({ status }) => status === "planned");
19
+ if (planned.length) {
20
+ issues.push(issue("planned-retention-schedule-row", `Complete or retire every planned retention row before approving the schedule revision: ${planned[0].title}.`));
21
+ }
22
+ const schedules = loaded.resources.filter((record) => (
23
+ record.type === "document"
24
+ && record.documentKind === "schedule"
25
+ && record.workflowScope === "program"
26
+ && !["superseded", "retired"].includes(record.status)
27
+ ));
28
+ if (!schedules.length) {
29
+ issues.push(issue("missing-retention-schedule-document", "Add and approve the Data Retention Schedule document before approving the schedule revision."));
30
+ }
31
+ if (schedules.length !== 1) {
32
+ if (schedules.length) {
33
+ issues.push(issue("multiple-retention-schedule-documents", "Keep exactly one current program Data Retention Schedule document before approving the schedule revision."));
34
+ }
35
+ }
36
+ const schedule = schedules.length === 1 ? schedules[0] : null;
37
+ const scheduleContent = schedule ? primaryMarkdown(loaded, schedule) : "";
38
+ const linkedControl = schedule && (schedule.controlIds || []).some((id) => (program.controlIds || []).includes(id));
39
+ if (
40
+ schedule
41
+ && (!["approved", "active"].includes(schedule.status)
42
+ || !schedule.approvedOn
43
+ || !schedule.approvedContentRevisions
44
+ || currentPartyPeople(schedule.ownerIds || [], byId).size === 0
45
+ || !partiesIndependent(schedule.ownerIds, schedule.approverIds, byId)
46
+ || !linkedControl
47
+ || !substantiveMarkdown(scheduleContent)
48
+ || openPlaceholderCount(scheduleContent) > 0
49
+ || !approvalBindingsMatch(loaded, schedule))
50
+ ) {
51
+ issues.push(issue("unapproved-retention-schedule-document", "Complete and independently approve the Data Retention Schedule document before approving the complete schedule."));
52
+ }
53
+ const activeRows = rows.filter(({ status }) => status === "active");
54
+ const mismatched = schedule
55
+ ? activeRows.filter(({ scheduleDocumentId }) => scheduleDocumentId !== schedule.id)
56
+ : [];
57
+ if (mismatched.length) {
58
+ issues.push(issue("retention-row-wrong-schedule-document", `Link every active retention row to the current Data Retention Schedule document before approval: ${mismatched[0].title}.`));
59
+ }
60
+ const revisions = resourceReviewRevisionsSync(
61
+ loaded,
62
+ activeRows.flatMap((row) => retentionReviewResourceIds(row, loaded))
63
+ );
64
+ const incomplete = activeRows.filter((row) => !retentionRuleIsCurrent(row, revisions, byId, loaded));
65
+ if (incomplete.length) {
66
+ issues.push(issue("incomplete-retention-schedule-row", `Complete the retention decisions before approving the schedule revision: ${incomplete[0].title}.`));
67
+ }
68
+ const usableRows = activeRows.filter((row) => !incomplete.includes(row));
69
+ const uncovered = retentionUses(loaded, program).find((use) => !usableRows.some((row) => (
70
+ (row.informationTypeIds || []).includes(use.informationTypeId)
71
+ && ((row.scopeResourceIds || []).includes(use.resource.id) || (row.scopeResourceIds || []).includes(program.id))
72
+ )));
73
+ if (uncovered) {
74
+ issues.push(issue(
75
+ "uncovered-retention-information-use",
76
+ `${uncovered.resource.title} uses ${byId.get(uncovered.informationTypeId)?.title || uncovered.informationTypeId}, but no active, current retention schedule row covers both the type and scope.`
77
+ ));
78
+ }
79
+ if (nearDuplicateInformationTypes(loaded.resources).length && !options.informationTypesReviewed) {
80
+ issues.push(issue("unreviewed-similar-information-types", "Review similar Information Types before approving the Data Retention Schedule."));
81
+ }
82
+ return issues;
83
+ }
84
+
85
+ function issue(code, message) {
86
+ return { code, message };
87
+ }
88
+
89
+ function primaryMarkdown(loaded, record) {
90
+ const definition = loaded.model.resources[record.type];
91
+ const selected = markdownEntries(loaded.model, record).find((entry) => (
92
+ definition.markdown?.[entry.name]?.primary || entry.name === loaded.model.recordContent?.slot
93
+ ));
94
+ if (!selected) return "";
95
+ try {
96
+ return readFileSync(resolveDataPath(loaded.root, selected.path), "utf8");
97
+ } catch {
98
+ return "";
99
+ }
100
+ }
101
+
102
+ function approvalBindingsMatch(loaded, record) {
103
+ const actual = {};
104
+ for (const entry of markdownEntries(loaded.model, record)) {
105
+ try {
106
+ actual[entry.path] = createHash("sha256")
107
+ .update(readFileSync(resolveDataPath(loaded.root, entry.path), "utf8"))
108
+ .digest("hex");
109
+ } catch (error) {
110
+ if (error.code !== "ENOENT") throw error;
111
+ }
112
+ }
113
+ const expected = record.approvedContentRevisions || {};
114
+ const actualKeys = Object.keys(actual).sort();
115
+ const expectedKeys = Object.keys(expected).sort();
116
+ return actualKeys.length === expectedKeys.length
117
+ && actualKeys.every((key, index) => (
118
+ key === expectedKeys[index]
119
+ && actual[key] === expected[key]
120
+ ));
121
+ }
package/src/retention.js CHANGED
@@ -1,8 +1,10 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { readFile } from "node:fs/promises";
2
3
  import { contentRevision } from "./files.js";
3
4
  import { resolveDataPath } from "./paths.js";
4
5
  import { programComponents } from "./program.js";
5
6
  import { markdownEntries } from "./resource-markdown.js";
7
+ import { modelSupports } from "../model/index.js";
6
8
 
7
9
  export async function assessRetentionReadiness(loaded, program, options = {}) {
8
10
  if (!loaded.model.resources["retention-schedule-item"]) return [];
@@ -113,7 +115,7 @@ export async function resourceReviewRevisions(loaded, ids) {
113
115
  const entry = entries.get(id);
114
116
  if (!entry || reviewing.has(id)) return null;
115
117
  reviewing.add(id);
116
- const parts = [entry.source];
118
+ const parts = [reviewSource(loaded, entry)];
117
119
  for (const markdown of markdownEntries(loaded.model, entry.record)) {
118
120
  try {
119
121
  parts.push(await readFile(resolveDataPath(loaded.root, markdown.path), "utf8"));
@@ -136,6 +138,37 @@ export async function resourceReviewRevisions(loaded, ids) {
136
138
  return new Map([...revisions].filter(([id]) => wanted.has(id)));
137
139
  }
138
140
 
141
+ export function resourceReviewRevisionsSync(loaded, ids) {
142
+ const wanted = new Set(ids);
143
+ const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
144
+ const revisions = new Map();
145
+ const reviewing = new Set();
146
+ const review = (id) => {
147
+ if (revisions.has(id)) return revisions.get(id);
148
+ const entry = entries.get(id);
149
+ if (!entry || reviewing.has(id)) return null;
150
+ reviewing.add(id);
151
+ const parts = [reviewSource(loaded, entry)];
152
+ for (const markdown of markdownEntries(loaded.model, entry.record)) {
153
+ try {
154
+ parts.push(readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8"));
155
+ } catch (error) {
156
+ if (error.code !== "ENOENT") throw error;
157
+ }
158
+ }
159
+ for (const sourceId of [...new Set(entry.record.sourceResourceIds || [])].sort()) {
160
+ const revision = review(sourceId);
161
+ if (revision) parts.push(`${sourceId}:${revision}`);
162
+ }
163
+ reviewing.delete(id);
164
+ const revision = contentRevision(parts.join("\n"));
165
+ revisions.set(id, revision);
166
+ return revision;
167
+ };
168
+ for (const id of wanted) review(id);
169
+ return new Map([...revisions].filter(([id]) => wanted.has(id)));
170
+ }
171
+
139
172
  export function retentionUses(loaded, program) {
140
173
  const systemIds = new Set(program.systemIds || []);
141
174
  const componentIds = new Set(programComponents(loaded, program).map(({ id }) => id));
@@ -157,6 +190,28 @@ export function retentionUses(loaded, program) {
157
190
  return [...new Map(uses.map((use) => [`${use.resource.id}:${use.informationTypeId}`, use])).values()];
158
191
  }
159
192
 
193
+ function reviewSource(loaded, entry) {
194
+ if (
195
+ modelSupports(loaded.model, "retention-schedule-approval")
196
+ && entry.record.type === "document"
197
+ && entry.record.documentKind === "schedule"
198
+ && entry.record.workflowScope === "program"
199
+ ) {
200
+ const approved = structuredClone(entry.record);
201
+ if (approved.status === "active") approved.status = "approved";
202
+ for (const field of [
203
+ "activationBasis",
204
+ "activatedByIds",
205
+ "activatedOn",
206
+ "activatedContentRevisions",
207
+ "effectiveOn",
208
+ "proposedEffectiveOn"
209
+ ]) delete approved[field];
210
+ return JSON.stringify(approved);
211
+ }
212
+ return entry.source;
213
+ }
214
+
160
215
  export function nearDuplicateInformationTypes(records) {
161
216
  const types = records.filter((record) => record.type === "information-type" && !["retired", "superseded"].includes(record.status));
162
217
  const pairs = [];