filegrc 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 })
@@ -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";
@@ -86,7 +87,7 @@ export {
86
87
  scaffoldExternalReviewerGovernance,
87
88
  setupExternalReviewerGovernance
88
89
  } from "./external-reviewer.js";
89
- export { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
90
+ export { assessEvidenceMap, assessProgramReadiness, calculateProgramProgress } from "./program-readiness.js";
90
91
  export {
91
92
  approveReportingRouteSet,
92
93
  assessReportingRoutePeriod,
@@ -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"],