filegrc 0.10.0 → 0.11.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.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli.js CHANGED
@@ -53,7 +53,9 @@ import { activatePolicies, planPolicyActivation, scaffoldPolicyActivation } from
53
53
  import { applyPolicyLibraryUpgrade, assessPolicyLibraryUpgrades } from "./policy-library.js";
54
54
  import { buildAgentProgramPath } from "./program-path.js";
55
55
  import { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
56
+ import { planProgramAmendment } from "./program-amendment.js";
56
57
  import { resolveProgram } from "./program.js";
58
+ import { resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
57
59
  import { applyReconciliation, planReconciliation } from "./reconciliation.js";
58
60
  import { markdownEntries } from "./resource-markdown.js";
59
61
  import { effectiveResourceStatus } from "./resource-status.js";
@@ -462,6 +464,46 @@ export async function runCli(argv = process.argv.slice(2)) {
462
464
  if (flags["require-ready"] && !result.evidenceReady) process.exitCode = 2;
463
465
  return output;
464
466
  }
467
+ if (command === "program-amendment") {
468
+ const sourceResourceId = flags.source || positionals[0];
469
+ if (!sourceResourceId) throw new Error("Pass a source resource ID or --source resource-id.");
470
+ const result = await planProgramAmendment(root, { sourceResourceId });
471
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
472
+ else {
473
+ console.log(`Program amendment review for ${result.source.title}`);
474
+ for (const [type, ids] of Object.entries(result.byResourceType)) console.log(`${type}\t${ids.join(",")}`);
475
+ for (const work of result.reviewWork) {
476
+ console.log(`REVIEW\t${work.resourceType}\t${work.resourceIds?.length ? work.resourceIds.join(",") + "\t" : ""}${work.message}`);
477
+ }
478
+ }
479
+ return result;
480
+ }
481
+ if (command === "review-bindings") {
482
+ const resourceId = positionals[0];
483
+ if (!resourceId) throw new Error("Pass a Retention Schedule Item or Requirement Mapping ID.");
484
+ const loaded = await loadWorkspace(root);
485
+ const record = loaded.resources.find(({ id }) => id === resourceId);
486
+ if (!record) throw new Error(`Resource "${resourceId}" was not found.`);
487
+ const dependencyIds = record.type === "retention-schedule-item"
488
+ ? retentionReviewResourceIds(record, loaded)
489
+ : record.type === "requirement-mapping"
490
+ ? [...new Set([...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])])]
491
+ : null;
492
+ if (!dependencyIds) throw new Error("Review bindings are available for Retention Schedule Items and Requirement Mappings.");
493
+ const revisions = await resourceReviewRevisions(loaded, dependencyIds);
494
+ const result = {
495
+ resource: { type: record.type, id: record.id, title: record.title },
496
+ dependencyIds,
497
+ reviewedSourceRevisions: Object.fromEntries(revisions),
498
+ missingResourceIds: dependencyIds.filter((id) => !revisions.has(id))
499
+ };
500
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
501
+ else {
502
+ console.log(`Review bindings for ${record.title}`);
503
+ for (const id of dependencyIds) console.log(`${revisions.has(id) ? "READY" : "MISSING"}\t${id}\t${revisions.get(id) || ""}`);
504
+ }
505
+ return result;
506
+ }
465
507
  if (command === "evidence-map") {
466
508
  const loaded = await loadWorkspace(root);
467
509
  const result = await assessEvidenceMap(loaded, { asOf: flags["as-of"], programId: flags.program });
@@ -1135,6 +1177,8 @@ Usage:
1135
1177
  filegrc search <query> [--type resource-type] [--json]
1136
1178
  filegrc obligations [--as-of YYYY-MM-DD] [--from YYYY-MM-DD] [--through YYYY-MM-DD] [--now RFC3339] [--complete] [--json]
1137
1179
  filegrc program-readiness [--as-of YYYY-MM-DD] [--require-ready] [--summary] [--json]
1180
+ filegrc program-amendment <source-resource-id> [--json]
1181
+ filegrc review-bindings <retention-or-mapping-id> [--json]
1138
1182
  filegrc evidence-map [--as-of YYYY-MM-DD] [--json]
1139
1183
  filegrc audit-readiness [audit-id] [--as-of YYYY-MM-DD] [--require-ready] [--json]
1140
1184
  filegrc prepare-audit <audit-id> [--json]
@@ -1222,7 +1266,8 @@ the repository Workspace, management Program, bounded Systems, operational Compo
1222
1266
  specific Assets, Vendors, normalized information, and Evidence Artifacts. Model v5 separates
1223
1267
  Document approval from activation and records program-versus-engagement scope. Model v6 gives
1224
1268
  Training the same approval and activation split and moves its schedule into Obligations. Model v7
1225
- keeps issued historical Documents neutral and requires a current activation for legacy Training. v3 migration
1269
+ keeps issued historical Documents neutral and requires a current activation for legacy Training. Model v8
1270
+ adds structured retention schedule items, reviewed requirement mappings, source-linked Commitments, and custom obligations. v3 migration
1226
1271
  previews may require a decisions JSON file for ambiguous old Systems. v3 still creates planned
1227
1272
  core Appointments, removal of obsolete manual page state, classified review work,
1228
1273
  and dataModelVersion changed last. The command writes no Git commit.
@@ -1261,6 +1306,24 @@ Options:
1261
1306
  --help Show this help`);
1262
1307
  return;
1263
1308
  }
1309
+ if (command === "program-amendment") {
1310
+ console.log(`Usage:
1311
+ filegrc program-amendment <source-resource-id> [--json]
1312
+
1313
+ Trace a Policy, Document, Framework, Requirement, or Commitment through its
1314
+ Commitments, mappings, Controls, Obligations, and retention rules. The command
1315
+ only reports review work. It does not infer or write management decisions.`);
1316
+ return;
1317
+ }
1318
+ if (command === "review-bindings") {
1319
+ console.log(`Usage:
1320
+ filegrc review-bindings <retention-or-mapping-id> [--json]
1321
+
1322
+ Calculate the exact current source revisions required by an active Retention
1323
+ Schedule Item or Requirement Mapping. Copy reviewedSourceRevisions into the
1324
+ record only after management completes the review.`);
1325
+ return;
1326
+ }
1264
1327
  if (command === "workflow") {
1265
1328
  console.log(`Usage:
1266
1329
  filegrc workflow [audit-id] [options]
@@ -1432,6 +1495,8 @@ function agentOverview(model) {
1432
1495
  search: "filegrc search <query> --json",
1433
1496
  obligations: "filegrc obligations --json",
1434
1497
  programReadiness: "filegrc program-readiness --json",
1498
+ programAmendment: "filegrc program-amendment <source-resource-id> --json",
1499
+ reviewBindings: "filegrc review-bindings <retention-or-mapping-id> --json",
1435
1500
  evidenceMap: "filegrc evidence-map --json",
1436
1501
  auditReadiness: "filegrc audit-readiness <audit-id> --json",
1437
1502
  prepareAudit: "filegrc prepare-audit <audit-id>",
@@ -131,6 +131,13 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
131
131
  addIds((record.informationUses || []).map(({ informationTypeId }) => informationTypeId));
132
132
  }
133
133
  }
