filegrc 0.15.1 → 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.
@@ -65,6 +65,77 @@ export function scopedCollectionRecords(loaded, resourceType, program) {
65
65
  ));
66
66
  }
67
67
 
68
+ export function retentionScheduleReviewScope(loaded) {
69
+ const programs = loaded.resources.filter((record) => (
70
+ record.type === "program" && record.status !== "retired"
71
+ ));
72
+ return {
73
+ id: "workspace",
74
+ programIds: programs.map(({ id }) => id).sort(),
75
+ systemIds: [...new Set(programs.flatMap((record) => record.systemIds || []))],
76
+ controlIds: [...new Set(programs.flatMap((record) => record.controlIds || []))],
77
+ frameworkIds: [...new Set(programs.flatMap((record) => record.frameworkIds || []))]
78
+ };
79
+ }
80
+
81
+ const RECORD_PROPOSAL_COLLECTIONS = new Set([
82
+ "component",
83
+ "vendor",
84
+ "complementary-control",
85
+ "information-type"
86
+ ]);
87
+
88
+ export function collectionRecordProposals(loaded, resourceType, records, program) {
89
+ if (!RECORD_PROPOSAL_COLLECTIONS.has(resourceType)) return [];
90
+ const currentRecords = records.filter((record) => collectionRecordIsCurrent(record));
91
+ const scopedIds = new Set(currentRecords.map(({ id }) => id));
92
+ const candidates = new Map(currentRecords.map((record) => [record.id, record]));
93
+ const activePrograms = loaded.resources.filter((record) => record.type === "program" && record.status !== "retired");
94
+ if (activePrograms.length === 1 && activePrograms[0].id === program?.id && ["component", "complementary-control"].includes(resourceType)) {
95
+ for (const record of loaded.resources) {
96
+ if (
97
+ record.type !== resourceType
98
+ || ["retired", "superseded"].includes(record.status)
99
+ || collectionRecordProposalComplete(record, loaded)
100
+ ) continue;
101
+ candidates.set(record.id, record);
102
+ }
103
+ }
104
+ return [...candidates.values()].map((record) => ({
105
+ resourceId: record.id,
106
+ title: record.title,
107
+ complete: scopedIds.has(record.id) && collectionRecordProposalComplete(record, loaded)
108
+ }));
109
+ }
110
+
111
+ function collectionRecordIsCurrent(record) {
112
+ if (record.type === "vendor") return !["deprecated", "terminated"].includes(record.status);
113
+ if (record.type === "information-type") return !["superseded", "retired"].includes(record.status);
114
+ if (record.type === "complementary-control") return !["superseded", "retired"].includes(record.status);
115
+ return record.status !== "retired";
116
+ }
117
+
118
+ function collectionRecordProposalComplete(record, loaded) {
119
+ if (record.type === "vendor") return record.status !== "evaluating";
120
+ if (record.type === "component") {
121
+ if (record.status === "planned") return false;
122
+ if (record.status === "retired") return true;
123
+ return record.status === "active"
124
+ && Boolean(String(record.description || "").trim())
125
+ && (record.ownerIds || []).length > 0
126
+ && (record.systemUses || []).length > 0
127
+ && (record.systemUses || []).every(({ roles, rationale }) => (
128
+ (roles || []).length > 0 && Boolean(String(rationale || "").trim())
129
+ ));
130
+ }
131
+ if (record.type === "information-type") {
132
+ if (record.status === "retired") return true;
133
+ const classification = loaded.resources.find(({ id, type }) => type === "classification" && id === record.classificationId);
134
+ return record.status === "active" && classification?.status === "active";
135
+ }
136
+ return record.status !== "planned";
137
+ }
138
+
68
139
  export function selectScopedCollectionRecords(loaded, selector, program) {
69
140
  if (!selector?.resourceType) return [];
70
141
  return scopedCollectionRecords(loaded, selector.resourceType, program).filter((record) => (
@@ -231,7 +302,10 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
231
302
  .map(({ id }) => id));
232
303
  }
233
304
  if (resourceType === "retention-schedule-item" && !legacy && modelSupports(loaded.model, "retention-schedule-approval")) {
234
- const uses = retentionUses(loaded, program || {});
305
+ const programs = program
306
+ ? [program]
307
+ : loaded.resources.filter((record) => record.type === "program" && record.status !== "retired");
308
+ const uses = programs.flatMap((candidate) => retentionUses(loaded, candidate));
235
309
  addIds(uses.map(({ resource }) => resource.id));
236
310
  addIds(uses.map(({ informationTypeId }) => informationTypeId));
237
311
  const schedules = loaded.resources
@@ -243,14 +317,14 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
243
317
  ))
