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.
@@ -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";
@@ -0,0 +1,31 @@
1
+ export function occurrenceMemberExceptionIsValid(member, occurrence, byId) {
2
+ const exception = byId.get(member.exceptionId);
3
+ const occurrenceStart = occurrence.coverage?.kind === "as-of"
4
+ ? occurrence.coverage.on
5
+ : occurrence.coverage?.startsOn;
6
+ const occurrenceEnd = occurrence.coverage?.kind === "as-of"
7
+ ? occurrence.coverage.on
8
+ : occurrence.coverage?.endsOn;
9
+ const exceptionCoversMember = exception?.scopeResourceIds?.includes(member.resourceId)
10
+ || exception?.scopeResourceIds?.includes(occurrence.obligationId);
11
+ const statusCoveredOccurrence = exception?.status === "approved"
12
+ || ["revoked", "closed"].includes(exception?.status)
13
+ && exception.resolution?.resolvedOn > occurrenceEnd;
14
+ return Boolean(
15
+ exception?.type === "exception"
16
+ && statusCoveredOccurrence
17
+ && exceptionCoversMember
18
+ && exception.approval?.approvedOn <= occurrenceStart
19
+ && exception.approval?.expiresOn >= occurrenceEnd
20
+ );
21
+ }
22
+
23
+ export function occurrenceMemberIsResolved(member, occurrence, byId, expectedCompletionIsValid = () => false) {
24
+ if (member.disposition === "expected") {
25
+ return member.result === "passed"
26
+ && (member.completionResourceIds || []).some((id) => expectedCompletionIsValid(id, member));
27
+ }
28
+ if (member.disposition === "exception") return occurrenceMemberExceptionIsValid(member, occurrence, byId);
29
+ if (member.disposition === "not-applicable") return Boolean(String(member.rationale || "").trim());
30
+ return false;
31
+ }
@@ -29,6 +29,7 @@ import { serializeWorkspaceMutation } from "./mutation.js";
29
29
  import { collectionReviewRevision, historicalCollectionReviewSnapshot } from "./collection-review-integrity.js";
30
30
  import { bindAttestationReportingRouteSet, reportingRouteRevision } from "./reporting-route-integrity.js";
31
31
  import { selectScopedCollectionRecords } from "./collection-scope.js";
32
+ import { occurrenceMemberIsResolved } from "./obligation-members.js";
32
33
 