134
+ if (resourceType === "information-type" && !legacy) {
135
+ addIds(program?.systemIds);
136
+ addIds(programComponents(loaded, program || {}).map(({ id }) => id));
137
+ addIds(loaded.resources
138
+ .filter((record) => record.type === "vendor" && record.status !== "retired")
139
+ .map(({ id }) => id));
140
+ }
134
141
  if (resourceType === "complementary-control") {
135
142
  addIds(program?.systemIds);
136
143
  addIds(program?.controlIds);
@@ -229,6 +236,11 @@ const dependencyFields = {
229
236
  classification: ["id", "type", "status", "rank", "description", "handlingRequirements"],
230
237
  "information-type": ["id", "type", "status", "classificationId", "description"]
231
238
  },
239
+ "information-type": {
240
+ system: ["id", "type", "status", "informationTypeIds"],
241
+ component: ["id", "type", "status", "systemUses", "informationUses"],
242
+ vendor: ["id", "type", "status", "informationTypeIds"]
243
+ },
232
244
  "complementary-control": {
233
245
  system: ["id", "type", "status", "purpose", "servicesProvided", "boundary", "exclusions", "criticality", "informationTypeIds", "classificationId", "internetExposed", "continuityObjectives"],
234
246
  control: ["id", "type", "status", "statement", "activity", "systemIds", "componentIds", "evidenceSourceComponentIds"],
package/src/git.js CHANGED
@@ -390,8 +390,8 @@ function unavailableSnapshot(error, extra = {}) {
390
390
  }
391
391
 
392
392
  export async function getBrowserRepositoryState(input = process.cwd(), options = {}) {
393
- const root = resolveWorkspaceRoot(input);
394
- const config = await getRepositoryConfig(root);
393
+ const root = input?.entries && input?.root ? input.root : resolveWorkspaceRoot(input);
394
+ const config = await getRepositoryConfig(input);
395
395
  const gitSummary = options.repositorySnapshot ?? await getRepositorySnapshot(root);
396
396
  if (config.mode !== "trunk") {
397
397
  return {
@@ -852,8 +852,8 @@ function syncReadySummary(root, action) {
852
852
  return summary;
853
853
  }
854
854
 
855
- async function getRepositoryConfig(root) {
856
- const loaded = await loadWorkspace(root);
855
+ async function getRepositoryConfig(input) {
856
+ const loaded = input?.entries && input?.root ? input : await loadWorkspace(input);
857
857
  const renderer = loaded.resources.find(({ type, id }) => type === "renderer-settings" && id === "renderer-settings");
858
858
  const mode = renderer?.repositoryMode;
859
859
  const authoritativeBranch = cleanGitName(renderer?.authoritativeBranch);
package/src/index.js CHANGED
@@ -82,6 +82,7 @@ export {
82
82
  setupExternalReviewerGovernance
83
83
  } from "./external-reviewer.js";
84
84
  export { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
85
+ export { planProgramAmendment } from "./program-amendment.js";
85
86
  export {
86
87
  buildAgentProgramPath,
87
88
  PROGRAM_PATH,
@@ -99,6 +100,8 @@ export {
99
100
  export { searchResources, searchableValues } from "./search.js";
100
101
  export { effectiveResourceStatus } from "./resource-status.js";
101
102
  export { applyReconciliation, planReconciliation } from "./reconciliation.js";
103
+ export { assessRequirementMappingReadiness } from "./requirement-mapping.js";
104
+ export { assessRetentionReadiness, nearDuplicateInformationTypes, resourceReviewRevision, resourceReviewRevisions, retentionReviewResourceIds, retentionRuleIsCurrent, retentionUses } from "./retention.js";
102
105
  export { createFilegrcServer, serveWorkspace } from "./server.js";
103
106
  export { normalizeSetupPayload, planWorkspaceSetup, setupWorkspace, summarizeSetupResult } from "./setup.js";
104
107
  export { createAppState, createResourceDetail } from "./state.js";
@@ -896,9 +896,10 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
896
896
  : sourceVersion === "1" ? V1_TARGET_MODEL_VERSION
897
897
  : sourceVersion === "2" ? "3"
898
898
  : sourceVersion === "3" ? "4"
899
- : sourceVersion === "4" ? "5"
900
- : sourceVersion === "5" ? "6"
901
- : ACTIVE_MODEL_VERSION;
899
+ : sourceVersion === "4" ? "5"
900
+ : sourceVersion === "5" ? "6"
901
+ : sourceVersion === "6" ? "7"
902
+ : ACTIVE_MODEL_VERSION;
902
903
  if (sourceVersion === requestedTarget) return emptyPlan(sourceVersion, requestedTarget);
903
904
  if (sourceVersion === "1" && requestedTarget === "2") {
904
905
  return planV1ToV2Migration(input, options);
@@ -915,9 +916,12 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
915
916
  if (sourceVersion === "5" && requestedTarget === "6") {
916
917
  return planV5ToV6Migration(loaded);
917
918
  }
918
- if (sourceVersion === "6" && requestedTarget === ACTIVE_MODEL_VERSION) {
919
+ if (sourceVersion === "6" && requestedTarget === "7") {
919
920
  return planV6ToV7Migration(loaded);
920
921
  }
922
+ if (sourceVersion === "7" && requestedTarget === ACTIVE_MODEL_VERSION) {
923
+ return planV7ToV8Migration(loaded);
924
+ }
921
925
  if (sourceVersion === "1" && requestedTarget === ACTIVE_MODEL_VERSION) {
922
926
  throw new Error(
923
927
  "Model v1 workspaces must migrate to model v2 first. "
@@ -2034,6 +2038,158 @@ async function planV6ToV7Migration(loaded) {
2034
2038
  };
2035
2039
  }
2036
2040
 
2041
+ async function planV7ToV8Migration(loaded) {
2042
+ if (!loaded.workspace?.id) throw new Error("Model migration requires a valid Workspace record.");
2043
+ const targetModel = loadModel("8");
2044
+ const revisions = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
2045
+ const automatic = [];
2046
+ const reviewRequired = [];
2047
+ const unsupported = [];
2048
+ const missing = [];
2049
+ const manualActions = [];
2050
+ const updates = [];
2051
+
2052
+ for (const original of loaded.resources) {
2053
+ const record = structuredClone(original);
2054
+ let changed = false;
2055
+ if (original.type === "workspace") {
2056
+ record.dataModelVersion = "8";
2057
+ changed = true;
2058
+ automatic.push(classifiedChange("automatic", original.id, "dataModelVersion", "Select model v8."));
2059
+ }
2060
+ if (original.type === "component" && Array.isArray(original.informationUses)) {
2061
+ record.informationUses = original.informationUses.map((use) => {
2062
+ if (!Array.isArray(use.activities)) return use;
2063
+ const migrated = { ...use, processingOperations: use.activities };
2064
+ delete migrated.activities;
2065
+ return migrated;
2066
+ });
2067
+ changed = true;
2068
+ automatic.push(classifiedChange(
2069
+ "automatic",
2070
+ original.id,
2071
+ "informationUses",
2072
+ "Rename information-use activities to the standard processingOperations term without changing the recorded values."
2073
+ ));
2074
+ }
2075
+ if (original.type === "commitment" && Array.isArray(original.sourceDocumentIds)) {
2076
+ record.sourceResourceIds = [...new Set([
2077
+ ...(record.sourceResourceIds || []),
2078
+ ...original.sourceDocumentIds
2079
+ ])];
2080
+ delete record.sourceDocumentIds;
2081
+ changed = true;
2082
+ automatic.push(classifiedChange(
2083
+ "automatic",
2084
+ original.id,
2085
+ "sourceResourceIds",
2086
+ "Preserve source Document links in the broader source-resource relationship."
2087
+ ));
2088
+ }
2089
+ if (original.type === "source-coverage" && typeof original.retention === "string") {
2090
+ record.retentionNotes = original.retention;
2091
+ delete record.retention;
2092
+ changed = true;
2093
+ automatic.push(classifiedChange(
2094
+ "automatic",
2095
+ original.id,
2096
+ "retentionNotes",
2097
+ "Preserve the prior narrative retention statement as notes without treating it as an approved schedule rule."
2098
+ ));
2099
+ }
2100
+ if (original.type === "audit" && typeof original.retentionDecision === "string") {
2101
+ record.retentionNotes = original.retentionDecision;
2102
+ delete record.retentionDecision;
2103
+ changed = true;
2104
+ automatic.push(classifiedChange(
2105
+ "automatic",
2106
+ original.id,
2107
+ "retentionNotes",
2108
+ "Preserve the prior audit retention decision as notes without inferring a structured retention rule."
2109
+ ));
2110
+ }
2111
+ if (changed) updates.push(record);
2112
+ }
2113
+
2114
+ for (const record of loaded.resources) {
2115
+ if (record.type === "source-coverage" && record.status === "active") {
2116
+ reviewRequired.push(classifiedChange(
2117
+ "review-required",
2118
+ record.id,
2119
+ "retentionScheduleItemIds",
2120
+ "Management must select or create an approved retention schedule item for this source coverage."
2121
+ ));
2122
+ }
2123
+ if (record.type === "component" && record.informationUses?.length) {
2124
+ reviewRequired.push(classifiedChange(
2125
+ "review-required",
2126
+ record.id,
2127
+ "informationUses",
2128
+ "Review every Information Type use against the structured retention schedule."
2129
+ ));
2130
+ }
2131
+ if (["system", "vendor"].includes(record.type) && record.informationTypeIds?.length) {
2132
+ reviewRequired.push(classifiedChange(
2133
+ "review-required",
2134
+ record.id,
2135
+ "informationTypeIds",
2136
+ `Review every Information Type used by this ${record.type === "system" ? "System" : "Vendor"} against the structured retention schedule.`
2137
+ ));
2138
+ }
2139
+ }
2140
+
2141
+ const updatedById = new Map(updates.map((record) => [record.id, record]));
2142
+ const migratedRecords = loaded.resources.map((record) => updatedById.get(record.id) || record);
2143
+ await collectTargetValidationActions(loaded, migratedRecords, targetModel, missing, manualActions);
2144
+ for (const item of [...missing, ...manualActions]) {
2145
+ unsupported.push(classifiedChange(
2146
+ "unsupported",
2147
+ item.resourceId,
2148
+ item.field,
2149
+ item.message || `Resolve ${item.field} before applying the model v8 migration.`
2150
+ ));
2151
+ }
2152
+ const ready = unsupported.length === 0;
2153
+ return {
2154
+ schemaVersion: 2,
2155
+ sourceModelVersion: "7",
2156
+ targetModelVersion: "8",
2157
+ ready,
2158
+ missing,
2159
+ conflicts: [],
2160
+ manualActions,
2161
+ classifications: { automatic, reviewRequired, unsupported },
2162
+ notes: [
2163
+ "The migration never chooses a retention period, cutoff, or disposition action.",
2164
+ "Review-required items remain visible after migration through program readiness."
2165
+ ],
2166
+ migrationReport: {},
2167
+ summary: {
2168
+ create: 0,
2169
+ update: updates.length,
2170
+ automatic: automatic.length,
2171
+ reviewRequired: reviewRequired.length,
2172
+ unsupported: unsupported.length
2173
+ },
2174
+ fileDiff: {
2175
+ create: [],
2176
+ update: updates.map((record) => ({
2177
+ type: record.type,
2178
+ id: record.id,
2179
+ before: loaded.resources.find(({ id }) => id === record.id),
2180
+ after: record
2181
+ }))
2182
+ },
2183
+ changes: {
2184
+ create: [],
2185
+ update: updates,
2186
+ expectedRevisions: Object.fromEntries(updates.map(({ id }) => [id, revisions.get(id)])),
2187
+ validateWholeWorkspace: true,
2188
+ targetModelVersion: "8"
2189
+ }
2190
+ };
2191
+ }
2192
+
2037
2193
  function normalizeDocumentScopeDecision(value) {
2038
2194
  const candidate = typeof value === "object" && value
2039
2195
  ? value.workflowScope || value.scope
@@ -78,7 +78,7 @@ export function planObligations(resources, options = {}) {
78
78
  };
79
79
 
80
80
  for (const obligation of obligations) {
81
- const activity = obligationActivity(model, obligation.activityType);
81
+ const activity = obligationActivity(model, obligation);
82
82
  const expectedCompletionTypes = activity.completionResourceTypes;
83
83
  const programStatus = obligationProgramStatus(obligation, byId, asOf, model);
84
84
  if (obligation.recurrence?.mode === "event" && obligation.recurrence.eventType) {
@@ -348,7 +348,7 @@ export async function scaffoldObligationCompletion(input, options = {}) {
348
348
  const item = action
349
349
  ? plannedActionForScaffold(loaded, action, completedOn)
350
350
  : plannedOccurrenceForScaffold(loaded, obligation, options.windowStart, completedOn);
351
- const activity = obligationActivity(loaded.model, obligation.activityType);
351
+ const activity = obligationActivity(loaded.model, obligation);
352
352
  const type = item.completionType || preferredCompletionType(activity, {
353
353
  ...obligation,
354
354
  subjectResourceIds: item.subjectResourceIds || []
@@ -462,7 +462,7 @@ function assertExpectedCompletionType(obligation, record, model) {
462
462
  if (!record || typeof record !== "object" || Array.isArray(record)) {
463
463
  throw new Error("A completion resource record is required.");
464
464
  }
465
- const expected = obligationActivity(model, obligation.activityType).completionResourceTypes;
465
+ const expected = obligationActivity(model, obligation).completionResourceTypes;
466
466
  if (expected.length && !expected.includes(record.type)) {
467
467
  throw new Error(
468
468
  `Obligation "${obligation.id}" expects a completion resource of type ${expected.join(" or ")}, not "${record.type ?? ""}".`
@@ -842,13 +842,13 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
842
842
  .map((record) => {
843
843
  const obligation = byId.get(record.obligationId);
844
844
  const expectedCompletionTypes = obligation?.type === "obligation"
845
- ? obligationActivity(model, obligation.activityType).completionResourceTypes
845
+ ? obligationActivity(model, obligation).completionResourceTypes
846
846
  : [];
847
847
  const completionProfile = obligation?.type === "obligation"
848
- ? obligationActivity(model, obligation.activityType).completionProfile || null
848
+ ? obligationActivity(model, obligation).completionProfile || null
849
849
  : null;
850
850
  const completionType = obligation?.type === "obligation"
851
- ? preferredCompletionType(obligationActivity(model, obligation.activityType), {
851
+ ? preferredCompletionType(obligationActivity(model, obligation), {
852
852
  ...obligation,
853
853
  subjectResourceIds: event.subjectResourceIds || []
854
854
  }, byId)
@@ -1135,17 +1135,23 @@ function comparePlannedItems(a, b) {
1135
1135
  function eventActionDescription(obligation, eventType, model) {
1136
1136
  const policy = obligation.policyIds?.length ? ` Policy sources: ${obligation.policyIds.join(", ")}.` : "";
1137
1137
  const scope = obligation.scopeResourceIds?.length ? ` Review scoped resources: ${obligation.scopeResourceIds.join(", ")}.` : "";
1138
- const expected = obligationActivity(model, obligation.activityType).completionResourceTypes;
1138
+ const expected = obligationActivity(model, obligation).completionResourceTypes;
1139
1139
  const completion = expected.length
1140
1140
  ? ` Link completion records of type ${expected.join(", ")} and any evidence before marking this done.`
1141
1141
  : " Link the completion record and evidence before marking this done.";
1142
1142
  return `Triggered by ${eventType}.${policy}${scope}${completion}`;
1143
1143
  }
1144
1144
 
1145
- function obligationActivity(model, activityType) {
1145
+ function obligationActivity(model, obligation) {
1146
+ const activityType = typeof obligation === "string" ? obligation : obligation?.activityType;
1146
1147
  const activity = model.obligationActivities?.[activityType];
1147
1148
  if (!activity) throw new Error(`Unknown obligation activity type "${activityType ?? ""}".`);
1148
- return activity;
1149
+ if (activityType !== "custom") return activity;
1150
+ return {
1151
+ ...activity,
1152
+ ...(obligation?.customActivity || {}),
1153
+ completionType: obligation?.customActivity?.completionResourceTypes?.[0] || activity.completionType
1154
+ };
1149
1155
  }
1150
1156
 
1151
1157
  function requireDate(value, label) {
@@ -0,0 +1,25 @@
1
+ # Data Retention Schedule
2
+
3
+ ## Use
4
+
5
+ This schedule records how long {{company_name}} keeps important record classes and what happens when each period ends. The policy owner and data owners must complete the organization-specific rows before approval.
6
+
7
+ Retention periods may come from law, contract, tax, audit, security, or a documented business need. Use the longest applicable period, but do not keep data indefinitely without a reason.
8
+
9
+ ## Schedule
10
+
11
+ The structured Retention Schedule Items linked to this document are its schedule rows. Each approved item must name the covered Information Types and operational scope, owner, cutoff, period, disposition action, instructions, and authority. Planned items are review prompts and are not approved retention behavior.
12
+
13
+ Management must cover important information used by Systems, Components, and Vendors, including security logs, backups or alternate recovery copies, governance records, audit evidence, customer and service records, and incident records when those classes exist. No starter period or disposition action is an approved organization value.
14
+
15
+ ## Holds and exceptions
16
+
17
+ An approved legal hold, investigation, or preservation duty suspends normal deletion for the affected records. Record the authority, scope, owner, start date, and release decision in controlled legal-hold records.
18
+
19
+ Any retention exception needs a reason, owner, approval, compensating safeguards, and expiration or next review date.
20
+
21
+ ## Review and disposal evidence
22
+
23
+ Review this schedule at least annually and within 30 days after a material change to systems, data use, vendors, contracts, or applicable duties. The approver must be separate from the owner.
24
+
25
+ For material disposal work, retain a record of the record class, source, date range, method, completion date, responsible person, exceptions, and verification.
@@ -42,14 +42,18 @@ const DOCUMENT_CONTENT_UPDATES = [
42
42
  id: "document-data-retention-schedule",
43
43
  path: "documents/document-data-retention-schedule.md",
44
44
  priorRevision: "45a408e8139bd57f42dda5ca5ae5c8cd4480b4e7bf08834f60058148a3a63475",
45
- additionalPriorRevisions: new Set(["d80b99ce53d1012cc169bbbc2afab8d0597bfbe9f30ac0812a8d5bbeb2ed9f90"]),
46
- currentRevision: "dd11857ae7d881f176bd93947ef3031c33c75ee41e3c0435198fd60c67a94cf7",
45
+ additionalPriorRevisions: new Set([
46
+ "d80b99ce53d1012cc169bbbc2afab8d0597bfbe9f30ac0812a8d5bbeb2ed9f90",
47
+ "dd11857ae7d881f176bd93947ef3031c33c75ee41e3c0435198fd60c67a94cf7"
48
+ ]),
49
+ currentRevision: "4a48c15a4e20e4f29028cf2ff8597315eb51878814120125b5268356b923c9db",
50
+ currentSourcePath: "./policy-library/data-retention-schedule-v2.md",
47
51
  replacements: [
48
52
  ["FileGRC detects the bracketed prompts as approval blockers. Remove each prompt only after replacing it with a reviewed fact.", "Remove each bracketed prompt only after replacing it with a reviewed fact."],
49
53
  ["Record the authority, scope, owner, start date, and release decision outside this public template.", "Record the authority, scope, owner, start date, and release decision in controlled legal-hold records."],
50
54
  ["| Production backups or alternate recovery copies | [Complete before approval: Systems or Components] | [Complete before approval: owner] | Backup or recovery-copy creation | [Confirm or replace proposed default before approval: 30 days, adjusted to approved System recovery objectives] | [Complete before approval: expiration or disposal action] | [Complete before approval: continuity objective or risk decision] |", "| Production backups or alternate recovery copies | [Complete before approval: Systems or Components] | [Complete before approval: owner] | Backup or recovery-copy creation | [Confirm or replace proposed default before approval: 30 days, adjusted to approved System recovery needs] | [Complete before approval: expiration or disposal action] | [Complete before approval: recovery need, commitment, or risk decision] |"]
51
55
  ],
52
- summary: "Keep FileGRC prompt handling in document guidance, make the Retention Schedule standalone, and avoid assuming numeric recovery objectives."
56
+ summary: "Move schedule rows into structured records and keep organization-specific periods and disposition choices under management review."
53
57
  },
54
58
  {
55
59
  id: "document-security-incident-recovery-plan",
@@ -592,31 +596,51 @@ async function buildPolicyLibraryPlan(loaded) {
592
596
  }
593
597
  if (sourceRevision === documentUpdate.currentRevision) {
594
598
  skipped.push(skippedItem(documentUpdate.id, "current", "The governed Document already contains the current standalone starter language."));
595
- continue;
596
- }
597
- if (sourceRevision !== documentUpdate.priorRevision
599
+ } else if (sourceRevision !== documentUpdate.priorRevision
598
600
  && !documentUpdate.additionalPriorRevisions?.has(sourceRevision)) {
599
601
  skipped.push(skippedItem(documentUpdate.id, "customized", "The governed Document differs from the recognized prior starter, so FileGRC will not rewrite it."));
600
- continue;
602
+ } else {
603
+ const nextSource = documentUpdate.currentSourcePath
604
+ ? materializeDocument(
605
+ await readFile(new URL(documentUpdate.currentSourcePath, import.meta.url), "utf8"),
606
+ loaded.workspace?.organizationName
607
+ )
608
+ : documentUpdate.replacements.reduce((current, [prior, next]) => current.replace(prior, next), source);
609
+ if (normalizedDocumentRevision(nextSource, loaded.workspace?.organizationName) !== documentUpdate.currentRevision) {
610
+ throw new Error(`The ${documentUpdate.id} starter update does not produce the current governed Document.`);
611
+ }
612
+ proposalChanges.push({
613
+ resourceType: "document",
614
+ resourceId: documentUpdate.id,
615
+ path: displayPath,
616
+ summary: documentUpdate.summary,
617
+ diff: fullReplacementDiff(displayPath, source, nextSource)
618
+ });
619
+ contentUpdates[documentUpdate.id] = { content: nextSource };
620
+ expectedContentRevisions[documentUpdate.id] = {
621
+ [documentUpdate.path]: rawSourceRevision
622
+ };
601
623
  }
602
- const nextSource = documentUpdate.replacements.reduce(
603
- (current, [prior, next]) => current.replace(prior, next),
604
- source
605
- );
606
- if (normalizedDocumentRevision(nextSource, loaded.workspace?.organizationName) !== documentUpdate.currentRevision) {
607
- throw new Error(`The ${documentUpdate.id} starter update does not produce the current governed Document.`);
624
+ if (documentUpdate.id === "document-data-retention-schedule") {
625
+ const expectedControlIds = ["control-logging-monitoring", "control-backup-restoration"];
626
+ const requiredControlIds = expectedControlIds.filter((id) => byId.get(id)?.record.type === "control");
627
+ for (const id of expectedControlIds.filter((id) => !requiredControlIds.includes(id))) {
628
+ skipped.push(skippedItem(id, "missing", `The starter ${id} Control is not present, so FileGRC did not add a dangling schedule relationship.`));
629
+ }
630
+ const nextControlIds = [...new Set([...(entry.record.controlIds || []), ...requiredControlIds])];
631
+ if (!sameValue(entry.record.controlIds || [], nextControlIds)) {
632
+ updates.push({ ...entry.record, controlIds: nextControlIds });
633
+ expectedRevisions[entry.record.id] = entry.revision;
634
+ const jsonPath = "data/documents/document-data-retention-schedule.json";
635
+ proposalChanges.push({
636
+ resourceType: "document",
637
+ resourceId: entry.record.id,
638
+ path: jsonPath,
639
+ summary: "Link the schedule to logging and backup Controls while preserving existing Control relationships.",
640
+ diff: replacementDiff(jsonPath, [["controlIds", entry.record.controlIds || [], nextControlIds]])
641
+ });
642
+ }
608
643
  }
609
- proposalChanges.push({
610
- resourceType: "document",
611
- resourceId: documentUpdate.id,
612
- path: displayPath,
613
- summary: documentUpdate.summary,
614
- diff: fullReplacementDiff(displayPath, source, nextSource)
615
- });
616
- contentUpdates[documentUpdate.id] = { content: nextSource };
617
- expectedContentRevisions[documentUpdate.id] = {
618
- [documentUpdate.path]: rawSourceRevision
619
- };
620
644
  }
621
645
 
622
646
  for (const controlUpdate of CONTROL_UPDATES) {
@@ -816,6 +840,10 @@ function materializePolicy(source, organizationName, securityContact) {
816
840
  .replaceAll("{{security_contact_email}}", securityContact || "security@example.com");
817
841
  }
818
842
 
843
+ function materializeDocument(source, organizationName) {
844
+ return source.replaceAll("{{company_name}}", organizationName || "Organization");
845
+ }
846
+
819
847
  function securityContactFromPolicy(source) {
820
848
  return source.match(/through the primary route at ([^\r\n]+?) or the usable alternate route documented/)?.[1];
821
849
  }