244
318
  addIds(schedules.map(({ id }) => id));
245
319
  for (const schedule of schedules) {
246
- addPartyIds([...(schedule.ownerIds || []), ...(schedule.approverIds || [])]);
320
+ addPartyIds(schedule.ownerIds || []);
247
321
  }
248
322
  for (const record of reviewed) {
249
323
  addIds([record.scheduleDocumentId]);
250
324
  addIds(record.informationTypeIds);
251
325
  addIds(record.scopeResourceIds);
252
326
  addIds(record.sourceResourceIds);
253
- addPartyIds([...(record.ownerIds || []), ...(record.approvedByIds || [])]);
327
+ addPartyIds(record.ownerIds || []);
254
328
  }
255
329
  }
256
330
  if (resourceType === "complementary-control") {
@@ -267,14 +341,43 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
267
341
  return [...records.values()].map((record) => ({
268
342
  record,
269
343
  value: reviewedIds.has(record.id)
270
- ? record
271
- : dependencyRevisionValue(resourceType, record, legacy),
344
+ ? resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
345
+ ? retentionScheduleRowProposalValue(record)
346
+ : record
347
+ : resourceType === "retention-schedule-item"
348
+ && modelSupports(loaded.model, "retention-schedule-approval")
349
+ && record.type === "document"
350
+ ? retentionScheduleDocumentProposalValue(record)
351
+ : dependencyRevisionValue(resourceType, record, legacy),
272
352
  includeContent: legacy
273
353
  || reviewedIds.has(record.id)
274
354
  || dependencyContentAffectsRevision(resourceType, record.type)
275
355
  }));
276
356
  }
277
357
 
