filegrc 0.15.1 → 0.15.3

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.
@@ -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
  }
package/src/retention.js CHANGED
@@ -10,18 +10,26 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
10
10
  if (!loaded.model.resources["retention-schedule-item"]) return [];
11
11
  const byId = new Map(loaded.resources.map((record) => [record.id, record]));
12
12
  const rules = loaded.resources.filter((record) => (
13
- record.type === "retention-schedule-item" && record.status === "active"
13
+ record.type === "retention-schedule-item"
14
+ && !["retired", "superseded"].includes(record.status)
14
15
  ));
15
- const revisions = await resourceReviewRevisions(loaded, rules.flatMap((rule) => retentionReviewResourceIds(rule, loaded)));
16
- const usableRules = rules.filter((rule) => retentionRuleIsCurrent(rule, revisions, byId, loaded));
16
+ const activeRules = rules.filter(({ status }) => status === "active");
17
+ const revisions = await resourceReviewRevisions(loaded, activeRules.flatMap((rule) => retentionReviewResourceIds(rule, loaded)));
18
+ const usableRules = activeRules.filter((rule) => retentionRuleIsCurrent(rule, revisions, byId, loaded));
19
+ const proposedRules = rules.filter(retentionRuleHasCoverageProposal);
17
20
  const uses = retentionUses(loaded, program);
