filegrc 0.14.0 → 0.15.1

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.1",
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,
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { modelSupports } from "../model/index.js";
4
4
  import { applicabilityReviewIsCurrent } from "./applicability-scope.js";
5
+ import { selectDefaultAudit } from "./audit-selection.js";
5
6
  import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
6
7
  import {
7
8
  coverageContains,
@@ -63,20 +64,21 @@ export async function assessAuditPreparation(input, options = {}) {
63
64
  const records = loaded.resources;
64
65
  const byId = new Map(records.map((record) => [record.id, record]));
65
66
  const audits = records.filter((record) => record.type === "audit");
67
+ const calendarAsOf = options.asOf || currentCalendarDate(loaded.workspace?.timezone || "UTC");
66
68
  const audit = options.auditId
67
69
  ? audits.find((record) => record.id === options.auditId)
68
70
  : options.selectDefault === false
69
71
  ? null
70
- : audits.find((record) => !["complete", "closed", "canceled"].includes(record.status)) || audits[0];
72
+ : selectDefaultAudit(audits, calendarAsOf);
71
73
  if (options.auditId && !audit) throw new Error(`Audit "${options.auditId}" was not found.`);
72
74
 
73
- const calendarAsOf = options.asOf || currentCalendarDate(loaded.workspace?.timezone || "UTC");
74
75
  const formalPeriodEnd = audit?.auditKind === "soc-2-type-2" ? coverageEnd(audit.coverage) : null;
75
76
  const readinessAsOf = formalPeriodEnd && formalPeriodEnd < calendarAsOf ? formalPeriodEnd : calendarAsOf;
76
77
  const programReadiness = options.programReadiness || await assessProgramReadiness(loaded, {
77
78
  asOf: readinessAsOf,
78
79
  generatedAt: options.generatedAt,
79
- programId: audit?.programId || options.programId
80
+ programId: audit?.programId || options.programId,
81
+ auditId: audit?.id
80
82
  });
81
83
  const documentActivations = audit && modelSupports(loaded.model, "governed-document-activation")
82
84
  ? await auditDocumentActivationAssessments(loaded, audit, byId, calendarAsOf)
@@ -746,6 +748,12 @@ function canonicalScopeValue(value) {
746
748
  function programFoundationStage(programReadiness, workspace, audit) {
747
749
  const needsOperatingPeriod = audit?.auditKind === "soc-2-type-2";
748
750
  const ready = needsOperatingPeriod ? programReadiness.operating : programReadiness.evidenceReady;
751
+ const setupRemaining = programReadiness.setupProgress?.remaining
752
+ ?? (programReadiness.progress?.mode === "setup-progress" ? programReadiness.progress.remaining : 0);
753
+ const activeWindow = programReadiness.progress?.mode === "operating-window";
754
+ const activeWindowLabel = programReadiness.progress?.source === "audit"
755
+ ? "audit window"
756
+ : "candidate operating period";
749
757
  return stage("program", "Program Readiness", "The management program can be prepared and operated without an audit record or CPA firm.", [
750
758
  item(
751
759
  "evidence-ready",
@@ -755,9 +763,13 @@ function programFoundationStage(programReadiness, workspace, audit) {
755
763
  ? needsOperatingPeriod
756
764
  ? `${programReadiness.target.label} is operating for the selected engagement.`
757
765
  : `${programReadiness.target.label} is evidence-ready.`
766
+ : needsOperatingPeriod && activeWindow
767
+ ? setupRemaining
768
+ ? `The ${activeWindowLabel} is active, but ${setupRemaining} required setup ${setupRemaining === 1 ? "action remains" : "actions remain"} across scope, policies, controls, and evidence preparation.`
769
+ : `The ${activeWindowLabel} is active, but current operating work needs attention before treating the engagement as management-ready.`
758
770
  : needsOperatingPeriod && programReadiness.evidenceReady
759
771
  ? "Start the candidate period and finish the current operating-readiness work before treating a Type 2 engagement as management-ready."
760
- : `${programReadiness.counts.action} program-readiness actions remain across scope, policies, controls, and evidence preparation.`,
772
+ : `${setupRemaining} required setup ${setupRemaining === 1 ? "action remains" : "actions remain"} across scope, policies, controls, and evidence preparation.`,
761
773
  workspace || { type: "workspace" }
762
774
  )
763
775
  ]);
@@ -0,0 +1,33 @@
1
+ export function selectDefaultAudit(audits = [], asOf = "") {
2
+ const terminal = new Set(["complete", "closed", "canceled", "delivered"]);
3
+ const coverageStart = (audit) => audit?.coverage?.startsOn
4
+ || audit?.coverage?.periodStart
5
+ || audit?.coverage?.on
6
+ || "";
7
+ const statusOrder = new Map([
8
+ ["planned", 0],
9
+ ["in-progress", 1],
10
+ ["fieldwork", 2],
11
+ ["report-draft", 3],
12
+ ["issued", 4],
13
+ ["delivered", 5],
14
+ ["complete", 6]
15
+ ]);
16
+ const open = audits.filter((audit) => !terminal.has(audit.status));
17
+ const candidates = [...(open.length ? open : audits)];
18
+ candidates.sort((left, right) => {
19
+ const leftStart = coverageStart(left);
20
+ const rightStart = coverageStart(right);
21
+ const leftStarted = Boolean(leftStart && (!asOf || leftStart <= asOf));
22
+ const rightStarted = Boolean(rightStart && (!asOf || rightStart <= asOf));
23
+ return Number(rightStarted) - Number(leftStarted)
24
+ || (leftStarted && rightStarted
25
+ ? rightStart.localeCompare(leftStart)
26
+ : leftStart && rightStart
27
+ ? leftStart.localeCompare(rightStart)
28
+ : Number(Boolean(rightStart)) - Number(Boolean(leftStart)))
29
+ || (statusOrder.get(right.status) ?? -1) - (statusOrder.get(left.status) ?? -1)
30
+ || String(left.id || "").localeCompare(String(right.id || ""));
31
+ });
32
+ return candidates[0] || null;
33
+ }
package/src/cli.js CHANGED
@@ -447,16 +447,17 @@ export async function runCli(argv = process.argv.slice(2)) {
447
447
  const loaded = await loadWorkspace(root);
448
448
  const result = await assessProgramReadiness(loaded, { asOf: flags["as-of"], programId: flags.program });
449
449
  const output = flags.summary ? summarizeProgramReadiness(result) : result;
450
+ const headlineStatus = result.progress.status || result.status;
450
451
  if (flags.json) console.log(JSON.stringify(output, null, 2));
451
452
  else if (flags.summary) {
452
- console.log(`${result.status.toUpperCase()}: ${result.progress.complete} of ${result.progress.total} program items complete`);
453
+ console.log(`${headlineStatus.toUpperCase()}: ${result.progress.label} ${result.progress.percent}%. ${result.progress.detail}`);
453
454
  for (const stage of output.stages) {
454
- console.log(`${stage.status.toUpperCase()}\t${stage.title}\t${stage.counts.action} actions`);
455
+ console.log(`${stage.status.toUpperCase()}\t${stage.title}\t${stage.counts.action} checks need action`);
455
456
  }
456
457
  if (output.firstAction) console.log(`Next: ${output.firstAction.title}\t${output.firstAction.message}`);
457
458
  }
458
459
  else {
459
- console.log(`${result.status.toUpperCase()}: ${result.progress.complete} of ${result.progress.total} program items complete`);
460
+ console.log(`${headlineStatus.toUpperCase()}: ${result.progress.label} ${result.progress.percent}%. ${result.progress.detail}`);
460
461
  console.log(
461
462
  `${result.target.label}`
462
463
  + (result.target.candidateCoverage?.kind === "range"
@@ -2036,6 +2037,8 @@ function summarizeProgramReadiness(result) {
2036
2037
  suggestedCandidatePeriodStart: result.suggestedCandidatePeriodStart,
2037
2038
  target: result.target,
2038
2039
  progress: result.progress,
2040
+ setupProgress: result.setupProgress,
2041
+ checkProgress: result.checkProgress,
2039
2042
  counts: result.counts,
2040
2043
  scopeCounts: Object.fromEntries(
2041
2044
  Object.entries(result.scope).map(([name, ids]) => [name.replace(/Ids$/, ""), ids.length])
@@ -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
 
package/src/index.js CHANGED
@@ -86,7 +86,7 @@ export {
86
86
  scaffoldExternalReviewerGovernance,
87
87
  setupExternalReviewerGovernance
88
88
  } from "./external-reviewer.js";
89
- export { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
89
+ export { assessEvidenceMap, assessProgramReadiness, calculateProgramProgress } from "./program-readiness.js";
90
90
  export {
91
91
  approveReportingRouteSet,
92
92
  assessReportingRoutePeriod,
@@ -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;