358
+ function retentionScheduleRowProposalValue(record) {
359
+ return Object.fromEntries(Object.entries(record).filter(([field]) => ![
360
+ "approvedByIds",
361
+ "approvedOn"
362
+ ].includes(field)));
363
+ }
364
+
365
+ function retentionScheduleDocumentProposalValue(record) {
366
+ return Object.fromEntries(Object.entries(record).filter(([field]) => ![
367
+ "status",
368
+ "approverIds",
369
+ "approvedOn",
370
+ "approvedContentRevisions",
371
+ "activationBasis",
372
+ "activatedByIds",
373
+ "activatedOn",
374
+ "activatedContentRevisions",
375
+ "effectiveOn",
376
+ "proposedEffectiveOn",
377
+ "statusTransition"
378
+ ].includes(field)));
379
+ }
380
+
278
381
  export function collectionScopeRevisionFacts(loaded, resourceType, program) {
279
382
  if (!modelSupports(loaded.model, "program-scope")) {
280
383
  const common = { programId: program?.id ?? null };
@@ -296,6 +399,19 @@ export function collectionScopeRevisionFacts(loaded, resourceType, program) {
296
399
  }
297
400
  return common;
298
401
  }
402
+ if (resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")) {
403
+ const programs = loaded.resources
404
+ .filter((record) => record.type === "program" && record.status !== "retired")
405
+ .sort((left, right) => left.id.localeCompare(right.id));
406
+ return {
407
+ programIds: programs.map(({ id }) => id),
408
+ programs: programs.map((record) => ({
409
+ id: record.id,
410
+ systemIds: sorted(record.systemIds),
411
+ controlIds: sorted(record.controlIds)
412
+ }))
413
+ };
414
+ }
299
415
  const common = { programId: program?.id ?? null };
300
416
  if (resourceType === "framework") {
301
417
  return {
package/src/files.js CHANGED
@@ -234,6 +234,12 @@ export async function applyGovernedContentActivationBatch(input, changes) {
234
234
  ));
235
235
  }
236
236
 
237
+ export async function applyRetentionScheduleReviewBatch(input, changes) {
238
+ return serializeWorkspaceMutation(input, (root) => (
239
+ applyResourceBatchUnlocked(root, changes, "retention-schedule-approval")
240
+ ));
241
+ }
242
+
237
243
  export async function applyModelMigrationBatch(input, changes) {
238
244
  return serializeWorkspaceMutation(input, (root) => (
239
245
  applyResourceBatchUnlocked(root, changes, "model-migration")
@@ -1231,7 +1237,13 @@ function assertGovernedContentLifecycleMutation(previousRecord, nextRecord, mode
1231
1237
  const title = getResourceDefinition(model, nextRecord.type).title;
1232
1238
  const approvedStatuses = new Set(["approved", "active", "superseded", "retired"]);
1233
1239
  const activatedStatuses = new Set(["active", "superseded", "retired"]);
1234
- if (approvedStatuses.has(previousRecord.status) && approvedStatuses.has(nextRecord.status)) {
1240
+ const retentionScheduleReapproval = lifecycleOperation === "retention-schedule-approval"
1241
+ && previousRecord.type === "document"
1242
+ && previousRecord.documentKind === "schedule"
1243
+ && previousRecord.workflowScope === "program"
1244
+ && nextRecord.documentKind === "schedule"
1245
+ && nextRecord.workflowScope === "program";
1246
+ if (approvedStatuses.has(previousRecord.status) && approvedStatuses.has(nextRecord.status) && !retentionScheduleReapproval) {
1235
1247
  assertLifecycleFieldsUnchanged(previousRecord, nextRecord, [
1236
1248
  "approverIds",
1237
1249
  "approvedOn",
package/src/git.js CHANGED
@@ -1774,6 +1774,92 @@ export async function prefetchBrowserRemote(
1774
1774
  return prefetch;
1775
1775
  }
1776
1776
 
1777
+ export async function synchronizeBrowserStartup(
1778
+ input = process.cwd(),
1779
+ options = {},
1780
+ ) {
1781
+ return serializeWorkspaceMutation(input, async (root) => {
1782
+ const config = await getRepositoryConfig(root);
1783
+ if (config.mode !== "trunk" || options.allowNonAuthoritativeWrites === true) {
1784
+ return { status: "not-needed", updated: false };
1785
+ }
1786
+
1787
+ const before = await requireTrunkPreconditionsAsync(root, config, {
1788
+ allowAhead: true,
1789
+ });
1790
+ await fetchConfiguredRemote(root, config);
1791
+ const synchronized = inspectTrunkRepository(
1792
+ root,
1793
+ config,
1794
+ await getRepositorySnapshot(root, { fresh: true }),
1795
+ );
1796
+ if (synchronized.ahead > 0 || synchronized.behind === 0) {
1797
+ return {
1798
+ status: synchronized.ahead > 0 ? "blocked" : "current",
1799
+ updated: false,
1800
+ ahead: synchronized.ahead,
1801
+ behind: synchronized.behind,
1802
+ commit: synchronized.currentCommit,
1803
+ };
1804
+ }
1805
+
1806
+ const target = synchronized.upstreamCommit;
1807
+ const incomingCommits = lines(
1808
+ git(root, [
1809
+ "rev-list",
1810
+ "--reverse",
1811
+ `${synchronized.currentCommit}..${target}`,
1812
+ ]),
1813
+ );
1814
+ assertCommitsInsideWorkspace(root, incomingCommits);
1815
+ for (const commit of incomingCommits) assertNoCommitWorkspaceContentFilters(root, commit);
1816
+ assertExpectedCheckout(
1817
+ root,
1818
+ `refs/heads/${config.authoritativeBranch}`,
1819
+ before.currentCommit,
1820
+ );
1821
+ await gitForWriteAsync(
1822
+ root,
1823
+ [
1824
+ "-c",
1825
+ "core.hooksPath=/dev/null",
1826
+ "merge",
1827
+ "--ff-only",
1828
+ "--no-edit",
1829
+ "--no-stat",
1830
+ target,
1831
+ ],
1832
+ `fast-forward ${config.authoritativeBranch} at server startup`,
1833
+ {
1834
+ expectedCheckout: {
1835
+ expectedRef: `refs/heads/${config.authoritativeBranch}`,
1836
+ expectedCommit: before.currentCommit,
1837
+ },
1838
+ expectedNoOperation: true,
1839
+ },
1840
+ );
1841
+ const after = inspectTrunkRepository(
1842
+ root,
1843
+ config,
1844
+ await getRepositorySnapshot(root, { fresh: true }),
1845
+ );
1846
+ if (after.ahead !== 0 || after.behind !== 0 || after.currentCommit !== target) {
1847
+ throw new Error(
1848
+ "The authoritative branch changed while FileGRC fast-forwarded it. Reconcile the repository with Git, then restart FileGRC.",
1849
+ );
1850
+ }
1851
+ const synchronizedAt = new Date().toISOString();
1852
+ lastSuccessfulSynchronizations.set(root, synchronizedAt);
1853
+ return {
1854
+ status: "updated",
1855
+ updated: true,
1856
+ commit: after.currentCommit,
1857
+ shortCommit: after.currentCommit.slice(0, 8),
1858
+ synchronizedAt,
1859
+ };
1860
+ });
1861
+ }
1862
+
1777
1863
  async function prefetchBrowserRemoteCoalesced(root, options) {
1778
1864
  const prepared = await serializeWorkspaceMutation(root, async () => {
1779
1865
  const config = await getRepositoryConfig(root);
package/src/index.js CHANGED
@@ -49,7 +49,8 @@ export {
49
49
  pullWorkspace,
50
50
  pushWorkspace,
51
51
  retryBrowserSync,
52
- runBrowserMutation
52
+ runBrowserMutation,
53
+ synchronizeBrowserStartup
53
54
  } from "./git.js";
54
55
  export { generateModelDocumentation } from "./model-docs.js";
55
56
  export { renderMarkdown } from "./markdown.js";
@@ -111,7 +111,7 @@ export const PROGRAM_PATH = [
111
111
  summary: "Review and independently approve Policies, program Documents, Training content, and the Data Retention Schedule.",
112
112
  sections: [
113
113
  { id: "policy-content", title: "Policies", description: "Review every program Policy, Document, and Training record in one table, then bind independent approval to each exact revision.", steps: ["Review each governed Markdown artifact and replace every organization placeholder.", "Confirm the owner, separate approver, linked Controls, audience, and intended values that apply to each artifact.", "Record approval and its date against the exact revision. Leave approved content inactive until its Step 3 implementation cutover."], types: [], relatedLinks: [{ type: "policy", label: "Policies", href: "#/policies" }], defaultOpen: true },
114
- { id: "retention", title: "Data Retention Schedule", description: "Approve the schedule document and its structured rows as one governed schedule.", steps: ["Review the schedule policy, legal holds, exceptions, and disposal requirements.", "Define rows only for Information Types and operational sources the organization actually uses.", "Confirm the complete document-and-row revision with one schedule review."], types: [], utility: "retention-schedule", defaultOpen: true }
114
+ { id: "retention", title: "Data Retention Schedule", description: "Complete each row proposal, then approve the document and all proposed rows once.", steps: ["Review the schedule policy, legal holds, exceptions, and disposal requirements.", "Have the owner finish each row for the Information Types and operational sources the organization actually uses.", "Trigger one independent approval only after the document and every row proposal are complete."], types: [], utility: "retention-schedule", defaultOpen: true }
115
115
  ],
116
116
  resourceTypes: [],
117
117
  supportingResourceTypes: ["policy", "document", "training", "retention-schedule-item"],
@@ -98,11 +98,17 @@ function calculateSetupActionProgress(stages, target, windowMilestoneComplete) {
98
98
  const units = new Map();
99
99
  for (const currentStage of stages.filter(({ id }) => id !== "operation")) {
100
100
  for (const current of currentStage.items || []) {
101
- if (["info", "later"].includes(current.status) || current.progressUnit === false) continue;
102
- const key = `${currentStage.id}:${current.progressUnitId || current.id}`;
103
- const existing = units.get(key);
104
- if (!existing || existing.status === "complete" && current.status !== "complete") {
105
- units.set(key, current);
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
+ }
106
112
  }
107
113
  }
108
114
  }
@@ -154,13 +160,24 @@ export async function assessProgramReadiness(input, options = {}) {
154
160
  .map(collectionReviewReadinessItem));
155
161
  const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
156
162
  controlStage.items.push(...sourceStage.items);
163
+ const retentionScheduleReview = collectionReviews.find(({ resourceType }) => resourceType === "retention-schedule-item");
157
164
  const retentionItems = await assessRetentionReadiness(loaded, program, {
158
- informationTypesReviewed: collectionReviews.find(({ resourceType }) => resourceType === "information-type")?.complete === true
165
+ informationTypesReviewed: collectionReviews.find(({ resourceType }) => resourceType === "information-type")?.complete === true,
166
+ scheduleApproved: retentionScheduleReview?.complete === true
159
167
  });
160
- const retentionReviewItems = collectionReviews
161
- .filter(({ resourceType }) => resourceType === "retention-schedule-item")
162
- .map(collectionReviewReadinessItem);
168
+ const retentionReviewItems = retentionScheduleReview
169
+ ? [collectionReviewReadinessItem(retentionScheduleReview)]
170
+ : [];
163
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
+ ));
164
181
  policyStage.items.push(...retentionItems.filter(({ id }) => !id.startsWith("retention-source-coverage-")));
165
182
  controlStage.items.push(...retentionItems.filter(({ id }) => id.startsWith("retention-source-coverage-")));
166
183
  policyStage.items.push(...retentionReviewItems);
@@ -395,7 +412,10 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
395
412
  ? `${completeComponents.length} of ${scope.components.length} Components have an active, owned, rationalized role in the selected Systems.`
396
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.",
397
414
  scope.components[0] || { type: "component" },
398
- { componentIds: scope.components.map(({ id }) => id) }
415
+ {
416
+ componentIds: scope.components.map(({ id }) => id),
417
+ progressUnit: false
418
+ }
399
419
  ));
400
420
  }
401
421
 
@@ -562,7 +582,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
562
582
  return stage("scope", "Define Scope", "Set program ownership, the management objective, service boundary, criteria, controls, and dependencies.", items);
563
583
  }
564
584
 
565
- function reportingRouteSetItem(assessment) {
585
+ export function reportingRouteSetItem(assessment) {
566
586
  const current = assessment.routeSets.find(({ effective, canceled }) => effective && !canceled);
567
587
  const draft = assessment.routeSets.find(({ record }) => ["draft", "proposed"].includes(record.status));
568
588
  const required = assessment.requirements.length > 0;
@@ -571,13 +591,18 @@ function reportingRouteSetItem(assessment) {
571
591
  .map(({ purposeKey }) => purposeKey)
572
592
  .filter(Boolean))];
573
593
  const preparedPurposeKeys = new Set(assessment.routeSets
574
- .filter(({ record, committed, canceled, proposedRequirementIssues }) => (
575
- ["proposed", "approved"].includes(record.status)
594
+ .filter(({ record, committed, effective, canceled, proposedRequirementIssues }) => (
595
+ (["proposed", "approved"].includes(record.status) || effective)
576
596
  && committed
577
597
  && !canceled
578
598
  && proposedRequirementIssues.length === 0
579
599
  ))
580
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
+ ])];
581
606
  const unpreparedPurposeKeys = proposedPurposeKeys.filter((purposeKey) => !preparedPurposeKeys.has(purposeKey));
582
607
  const ready = assessment.issues.length === 0
583
608
  && (!required || Boolean(current))
@@ -612,6 +637,42 @@ function reportingRouteSetItem(assessment) {
612
637
  proposedRequirementIssues: assessment.routeSets.flatMap(({ proposedRequirementIssues }) => proposedRequirementIssues),
613
638
  unpreparedPurposeKeys,
614
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
+ }),
615
676
  commands: [
616
677
  "npx filegrc reporting-route-sets --json",
617
678
  "npx filegrc reporting-route-set --scaffold"
@@ -647,6 +708,11 @@ function reportingRouteItem(records, byId, asOf, timezone = "UTC") {
647
708
  }
648
709
 
649
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
+ }));
650
716
  return item(
651
717
  `collection-review-${assessment.resourceType}`,
652
718
  assessment.complete ? "complete" : "action",
@@ -658,6 +724,18 @@ function collectionReviewReadinessItem(assessment) {
658
724
  {
659
725
  resourceType: assessment.resourceType,
660
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
+ } : {}),
661
739
  commands: [
662
740
  `npx filegrc review-collection ${assessment.resourceType} --scaffold`,
663
741
  `npx filegrc review-collection ${assessment.resourceType} REVIEW.json --preview --json`
@@ -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
  }