filegrc 0.9.2 → 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.
@@ -37,6 +37,11 @@ const COMPLETION_TIMESTAMP_FIELDS = [
37
37
  "deprovisionedOn"
38
38
  ];
39
39
  const MAX_PLANNED_ITEMS = 10_000;
40
+ const SCAFFOLDED_COMPLETION_TYPES = new Set([
41
+ "access-review", "attestation", "backup-test", "control-activity", "control-test",
42
+ "evidence", "exercise", "meeting", "penetration-test", "policy-review",
43
+ "risk-assessment", "vendor-review", "vulnerability-scan"
44
+ ]);
40
45
 
41
46
  export function planObligations(resources, options = {}) {
42
47
  const records = resources.map((item) => item?.record ?? item).filter(Boolean);
@@ -73,7 +78,7 @@ export function planObligations(resources, options = {}) {
73
78
  };
74
79
 
75
80
  for (const obligation of obligations) {
76
- const activity = obligationActivity(model, obligation.activityType);
81
+ const activity = obligationActivity(model, obligation);
77
82
  const expectedCompletionTypes = activity.completionResourceTypes;
78
83
  const programStatus = obligationProgramStatus(obligation, byId, asOf, model);
79
84
  if (obligation.recurrence?.mode === "event" && obligation.recurrence.eventType) {
@@ -101,7 +106,7 @@ export function planObligations(resources, options = {}) {
101
106
  eventRiskLevels: obligation.eventRiskLevels || [],
102
107
  templateResourceId: obligation.templateResourceId || null,
103
108
  completionResourceTypes: expectedCompletionTypes,
104
- completionType: activity.completionType,
109
+ completionType: preferredCompletionType(activity, obligation, byId),
105
110
  completionProfile: activity.completionProfile || null,
106
111
  programStatus,
107
112
  window: normalizedEventWindow(obligation.window)
@@ -155,7 +160,7 @@ export function planObligations(resources, options = {}) {
155
160
  controlIds: obligation.controlIds || [],
156
161
  scopeResourceIds: obligation.scopeResourceIds || [],
157
162
  completionResourceTypes: expectedCompletionTypes,
158
- completionType: activity.completionType,
163
+ completionType: preferredCompletionType(activity, obligation, byId),
159
164
  completionProfile: activity.completionProfile || null,
160
165
  completionResourceIds: completions.map((record) => record.id),
161
166
  status,
@@ -313,6 +318,7 @@ export async function completeObligationOccurrence(input, options) {
313
318
  ));
314
319
  if (!obligation) throw new Error(`Obligation "${options?.obligationId ?? ""}" was not found.`);
315
320
  assertExpectedCompletionType(obligation, options?.record, loaded.model);
321
+ assertAttestationCompletionScope(obligation, options.record, loaded.resources);
316
322
  return createResourceAndLink(loaded.root, options.record, {
317
323
  type: "obligation",
318
324
  id: obligation.id,
@@ -342,8 +348,11 @@ export async function scaffoldObligationCompletion(input, options = {}) {
342
348
  const item = action
343
349
  ? plannedActionForScaffold(loaded, action, completedOn)
344
350
  : plannedOccurrenceForScaffold(loaded, obligation, options.windowStart, completedOn);
345
- const activity = obligationActivity(loaded.model, obligation.activityType);
346
- const type = activity.completionType;
351
+ const activity = obligationActivity(loaded.model, obligation);
352
+ const type = item.completionType || preferredCompletionType(activity, {
353
+ ...obligation,
354
+ subjectResourceIds: item.subjectResourceIds || []
355
+ }, new Map(loaded.resources.map((record) => [record.id, record])));
347
356
  if (!type) throw new Error(`Obligation "${obligation.id}" has no configured completion resource type.`);
348
357
 
349
358
  const mutation = scaffoldResourceMutation(
@@ -369,10 +378,14 @@ export async function scaffoldObligationCompletion(input, options = {}) {
369
378
  activityType: obligation.activityType,
370
379
  completionResourceType: type,
371
380
  completionProfile: activity.completionProfile || null,
381
+ workItemStatus: item.status,
382
+ programStatus: item.programStatus || null,
372
383
  requiredFacts: loaded.model.completionProfiles?.[activity.completionProfile]?.requiredFacts || [],
373
384
  dueWindowStart: item.dueWindowStart || null,
374
385
  dueWindowEnd: item.dueWindowEnd || null,
375
- instructions: "Replace every null or empty required value with the actual work performed. Keep the actual completion date and time, actors, result, scope, independent review, and supporting evidence. This revision makes the completed write safe against a stale Work Queue item."
386
+ instructions: item.status === "proposed" || item.programStatus === "proposed"
387
+ ? "This work is still a proposal. Resolve its governing Policy, Control, owner, and completion profile before recording completion."
388
+ : "Replace every null or empty required value with the actual work performed. Keep the actual completion date and time, actors, result, scope, independent review, and supporting evidence. This revision makes the completed write safe against a stale Work Queue item."
376
389
  }
377
390
  };
378
391
  }
@@ -394,6 +407,7 @@ export async function completeObligationAction(input, options) {
394
407
  assertExpectedCompletionType(obligation, options?.record, loaded.model);
395
408
  const completedOn = requireDate(options?.completedOn, "completion date");
396
409
  const event = loaded.resources.find((record) => record.type === "obligation-event" && record.id === action.sourceResourceId);
410
+ assertAttestationCompletionScope(obligation, options.record, loaded.resources, event);
397
411
  if (event?.occurredOn && completedOn < event.occurredOn) {
398
412
  throw new Error("The action completion date cannot be before its policy event date.");
399
413
  }
@@ -448,7 +462,7 @@ function assertExpectedCompletionType(obligation, record, model) {
448
462
  if (!record || typeof record !== "object" || Array.isArray(record)) {
449
463
  throw new Error("A completion resource record is required.");
450
464
  }
451
- const expected = obligationActivity(model, obligation.activityType).completionResourceTypes;
465
+ const expected = obligationActivity(model, obligation).completionResourceTypes;
452
466
  if (expected.length && !expected.includes(record.type)) {
453
467
  throw new Error(
454
468
  `Obligation "${obligation.id}" expects a completion resource of type ${expected.join(" or ")}, not "${record.type ?? ""}".`
@@ -456,6 +470,28 @@ function assertExpectedCompletionType(obligation, record, model) {
456
470
  }
457
471
  }
458
472
 
473
+ function assertAttestationCompletionScope(obligation, record, resources, event = null) {
474
+ if (record?.type !== "attestation") return;
475
+ const byId = new Map(resources.map((candidate) => [candidate.id, candidate]));
476
+ const eventPeople = (event?.subjectResourceIds || []).filter((id) => byId.get(id)?.type === "person");
477
+ const expectedPeople = eventPeople.length
478
+ ? eventPeople
479
+ : (obligation.scopeResourceIds || []).filter((id) => byId.get(id)?.type === "person");
480
+ if (expectedPeople.length && !expectedPeople.includes(record.personId)) {
481
+ throw new Error(`Attestation personId must name the Person in scope for Obligation "${obligation.id}".`);
482
+ }
483
+ const primarySubjects = [obligation.templateResourceId, ...(obligation.scopeResourceIds || [])]
484
+ .filter((id) => ["policy", "document", "training", "action-item"].includes(byId.get(id)?.type));
485
+ const allowedSubjects = new Set(primarySubjects.length ? primarySubjects : obligation.policyIds || []);
486
+ const actualSubjects = new Set(record.subjectResourceIds || []);
487
+ if (
488
+ actualSubjects.size !== allowedSubjects.size
489
+ || [...actualSubjects].some((id) => !allowedSubjects.has(id))
490
+ ) {
491
+ throw new Error(`Attestation subjects must name the exact authored content in scope for Obligation "${obligation.id}".`);
492
+ }
493
+ }
494
+
459
495
  function plannedOccurrenceForScaffold(loaded, obligation, windowStart, completedOn) {
460
496
  const start = requireDate(windowStart, "occurrence window start");
461
497
  const plan = planObligations(loaded.resources, {
@@ -489,6 +525,7 @@ function plannedActionForScaffold(loaded, action, completedOn) {
489
525
  function applyCompletionScaffoldDefaults(record, context) {
490
526
  const { loaded, item, obligation, completedOn, activity } = context;
491
527
  const program = resolveProgram(loaded);
528
+ const byId = new Map(loaded.resources.map((candidate) => [candidate.id, candidate]));
492
529
  const responsiblePeople = currentPeopleForParties(loaded.resources, item.ownerIds || []);
493
530
  if (!responsiblePeople.length) {
494
531
  throw new Error(`Obligation "${obligation.id}" needs an active owner whose Appointment or Team resolves to a current Person.`);
@@ -554,19 +591,27 @@ function applyCompletionScaffoldDefaults(record, context) {
554
591
  evidenceIds: [],
555
592
  approvedOn: completedOn
556
593
  }),
557
- attestation: () => ({
558
- status: "completed",
559
- subjectResourceIds: [...new Set([
594
+ attestation: () => {
595
+ const personId = (item.subjectResourceIds || []).find((id) => byId.get(id)?.type === "person")
596
+ || (obligation.scopeResourceIds || []).find((id) => byId.get(id)?.type === "person");
597
+ const primarySubjectIds = [...new Set([
560
598
  obligation.templateResourceId,
561
599
  ...(obligation.scopeResourceIds || [])
562
- ].filter(Boolean))],
563
- personId: responsiblePeople[0],
564
- attestationKind: obligation.activityType || "completion",
565
- assignedOn: item.dueWindowStart || completedOn,
566
- dueOn: item.dueWindowEnd || completedOn,
567
- completedOn,
568
- attestationMethod: "git-approval"
569
- }),
600
+ ].filter((id) => ["policy", "document", "training", "action-item"].includes(byId.get(id)?.type)))];
601
+ const subjectResourceIds = primarySubjectIds.length ? primarySubjectIds : [...(obligation.policyIds || [])];
602
+ if (!personId) throw new Error("An Attestation completion needs the Person who made the acknowledgement or completed the training.");
603
+ if (!subjectResourceIds.length) throw new Error("An Attestation completion needs the exact Policy, Document, Training, or Action Item content acknowledged.");
604
+ return {
605
+ status: "completed",
606
+ subjectResourceIds,
607
+ personId,
608
+ attestationKind: obligation.activityType || "completion",
609
+ assignedOn: item.dueWindowStart || completedOn,
610
+ dueOn: item.dueWindowEnd || completedOn,
611
+ completedOn,
612
+ attestationMethod: "git-approval"
613
+ };
614
+ },
570
615
  "access-review": () => {
571
616
  if (!systemIds.length) throw new Error("An Access Review completion needs an active in-scope System.");
572
617
  return {
@@ -621,22 +666,32 @@ function applyCompletionScaffoldDefaults(record, context) {
621
666
  evidenceIds: [],
622
667
  coverage
623
668
  }),
624
- "control-activity": () => ({
625
- ...common,
626
- profileId: activity.completionProfile || obligation.activityType,
627
- obligationId: obligation.id,
628
- controlIds: item.controlIds || obligation.controlIds || [],
629
- scopeResourceIds: (item.scopeResourceIds || obligation.scopeResourceIds || []).length
630
- ? (item.scopeResourceIds || obligation.scopeResourceIds)
631
- : [program.id],
632
- performerIds: responsiblePeople,
633
- completedAt: timestamp,
634
- method: "",
635
- result: "",
636
- reviewerIds,
637
- reviewedOn: completedOn,
638
- ownerIds: item.ownerIds || obligation.ownerIds || []
639
- }),
669
+ "control-activity": () => {
670
+ const allowedScopeTypes = new Set(
671
+ loaded.model.resources["control-activity"].fields.scopeResourceIds.relation || []
672
+ );
673
+ const requestedScopeIds = item.subjectResourceIds || item.scopeResourceIds || obligation.scopeResourceIds || [];
674
+ const validScopeIds = requestedScopeIds.filter((id) => allowedScopeTypes.has(byId.get(id)?.type));
675
+ const fallbackScopeIds = systemIds.length
676
+ ? systemIds
677
+ : (item.controlIds || obligation.controlIds || []).filter((id) => allowedScopeTypes.has(byId.get(id)?.type));
678
+ return {
679
+ ...common,
680
+ profileId: activity.completionProfile || obligation.activityType,
681
+ obligationId: obligation.id,
682
+ controlIds: item.controlIds || obligation.controlIds || [],
683
+ scopeResourceIds: validScopeIds.length
684
+ ? validScopeIds
685
+ : fallbackScopeIds.length ? fallbackScopeIds : [loaded.workspace.id],
686
+ performerIds: responsiblePeople,
687
+ completedAt: timestamp,
688
+ method: "",
689
+ result: "",
690
+ reviewerIds,
691
+ reviewedOn: completedOn,
692
+ ownerIds: item.ownerIds || obligation.ownerIds || []
693
+ };
694
+ },
640
695
  exercise: () => ({
641
696
  ...common,
642
697
  exerciseKind: item.title.toLowerCase().includes("continuity") ? "business-continuity" : "incident-response",
@@ -787,10 +842,16 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
787
842
  .map((record) => {
788
843
  const obligation = byId.get(record.obligationId);
789
844
  const expectedCompletionTypes = obligation?.type === "obligation"
790
- ? obligationActivity(model, obligation.activityType).completionResourceTypes
845
+ ? obligationActivity(model, obligation).completionResourceTypes
791
846
  : [];
792
847
  const completionProfile = obligation?.type === "obligation"
793
- ? obligationActivity(model, obligation.activityType).completionProfile || null
848
+ ? obligationActivity(model, obligation).completionProfile || null
849
+ : null;
850
+ const completionType = obligation?.type === "obligation"
851
+ ? preferredCompletionType(obligationActivity(model, obligation), {
852
+ ...obligation,
853
+ subjectResourceIds: event.subjectResourceIds || []
854
+ }, byId)
794
855
  : null;
795
856
  const completionIds = modelSupports(model, "guided-workflow")
796
857
  ? record.completionResourceIds || []
@@ -801,6 +862,7 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
801
862
  || matchingCompletionIds.length > 0;
802
863
  const complete = record.status === "done" && completionSatisfied;
803
864
  const window = plannedCompletionWindow(record.completionWindow);
865
+ const lateCompletion = complete && completionWasLate(record, matchingCompletionIds, byId, window);
804
866
  const timingStatus = complete
805
867
  ? "complete"
806
868
  : window.overdueAt && new Date(now) > new Date(window.overdueAt)
@@ -824,14 +886,17 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
824
886
  ownerIds: record.assigneeIds || [],
825
887
  policyIds: obligation?.policyIds || [],
826
888
  controlIds: obligation?.controlIds || [],
889
+ subjectResourceIds: event.subjectResourceIds || [],
827
890
  scopeResourceIds: obligation?.scopeResourceIds || [],
828
891
  templateResourceId: obligation?.templateResourceId || null,
829
892
  completionResourceIds: record.completionResourceIds || [],
830
893
  evidenceIds: record.evidenceIds || [],
831
894
  expectedCompletionTypes,
832
895
  completionProfile,
896
+ completionType,
833
897
  matchingCompletionIds,
834
898
  missingCompletion: record.status === "done" && !completionSatisfied,
899
+ lateCompletion,
835
900
  canceledAction: record.status === "canceled",
836
901
  recordedStatus: record.status,
837
902
  completedOn: record.completedOn || null,
@@ -870,6 +935,38 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
870
935
  };
871
936
  }
872
937
 
938
+ function completionWasLate(action, completionIds, byId, window) {
939
+ if (window.dueWindowEndAt) {
940
+ const completedAt = completionIds
941
+ .map((id) => completionTimestamp(byId.get(id)))
942
+ .filter(Boolean)
943
+ .sort()[0];
944
+ if (completedAt) return new Date(completedAt) > new Date(window.dueWindowEndAt);
945
+ return !action.completedOn || action.completedOn >= window.dueWindowEndAt.slice(0, 10);
946
+ }
947
+ return Boolean(window.dueWindowEnd && action.completedOn && action.completedOn > window.dueWindowEnd);
948
+ }
949
+
950
+ function completionTimestamp(record) {
951
+ if (!record) return null;
952
+ return record.completedAt || record.occurredAt || record.collectedAt || record.verifiedAt || null;
953
+ }
954
+
955
+ function preferredCompletionType(activity, item, byId) {
956
+ const primary = activity.completionType;
957
+ const hasPersonSubject = [...(item.subjectResourceIds || []), ...(item.scopeResourceIds || [])]
958
+ .some((id) => byId.get(id)?.type === "person");
959
+ const hasAuthoredSubject = [item.templateResourceId, ...(item.scopeResourceIds || [])]
960
+ .some((id) => ["policy", "document", "training", "action-item"].includes(byId.get(id)?.type))
961
+ || (item.policyIds || []).some((id) => byId.get(id)?.type === "policy");
962
+ if (primary === "attestation" && (!hasPersonSubject || !hasAuthoredSubject) && activity.completionResourceTypes.includes("evidence")) {
963
+ return "evidence";
964
+ }
965
+ if (SCAFFOLDED_COMPLETION_TYPES.has(primary)) return primary;
966
+ if (activity.completionResourceTypes.includes("evidence")) return "evidence";
967
+ return primary;
968
+ }
969
+
873
970
  function planStandaloneAction(record, byId, asOf, now) {
874
971
  const source = byId.get(record.sourceResourceId);
875
972
  const window = plannedCompletionWindow(record.completionWindow);
@@ -1038,17 +1135,23 @@ function comparePlannedItems(a, b) {
1038
1135
  function eventActionDescription(obligation, eventType, model) {
1039
1136
  const policy = obligation.policyIds?.length ? ` Policy sources: ${obligation.policyIds.join(", ")}.` : "";
1040
1137
  const scope = obligation.scopeResourceIds?.length ? ` Review scoped resources: ${obligation.scopeResourceIds.join(", ")}.` : "";
1041
- const expected = obligationActivity(model, obligation.activityType).completionResourceTypes;
1138
+ const expected = obligationActivity(model, obligation).completionResourceTypes;
1042
1139
  const completion = expected.length
1043
1140
  ? ` Link completion records of type ${expected.join(", ")} and any evidence before marking this done.`
1044
1141
  : " Link the completion record and evidence before marking this done.";
1045
1142
  return `Triggered by ${eventType}.${policy}${scope}${completion}`;
1046
1143
  }
1047
1144
 
1048
- function obligationActivity(model, activityType) {
1145
+ function obligationActivity(model, obligation) {
1146
+ const activityType = typeof obligation === "string" ? obligation : obligation?.activityType;
1049
1147
  const activity = model.obligationActivities?.[activityType];
1050
1148
  if (!activity) throw new Error(`Unknown obligation activity type "${activityType ?? ""}".`);
1051
- return activity;
1149
+ if (activityType !== "custom") return activity;
1150
+ return {
1151
+ ...activity,
1152
+ ...(obligation?.customActivity || {}),
1153
+ completionType: obligation?.customActivity?.completionResourceTypes?.[0] || activity.completionType
1154
+ };
1052
1155
  }
1053
1156
 
1054
1157
  function requireDate(value, label) {
@@ -12,6 +12,11 @@ export async function scaffoldPolicyActivation(input = process.cwd(), options =
12
12
  ));
13
13
  const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
14
14
  return {
15
+ available: approved.length > 0,
16
+ message: approved.length
17
+ ? `${approved.length} approved ${approved.length === 1 ? "Policy is" : "Policies are"} available for the Step 3 cutover.`
18
+ : "No Policy is ready for activation. Finish Step 2 approval, then resolve its Step 3 implementation gaps.",
19
+ nextCommand: approved.length ? null : "npx filegrc program-path --next --json",
15
20
  policyIds: approved.map(({ policyId }) => policyId),
16
21
  effectiveOn: currentCalendarDate(loaded.workspace.timezone),
17
22
  expectedRevisions: Object.fromEntries(approved.map(({ policyId }) => [policyId, revisionById.get(policyId)])),
@@ -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
  }