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.
package/model/v11.json CHANGED
@@ -567,7 +567,7 @@
567
567
  },
568
568
  "vendor": {
569
569
  "title": "Vendor inventory",
570
- "description": "Confirm material external provider relationships separately from Program System and Component scope.",
570
+ "description": "Finish each material external provider record, then confirm the Vendor inventory once as a complete collection separate from Program System and Component scope.",
571
571
  "reviewPoints": [
572
572
  "Add Vendors for material external commercial or service relationships.",
573
573
  "Confirm contracts, due diligence, criticality, information access, and monitoring.",
@@ -593,7 +593,7 @@
593
593
  },
594
594
  "complementary-control": {
595
595
  "title": "Customer and provider responsibilities",
596
- "description": "Confirm whether any in-scope Control depends on a customer or carved-out provider action, including when no such dependency exists.",
596
+ "description": "Finish each customer or carved-out provider responsibility record, then confirm the collection once, including when no such dependency exists.",
597
597
  "reviewPoints": [
598
598
  "Think through the customer and carved-out provider actions that in-scope Controls depend on, and add any missing Complementary Control.",
599
599
  "Confirm each statement names a specific action, responsible party, affected System, and dependent Control.",
@@ -606,7 +606,7 @@
606
606
  },
607
607
  "component": {
608
608
  "title": "Scoped Components",
609
- "description": "Confirm the Components that materially participate in the selected Program Systems.",
609
+ "description": "Finish each Component proposal, then confirm once that the collection materially participating in the selected Program Systems is complete.",
610
610
  "reviewPoints": [
611
611
  "Start with each bounded System and include only Components that deliver its service, support Controls, produce authoritative Evidence, or support relevant operations.",
612
612
  "Confirm every System use has the right role and a specific rationale.",
@@ -631,7 +631,7 @@
631
631
  },
632
632
  "information-type": {
633
633
  "title": "Information Type inventory",
634
- "description": "Confirm that Information Types are complete, distinct, and linked to the Systems, Components, and Vendors that use or access them.",
634
+ "description": "Finish each Information Type proposal, then confirm once that the collection is complete, distinct, and linked to the Systems, Components, and Vendors that use or access it.",
635
635
  "reviewPoints": [
636
636
  "Review near-duplicate names and choose one canonical Information Type only after confirming they mean the same thing.",
637
637
  "Retire superseded records through a reviewed migration that rewrites every relationship.",
@@ -644,11 +644,11 @@
644
644
  },
645
645
  "retention-schedule-item": {
646
646
  "title": "Data Retention Schedule",
647
- "description": "Approve the schedule document and its structured rows as one governed schedule. Include only actual information uses and keep undecided organization values planned.",
647
+ "description": "Complete each structured row proposal, then approve the schedule document and all proposed rows in one independent review. Include only actual information uses and keep undecided organization values planned.",
648
648
  "reviewPoints": [
649
- "Review the Data Retention Schedule document, its Markdown, and every included structured row as one revision.",
649
+ "Confirm the owner completed every row proposal, then review the Data Retention Schedule document, its Markdown, and every proposed row as one revision.",
650
650
  "Confirm schedule coverage across the Systems, Components, Vendors, source families, logs, backups, audit records, and FileGRC records the organization actually uses.",
651
- "Confirm every active item has an approved cutoff, retention period, disposition action, owner, approver, and exact reviewed source revisions; keep undecided behavior planned."
651
+ "Confirm every proposed item has a reviewed cutoff, retention period, disposition action, owner, and exact source revisions; keep undecided behavior planned."
652
652
  ],
653
653
  "decisions": [
654
654
  "complete",
@@ -7699,14 +7699,14 @@
7699
7699
  "pluralTitle": "Retention Schedule",
7700
7700
  "group": "governance",
7701
7701
  "collection": "retention-schedule-items",
7702
- "description": "One reviewed schedule item that states which Information Types and operational scope it covers, when retention starts, how long information is kept, and its approved disposition.",
7702
+ "description": "One owner-completed proposal that states which Information Types and operational scope it covers, when retention starts, how long information is kept, and its proposed disposition. It becomes authoritative through the single whole-schedule approval.",
7703
7703
  "guidance": {
7704
7704
  "policyBasis": "Management must translate legal, contractual, privacy, security, and business needs into explicit retention and disposition instructions without treating starter periods as organization facts.",
7705
- "cadence": "Review before activation and after a source Policy, Requirement, Commitment, information use, System, Component, Vendor, or legal-hold process changes.",
7705
+ "cadence": "Complete the proposal before whole-schedule approval and revisit it after a source Policy, Requirement, Commitment, information use, System, Component, Vendor, or legal-hold process changes.",
7706
7706
  "reviewPoints": [
7707
7707
  "Confirm the Information Types and operational scope covered by this item.",
7708
- "Confirm the cutoff, retention period, and disposition action from approved management sources.",
7709
- "Keep the item planned until management approves every organization-specific value."
7708
+ "Confirm the cutoff, retention period, and disposition action from management sources.",
7709
+ "Keep the item planned until the owner completes every organization-specific value and binds the current source revisions."
7710
7710
  ],
7711
7711
  "sourceResourceIds": [
7712
7712
  "policy-information-security",
@@ -7828,16 +7828,11 @@
7828
7828
  "relation": [
7829
7829
  "person"
7830
7830
  ],
7831
- "requiredWhen": {
7832
- "status": "active"
7833
- },
7834
- "label": "Approved by"
7831
+ "label": "Legacy row reviewer"
7835
7832
  },
7836
7833
  "approvedOn": {
7837
7834
  "type": "date",
7838
- "requiredWhen": {
7839
- "status": "active"
7840
- }
7835
+ "label": "Legacy row review date"
7841
7836
  },
7842
7837
  "reviewedSourceRevisions": {
7843
7838
  "type": "object",
@@ -7891,8 +7886,6 @@
7891
7886
  "retentionPeriod",
7892
7887
  "dispositionAction",
7893
7888
  "dispositionInstructions",
7894
- "approvedByIds",
7895
- "approvedOn",
7896
7889
  "reviewedSourceRevisions",
7897
7890
  "supersedesId",
7898
7891
  "statusTransition"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -105,17 +105,21 @@ export async function assessAuditPreparation(input, options = {}) {
105
105
  }
106
106
  const items = stages.flatMap((stage) => stage.items);
107
107
  const counts = countStatuses(items);
108
- const managedItems = items.filter((item) => !["external", "info", "later"].includes(item.status));
109
- const completedManagedItems = managedItems.filter((item) => item.status === "complete");
108
+ const progressUnits = items.flatMap((current) => (
109
+ current.progressUnit === false || ["external", "info", "later"].includes(current.status)
110
+ ? []
111
+ : current.progressUnits?.length ? current.progressUnits : [current]
112
+ )).filter((current) => !["external", "info", "later"].includes(current.status));
113
+ const completedProgressUnits = progressUnits.filter((current) => current.status === "complete");
110
114
  return {
111
115
  schemaVersion: 1,
112
116
  generatedAt: options.generatedAt || new Date().toISOString(),
113
117
  audit: audit ? auditSummary(audit) : null,
114
118
  status: !audit ? "not-started" : counts.action ? "needs-work" : "management-ready",
115
119
  progress: {
116
- complete: completedManagedItems.length,
117
- total: managedItems.length,
118
- percent: managedItems.length ? Math.round((completedManagedItems.length / managedItems.length) * 100) : 0
120
+ complete: completedProgressUnits.length,
121
+ total: progressUnits.length,
122
+ percent: progressUnits.length ? Math.round((completedProgressUnits.length / progressUnits.length) * 100) : 0
119
123
  },
120
124
  counts,
121
125
  canInitialize: Boolean(audit
@@ -965,7 +969,7 @@ function occurrenceContinuityStage(audit, records, model, asOf) {
965
969
  const gapStatuses = new Set(["overdue", "blocked", "due", "proposed"]);
966
970
  const calendarGaps = plan.calendarItems.filter(({ status }) => gapStatuses.has(status));
967
971
  const eventGaps = plan.eventRuns
968
- .filter((run) => run.occurredOn >= periodStart && run.occurredOn <= periodThrough)
972
+ .filter((run) => run.status !== "canceled" && run.occurredOn >= periodStart && run.occurredOn <= periodThrough)
969
973
  .flatMap((run) => run.actions)
970
974
  .filter((action) => (
971
975
  (gapStatuses.has(action.status) || action.lateCompletion)
@@ -974,6 +978,57 @@ function occurrenceContinuityStage(audit, records, model, asOf) {
974
978
  const gaps = [...calendarGaps, ...eventGaps];
975
979
  const lateGaps = eventGaps.filter(({ lateCompletion }) => lateCompletion);
976
980
  const firstGap = gaps[0];
981
+ const progressUnits = occurrenceProgressUnits(plan, {
982
+ selectedControlIds,
983
+ periodStart,
984
+ periodThrough
985
+ });
986
+ return occurrenceContinuityStageResult({
987
+ audit,
988
+ gaps,
989
+ lateGaps,
990
+ firstGap,
991
+ eventGaps,
992
+ periodThrough,
993
+ progressUnits
994
+ });
995
+ }
996
+
997
+ export function occurrenceProgressUnits(plan, { selectedControlIds, periodStart, periodThrough }) {
998
+ return [
999
+ ...plan.calendarItems.flatMap((occurrence) => {
1000
+ const completedMemberIds = new Set(occurrence.completedMemberIds || []);
1001
+ const conclusionComplete = occurrence.reconciliationStatus === "reconciled"
1002
+ && ["complete", "complete-with-exceptions", "zero-population"].includes(occurrence.operatingResult);
1003
+ const occurrenceComplete = conclusionComplete || occurrence.legacySchedule && occurrence.status === "complete";
1004
+ const memberUnits = (occurrence.expectedMemberIds || []).map((resourceId) => ({
1005
+ id: `occurrence-${occurrence.occurrenceKey}-member-${resourceId}`,
1006
+ status: occurrenceComplete || completedMemberIds.has(resourceId) ? "complete" : "action",
1007
+ title: `${occurrence.title} member ${resourceId}`
1008
+ }));
1009
+ const membersComplete = memberUnits.every(({ status }) => status === "complete");
1010
+ return [
1011
+ ...memberUnits,
1012
+ {
1013
+ id: `occurrence-${occurrence.occurrenceKey}-reconciliation`,
1014
+ status: occurrenceComplete ? "complete" : membersComplete && occurrence.membershipFinal ? "action" : "blocked",
1015
+ title: `${occurrence.title} reconciliation`
1016
+ }
1017
+ ];
1018
+ }),
1019
+ ...plan.eventRuns
1020
+ .filter((run) => run.status !== "canceled" && run.occurredOn >= periodStart && run.occurredOn <= periodThrough)
1021
+ .flatMap((run) => run.actions)
1022
+ .filter((action) => action.controlIds.some((id) => selectedControlIds.has(id)))
1023
+ .map((action) => ({
1024
+ id: `event-action-${action.actionItemId || action.key}`,
1025
+ status: action.status === "complete" && !action.lateCompletion ? "complete" : "action",
1026
+ title: action.title
1027
+ }))
1028
+ ];
1029
+ }
1030
+
1031
+ function occurrenceContinuityStageResult({ audit, gaps, lateGaps, firstGap, eventGaps, periodThrough, progressUnits }) {
977
1032
  return stage(
978
1033
  "occurrences",
979
1034
  "Operating Occurrences",
@@ -997,7 +1052,8 @@ function occurrenceContinuityStage(audit, records, model, asOf) {
997
1052
  commands: firstGap?.actionItemId ? [
998
1053
  `npx filegrc get ${firstGap.actionItemId} --json`,
999
1054
  `npx filegrc scaffold finding --title "Late operating occurrence review"`
1000
- ] : []
1055
+ ] : [],
1056
+ progressUnits
1001
1057
  }
1002
1058
  )]
1003
1059
  );
@@ -1348,12 +1404,33 @@ function populationsStage(audit, records, byId, model) {
1348
1404
  const items = templates.map((template) => {
1349
1405
  const population = populations.find((record) => record.populationKind === template.kind);
1350
1406
  const result = populationResult(population, audit, byId);
1407
+ const preparationComplete = populationPreparationComplete(population, audit);
1351
1408
  return item(
1352
1409
  `population-${template.kind}`,
1353
1410
  result.status,
1354
1411
  template.title,
1355
1412
  result.message || `Use ${template.sourcePrompt} as the starting point, then record the exact authoritative source.`,
1356
- population || { type: "audit-population" }
1413
+ population || { type: "audit-population" },
1414
+ {
1415
+ progressUnits: population?.status === "not-applicable" ? [
1416
+ {
1417
+ id: `population-${template.kind}-applicability`,
1418
+ status: result.status,
1419
+ title: `${template.title} applicability decision`
1420
+ }
1421
+ ] : [
1422
+ {
1423
+ id: `population-${template.kind}-preparation`,
1424
+ status: preparationComplete ? "complete" : "action",
1425
+ title: `${template.title} preparation`
1426
+ },
1427
+ {
1428
+ id: `population-${template.kind}-conclusion`,
1429
+ status: result.status === "complete" ? "complete" : preparationComplete ? "action" : "blocked",
1430
+ title: `${template.title} reconciliation conclusion`
1431
+ }
1432
+ ]
1433
+ }
1357
1434
  );
1358
1435
  });
1359
1436
  return stage(
@@ -1400,7 +1477,7 @@ function auditorStage(audit, byId, modelVersion) {
1400
1477
  ]);
1401
1478
  }
1402
1479
 
1403
- function populationResult(population, audit, byId) {
1480
+ export function populationResult(population, audit, byId) {
1404
1481
  if (!population) return { status: "action", message: "Initialize this population for the engagement." };
1405
1482
  if (!coverageMatches(
1406
1483
  population.coverage,
@@ -1433,6 +1510,7 @@ function populationResult(population, audit, byId) {
1433
1510
  && evidence.type === "evidence"
1434
1511
  && evidence.artifactKind === "population-export"
1435
1512
  && evidence.status === "verified"
1513
+ && (population.ownerIds || []).length > 0
1436
1514
  && (population.sourceComponentId || population.sourceSystemId)
1437
1515
  && (evidence.sourceComponentId || evidence.sourceSystemId) === (population.sourceComponentId || population.sourceSystemId)
1438
1516
  && coverageMatches(
@@ -1459,6 +1537,21 @@ function populationResult(population, audit, byId) {
1459
1537
  };
1460
1538
  }
1461
1539
 
1540
+ function populationPreparationComplete(population, audit) {
1541
+ if (!population || !coverageMatches(
1542
+ population.coverage,
1543
+ coverageStart(audit?.coverage),
1544
+ coverageEnd(audit?.coverage)
1545
+ )) return false;
1546
+ if (population.status === "not-applicable") {
1547
+ return !(population.controlIds || []).length && Boolean(String(population.notApplicableReason || "").trim());
1548
+ }
1549
+ return Boolean(
1550
+ (population.ownerIds || []).length
1551
+ && (population.sourceComponentId || population.sourceSystemId)
1552
+ );
1553
+ }
1554
+
1462
1555
  function timestampDate(value, timezone) {
1463
1556
  if (!value || !timezone) return null;
1464
1557
  const instant = new Date(value);
@@ -1730,7 +1823,9 @@ function item(id, status, title, message, resource = {}, options = {}) {
1730
1823
  ...(options.commands?.length ? { commands: options.commands } : {}),
1731
1824
  ...(options.affectedActionItemIds?.length ? { affectedActionItemIds: options.affectedActionItemIds } : {}),
1732
1825
  ...(options.affectedEventIds?.length ? { affectedEventIds: options.affectedEventIds } : {}),
1733
- ...(options.lateCount ? { lateCount: options.lateCount } : {})
1826
+ ...(options.lateCount ? { lateCount: options.lateCount } : {}),
1827
+ ...(options.progressUnits?.length ? { progressUnits: options.progressUnits } : {}),
1828
+ ...(options.progressUnit === false ? { progressUnit: false } : {})
1734
1829
  };
1735
1830
  }
1736
1831
 
package/src/cli.js CHANGED
@@ -136,6 +136,13 @@ export async function runCli(argv = process.argv.slice(2)) {
136
136
  if (result.usedFallbackPort) {
137
137
  console.log(`Port ${result.requestedPort} is already in use. Using ${result.address.port} instead.`);
138
138
  }
139
+ if (result.startupSynchronization?.status === "updated") {
140
+ console.log(`Updated the authoritative branch to ${result.startupSynchronization.shortCommit} before serving.`);
141
+ } else if (result.startupSynchronization?.status === "blocked") {
142
+ console.warn(`Remote startup sync is blocked because local and remote history diverged (${result.startupSynchronization.ahead} ahead, ${result.startupSynchronization.behind} behind). Reconcile the branch before relying on this server.`);
143
+ } else if (result.startupSynchronization?.status === "failed") {
144
+ console.warn(`Remote startup sync did not complete: ${result.startupSynchronization.message}`);
145
+ }
139
146
  console.log(`filegrc workspace: ${result.url}`);
140
147
  console.log(`Data: ${result.root}/data`);
141
148
  printGithubStarMessage();
@@ -1,11 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
2
3
  import { modelSupports } from "../model/index.js";
3
4
  import {
4
5
  collectionRevision,
5
6
  collectionRevisionMatches
6
7
  } from "./collection-revision.js";
7
- import { scopedCollectionRecords } from "./collection-scope.js";
8
- import { applyResourceBatch, INTERNAL_WORKFLOW_CAPABILITIES } from "./files.js";
8
+ import { collectionRecordProposals, retentionScheduleReviewScope, scopedCollectionRecords } from "./collection-scope.js";
9
+ import { applyResourceBatch, applyRetentionScheduleReviewBatch, contentRevision, INTERNAL_WORKFLOW_CAPABILITIES } from "./files.js";
9
10
  import { getGitSummary } from "./git.js";
10
11
  import { createResourceId } from "./id.js";
11
12
  import { loadWorkspace } from "./workspace.js";
@@ -13,6 +14,8 @@ import { resolveProgram } from "./program.js";
13
14
  import { currentCalendarDate } from "./time.js";
14
15
  import { currentPartyPeople } from "./parties.js";
15
16
  import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js";
17
+ import { markdownEntries } from "./resource-markdown.js";
18
+ import { resolveDataPath } from "./paths.js";
16
19
 
17
20
  export { collectionRevision };
18
21
 
@@ -29,13 +32,21 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
29
32
  const configuration = loaded.model.collectionReviews?.[resourceType];
30
33
  if (!configuration) return null;
31
34
  const program = resolveProgram(loaded, options.programId);
35
+ const workspaceWideRetentionReview = resourceType === "retention-schedule-item"
36
+ && modelSupports(loaded.model, "retention-schedule-approval");
37
+ const retentionScope = workspaceWideRetentionReview ? retentionScheduleReviewScope(loaded) : null;
32
38
  const records = scopedCollectionRecords(loaded, resourceType, program);
33
- const reviewEntry = loaded.entries.find(({ record }) => (
39
+ const reviewEntries = loaded.entries.filter(({ record }) => (
34
40
  record.type === "collection-review"
35
41
  && record.resourceType === resourceType
36
42
  && record.status !== "retired"
37
- && (!modelSupports(loaded.model, "program-scope") || (record.scopeResourceIds || []).includes(program.id))
43
+ && (workspaceWideRetentionReview
44
+ || !modelSupports(loaded.model, "program-scope")
45
+ || (record.scopeResourceIds || []).includes(program.id))
38
46
  ));
47
+ const reviewEntry = workspaceWideRetentionReview
48
+ ? reviewEntries.find(({ record }) => sameIds(record.scopeResourceIds, retentionScope.programIds)) || reviewEntries[0]
49
+ : reviewEntries[0];
39
50
  const review = reviewEntry?.record || null;
40
51
  const authoritativeSourceId = review?.decision === "externally-managed"
41
52
  ? review.authoritativeComponentId || review.authoritativeSystemId
@@ -71,26 +82,35 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
71
82
  ? assessRetentionScheduleReviewers(loaded, records)
72
83
  : null;
73
84
  const approvalIssues = resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
74
- ? retentionScheduleApprovalIssues(loaded, program, records, {
75
- informationTypesReviewed: assessCollectionReview(loaded, "information-type", options).complete
85
+ ? retentionScheduleApprovalIssues(loaded, retentionScope, records, {
86
+ informationTypesReviewed: retentionScope.programIds.every((id) => (
87
+ assessCollectionReview(loaded, "information-type", { programId: id }).complete
88
+ )),
89
+ approvalReview: review?.status === "active" ? review : null
76
90
  })
77
91
  : [];
92
+ const recordProposals = collectionRecordProposals(loaded, resourceType, records, program);
93
+ const incompleteRecordProposals = recordProposals.filter(({ complete: ready }) => !ready);
78
94
  const reviewersEligible = !reviewerEligibility || Boolean(
79
95
  review?.reviewedByIds?.length
80
96
  && review.reviewedByIds.every((id) => reviewerEligibility.eligibleReviewerIds.includes(id))
81
97
  );
98
+ const reviewScopeMatches = !workspaceWideRetentionReview
99
+ || sameIds(review?.scopeResourceIds, retentionScope.programIds);
82
100
  const complete = Boolean(
83
101
  review?.status === "active"
84
102
  && allowedDecisions.includes(review.decision)
85
103
  && revisionMatches
86
104
  && temporalReview
105
+ && reviewScopeMatches
87
106
  && reviewersEligible
107
+ && !incompleteRecordProposals.length
88
108
  && !approvalIssues.length
89
109
  );
90
110
  const stale = Boolean(
91
111
  review?.status === "active"
92
112
  && review.collectionRevision
93
- && !revisionMatches
113
+ && (!revisionMatches || !reviewScopeMatches)
94
114
  );
95
115
  return {
96
116
  resourceType,
@@ -101,15 +121,19 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
101
121
  reviewRevision: reviewEntry
102
122
  ? createHash("sha256").update(reviewEntry.source).digest("hex")
103
123
  : null,
124
+ reviewEntries,
104
125
  collectionRevision: currentRevision,
105
126
  status: complete ? "current" : stale ? "stale" : "review-required",
106
127
  complete,
107
128
  ...(reviewerEligibility || {}),
129
+ ...(recordProposals.length ? { recordProposals, incompleteRecordProposals } : {}),
108
130
  ...(approvalIssues.length ? { approvalIssues } : {}),
109
131
  message: complete
110
132
  ? `${configuration.title} were reviewed on ${review.reviewedOn}.`
111
133
  : approvalIssues.length
112
134
  ? approvalIssues[0].message
135
+ : incompleteRecordProposals.length
136
+ ? `Complete ${incompleteRecordProposals.length} ${configuration.title.toLowerCase()} ${incompleteRecordProposals.length === 1 ? "record proposal" : "record proposals"} before the collection review.`
113
137
  : revisionMatches && review?.status === "active" && !reviewersEligible
114
138
  ? resourceType === "retention-schedule-item"
115
139
  ? "Review the Data Retention Schedule again with a reviewer who does not own its governing document or an included schedule row."
@@ -122,6 +146,11 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
122
146
  };
123
147
  }
124
148
 
149
+ export function retentionScheduleIsAuthoritative(loaded, program) {
150
+ return !modelSupports(loaded.model, "retention-schedule-approval")
151
+ || assessCollectionReview(loaded, "retention-schedule-item", { programId: program?.id }).complete;
152
+ }
153
+
125
154
  export async function scaffoldCollectionReview(input = process.cwd(), options = {}) {
126
155
  const loaded = await loadWorkspace(input);
127
156
  const program = resolveProgram(loaded, options.programId);
@@ -170,6 +199,9 @@ export async function planCollectionReview(input = process.cwd(), options = {})
170
199
  const loaded = await loadWorkspace(input);
171
200
  const program = resolveProgram(loaded, options.programId);
172
201
  const resourceType = requiredType(loaded, options.resourceType);
202
+ const workspaceWideRetentionReview = resourceType === "retention-schedule-item"
203
+ && modelSupports(loaded.model, "retention-schedule-approval");
204
+ const retentionScope = workspaceWideRetentionReview ? retentionScheduleReviewScope(loaded) : null;
173
205
  const assessment = assessCollectionReview(loaded, resourceType, { programId: program.id });
174
206
  if (options.expectedCollectionRevision && options.expectedCollectionRevision !== assessment.collectionRevision) {
175
207
  throw new Error(`${assessment.configuration.title} changed after it was displayed. Reload and review the current revision before approving it.`);
@@ -178,7 +210,8 @@ export async function planCollectionReview(input = process.cwd(), options = {})
178
210
  throw new Error("Data Retention Schedule approval requires the displayed collection revision. Reload or scaffold the current review before approving it.");
179
211
  }
180
212
  await requireControlReviewReady(loaded, resourceType, program.id);
181
- await requireRetentionScheduleReady(loaded, resourceType, program);
213
+ await requireRetentionScheduleReady(loaded, resourceType, program, retentionScope);
214
+ requireCollectionRecordProposalsReady(assessment);
182
215
  const configuration = assessment.configuration;
183
216
  const decision = String(options.decision || "").trim();
184
217
  const rationale = String(options.rationale || "").trim();
@@ -251,6 +284,9 @@ export async function planCollectionReview(input = process.cwd(), options = {})
251
284
  authoritativeSourceId: decision === "externally-managed" ? authoritativeSourceId : null
252
285
  });
253
286
  const existing = assessment.review;
287
+ const existingEntries = workspaceWideRetentionReview
288
+ ? assessment.reviewEntries
289
+ : assessment.reviewEntries.filter(({ record }) => record.id === existing?.id);
254
290
  const preservesReviewHistory = temporalReviews;
255
291
  const record = {
256
292
  ...(!existing ? {
@@ -264,7 +300,7 @@ export async function planCollectionReview(input = process.cwd(), options = {})
264
300
  type: "collection-review",
265
301
  title: `${configuration.title} review`,
266
302
  resourceType,
267
- scopeResourceIds: [program.id]
303
+ scopeResourceIds: workspaceWideRetentionReview ? retentionScope.programIds : [program.id]
268
304
  } : preservesReviewHistory ? {
269
305
  ...existing,
270
306
  id: createResourceId(
@@ -281,6 +317,7 @@ export async function planCollectionReview(input = process.cwd(), options = {})
281
317
  reviewedOn,
282
318
  collectionRevision: currentRevision,
283
319
  scopeRevision,
320
+ ...(workspaceWideRetentionReview ? { scopeResourceIds: retentionScope.programIds } : {}),
284
321
  ...(temporalReviews ? {
285
322
  coverage: { kind: "as-of", on: reviewedOn },
286
323
  knowledgeCutoffAt: now.toISOString(),
@@ -294,34 +331,86 @@ export async function planCollectionReview(input = process.cwd(), options = {})
294
331
  delete record.authoritativeSystemId;
295
332
  delete record.authoritativeComponentId;
296
333
  }
334
+ const creates = !existing || preservesReviewHistory ? [record] : [];
335
+ const updates = !existing || preservesReviewHistory ? [] : [record];
336
+ const expectedRevisions = Object.fromEntries(existingEntries.map((entry) => [
337
+ entry.record.id,
338
+ entry.record.id === existing?.id && options.expectedRevision
339
+ ? options.expectedRevision
340
+ : createHash("sha256").update(entry.source).digest("hex")
341
+ ]));
342
+ if (existingEntries.length && preservesReviewHistory) {
343
+ for (const entry of existingEntries) {
344
+ updates.push({
345
+ ...entry.record,
346
+ status: "retired",
347
+ statusTransition: {
348
+ changedByIds: reviewedByIds,
349
+ changedOn: reviewedOn,
350
+ reason: `Superseded by ${record.id}.`
351
+ }
352
+ });
353
+ }
354
+ }
355
+ if (resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")) {
356
+ const scheduleEntry = loaded.entries.find(({ record: candidate }) => (
357
+ candidate.type === "document"
358
+ && candidate.documentKind === "schedule"
359
+ && candidate.workflowScope === "program"
360
+ && !["superseded", "retired"].includes(candidate.status)
361
+ ));
362
+ if (scheduleEntry && ["draft", "approved", "active"].includes(scheduleEntry.record.status)) {
363
+ const currentContentRevisions = await contentRevisions(loaded, scheduleEntry.record);
364
+ const approvedSchedule = {
365
+ ...scheduleEntry.record,
366
+ status: "approved",
367
+ approverIds: reviewedByIds,
368
+ approvedOn: reviewedOn,
369
+ approvedContentRevisions: currentContentRevisions
370
+ };
371
+ for (const field of [
372
+ "activationBasis",
373
+ "activatedByIds",
374
+ "activatedOn",
375
+ "activatedContentRevisions",
376
+ "effectiveOn",
377
+ "statusTransition"
378
+ ]) delete approvedSchedule[field];
379
+ updates.push(approvedSchedule);
380
+ expectedRevisions[scheduleEntry.record.id] = scheduleEntry.revision;
381
+ }
382
+ }
297
383
  return {
298
384
  operation: "collection-review",
299
385
  resourceType,
300
386
  assessment,
301
387
  changes: {
302
- ...(!existing || preservesReviewHistory ? { create: [record] } : { update: [record] }),
303
- ...(existing && preservesReviewHistory ? {
304
- update: [{
305
- ...existing,
306
- status: "retired",
307
- statusTransition: {
308
- changedByIds: reviewedByIds,
309
- changedOn: reviewedOn,
310
- reason: `Superseded by ${record.id}.`
311
- }
312
- }]
313
- } : {}),
314
- ...(existing ? {
315
- expectedRevisions: {
316
- [existing.id]: options.expectedRevision || assessment.reviewRevision
317
- }
318
- } : {}),
388
+ ...(creates.length ? { create: creates } : {}),
389
+ ...(updates.length ? { update: updates } : {}),
390
+ ...(Object.keys(expectedRevisions).length ? { expectedRevisions } : {}),
319
391
  validateWholeWorkspace: true,
320
392
  workflowCapability: INTERNAL_WORKFLOW_CAPABILITIES.collectionReviewReassessment
321
393
  }
322
394
  };
323
395
  }
324
396
 
397
+ function requireCollectionRecordProposalsReady(assessment) {
398
+ const incomplete = assessment.incompleteRecordProposals || [];
399
+ if (!incomplete.length) return;
400
+ throw new Error(
401
+ `Complete every ${assessment.configuration.title.toLowerCase()} record proposal before the collection review. Remaining: ${incomplete.map(({ title }) => title).join(", ")}.`
402
+ );
403
+ }
404
+
405
+ async function contentRevisions(loaded, record) {
406
+ const revisions = {};
407
+ for (const markdown of markdownEntries(loaded.model, record)) {
408
+ const source = await readFile(resolveDataPath(loaded.root, markdown.path), "utf8");
409
+ revisions[markdown.path] = contentRevision(source);
410
+ }
411
+ return revisions;
412
+ }
413
+
325
414
  async function requireControlReviewReady(loaded, resourceType, programId) {
326
415
  if (resourceType !== "control") return;
327
416
  const { assessProgramReadiness } = await import("./program-readiness.js");
@@ -339,11 +428,13 @@ async function requireControlReviewReady(loaded, resourceType, programId) {
339
428
  }
340
429
  }
341
430
 
342
- async function requireRetentionScheduleReady(loaded, resourceType, program) {
431
+ async function requireRetentionScheduleReady(loaded, resourceType, program, retentionScope) {
343
432
  if (resourceType !== "retention-schedule-item" || !modelSupports(loaded.model, "retention-schedule-approval")) return;
344
433
  const rows = scopedCollectionRecords(loaded, resourceType, program);
345
- const approvalIssues = retentionScheduleApprovalIssues(loaded, program, rows, {
346
- informationTypesReviewed: assessCollectionReview(loaded, "information-type", { programId: program.id }).complete
434
+ const approvalIssues = retentionScheduleApprovalIssues(loaded, retentionScope || program, rows, {
435
+ informationTypesReviewed: (retentionScope?.programIds || [program.id]).every((id) => (
436
+ assessCollectionReview(loaded, "information-type", { programId: id }).complete
437
+ ))
347
438
  });
348
439
  if (approvalIssues.length) throw new Error(approvalIssues[0].message);
349
440
  }
@@ -388,7 +479,7 @@ function assessRetentionScheduleReviewers(loaded, rows) {
388
479
  };
389
480
  }
390
481
 
391
- function sameIds(left, right) {
482
+ function sameIds(left = [], right = []) {
392
483
  return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
393
484
  }
394
485
 
@@ -397,7 +488,9 @@ export async function applyCollectionReview(input = process.cwd(), options = {})
397
488
  throw new Error("Preview the collection review and confirm the write.");
398
489
  }
399
490
  const plan = await planCollectionReview(input, options);
400
- const result = await applyResourceBatch(input, plan.changes);
491
+ const result = plan.resourceType === "retention-schedule-item"
492
+ ? await applyRetentionScheduleReviewBatch(input, plan.changes)
493
+ : await applyResourceBatch(input, plan.changes);
401
494
  const loaded = await loadWorkspace(input);
402
495
  return {
403
496
  ...plan,
@@ -30,7 +30,12 @@ export function collectionRevisionMatches(loaded, resourceType, storedRevision,
30
30
  }
31
31
 
32
32
  function calculateCollectionRevision(loaded, resourceType, options, legacy) {
33
- const program = Object.hasOwn(options, "program")
33
+ const workspaceWideRetentionReview = resourceType === "retention-schedule-item"
34
+ && modelSupports(loaded.model, "retention-schedule-approval")
35
+ && !legacy;
36
+ const program = workspaceWideRetentionReview
37
+ ? null
38
+ : Object.hasOwn(options, "program")
34
39
  ? options.program
35
40
  : resolveProgram(loaded, options.programId);
36
41
  const inputs = new Map(collectionRevisionInputs(loaded, resourceType, program, { legacy })