filegrc 0.15.0 → 0.15.2

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.
@@ -32,6 +32,100 @@ import { assessSourceCoverageReadiness } from "./source-coverage.js";
32
32
  import { currentCalendarDate, timestampFromLocalDateTime } from "./time.js";
33
33
  import { loadWorkspace } from "./workspace.js";
34
34
 
35
+ export function calculateProgramProgress({
36
+ stages = [],
37
+ target = {},
38
+ evidenceReady = false,
39
+ operating = false,
40
+ asOf,
41
+ window = null
42
+ } = {}) {
43
+ const typeTwo = target.goal === "soc-2-type-2";
44
+ const trackedWindow = normalizeProgressWindow(window || target.candidateCoverage, window ? "audit" : "candidate");
45
+ const windowStarted = Boolean(
46
+ typeTwo
47
+ && trackedWindow
48
+ && asOf
49
+ && trackedWindow.start <= asOf
50
+ );
51
+ const setupProgress = calculateSetupActionProgress(stages, target, Boolean(trackedWindow));
52
+ if (windowStarted) {
53
+ const totalDays = calendarDayDistance(trackedWindow.start, trackedWindow.end) + 1;
54
+ const elapsedDays = Math.min(
55
+ totalDays,
56
+ Math.max(0, calendarDayDistance(trackedWindow.start, asOf) + 1)
57
+ );
58
+ const percent = totalDays ? Math.round((elapsedDays / totalDays) * 100) : 0;
59
+ const complete = elapsedDays >= totalDays;
60
+ const healthy = Boolean(operating);
61
+ return {
62
+ mode: "operating-window",
63
+ label: trackedWindow.source === "audit" ? "Audit window" : "Operating window",
64
+ status: healthy ? complete ? "Window complete" : "Operating" : "Needs attention",
65
+ complete: elapsedDays,
66
+ total: totalDays,
67
+ remaining: Math.max(0, totalDays - elapsedDays),
68
+ percent,
69
+ unit: "day",
70
+ start: trackedWindow.start,
71
+ end: trackedWindow.end,
72
+ source: trackedWindow.source,
73
+ detail: complete
74
+ ? `${totalDays} of ${totalDays} days elapsed.`
75
+ : `Day ${elapsedDays} of ${totalDays}; ${Math.max(0, totalDays - elapsedDays)} days remain.`,
76
+ operating
77
+ };
78
+ }
79
+
80
+ return {
81
+ mode: "setup-progress",
82
+ label: "Program setup",
83
+ status: !setupProgress.remaining
84
+ ? evidenceReady && typeTwo && trackedWindow?.start > asOf
85
+ ? "Scheduled"
86
+ : evidenceReady ? "Evidence ready" : "Ready"
87
+ : evidenceReady && typeTwo ? "Ready to start" : "Needs work",
88
+ ...setupProgress,
89
+ unit: "action",
90
+ detail: setupProgress.remaining
91
+ ? `${setupProgress.complete} of ${setupProgress.total} required actions complete.`
92
+ : `All ${setupProgress.total} required actions are complete.`,
93
+ operating
94
+ };
95
+ }
96
+
97
+ function calculateSetupActionProgress(stages, target, windowMilestoneComplete) {
98
+ const units = new Map();
99
+ for (const currentStage of stages.filter(({ id }) => id !== "operation")) {
100
+ for (const current of currentStage.items || []) {
101
+ if (current.progressUnit === false) continue;
102
+ const candidates = current.progressUnits?.length
103
+ ? current.progressUnits
104
+ : [current];
105
+ for (const candidate of candidates) {
106
+ if (["info", "later"].includes(candidate.status) || candidate.progressUnit === false) continue;
107
+ const key = `${currentStage.id}:${candidate.progressUnitId || current.progressUnitId || candidate.id || current.id}`;
108
+ const existing = units.get(key);
109
+ if (!existing || existing.status === "complete" && candidate.status !== "complete") {
110
+ units.set(key, candidate);
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ const setupUnits = [...units.values()];
117
+ const complete = setupUnits.filter(({ status }) => status === "complete").length;
118
+ const total = setupUnits.length + (target.goal === "soc-2-type-2" ? 1 : 0);
119
+ const completed = complete + (windowMilestoneComplete ? 1 : 0);
120
+ const remaining = Math.max(0, total - completed);
121
+ return {
122
+ complete: completed,
123
+ total,
124
+ remaining,
125
+ percent: total ? Math.round((completed / total) * 100) : 0
126
+ };
127
+ }
128
+
35
129
  export async function assessProgramReadiness(input, options = {}) {
36
130
  const loaded = input?.resources && input?.model && input?.entries
37
131
  ? input
@@ -41,6 +135,15 @@ export async function assessProgramReadiness(input, options = {}) {
41
135
  const workspace = loaded.workspace || records.find((record) => record.type === "workspace");
42
136
  const program = resolveProgram(loaded, options.programId);
43
137
  const asOf = options.asOf || currentCalendarDate(workspace?.timezone || "UTC");
138
+ const requestedAudit = options.auditId
139
+ ? records.find((record) => record.type === "audit" && record.id === options.auditId)
140
+ : null;
141
+ const auditWindow = requestedAudit?.auditKind === "soc-2-type-2"
142
+ ? requestedAudit.coverage
143
+ : selectedAuditWindow(records, program, asOf);
144
+ const progressWindow = requestedAudit?.auditKind === "soc-2-type-2"
145
+ ? normalizeProgressWindow(auditWindow, "audit")
146
+ : selectedProgressWindow(program?.candidateCoverage, auditWindow, asOf);
44
147
  const scope = programScope(program, records, byId, loaded.model, loaded);
45
148
  const collectionReviews = assessCollectionReviews(loaded, { programId: program.id });
46
149
  const markdown = new Map();
@@ -57,13 +160,24 @@ export async function assessProgramReadiness(input, options = {}) {
57
160
  .map(collectionReviewReadinessItem));
58
161
  const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
59
162
  controlStage.items.push(...sourceStage.items);
163
+ const retentionScheduleReview = collectionReviews.find(({ resourceType }) => resourceType === "retention-schedule-item");
60
164
  const retentionItems = await assessRetentionReadiness(loaded, program, {
61
- informationTypesReviewed: collectionReviews.find(({ resourceType }) => resourceType === "information-type")?.complete === true
165
+ informationTypesReviewed: collectionReviews.find(({ resourceType }) => resourceType === "information-type")?.complete === true,
166
+ scheduleApproved: retentionScheduleReview?.complete === true
62
167
  });
63
- const retentionReviewItems = collectionReviews
64
- .filter(({ resourceType }) => resourceType === "retention-schedule-item")
65
- .map(collectionReviewReadinessItem);
168
+ const retentionReviewItems = retentionScheduleReview
169
+ ? [collectionReviewReadinessItem(retentionScheduleReview)]
170
+ : [];
66
171
  if (modelSupports(loaded.model, "retention-schedule-approval")) {
172
+ const scheduleDocumentIds = new Set(records.filter((record) => (
173
+ record.type === "document"
174
+ && record.documentKind === "schedule"
175
+ && record.workflowScope === "program"
176
+ && !["superseded", "retired"].includes(record.status)
177
+ )).map(({ id }) => id));
178
+ policyStage.items = policyStage.items.filter(({ id }) => (
179
+ ![...scheduleDocumentIds].some((documentId) => id === `document-approval-${documentId}`)
180
+ ));
67
181
  policyStage.items.push(...retentionItems.filter(({ id }) => !id.startsWith("retention-source-coverage-")));
68
182
  controlStage.items.push(...retentionItems.filter(({ id }) => id.startsWith("retention-source-coverage-")));
69
183
  policyStage.items.push(...retentionReviewItems);
@@ -123,26 +237,54 @@ export async function assessProgramReadiness(input, options = {}) {
123
237
  const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0);
124
238
  const stages = [
125
239
  ...evidenceGateStages,
126
- await operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model)
240
+ await operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model, progressWindow)
127
241
  ];
128
242
  finalizeStage(stages.at(-1));
129
- const candidateStarted = Boolean(
243
+ const periodStarted = Boolean(
130
244
  program?.assuranceGoal === "soc-2-type-2"
131
- && program.candidateCoverage?.kind === "range"
132
- && coverageStart(program.candidateCoverage) <= asOf
245
+ && progressWindow?.start <= asOf
133
246
  );
134
247
  const obligations = planObligations(records, { programId: program.id, asOf, through: asOf, model: loaded.model });
135
248
  const policyLibrary = await assessPolicyLibraryUpgrades(loaded);
136
- const operating = evidenceReady && candidateStarted && stages.at(-1).counts.action === 0;
249
+ const operating = Boolean(
250
+ evidenceReady
251
+ && periodStarted
252
+ && stages.at(-1).counts.action === 0
253
+ );
137
254
  const canStartCandidatePeriod = Boolean(
138
255
  evidenceReady
139
256
  && program?.assuranceGoal === "soc-2-type-2"
140
257
  && !program.candidateCoverage
258
+ && !periodStarted
141
259
  );
142
260
  const items = stages.flatMap((current) => current.items);
143
261
  const managedItems = items.filter((current) => !["info", "later"].includes(current.status));
144
262
  const complete = managedItems.filter((current) => current.status === "complete").length;
145
263
  const firstAction = items.find((current) => current.status === "action") || null;
264
+ const target = {
265
+ programId: program?.id || null,
266
+ goal: program?.assuranceGoal || "none",
267
+ label: assuranceGoalLabel(program?.assuranceGoal),
268
+ candidateCoverage: program?.candidateCoverage || null
269
+ };
270
+ const checkProgress = {
271
+ complete,
272
+ total: managedItems.length,
273
+ percent: managedItems.length ? Math.round((complete / managedItems.length) * 100) : 0
274
+ };
275
+ const progress = calculateProgramProgress({
276
+ stages,
277
+ target,
278
+ evidenceReady,
279
+ operating,
280
+ asOf,
281
+ window: progressWindow
282
+ });
283
+ const setupProgress = calculateSetupActionProgress(
284
+ stages,
285
+ target,
286
+ Boolean(progressWindow)
287
+ );
146
288
 
147
289
  return {
148
290
  program,
@@ -150,12 +292,7 @@ export async function assessProgramReadiness(input, options = {}) {
150
292
  dataModelVersion: String(loaded.model.modelVersion),
151
293
  generatedAt: options.generatedAt || new Date().toISOString(),
152
294
  asOf,
153
- target: {
154
- programId: program?.id || null,
155
- goal: program?.assuranceGoal || "none",
156
- label: assuranceGoalLabel(program?.assuranceGoal),
157
- candidateCoverage: program?.candidateCoverage || null
158
- },
295
+ target,
159
296
  status: operating ? "operating" : evidenceReady ? "evidence-ready" : "needs-work",
160
297
  evidenceReady,
161
298
  operating,
@@ -168,11 +305,9 @@ export async function assessProgramReadiness(input, options = {}) {
168
305
  ...policyLibrary.proposals,
169
306
  ...legacyPolicyLibraryProposals(records)
170
307
  ],
171
- progress: {
172
- complete,
173
- total: managedItems.length,
174
- percent: managedItems.length ? Math.round((complete / managedItems.length) * 100) : 0
175
- },
308
+ progress,
309
+ setupProgress,
310
+ checkProgress,
176
311
  counts: countStatuses(items),
177
312
  firstAction,
178
313
  scope: {
@@ -277,7 +412,10 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
277
412
  ? `${completeComponents.length} of ${scope.components.length} Components have an active, owned, rationalized role in the selected Systems.`
278
413
  : "No Components are selected. This is valid only when the bounded Systems do not rely on a separately managed service-delivery, Control-support, evidence-source, or supporting-operations building block.",
279
414
  scope.components[0] || { type: "component" },
280
- { componentIds: scope.components.map(({ id }) => id) }
415
+ {
416
+ componentIds: scope.components.map(({ id }) => id),
417
+ progressUnit: false
418
+ }
281
419
  ));
282
420
  }
283
421
 
@@ -444,7 +582,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
444
582
  return stage("scope", "Define Scope", "Set program ownership, the management objective, service boundary, criteria, controls, and dependencies.", items);
445
583
  }
446
584
 
447
- function reportingRouteSetItem(assessment) {
585
+ export function reportingRouteSetItem(assessment) {
448
586
  const current = assessment.routeSets.find(({ effective, canceled }) => effective && !canceled);
449
587
  const draft = assessment.routeSets.find(({ record }) => ["draft", "proposed"].includes(record.status));
450
588
  const required = assessment.requirements.length > 0;
@@ -453,13 +591,18 @@ function reportingRouteSetItem(assessment) {
453
591
  .map(({ purposeKey }) => purposeKey)
454
592
  .filter(Boolean))];
455
593
  const preparedPurposeKeys = new Set(assessment.routeSets
456
- .filter(({ record, committed, canceled, proposedRequirementIssues }) => (
457
- ["proposed", "approved"].includes(record.status)
594
+ .filter(({ record, committed, effective, canceled, proposedRequirementIssues }) => (
595
+ (["proposed", "approved"].includes(record.status) || effective)
458
596
  && committed
459
597
  && !canceled
460
598
  && proposedRequirementIssues.length === 0
461
599
  ))
462
600
  .map(({ record }) => record.purposeKey));
601
+ const requiredPurposeKeys = new Set(assessment.requirements.map(({ purposeKey }) => purposeKey).filter(Boolean));
602
+ const purposeKeys = [...new Set([
603
+ ...requiredPurposeKeys,
604
+ ...proposedPurposeKeys
605
+ ])];
463
606
  const unpreparedPurposeKeys = proposedPurposeKeys.filter((purposeKey) => !preparedPurposeKeys.has(purposeKey));
464
607
  const ready = assessment.issues.length === 0
465
608
  && (!required || Boolean(current))
@@ -494,6 +637,42 @@ function reportingRouteSetItem(assessment) {
494
637
  proposedRequirementIssues: assessment.routeSets.flatMap(({ proposedRequirementIssues }) => proposedRequirementIssues),
495
638
  unpreparedPurposeKeys,
496
639
  issues: assessment.issues,
640
+ progressUnits: purposeKeys.flatMap((purposeKey) => {
641
+ const purposeResourceIds = new Set([
642
+ ...assessment.routeSets.filter(({ record }) => record.purposeKey === purposeKey).map(({ record }) => record.id),
643
+ ...assessment.requirements.filter((requirement) => requirement.purposeKey === purposeKey).map(({ sourceId }) => sourceId),
644
+ ...assessment.proposedRequirements.filter((requirement) => requirement.purposeKey === purposeKey).map(({ sourceId }) => sourceId)
645
+ ].filter(Boolean));
646
+ const purposeIssues = assessment.issues.filter(({ resourceId }) => (
647
+ resourceId === assessment.programId || purposeResourceIds.has(resourceId)
648
+ ));
649
+ const approvedSet = assessment.routeSets.find(({ record, effective, canceled }) => (
650
+ record.purposeKey === purposeKey && effective && !canceled
651
+ ));
652
+ const proposedSet = assessment.routeSets.find(({ record, effective, canceled }) => (
653
+ record.purposeKey === purposeKey
654
+ && !canceled
655
+ && (effective || ["proposed", "approved"].includes(record.status))
656
+ ));
657
+ const proposalComplete = Boolean(
658
+ (approvedSet || proposedSet?.committed)
659
+ && proposedSet?.proposedRequirementIssues.length === 0
660
+ );
661
+ return [
662
+ {
663
+ id: `reporting-route-proposal-${purposeKey}`,
664
+ status: proposalComplete ? "complete" : "action",
665
+ title: `${proposedSet?.record.title || purposeKey} proposal`
666
+ },
667
+ {
668
+ id: `reporting-route-approval-${purposeKey}`,
669
+ status: approvedSet && purposeIssues.length === 0
670
+ ? "complete"
671
+ : requiredPurposeKeys.has(purposeKey) ? "action" : "later",
672
+ title: `${proposedSet?.record.title || purposeKey} approval`
673
+ }
674
+ ];
675
+ }),
497
676
  commands: [
498
677
  "npx filegrc reporting-route-sets --json",
499
678
  "npx filegrc reporting-route-set --scaffold"
@@ -529,6 +708,11 @@ function reportingRouteItem(records, byId, asOf, timezone = "UTC") {
529
708
  }
530
709
 
531
710
  function collectionReviewReadinessItem(assessment) {
711
+ const proposalUnits = (assessment.recordProposals || []).map((proposal) => ({
712
+ id: `${assessment.resourceType}-proposal-${proposal.resourceId}`,
713
+ status: proposal.complete ? "complete" : "action",
714
+ title: `${proposal.title} proposal`
715
+ }));
532
716
  return item(
533
717
  `collection-review-${assessment.resourceType}`,
534
718
  assessment.complete ? "complete" : "action",
@@ -540,6 +724,18 @@ function collectionReviewReadinessItem(assessment) {
540
724
  {
541
725
  resourceType: assessment.resourceType,
542
726
  reviewPoints: assessment.configuration.reviewPoints,
727
+ ...(proposalUnits.length ? {
728
+ progressUnits: [
729
+ ...proposalUnits,
730
+ {
731
+ id: `${assessment.resourceType}-collection-review`,
732
+ status: proposalUnits.every(({ status }) => status === "complete")
733
+ ? assessment.complete ? "complete" : "action"
734
+ : "blocked",
735
+ title: `${assessment.configuration.title} collection review`
736
+ }
737
+ ]
738
+ } : {}),
543
739
  commands: [
544
740
  `npx filegrc review-collection ${assessment.resourceType} --scaffold`,
545
741
  `npx filegrc review-collection ${assessment.resourceType} REVIEW.json --preview --json`
@@ -724,6 +920,7 @@ async function policiesStage(scope, records, byId, readMarkdown, model) {
724
920
  : "Appoint a reviewer who is separate from the policy owner. The reviewer may be another person in the organization or an external person, and is separate from the CPA firm that may later perform the audit.",
725
921
  appointedReviewer || (reviewerNeedsAssignment ? governedRecords[0] : { type: "person" }),
726
922
  {
923
+ progressUnit: false,
727
924
  commands: [
728
925
  "npx filegrc list appointment --workflow --json",
729
926
  "npx filegrc guide appointment --json",
@@ -1468,7 +1665,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
1468
1665
  return stage("sources", "Control Evidence Sources", `Complete the authoritative ${modelSupports(model, "component-sources") ? "Components" : "Systems"} for every selected control family before marking the Controls implemented.`, items);
1469
1666
  }
1470
1667
 
1471
- async function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
1668
+ async function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model, progressWindow = null) {
1472
1669
  const goal = workspace?.assuranceGoal || "none";
1473
1670
  if (!evidenceReady) {
1474
1671
  return stage("operation", "Operate the Program", "Run the controls and preserve dated evidence after the Evidence Ready gate passes.", [
@@ -1500,12 +1697,9 @@ async function operationStage(loaded, workspace, scope, records, byId, asOf, evi
1500
1697
  }
1501
1698
 
1502
1699
  const obligations = planObligations(records, { programId: workspace.id, asOf, through: asOf, model });
1503
- const start = workspace.candidateCoverage?.kind === "range"
1504
- ? coverageStart(workspace.candidateCoverage)
1505
- : null;
1506
- const end = workspace.candidateCoverage?.kind === "range"
1507
- ? coverageEnd(workspace.candidateCoverage)
1508
- : null;
1700
+ const start = progressWindow?.start || null;
1701
+ const end = progressWindow?.end || null;
1702
+ const formalWindow = progressWindow?.source === "audit";
1509
1703
  const startStatus = !start ? "action" : start <= asOf ? "complete" : "later";
1510
1704
  const sourceCoverage = await assessSourceCoverageReadiness(loaded, scope.controls.map(({ id }) => id), workspace);
1511
1705
  const incompleteSourceCoverage = sourceCoverage.filter(({ complete }) => !complete);
@@ -1517,16 +1711,20 @@ async function operationStage(loaded, workspace, scope, records, byId, asOf, evi
1517
1711
  !start
1518
1712
  ? "Set the management candidate period start when the Evidence Ready gate passes. Do not backdate it."
1519
1713
  : start <= asOf
1520
- ? `Management began the candidate Type 2 evidence period on ${start}. This is not the auditor-agreed report period.`
1714
+ ? formalWindow
1715
+ ? `The auditor-agreed Type 2 period began on ${start}. Keep evidence collection and operating work current through the period.`
1716
+ : `Management began the candidate Type 2 evidence period on ${start}. This is not the auditor-agreed report period.`
1521
1717
  : `Evidence collection is scheduled to begin on ${start}.`,
1522
1718
  workspace
1523
1719
  ),
1524
1720
  item(
1525
1721
  "candidate-period-end",
1526
1722
  end ? "complete" : start ? "later" : "info",
1527
- "Plan the candidate period end",
1723
+ formalWindow ? "Confirm the auditor-agreed period end" : "Plan the candidate period end",
1528
1724
  end
1529
- ? `Management candidate period: ${start || "start not set"} through ${end}. The CPA firm may agree to different dates.`
1725
+ ? formalWindow
1726
+ ? `Auditor-agreed period: ${start || "start not set"} through ${end}.`
1727
+ : `Management candidate period: ${start || "start not set"} through ${end}. The CPA firm may agree to different dates.`
1530
1728
  : "Add the management target end when useful. Starting reliable evidence collection is the immediate milestone.",
1531
1729
  workspace
1532
1730
  ),
@@ -1784,6 +1982,61 @@ function countStatuses(items) {
1784
1982
  return counts;
1785
1983
  }
1786
1984
 
1985
+ export function selectedAuditWindow(records, program, asOf) {
1986
+ if (program?.assuranceGoal !== "soc-2-type-2") return null;
1987
+ const candidates = records.filter((record) => (
1988
+ record.type === "audit"
1989
+ && record.auditKind === "soc-2-type-2"
1990
+ && (!record.programId || record.programId === program.id)
1991
+ && !["canceled", "closed", "complete", "delivered"].includes(record.status)
1992
+ && normalizeProgressWindow(record.coverage)
1993
+ && coverageStart(record.coverage) <= asOf
1994
+ ));
1995
+ const statusOrder = new Map([
1996
+ ["planned", 0],
1997
+ ["in-progress", 1],
1998
+ ["fieldwork", 2],
1999
+ ["report-draft", 3],
2000
+ ["issued", 4]
2001
+ ]);
2002
+ candidates.sort((left, right) => (
2003
+ coverageStart(right.coverage).localeCompare(coverageStart(left.coverage))
2004
+ || (statusOrder.get(right.status) ?? -1) - (statusOrder.get(left.status) ?? -1)
2005
+ || coverageEnd(right.coverage).localeCompare(coverageEnd(left.coverage))
2006
+ || left.id.localeCompare(right.id)
2007
+ ));
2008
+ return candidates[0]?.coverage || null;
2009
+ }
2010
+
2011
+ export function selectedProgressWindow(candidateCoverage, auditCoverage, asOf) {
2012
+ const candidates = [
2013
+ normalizeProgressWindow(candidateCoverage, "candidate"),
2014
+ normalizeProgressWindow(auditCoverage, "audit")
2015
+ ].filter((window) => window && (!asOf || window.start <= asOf));
2016
+ candidates.sort((left, right) => (
2017
+ right.start.localeCompare(left.start)
2018
+ || Number(right.source === "audit") - Number(left.source === "audit")
2019
+ || right.end.localeCompare(left.end)
2020
+ ));
2021
+ if (candidates.length) return candidates[0];
2022
+ return normalizeProgressWindow(candidateCoverage, "candidate")
2023
+ || normalizeProgressWindow(auditCoverage, "audit");
2024
+ }
2025
+
2026
+ function normalizeProgressWindow(coverage, source = "candidate") {
2027
+ const start = coverage?.start || coverageStart(coverage);
2028
+ const end = coverage?.end || coverageEnd(coverage);
2029
+ if (!start || !end || end < start) return null;
2030
+ return { start, end, source: coverage?.source || source };
2031
+ }
2032
+
2033
+ function calendarDayDistance(start, end) {
2034
+ const startTime = Date.parse(`${start}T00:00:00Z`);
2035
+ const endTime = Date.parse(`${end}T00:00:00Z`);
2036
+ if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) return 0;
2037
+ return Math.round((endTime - startTime) / 86_400_000);
2038
+ }
2039
+
1787
2040
  function shiftYear(value, offset) {
1788
2041
  const date = new Date(`${value}T00:00:00Z`);
1789
2042
  date.setUTCFullYear(date.getUTCFullYear() + offset);
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
4
4
  import { markdownEntries } from "./resource-markdown.js";
5
5
  import { resolveDataPath } from "./paths.js";
6
- import { currentPartyPeople, partiesIndependent } from "./parties.js";
6
+ import { currentPartyPeople } from "./parties.js";
7
7
  import {
8
8
  nearDuplicateInformationTypes,
9
9
  resourceReviewRevisionsSync,
@@ -26,7 +26,7 @@ export function retentionScheduleApprovalIssues(loaded, program, rows, options =
26
26
  && !["superseded", "retired"].includes(record.status)
27
27
  ));
28
28
  if (!schedules.length) {
29
- issues.push(issue("missing-retention-schedule-document", "Add and approve the Data Retention Schedule document before approving the schedule revision."));
29
+ issues.push(issue("missing-retention-schedule-document", "Add the Data Retention Schedule document before requesting approval of the complete schedule."));
30
30
  }
31
31
  if (schedules.length !== 1) {
32
32
  if (schedules.length) {
@@ -38,17 +38,22 @@ export function retentionScheduleApprovalIssues(loaded, program, rows, options =
38
38
  const linkedControl = schedule && (schedule.controlIds || []).some((id) => (program.controlIds || []).includes(id));
39
39
  if (
40
40
  schedule
41
- && (!["approved", "active"].includes(schedule.status)
42
- || !schedule.approvedOn
43
- || !schedule.approvedContentRevisions
44
- || currentPartyPeople(schedule.ownerIds || [], byId).size === 0
45
- || !partiesIndependent(schedule.ownerIds, schedule.approverIds, byId)
41
+ && (currentPartyPeople(schedule.ownerIds || [], byId).size === 0
46
42
  || !linkedControl
47
43
  || !substantiveMarkdown(scheduleContent)
48
- || openPlaceholderCount(scheduleContent) > 0
44
+ || openPlaceholderCount(scheduleContent) > 0)
45
+ ) {
46
+ issues.push(issue("incomplete-retention-schedule-document", "Complete the Data Retention Schedule document, its owner, linked Control, and Markdown before requesting approval of the complete schedule."));
47
+ }
48
+ if (
49
+ schedule
50
+ && options.approvalReview
51
+ && (!["approved", "active"].includes(schedule.status)
52
+ || schedule.approvedOn !== options.approvalReview.reviewedOn
53
+ || !sameIds(schedule.approverIds, options.approvalReview.reviewedByIds)
49
54
  || !approvalBindingsMatch(loaded, schedule))
50
55
  ) {
51
- issues.push(issue("unapproved-retention-schedule-document", "Complete and independently approve the Data Retention Schedule document before approving the complete schedule."));
56
+ issues.push(issue("invalid-retention-schedule-approval-binding", "The Data Retention Schedule document no longer matches the recorded whole-schedule approval. Review and approve the current complete schedule again."));
52
57
  }
53
58
  const activeRows = rows.filter(({ status }) => status === "active");
54
59
  const mismatched = schedule
@@ -65,15 +70,20 @@ export function retentionScheduleApprovalIssues(loaded, program, rows, options =
65
70
  if (incomplete.length) {
66
71
  issues.push(issue("incomplete-retention-schedule-row", `Complete the retention decisions before approving the schedule revision: ${incomplete[0].title}.`));
67
72
  }
68
- const usableRows = activeRows.filter((row) => !incomplete.includes(row));
69
- const uncovered = retentionUses(loaded, program).find((use) => !usableRows.some((row) => (
73
+ const proposedRows = rows.filter(rowProposesCoverage);
74
+ const scopedPrograms = program.programIds
75
+ ? program.programIds.map((id) => byId.get(id)).filter((record) => record?.type === "program")
76
+ : [program];
77
+ const uncovered = scopedPrograms.flatMap((candidate) => (
78
+ retentionUses(loaded, candidate).map((use) => ({ ...use, programId: candidate.id }))
79
+ )).find((use) => !proposedRows.some((row) => (
70
80
  (row.informationTypeIds || []).includes(use.informationTypeId)
71
- && ((row.scopeResourceIds || []).includes(use.resource.id) || (row.scopeResourceIds || []).includes(program.id))
81
+ && ((row.scopeResourceIds || []).includes(use.resource.id) || (row.scopeResourceIds || []).includes(use.programId))
72
82
  )));
73
83
  if (uncovered) {
74
84
  issues.push(issue(
75
85
  "uncovered-retention-information-use",
76
- `${uncovered.resource.title} uses ${byId.get(uncovered.informationTypeId)?.title || uncovered.informationTypeId}, but no active, current retention schedule row covers both the type and scope.`
86
+ `${uncovered.resource.title} uses ${byId.get(uncovered.informationTypeId)?.title || uncovered.informationTypeId}, but no proposed retention schedule row covers both the type and scope.`
77
87
  ));
78
88
  }
79
89
  if (nearDuplicateInformationTypes(loaded.resources).length && !options.informationTypesReviewed) {
@@ -82,6 +92,10 @@ export function retentionScheduleApprovalIssues(loaded, program, rows, options =
82
92
  return issues;
83
93
  }
84
94
 
95
+ function sameIds(left = [], right = []) {
96
+ return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
97
+ }
98
+
85
99
  function issue(code, message) {
86
100
  return { code, message };
87
101
  }
@@ -99,6 +113,14 @@ function primaryMarkdown(loaded, record) {
99
113
  }
100
114
  }
101
115
 
116
+ function rowProposesCoverage(row) {
117
+ return Boolean(
118
+ (row.informationTypeIds || []).length
119
+ && (row.scopeResourceIds || []).length
120
+ && row.scheduleDocumentId
121
+ );
122
+ }
123
+
102
124
  function approvalBindingsMatch(loaded, record) {
103
125
  const actual = {};
104
126
  for (const entry of markdownEntries(loaded.model, record)) {
@@ -114,8 +136,5 @@ function approvalBindingsMatch(loaded, record) {
114
136
  const actualKeys = Object.keys(actual).sort();
115
137
  const expectedKeys = Object.keys(expected).sort();
116
138
  return actualKeys.length === expectedKeys.length
117
- && actualKeys.every((key, index) => (
118
- key === expectedKeys[index]
119
- && actual[key] === expected[key]
120
- ));
139
+ && actualKeys.every((key, index) => key === expectedKeys[index] && actual[key] === expected[key]);
121
140
  }