18
- const items = rules.filter((rule) => !usableRules.includes(rule)).map((rule) => readinessItem(
21
+ const items = rules.map((rule) => readinessItem(
19
22
  `retention-rule-${rule.id}`,
20
- "action",
21
- `Review ${rule.title}`,
22
- "This active retention schedule item is incomplete or is not bound to every current source revision. Review it before relying on its period or disposition behavior.",
23
+ usableRules.includes(rule) ? "complete" : "action",
24
+ usableRules.includes(rule) ? `${rule.title} proposal is ready` : `Finish ${rule.title}`,
25
+ usableRules.includes(rule)
26
+ ? "The owner completed this row and bound it to its current source revisions. It will become part of the authoritative schedule only when the complete schedule is approved."
27
+ : rule.status === "planned"
28
+ ? "Finish the type, scope, cutoff, period, disposition, sources, and owner review for this proposed row before requesting schedule approval."
29
+ : "This row is incomplete or is not bound to every current source revision. Review it before requesting schedule approval.",
23
30
  rule,
24
31
  {
32
+ coverageState: usableRules.includes(rule) ? "proposed" : "incomplete",
25
33
  sourceResourceIds: retentionReviewResourceIds(rule, loaded),
26
34
  commands: [
27
35
  `npx filegrc get ${rule.id} --mutation`,
@@ -39,31 +47,41 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
39
47
  usesByInformationType.set(use.informationTypeId, grouped);
40
48
  }
41
49
  items.push(...[...usesByInformationType].map(([informationTypeId, informationTypeUses]) => {
42
- const uncoveredUses = informationTypeUses.filter((use) => (
50
+ const absentUses = informationTypeUses.filter((use) => (
51
+ !proposedRules.some((rule) => ruleCoversUse(rule, use, program))
52
+ ));
53
+ const pendingUses = informationTypeUses.filter((use) => (
43
54
  !usableRules.some((rule) => ruleCoversUse(rule, use, program))
44
55
  ));
56
+ const approvalPending = !options.scheduleApproved;
45
57
  const matches = usableRules.filter((rule) => (
46
58
  informationTypeUses.some((use) => ruleCoversUse(rule, use, program))
47
59
  ));
48
60
  const informationTypeTitle = byId.get(informationTypeId)?.title || informationTypeId;
49
- const affectedTitles = uncoveredUses.map(({ resource }) => resource.title);
61
+ const affectedTitles = absentUses.map(({ resource }) => resource.title);
50
62
  const affectedSummary = affectedTitles.length > 3
51
63
  ? `${affectedTitles.slice(0, 3).join(", ")}, and ${affectedTitles.length - 3} more`
52
64
  : affectedTitles.join(", ");
53
65
  return readinessItem(
54
66
  `retention-use-${informationTypeId}`,
55
- uncoveredUses.length ? "action" : "complete",
67
+ absentUses.length ? "action" : pendingUses.length || approvalPending ? "info" : "complete",
56
68
  `Decide retention for ${informationTypeTitle}`,
57
- uncoveredUses.length
69
+ absentUses.length
58
70
  ? `${affectedTitles.length} in-scope ${affectedTitles.length === 1 ? "resource uses" : "resources use"} this Information Type without an active, current retention decision, including ${affectedSummary}. Management must choose the cutoff, period, disposition, and whether one program-wide rule or separate scoped rules apply.`
59
- : `All ${informationTypeUses.length} in-scope ${informationTypeUses.length === 1 ? "use is" : "uses are"} covered by ${matches.map(({ title }) => title).join(", ")}.`,
71
+ : pendingUses.length
72
+ ? `Proposed rows cover every in-scope type-and-scope pair. Finish the row review, then approve the complete schedule once instead of approving these coverage checks separately.`
73
+ : approvalPending
74
+ ? "Completed row proposals cover every in-scope type-and-scope pair. Approve the complete schedule once; these derived checks are not separate approval work."
75
+ : `The approved schedule covers all ${informationTypeUses.length} in-scope ${informationTypeUses.length === 1 ? "use" : "uses"}.`,
60
76
  { type: "retention-schedule-item" },
61
77
  {
78
+ progressUnit: false,
79
+ coverageState: absentUses.length ? "absent" : pendingUses.length || approvalPending ? "proposed" : "approved",
62
80
  informationTypeId,
63
- affectedResourceIds: uncoveredUses.map(({ resource }) => resource.id),
64
- retentionScopeResourceIds: uncoveredUses.length === informationTypeUses.length
81
+ affectedResourceIds: absentUses.map(({ resource }) => resource.id),
82
+ retentionScopeResourceIds: absentUses.length === informationTypeUses.length
65
83
  ? [program.id]
66
- : uncoveredUses.map(({ resource }) => resource.id),
84
+ : absentUses.map(({ resource }) => resource.id),
67
85
  retentionScheduleItemIds: matches.map(({ id }) => id),
68
86
  commands: [
69
87
  "npx filegrc guide retention-schedule-item --json",
@@ -75,6 +93,11 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
75
93
 
76
94
  for (const coverage of loaded.resources.filter((record) => record.type === "source-coverage" && record.status === "active")) {
77
95
  const linked = (coverage.retentionScheduleItemIds || []).map((id) => byId.get(id)).filter(Boolean);
96
+ const proposed = linked.filter((rule) => (
97
+ rule.type === "retention-schedule-item"
98
+ && proposedRules.includes(rule)
99
+ && (rule.scopeResourceIds || []).includes(coverage.id)
100
+ ));
78
101
  const matching = linked.filter((rule) => (
79
102
  rule.type === "retention-schedule-item"
80
103
  && rule.status === "active"
@@ -83,13 +106,17 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
83
106
  ));
84
107
  items.push(readinessItem(
85
108
  `retention-source-coverage-${coverage.id}`,
86
- matching.length ? "complete" : "action",
109
+ matching.length && options.scheduleApproved ? "complete" : proposed.length ? "info" : "action",
87
110
  `Confirm retained evidence for ${coverage.title}`,
88
- matching.length
111
+ matching.length && options.scheduleApproved
89
112
  ? `The source-coverage record references a current schedule item scoped to this population.`
90
- : `The source-coverage record must reference an active, current schedule item whose scope includes ${coverage.id}. A draft schedule or unrelated rule does not satisfy this check.`,
113
+ : proposed.length
114
+ ? "A proposed schedule row covers this evidence source. Finish that row and approve the complete schedule before relying on it."
115
+ : `No proposed schedule row covers ${coverage.id}. Add the genuine type-and-scope decision before schedule approval.`,
91
116
  coverage,
92
117
  {
118
+ progressUnit: false,
119
+ coverageState: matching.length && options.scheduleApproved ? "approved" : proposed.length ? "proposed" : "absent",
93
120
  retentionScheduleItemIds: matching.map(({ id }) => id),
94
121
  commands: [
95
122
  `npx filegrc get ${coverage.id} --mutation`,
@@ -110,6 +137,7 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
110
137
  : `${duplicates.length} similar pair${duplicates.length === 1 ? " needs" : "s need"} management review. FileGRC will not merge records or rewrite relationships automatically.`,
111
138
  { type: "information-type" },
112
139
  {
140
+ progressUnit: false,
113
141
  candidates: duplicates,
114
142
  commands: [
115
143
  "npx filegrc list information-type --workflow --json",
@@ -218,8 +246,11 @@ function reviewSource(loaded, entry) {
218
246
  && entry.record.workflowScope === "program"
219
247
  ) {
220
248
  const approved = structuredClone(entry.record);
221
- if (approved.status === "active") approved.status = "approved";
222
249
  for (const field of [
250
+ "status",
251
+ "approverIds",
252
+ "approvedOn",
253
+ "approvedContentRevisions",
223
254
  "activationBasis",
224
255
  "activatedByIds",
225
256
  "activatedOn",
@@ -252,6 +283,14 @@ function ruleCoversUse(rule, use, program) {
252
283
  return scope.has(use.resource.id) || scope.has(program.id);
253
284
  }
254
285
 
286
+ function retentionRuleHasCoverageProposal(rule) {
287
+ return Boolean(
288
+ (rule.informationTypeIds || []).length
289
+ && (rule.scopeResourceIds || []).length
290
+ && rule.scheduleDocumentId
291
+ );
292
+ }
293
+
255
294
  export function retentionReviewResourceIds(rule, loaded) {
256
295
  const programUseIds = [];
257
296
  if (loaded) {
@@ -279,7 +318,7 @@ export function retentionRuleIsCurrent(rule, revisions, byId = new Map(), loaded
279
318
  if (!(rule.informationTypeIds || []).length || !(rule.scopeResourceIds || []).length || !rule.scheduleDocumentId) return false;
280
319
  const schedule = byId.get(rule.scheduleDocumentId);
281
320
  if (schedule?.type !== "document" || schedule.documentKind !== "schedule" || schedule.workflowScope !== "program" || ["superseded", "retired"].includes(schedule.status)) return false;
282
- if (!(rule.ownerIds || []).length || !(rule.approvedByIds || []).length || !rule.approvedOn) return false;
321
+ if (!(rule.ownerIds || []).length) return false;
283
322
  if (!rule.reviewedSourceRevisions || typeof rule.reviewedSourceRevisions !== "object") return false;
284
323
  if (!retentionCutoffIsComplete(rule.cutoff) || !retentionPeriodIsComplete(rule.retentionPeriod)) return false;
285
324
  if (!["delete", "destroy", "erase", "anonymize", "transfer", "retain-permanently"].includes(rule.dispositionAction)) return false;
package/src/server.js CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  pushWorkspace,
34
34
  retryBrowserSync,
35
35
  runBrowserMutation,
36
+ synchronizeBrowserStartup,
36
37
  withGitCommandCache,
37
38
  withGitCommandDeadline
38
39
  } from "./git.js";
@@ -777,6 +778,18 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
777
778
  if (!Number.isInteger(port) || port < 0 || port > 65_535) {
778
779
  throw new Error("The server port must be an integer from 0 through 65535.");
779
780
  }
781
+ let startupSynchronization;
782
+ try {
783
+ startupSynchronization = await synchronizeBrowserStartup(input, {
784
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true
785
+ });
786
+ } catch (error) {
787
+ startupSynchronization = {
788
+ status: "failed",
789
+ updated: false,
790
+ message: error.message
791
+ };
792
+ }
780
793
  const loaded = await loadWorkspace(input);
781
794
  getResourceDefinition(loaded.model, "workspace");
782
795
  const server = createFilegrcServer(loaded.root, {
@@ -806,7 +819,8 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
806
819
  address: server.address(),
807
820
  url: `http://${urlHost(host)}:${server.address().port}`,
808
821
  requestedPort: port,
809
- usedFallbackPort
822
+ usedFallbackPort,
823
+ startupSynchronization
810
824
  };
811
825
  }
812
826
 
@@ -1,4 +1,7 @@
1
1
  import { resourceReviewRevisions, retentionReviewResourceIds, retentionRuleIsCurrent } from "./retention.js";
2
+ import { retentionScheduleIsAuthoritative } from "./collection-review.js";
3
+ import { modelSupports } from "../model/index.js";
4
+ import { resolveProgram } from "./program.js";
2
5
 
3
6
  export async function sourceCoverageComplete(record, loaded, program = loaded.workspace) {
4
7
  const structuredRetention = loaded.model.resources["retention-schedule-item"];
@@ -6,6 +9,10 @@ export async function sourceCoverageComplete(record, loaded, program = loaded.wo
6
9
  return false;
7
10
  }
8
11
  if (structuredRetention) {
12
+ const retentionProgram = modelSupports(loaded.model, "program-scope")
13
+ ? resolveProgram(loaded, program?.type === "program" ? program.id : undefined)
14
+ : program;
15
+ if (!retentionScheduleIsAuthoritative(loaded, retentionProgram)) return false;
9
16
  const linkedRules = (record.retentionScheduleItemIds || []).map((id) => (
10
17
  loaded.resources.find((resource) => resource.id === id && resource.type === "retention-schedule-item")
11
18
  ));
package/src/state.js CHANGED
@@ -347,6 +347,8 @@ function serializeCollectionReviewAssessment(assessment) {
347
347
  status: assessment.status,
348
348
  complete: assessment.complete,
349
349
  message: assessment.message,
350
+ ...(assessment.recordProposals ? { recordProposals: assessment.recordProposals } : {}),
351
+ ...(assessment.incompleteRecordProposals ? { incompleteRecordProposals: assessment.incompleteRecordProposals } : {}),
350
352
  ...(assessment.eligibleReviewerIds ? { eligibleReviewerIds: assessment.eligibleReviewerIds } : {}),
351
353
  ...(assessment.reviewerConflictIds ? { reviewerConflictIds: assessment.reviewerConflictIds } : {}),
352
354
  ...(assessment.approvalIssues ? { approvalIssues: assessment.approvalIssues } : {})
package/src/validate.js CHANGED
@@ -3,7 +3,7 @@ import { createReadStream } from "node:fs";
3
3
  import { readFile, stat } from "node:fs/promises";
4
4
  import { performance } from "node:perf_hooks";
5
5
  import { getResourceDefinition, modelSupports } from "../model/index.js";
6
- import { scopedCollectionRecords, selectScopedCollectionRecords } from "./collection-scope.js";
6
+ import { collectionRecordProposals, retentionScheduleReviewScope, scopedCollectionRecords, selectScopedCollectionRecords } from "./collection-scope.js";
7
7
  import {
8
8
  collectionRevision,
9
9
  collectionRevisionMatches
@@ -44,6 +44,7 @@ import {
44
44
  recordsAtRevision
45
45
  } from "./reporting-route-integrity.js";
46
46
  import { validateWorkflowHistoryIntegrity } from "./workflow-history-integrity.js";
47
+ import { occurrenceMemberExceptionIsValid } from "./obligation-members.js";
47
48
 
48
49
  let fingerprintFileReadObserver = null;
49
50
 
@@ -667,18 +668,7 @@ function validateObligationOccurrence(record, model, resources, entries, root, w
667
668
  ));
668
669
  }
669
670
  if (member.disposition === "exception") {
670
- const exception = byId.get(member.exceptionId);
671
- const occurrenceStart = record.coverage?.kind === "as-of" ? record.coverage.on : record.coverage?.startsOn;
672
- const occurrenceEnd = record.coverage?.kind === "as-of" ? record.coverage.on : record.coverage?.endsOn;
673
- const exceptionCoversMember = exception?.scopeResourceIds?.includes(member.resourceId)
674
- || exception?.scopeResourceIds?.includes(record.obligationId);
675
- if (
676
- exception?.type !== "exception"
677
- || exception.status !== "approved"
678
- || !exceptionCoversMember
679
- || exception.approval?.approvedOn > occurrenceStart
680
- || exception.approval?.expiresOn < occurrenceEnd
681
- ) {
671
+ if (!occurrenceMemberExceptionIsValid(member, record, byId)) {
682
672
  diagnostics.push(error(
683
673
  "invalid-member-exception",
684
674
  path,
@@ -1224,6 +1214,10 @@ function sameRouteProposal(proposal, finalized) {
1224
1214
  return [...keys].every((key) => allowed.has(key) || JSON.stringify(proposal[key]) === JSON.stringify(finalized[key]));
1225
1215
  }
1226
1216
 
1217
+ function sameStringIds(left = [], right = []) {
1218
+ return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
1219
+ }
1220
+
1227
1221
  function validateTimestampTimezone(value, timezone, path, diagnostics, label) {
1228
1222
  if (!value || !timezone) return;
1229
1223
  const source = String(value);
@@ -1252,10 +1246,19 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
1252
1246
  const program = modelSupports(model, "program-scope")
1253
1247
  ? (record.scopeResourceIds || []).map((id) => byId.get(id)).find(({ type } = {}) => type === "program")
1254
1248
  : null;
1249
+ const workspaceWideRetentionReview = record.resourceType === "retention-schedule-item"
1250
+ && modelSupports(model, "retention-schedule-approval");
1251
+ const retentionScope = workspaceWideRetentionReview ? retentionScheduleReviewScope(loaded) : null;
1255
1252
  const recordCount = scopedCollectionRecords(loaded, record.resourceType, program).length;
1256
1253
  const currentPopulationIds = scopedCollectionRecords(loaded, record.resourceType, program)
1257
1254
  .map(({ id }) => id)
1258
1255
  .sort();
1256
+ const incompleteProposals = collectionRecordProposals(
1257
+ loaded,
1258
+ record.resourceType,
1259
+ scopedCollectionRecords(loaded, record.resourceType, program),
1260
+ program
1261
+ ).filter(({ complete }) => !complete);
1259
1262
  const currentRevision = collectionRevision(loaded, record.resourceType, {
1260
1263
  program,
1261
1264
  authoritativeSourceId: record.decision === "externally-managed"
@@ -1274,6 +1277,20 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
1274
1277
  currentRevision
1275
1278
  }
1276
1279
  );
1280
+ if (current && workspaceWideRetentionReview && !sameStringIds(record.scopeResourceIds, retentionScope.programIds)) {
1281
+ diagnostics.push(error(
1282
+ "invalid-retention-schedule-review-scope",
1283
+ path,
1284
+ "The current Data Retention Schedule review must cover every active Program as one workspace-wide approval."
1285
+ ));
1286
+ }
1287
+ if (current && incompleteProposals.length) {
1288
+ diagnostics.push(error(
1289
+ "incomplete-collection-review-proposals",
1290
+ path,
1291
+ `Complete every ${configuration.title.toLowerCase()} record proposal before the collection review. Remaining: ${incompleteProposals.map(({ title }) => title).join(", ")}.`
1292
+ ));
1293
+ }
1277
1294
  if (current && record.resourceType === "control") {
1278
1295
  const controls = scopedCollectionRecords(loaded, "control", program);
1279
1296
  const controlIds = new Set(controls.map(({ id }) => id));
@@ -1299,8 +1316,11 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
1299
1316
  }
1300
1317
  if (current && record.resourceType === "retention-schedule-item" && modelSupports(model, "retention-schedule-approval")) {
1301
1318
  const rows = scopedCollectionRecords(loaded, record.resourceType, program);
1302
- for (const issue of retentionScheduleApprovalIssues(loaded, program || {}, rows, {
1303
- informationTypesReviewed: collectionReviewIsCurrent(loaded, "information-type", program)
1319
+ for (const issue of retentionScheduleApprovalIssues(loaded, retentionScope, rows, {
1320
+ informationTypesReviewed: retentionScope.programIds.every((id) => (
1321
+ collectionReviewIsCurrent(loaded, "information-type", byId.get(id))
1322
+ )),
1323
+ approvalReview: record
1304
1324
  })) {
1305
1325
  diagnostics.push(error(issue.code, path, issue.message));
1306
1326
  }