33
34
  const COMPLETION_DATE_FIELDS = [
34
35
  "completedOn",
@@ -214,8 +215,22 @@ export function planObligations(resources, options = {}) {
214
215
  const completedMemberIds = new Set((reconciliation?.members || [])
215
216
  .filter((member) => member.result === "passed" && member.disposition === "expected")
216
217
  .map((member) => member.resourceId));
218
+ const resolvedMemberIds = new Set((reconciliation?.members || [])
219
+ .filter((member) => occurrenceMemberIsResolved(member, reconciliation, byId, (completionId) => {
220
+ const completion = byId.get(completionId);
221
+ return completionTypeMatches(completion, expectedCompletionTypes)
222
+ && completionMatchesMember(completion, activity.aggregate?.completionMemberField, member.resourceId)
223
+ && completionFallsInWindow(completion, window, rule?.timezone || workspace?.timezone || "UTC")
224
+ && completionPassesActivity(completion, activity);
225
+ }))
226
+ .map((member) => member.resourceId));
227
+ const resolutionsComplete = membershipFinal
228
+ && expectedMemberIds.every((resourceId) => resolvedMemberIds.has(resourceId));
229
+ const conclusionValid = ["complete", "complete-with-exceptions", "zero-population"].includes(reconciliation?.conclusion)
230
+ && resolutionsComplete;
217
231
  const successful = reconciliation
218
- ? reconciliation.status === "reconciled" && ["complete", "complete-with-exceptions", "zero-population"].includes(reconciliation.conclusion)
232
+ ? reconciliation.status === "reconciled"
233
+ && conclusionValid
219
234
  : legacyCompletions.length > 0;
220
235
  const timingStatus = occurrenceStatus(window, asOf, successful);
221
236
  const status = timingStatus === "complete" || programStatus === "accepted" ? timingStatus : "proposed";
@@ -244,6 +259,9 @@ export function planObligations(resources, options = {}) {
244
259
  completionResourceIds,
245
260
  expectedMemberIds,
246
261
  completedMemberIds: [...completedMemberIds],
262
+ resolvedMemberIds: [...resolvedMemberIds],
263
+ resolutionsComplete,
264
+ conclusionValid,
247
265
  expectedCount: reconciliation?.status === "open" && !membershipFinal
248
266
  ? expectedMemberIds.length
249
267
  : reconciliation?.expectedCount ?? expectedMemberIds.length,
@@ -282,8 +300,20 @@ export function planObligations(resources, options = {}) {
282
300
  const completedMemberIds = (occurrence.members || [])
283
301
  .filter(({ disposition, result }) => disposition === "expected" && result === "passed")
284
302
  .map(({ resourceId }) => resourceId);
303
+ const resolvedMemberIds = (occurrence.members || [])
304
+ .filter((member) => occurrenceMemberIsResolved(member, occurrence, byId, (completionId) => {
305
+ const completion = byId.get(completionId);
306
+ return completionTypeMatches(completion, activity.completionResourceTypes)
307
+ && completionMatchesMember(completion, activity.aggregate?.completionMemberField, member.resourceId)
308
+ && completionFallsInWindow(completion, window, rule.timezone || workspace?.timezone || "UTC")
309
+ && completionPassesActivity(completion, activity);
310
+ }))
311
+ .map(({ resourceId }) => resourceId);
312
+ const resolutionsComplete = (occurrence.members || []).every(({ resourceId }) => resolvedMemberIds.includes(resourceId));
313
+ const conclusionValid = ["complete", "complete-with-exceptions", "zero-population"].includes(occurrence.conclusion)
314
+ && resolutionsComplete;
285
315
  const successful = occurrence.status === "reconciled"
286
- && ["complete", "complete-with-exceptions", "zero-population"].includes(occurrence.conclusion);
316
+ && conclusionValid;
287
317
  const timingStatus = occurrenceStatus(window, asOf, successful);
288
318
  if (timingStatus === "complete" && !options.includeComplete) continue;
289
319
  calendarItems.push({
@@ -307,6 +337,9 @@ export function planObligations(resources, options = {}) {
307
337
  completionResourceIds: [...new Set((occurrence.members || []).flatMap(({ completionResourceIds = [] }) => completionResourceIds))],
308
338
  expectedMemberIds: (occurrence.members || []).map(({ resourceId }) => resourceId),
309
339
  completedMemberIds,
340
+ resolvedMemberIds,
341
+ resolutionsComplete,
342
+ conclusionValid,
310
343
  expectedCount: occurrence.expectedCount,
311
344
  completedCount: occurrence.completedCount,
312
345
  membershipFinal: true,
@@ -1750,8 +1783,8 @@ function plannedCompletionWindow(window) {
1750
1783
  };
1751
1784
  }
1752
1785
 
1753
- function completionFallsInWindow(record, window) {
1754
- const date = completionDate(record);
1786
+ function completionFallsInWindow(record, window, timezone = "UTC") {
1787
+ const date = completionDate(record, timezone);
1755
1788
  return Boolean(date && date >= window.dueWindowStart && date <= window.dueWindowEnd);
1756
1789
  }
1757
1790
 
@@ -1765,11 +1798,11 @@ function completionTypeMatches(record, expectedTypes = []) {
1765
1798
  return Boolean(record && (expectedTypes.length === 0 || expectedTypes.includes(record.type)));
1766
1799
  }
1767
1800
 
1768
- function completionDate(record) {
1801
+ function completionDate(record, timezone = "UTC") {
1769
1802
  const coverageDate = coverageEnd(record.coverage);
1770
1803
  if (parseCalendarDate(coverageDate)) return coverageDate;
1771
1804
  for (const field of COMPLETION_TIMESTAMP_FIELDS) {
1772
- if (isRfc3339Timestamp(record[field])) return record[field].slice(0, 10);
1805
+ if (isRfc3339Timestamp(record[field])) return currentCalendarDate(timezone, new Date(record[field]));
1773
1806
  }
1774
1807
  for (const field of COMPLETION_DATE_FIELDS) {
1775
1808
  if (parseCalendarDate(record[field])) return record[field];
@@ -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"
@@ -646,10 +707,34 @@ function reportingRouteItem(records, byId, asOf, timezone = "UTC") {
646
707
  );
647
708
  }
648
709
 
649
- function collectionReviewReadinessItem(assessment) {
710
+ export 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
+ }));
716
+ const proposalBatchComplete = proposalUnits.every(({ status }) => status === "complete");
717
+ const scheduleApprovalBlocked = assessment.resourceType === "retention-schedule-item"
718
+ && proposalUnits.some(({ status }) => status !== "complete");
719
+ const batchProgressUnits = assessment.resourceType !== "retention-schedule-item" && proposalUnits.length
720
+ ? [
721
+ {
722
+ id: `${assessment.resourceType}-proposal-batch`,
723
+ status: proposalBatchComplete ? "complete" : "action",
724
+ title: `${assessment.configuration.title} proposals`
725
+ },
726
+ {
727
+ id: `${assessment.resourceType}-collection-review`,
728
+ status: proposalBatchComplete
729
+ ? assessment.complete ? "complete" : "action"
730
+ : "blocked",
731
+ title: `${assessment.configuration.title} collection review`
732
+ }
733
+ ]
734
+ : null;
650
735
  return item(
651
736
  `collection-review-${assessment.resourceType}`,
652
- assessment.complete ? "complete" : "action",
737
+ assessment.complete ? "complete" : scheduleApprovalBlocked ? "blocked" : "action",
653
738
  assessment.status === "stale"
654
739
  ? `Review ${assessment.configuration.title.toLowerCase()} again`
655
740
  : `Review ${assessment.configuration.title.toLowerCase()}`,
@@ -658,6 +743,7 @@ function collectionReviewReadinessItem(assessment) {
658
743
  {
659
744
  resourceType: assessment.resourceType,
660
745
  reviewPoints: assessment.configuration.reviewPoints,
746
+ ...(batchProgressUnits ? { progressUnits: batchProgressUnits } : {}),
661
747
  commands: [
662
748
  `npx filegrc review-collection ${assessment.resourceType} --scaffold`,
663
749
  `npx filegrc review-collection ${assessment.resourceType} REVIEW.json --preview --json`
@@ -1568,6 +1654,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
1568
1654
  : `${coveredControls.length} of ${family.controls.length} selected controls have an active authoritative ${componentSources ? "Component" : "System"} with the required source role, access owners, and extraction instructions.`,
1569
1655
  completeSources[0] || selectedSources[0] || { type: sourceType },
1570
1656
  {
1657
+ progressUnit: false,
1571
1658
  familyId: family.id,
1572
1659
  sourceKinds: family.sourceKinds,
1573
1660
  controlIds: family.controls.map((control) => control.id),