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/src/retention.js CHANGED
@@ -10,18 +10,26 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
10
10
  if (!loaded.model.resources["retention-schedule-item"]) return [];
11
11
  const byId = new Map(loaded.resources.map((record) => [record.id, record]));
12
12
  const rules = loaded.resources.filter((record) => (
13
- record.type === "retention-schedule-item" && record.status === "active"
13
+ record.type === "retention-schedule-item"
14
+ && !["retired", "superseded"].includes(record.status)
14
15
  ));
15
- const revisions = await resourceReviewRevisions(loaded, rules.flatMap((rule) => retentionReviewResourceIds(rule, loaded)));
16
- const usableRules = rules.filter((rule) => retentionRuleIsCurrent(rule, revisions, byId, loaded));
16
+ const activeRules = rules.filter(({ status }) => status === "active");
17
+ const revisions = await resourceReviewRevisions(loaded, activeRules.flatMap((rule) => retentionReviewResourceIds(rule, loaded)));
18
+ const usableRules = activeRules.filter((rule) => retentionRuleIsCurrent(rule, revisions, byId, loaded));
19
+ const proposedRules = rules.filter(retentionRuleHasCoverageProposal);
17
20
  const uses = retentionUses(loaded, program);
18
- const items = rules.filter((rule) => !usableRules.includes(rule)).map((rule) => readinessItem(
21
+ const items = rules.map((rule) => readinessItem(
19
22
  `retention-rule-${rule.id}`,
20
- "action",
21
- `Review ${rule.title}`,
22
- "This active retention schedule item is incomplete or is not bound to every current source revision. Review it before relying on its period or disposition behavior.",
23
+ usableRules.includes(rule) ? "complete" : "action",
24
+ usableRules.includes(rule) ? `${rule.title} proposal is ready` : `Finish ${rule.title}`,
25
+ usableRules.includes(rule)
26
+ ? "The owner completed this row and bound it to its current source revisions. It will become part of the authoritative schedule only when the complete schedule is approved."
27
+ : rule.status === "planned"
28
+ ? "Finish the type, scope, cutoff, period, disposition, sources, and owner review for this proposed row before requesting schedule approval."
29
+ : "This row is incomplete or is not bound to every current source revision. Review it before requesting schedule approval.",
23
30
  rule,
24
31
  {
32
+ coverageState: usableRules.includes(rule) ? "proposed" : "incomplete",
25
33
  sourceResourceIds: retentionReviewResourceIds(rule, loaded),
26
34
  commands: [
27
35
  `npx filegrc get ${rule.id} --mutation`,
@@ -39,31 +47,41 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
39
47
  usesByInformationType.set(use.informationTypeId, grouped);
40
48
  }
41
49
  items.push(...[...usesByInformationType].map(([informationTypeId, informationTypeUses]) => {
42
- const uncoveredUses = informationTypeUses.filter((use) => (
50
+ const absentUses = informationTypeUses.filter((use) => (
51
+ !proposedRules.some((rule) => ruleCoversUse(rule, use, program))
52
+ ));
53
+ const pendingUses = informationTypeUses.filter((use) => (
43
54
  !usableRules.some((rule) => ruleCoversUse(rule, use, program))
44
55
  ));
56
+ const approvalPending = !options.scheduleApproved;
45
57
  const matches = usableRules.filter((rule) => (
46
58
  informationTypeUses.some((use) => ruleCoversUse(rule, use, program))
47
59
  ));
48
60
  const informationTypeTitle = byId.get(informationTypeId)?.title || informationTypeId;
49
- const affectedTitles = uncoveredUses.map(({ resource }) => resource.title);
61
+ const affectedTitles = absentUses.map(({ resource }) => resource.title);
50
62
  const affectedSummary = affectedTitles.length > 3
51
63
  ? `${affectedTitles.slice(0, 3).join(", ")}, and ${affectedTitles.length - 3} more`
52
64
  : affectedTitles.join(", ");
53
65
  return readinessItem(
54
66
  `retention-use-${informationTypeId}`,
55
- uncoveredUses.length ? "action" : "complete",
67
+ absentUses.length ? "action" : pendingUses.length || approvalPending ? "info" : "complete",
56
68
  `Decide retention for ${informationTypeTitle}`,
57
- uncoveredUses.length
69
+ absentUses.length
58
70
  ? `${affectedTitles.length} in-scope ${affectedTitles.length === 1 ? "resource uses" : "resources use"} this Information Type without an active, current retention decision, including ${affectedSummary}. Management must choose the cutoff, period, disposition, and whether one program-wide rule or separate scoped rules apply.`
59
- : `All ${informationTypeUses.length} in-scope ${informationTypeUses.length === 1 ? "use is" : "uses are"} covered by ${matches.map(({ title }) => title).join(", ")}.`,
71
+ : pendingUses.length
72
+ ? `Proposed rows cover every in-scope type-and-scope pair. Finish the row review, then approve the complete schedule once instead of approving these coverage checks separately.`
73
+ : approvalPending
74
+ ? "Completed row proposals cover every in-scope type-and-scope pair. Approve the complete schedule once; these derived checks are not separate approval work."
75
+ : `The approved schedule covers all ${informationTypeUses.length} in-scope ${informationTypeUses.length === 1 ? "use" : "uses"}.`,
60
76
  { type: "retention-schedule-item" },
61
77
  {
78
+ progressUnit: false,
79
+ coverageState: absentUses.length ? "absent" : pendingUses.length || approvalPending ? "proposed" : "approved",
62
80
  informationTypeId,
63
- affectedResourceIds: uncoveredUses.map(({ resource }) => resource.id),
64
- retentionScopeResourceIds: uncoveredUses.length === informationTypeUses.length
81
+ affectedResourceIds: absentUses.map(({ resource }) => resource.id),
82
+ retentionScopeResourceIds: absentUses.length === informationTypeUses.length
65
83
  ? [program.id]
66
- : uncoveredUses.map(({ resource }) => resource.id),
84
+ : absentUses.map(({ resource }) => resource.id),
67
85
  retentionScheduleItemIds: matches.map(({ id }) => id),
68
86
  commands: [
69
87
  "npx filegrc guide retention-schedule-item --json",
@@ -75,6 +93,11 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
75
93
 
76
94
  for (const coverage of loaded.resources.filter((record) => record.type === "source-coverage" && record.status === "active")) {
77
95
  const linked = (coverage.retentionScheduleItemIds || []).map((id) => byId.get(id)).filter(Boolean);
96
+ const proposed = linked.filter((rule) => (
97
+ rule.type === "retention-schedule-item"
98
+ && proposedRules.includes(rule)
99
+ && (rule.scopeResourceIds || []).includes(coverage.id)
100
+ ));
78
101
  const matching = linked.filter((rule) => (
79
102
  rule.type === "retention-schedule-item"
80
103
  && rule.status === "active"
@@ -83,13 +106,17 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
83
106
  ));
84
107
  items.push(readinessItem(
85
108
  `retention-source-coverage-${coverage.id}`,
86
- matching.length ? "complete" : "action",
109
+ matching.length && options.scheduleApproved ? "complete" : proposed.length ? "info" : "action",
87
110
  `Confirm retained evidence for ${coverage.title}`,
88
- matching.length
111
+ matching.length && options.scheduleApproved
89
112
  ? `The source-coverage record references a current schedule item scoped to this population.`
90
- : `The source-coverage record must reference an active, current schedule item whose scope includes ${coverage.id}. A draft schedule or unrelated rule does not satisfy this check.`,
113
+ : proposed.length
114
+ ? "A proposed schedule row covers this evidence source. Finish that row and approve the complete schedule before relying on it."
115
+ : `No proposed schedule row covers ${coverage.id}. Add the genuine type-and-scope decision before schedule approval.`,
91
116
  coverage,
92
117
  {
118
+ progressUnit: false,
119
+ coverageState: matching.length && options.scheduleApproved ? "approved" : proposed.length ? "proposed" : "absent",
93
120
  retentionScheduleItemIds: matching.map(({ id }) => id),
94
121
  commands: [
95
122
  `npx filegrc get ${coverage.id} --mutation`,
@@ -218,8 +245,11 @@ function reviewSource(loaded, entry) {
218
245
  && entry.record.workflowScope === "program"
219
246
  ) {
220
247
  const approved = structuredClone(entry.record);
221
- if (approved.status === "active") approved.status = "approved";
222
248
  for (const field of [
249
+ "status",
250
+ "approverIds",
251
+ "approvedOn",
252
+ "approvedContentRevisions",
223
253
  "activationBasis",
224
254
  "activatedByIds",
225
255
  "activatedOn",
@@ -252,6 +282,14 @@ function ruleCoversUse(rule, use, program) {
252
282
  return scope.has(use.resource.id) || scope.has(program.id);
253
283
  }
254
284
 
285
+ function retentionRuleHasCoverageProposal(rule) {
286
+ return Boolean(
287
+ (rule.informationTypeIds || []).length
288
+ && (rule.scopeResourceIds || []).length
289
+ && rule.scheduleDocumentId
290
+ );
291
+ }
292
+
255
293
  export function retentionReviewResourceIds(rule, loaded) {
256
294
  const programUseIds = [];
257
295
  if (loaded) {
@@ -279,7 +317,7 @@ export function retentionRuleIsCurrent(rule, revisions, byId = new Map(), loaded
279
317
  if (!(rule.informationTypeIds || []).length || !(rule.scopeResourceIds || []).length || !rule.scheduleDocumentId) return false;
280
318
  const schedule = byId.get(rule.scheduleDocumentId);
281
319
  if (schedule?.type !== "document" || schedule.documentKind !== "schedule" || schedule.workflowScope !== "program" || ["superseded", "retired"].includes(schedule.status)) return false;
282
- if (!(rule.ownerIds || []).length || !(rule.approvedByIds || []).length || !rule.approvedOn) return false;
320
+ if (!(rule.ownerIds || []).length) return false;
283
321
  if (!rule.reviewedSourceRevisions || typeof rule.reviewedSourceRevisions !== "object") return false;
284
322
  if (!retentionCutoffIsComplete(rule.cutoff) || !retentionPeriodIsComplete(rule.retentionPeriod)) return false;
285
323
  if (!["delete", "destroy", "erase", "anonymize", "transfer", "retain-permanently"].includes(rule.dispositionAction)) return false;
package/src/server.js CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  pushWorkspace,
34
34
  retryBrowserSync,
35
35
  runBrowserMutation,
36
+ synchronizeBrowserStartup,
36
37
  withGitCommandCache,
37
38
  withGitCommandDeadline
38
39
  } from "./git.js";
@@ -777,6 +778,18 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
777
778
  if (!Number.isInteger(port) || port < 0 || port > 65_535) {
778
779
  throw new Error("The server port must be an integer from 0 through 65535.");
779
780
  }
781
+ let startupSynchronization;
782
+ try {
783
+ startupSynchronization = await synchronizeBrowserStartup(input, {
784
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true
785
+ });
786
+ } catch (error) {
787
+ startupSynchronization = {
788
+ status: "failed",
789
+ updated: false,
790
+ message: error.message
791
+ };
792
+ }
780
793
  const loaded = await loadWorkspace(input);
781
794
  getResourceDefinition(loaded.model, "workspace");
782
795
  const server = createFilegrcServer(loaded.root, {
@@ -806,7 +819,8 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
806
819
  address: server.address(),
807
820
  url: `http://${urlHost(host)}:${server.address().port}`,
808
821
  requestedPort: port,
809
- usedFallbackPort
822
+ usedFallbackPort,
823
+ startupSynchronization
810
824
  };
811
825
  }
812
826
 
@@ -1,4 +1,7 @@
1
1
  import { resourceReviewRevisions, retentionReviewResourceIds, retentionRuleIsCurrent } from "./retention.js";
2
+ import { retentionScheduleIsAuthoritative } from "./collection-review.js";
3
+ import { modelSupports } from "../model/index.js";
4
+ import { resolveProgram } from "./program.js";
2
5
 
3
6
  export async function sourceCoverageComplete(record, loaded, program = loaded.workspace) {
4
7
  const structuredRetention = loaded.model.resources["retention-schedule-item"];
@@ -6,6 +9,10 @@ export async function sourceCoverageComplete(record, loaded, program = loaded.wo
6
9
  return false;
7
10
  }
8
11
  if (structuredRetention) {
12
+ const retentionProgram = modelSupports(loaded.model, "program-scope")
13
+ ? resolveProgram(loaded, program?.type === "program" ? program.id : undefined)
14
+ : program;
15
+ if (!retentionScheduleIsAuthoritative(loaded, retentionProgram)) return false;
9
16
  const linkedRules = (record.retentionScheduleItemIds || []).map((id) => (
10
17
  loaded.resources.find((resource) => resource.id === id && resource.type === "retention-schedule-item")
11
18
  ));
package/src/state.js CHANGED
@@ -347,6 +347,8 @@ function serializeCollectionReviewAssessment(assessment) {
347
347
  status: assessment.status,
348
348
  complete: assessment.complete,
349
349
  message: assessment.message,
350
+ ...(assessment.recordProposals ? { recordProposals: assessment.recordProposals } : {}),
351
+ ...(assessment.incompleteRecordProposals ? { incompleteRecordProposals: assessment.incompleteRecordProposals } : {}),
350
352
  ...(assessment.eligibleReviewerIds ? { eligibleReviewerIds: assessment.eligibleReviewerIds } : {}),
351
353
  ...(assessment.reviewerConflictIds ? { reviewerConflictIds: assessment.reviewerConflictIds } : {}),
352
354
  ...(assessment.approvalIssues ? { approvalIssues: assessment.approvalIssues } : {})
package/src/validate.js CHANGED
@@ -3,7 +3,7 @@ import { createReadStream } from "node:fs";
3
3
  import { readFile, stat } from "node:fs/promises";
4
4
  import { performance } from "node:perf_hooks";
5
5
  import { getResourceDefinition, modelSupports } from "../model/index.js";
6
- import { scopedCollectionRecords, selectScopedCollectionRecords } from "./collection-scope.js";
6
+ import { collectionRecordProposals, retentionScheduleReviewScope, scopedCollectionRecords, selectScopedCollectionRecords } from "./collection-scope.js";
7
7
  import {
8
8
  collectionRevision,
9
9
  collectionRevisionMatches
@@ -1224,6 +1224,10 @@ function sameRouteProposal(proposal, finalized) {
1224
1224
  return [...keys].every((key) => allowed.has(key) || JSON.stringify(proposal[key]) === JSON.stringify(finalized[key]));
1225
1225
  }
1226
1226
 
1227
+ function sameStringIds(left = [], right = []) {
1228
+ return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
1229
+ }
1230
+
1227
1231
  function validateTimestampTimezone(value, timezone, path, diagnostics, label) {
1228
1232
  if (!value || !timezone) return;
1229
1233
  const source = String(value);
@@ -1252,10 +1256,19 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
1252
1256
  const program = modelSupports(model, "program-scope")
1253
1257
  ? (record.scopeResourceIds || []).map((id) => byId.get(id)).find(({ type } = {}) => type === "program")
1254
1258
  : null;
1259
+ const workspaceWideRetentionReview = record.resourceType === "retention-schedule-item"
1260
+ && modelSupports(model, "retention-schedule-approval");
1261
+ const retentionScope = workspaceWideRetentionReview ? retentionScheduleReviewScope(loaded) : null;
1255
1262
  const recordCount = scopedCollectionRecords(loaded, record.resourceType, program).length;
1256
1263
  const currentPopulationIds = scopedCollectionRecords(loaded, record.resourceType, program)
1257
1264
  .map(({ id }) => id)
1258
1265
  .sort();
1266
+ const incompleteProposals = collectionRecordProposals(
1267
+ loaded,
1268
+ record.resourceType,
1269
+ scopedCollectionRecords(loaded, record.resourceType, program),
1270
+ program
1271
+ ).filter(({ complete }) => !complete);
1259
1272
  const currentRevision = collectionRevision(loaded, record.resourceType, {
1260
1273
  program,
1261
1274
  authoritativeSourceId: record.decision === "externally-managed"
@@ -1274,6 +1287,20 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
1274
1287
  currentRevision
1275
1288
  }
1276
1289
  );
1290
+ if (current && workspaceWideRetentionReview && !sameStringIds(record.scopeResourceIds, retentionScope.programIds)) {
1291
+ diagnostics.push(error(
1292
+ "invalid-retention-schedule-review-scope",
1293
+ path,
1294
+ "The current Data Retention Schedule review must cover every active Program as one workspace-wide approval."
1295
+ ));
1296
+ }
1297
+ if (current && incompleteProposals.length) {
1298
+ diagnostics.push(error(
1299
+ "incomplete-collection-review-proposals",
1300
+ path,
1301
+ `Complete every ${configuration.title.toLowerCase()} record proposal before the collection review. Remaining: ${incompleteProposals.map(({ title }) => title).join(", ")}.`
1302
+ ));
1303
+ }
1277
1304
  if (current && record.resourceType === "control") {
1278
1305
  const controls = scopedCollectionRecords(loaded, "control", program);
1279
1306
  const controlIds = new Set(controls.map(({ id }) => id));
@@ -1299,8 +1326,11 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
1299
1326
  }
1300
1327
  if (current && record.resourceType === "retention-schedule-item" && modelSupports(model, "retention-schedule-approval")) {
1301
1328
  const rows = scopedCollectionRecords(loaded, record.resourceType, program);
1302
- for (const issue of retentionScheduleApprovalIssues(loaded, program || {}, rows, {
1303
- informationTypesReviewed: collectionReviewIsCurrent(loaded, "information-type", program)
1329
+ for (const issue of retentionScheduleApprovalIssues(loaded, retentionScope, rows, {
1330
+ informationTypesReviewed: retentionScope.programIds.every((id) => (
1331
+ collectionReviewIsCurrent(loaded, "information-type", byId.get(id))
1332
+ )),
1333
+ approvalReview: record
1304
1334
  })) {
1305
1335
  diagnostics.push(error(issue.code, path, issue.message));
1306
1336
  }
package/src/web.js CHANGED
@@ -639,7 +639,11 @@ function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
639
639
  const documentEntry = documentEntries.length === 1 ? documentEntries[0] : null;
640
640
  const rows = resourcesOfType("retention-schedule-item")
641
641
  .sort((left, right) => left.record.title.localeCompare(right.record.title));
642
- const activeRows = rows.filter(({ record }) => record.status === "active").length;
642
+ const completedProposalIds = new Set(state.programReadiness?.stages
643
+ ?.find(({ id }) => id === "policies")
644
+ ?.items.filter(({ id, status }) => id.startsWith("retention-rule-") && status === "complete")
645
+ .map(({ id }) => id.slice("retention-rule-".length)) || []);
646
+ const proposedRows = rows.filter(({ record }) => completedProposalIds.has(record.id)).length;
643
647
  const documentReadiness = documentEntry ? retentionScheduleDocumentReadiness(documentEntry.record.id) : null;
644
648
  const documentReady = documentEntries.length === 1 && documentReadiness?.status === "complete";
645
649
  const documentAction = documentEntry
@@ -656,7 +660,10 @@ function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
656
660
  const cutoff = retentionCutoffLabel(record.cutoff);
657
661
  const period = retentionPeriodLabel(record.retentionPeriod);
658
662
  const action = record.dispositionAction ? properCase(record.dispositionAction) : "Not selected";
659
- return '<tr data-retention-row data-history="' + (["superseded", "retired"].includes(record.status) ? "true" : "false") + '"><td data-label="Rule" data-primary-field><a class="record-title" href="#/resource/retention-schedule-item/' + encodeURIComponent(record.id) + '?stage=policies">' + esc(record.title) + '</a><small>' + esc(record.description || record.id) + '</small></td><td data-label="Information types">' + esc(informationTypes) + '</td><td data-label="Scope">' + esc(scope) + '</td><td data-label="Retention"><strong>' + esc(period) + '</strong><small>From ' + esc(cutoff) + '</small></td><td data-label="Disposition">' + esc(action) + '</td><td data-label="Status">' + formatValue(displayStatus(record), "status", record.type, true) + '</td><td data-label="Next action">' + recordWorkflowCell("retention-schedule-item", entry, "?stage=policies") + '</td></tr>';
663
+ const status = record.status === "active" && state.collectionReviews?.["retention-schedule-item"]?.status !== "current"
664
+ ? "proposed"
665
+ : displayStatus(record);
666
+ return '<tr data-retention-row data-history="' + (["superseded", "retired"].includes(record.status) ? "true" : "false") + '"><td data-label="Rule" data-primary-field><a class="record-title" href="#/resource/retention-schedule-item/' + encodeURIComponent(record.id) + '?stage=policies">' + esc(record.title) + '</a><small>' + esc(record.description || record.id) + '</small></td><td data-label="Information types">' + esc(informationTypes) + '</td><td data-label="Scope">' + esc(scope) + '</td><td data-label="Retention"><strong>' + esc(period) + '</strong><small>From ' + esc(cutoff) + '</small></td><td data-label="Disposition">' + esc(action) + '</td><td data-label="Status">' + formatValue(status, "status", record.type, true) + '</td><td data-label="Next action">' + recordWorkflowCell("retention-schedule-item", entry, "?stage=policies") + '</td></tr>';
660
667
  }).join("")
661
668
  : '<tr><td colspan="7">' + empty("No retention rules are needed until an actual information use requires one.") + '</td></tr>';
662
669
  const add = state.readOnly ? "" : '<button class="button primary" type="button" data-add-retention-row>Add schedule row</button>';
@@ -665,12 +672,12 @@ function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
665
672
  const showHistory = params.get("history") === "1";
666
673
  const tableTools = '<div class="retention-table-tools"><label><span class="sr-only">Filter schedule rows</span><input type="search" data-retention-search placeholder="Filter schedule rows" value="' + esc(initialFilter) + '"></label><label class="history-toggle"><input type="checkbox" data-retention-history ' + (showHistory ? "checked" : "") + '> Show retired</label></div>';
667
674
  main.innerHTML = '<div class="page retention-schedule-page"><nav class="breadcrumbs"><a href="#/stage/policies">Step 2</a><span>/</span><span>Data Retention Schedule</span></nav>' +
668
- '<div class="page-intro"><div><p class="kicker">Step 2</p><div class="page-title-line"><h2>Data Retention Schedule</h2>' + guideTrigger + '</div><p>Define the retention rules management has approved. Step 3 checks whether implemented systems and evidence sources follow them.</p></div><div class="actions">' + documentAction + add + '</div></div>' + resourceGuide("retention-schedule-item") +
669
- '<div class="retention-summary"><div><span class="retention-summary-label">Governing document</span>' + documentSummary + '</div><div><span class="retention-summary-label">Schedule revision</span><span><strong>' + activeRows + ' active ' + pluralize("row", activeRows) + '</strong><small>Document and rows are approved together</small></span><span class="badge ' + (state.collectionReviews?.["retention-schedule-item"]?.status === "current" ? "good" : "warn") + '">' + (state.collectionReviews?.["retention-schedule-item"]?.status === "current" ? "Approved" : "Approval needed") + '</span></div></div>' +
670
- '<section class="retention-rows"><div class="section-head"><div><p class="kicker">Structured schedule</p><h2>Schedule rows</h2><p>Add a row when an Information Type and its operational scope need a distinct cutoff, period, or disposition rule.</p></div>' + tableTools + '</div><div class="record-table-wrap"><table class="record-table"><thead><tr><th>Rule</th><th>Information types</th><th>Scope</th><th>Retention</th><th>Disposition</th><th>Status</th><th>Next action</th></tr></thead><tbody>' + rowHtml + '</tbody></table></div><p class="retention-filter-empty" data-retention-empty hidden>No schedule rows match this filter.</p><p class="retention-row-count" data-retention-count aria-live="polite"></p></section>' +
675
+ '<div class="page-intro"><div><p class="kicker">Step 2</p><div class="page-title-line"><h2>Data Retention Schedule</h2>' + guideTrigger + '</div><p>Owners finish each proposed retention row first. A separate reviewer then approves the complete document and all proposed rows once.</p></div><div class="actions">' + documentAction + add + '</div></div>' + resourceGuide("retention-schedule-item") +
676
+ '<div class="retention-summary"><div><span class="retention-summary-label">Governing document</span>' + documentSummary + '</div><div><span class="retention-summary-label">Schedule revision</span><span><strong>' + proposedRows + ' completed ' + pluralize("proposal", proposedRows) + '</strong><small>Final approval follows every row proposal</small></span><span class="badge ' + (state.collectionReviews?.["retention-schedule-item"]?.status === "current" ? "good" : "warn") + '">' + (state.collectionReviews?.["retention-schedule-item"]?.status === "current" ? "Approved" : "Approval needed") + '</span></div></div>' +
677
+ '<section class="retention-rows"><div class="section-head"><div><p class="kicker">Structured schedule</p><h2>Schedule rows</h2><p>Add a row when an Information Type and its operational scope need a distinct cutoff, period, or disposition rule.</p></div>' + tableTools + '</div><div class="record-table-wrap"><table class="record-table"><thead><tr><th>Rule</th><th>Information types</th><th>Scope</th><th>Retention</th><th>Disposition</th><th>Status</th><th>Next action</th></tr></thead><tbody>' + rowHtml + '</tbody></table></div><p class="retention-filter-empty" data-retention-empty hidden>No schedule rows match this filter.</p><p class="retention-row-count" data-retention-count aria-live="polite"></p><nav class="pagination retention-pagination" aria-label="Data Retention Schedule pages" hidden><button class="button" type="button" data-retention-page="previous">Previous</button><span class="page-status" aria-live="polite"></span><button class="button" type="button" data-retention-page="next">Next</button></nav></section>' +
671
678
  renderRetentionScheduleIssues() + collectionReviewPanel("retention-schedule-item", true) + '</div>';
672
679
  resourceGuideCleanup = setupResourceGuide(main);
673
- wireRetentionScheduleTable(rows.length);
680
+ wireRetentionScheduleTable(rows.length, Number(params.get("page")) || 1);
674
681
  main.querySelector("[data-review-collection]")?.addEventListener("click", () => openCollectionReviewDialog("retention-schedule-item"));
675
682
  main.querySelector("[data-add-retention-document]")?.addEventListener("click", () => openEditor("document", null, {
676
683
  seed: {
@@ -708,38 +715,54 @@ function openRetentionScheduleItemEditor(seed = {}) {
708
715
  ...(scheduleDocuments.length === 1 ? { scheduleDocumentId: scheduleDocuments[0].record.id } : {}),
709
716
  ...seed
710
717
  },
711
- description: "Define one retention decision. Keep it planned until its scope, cutoff, period, disposition, sources, and approval are complete.",
718
+ description: "Define one retention decision. Keep it planned until its scope, cutoff, period, disposition, sources, owner review, and current revision bindings are complete.",
712
719
  saveLabel: "Save schedule row"
713
720
  });
714
721
  }
715
722
 
716
- function wireRetentionScheduleTable(total) {
723
+ function wireRetentionScheduleTable(total, initialPage = 1) {
717
724
  const table = root.querySelector(".retention-rows");
718
725
  if (!table) return;
719
726
  const search = table.querySelector("[data-retention-search]");
720
727
  const historyToggle = table.querySelector("[data-retention-history]");
721
728
  const count = table.querySelector("[data-retention-count]");
722
729
  const noResults = table.querySelector("[data-retention-empty]");
730
+ const pagination = table.querySelector(".retention-pagination");
731
+ const pageStatus = pagination.querySelector(".page-status");
732
+ const previous = pagination.querySelector('[data-retention-page="previous"]');
733
+ const next = pagination.querySelector('[data-retention-page="next"]');
734
+ let pageNumber = Number.isInteger(initialPage) && initialPage > 0 ? initialPage : 1;
723
735
  const filter = () => {
724
736
  const query = search.value.trim().toLowerCase();
725
- let visible = 0;
726
- table.querySelectorAll("[data-retention-row]").forEach((row) => {
727
- const show = (historyToggle.checked || row.dataset.history !== "true")
728
- && (!query || row.textContent.toLowerCase().includes(query));
729
- row.hidden = !show;
730
- if (show) visible += 1;
731
- });
732
- noResults.hidden = visible !== 0 || total === 0;
733
- count.textContent = visible + " of " + total + " " + pluralize("row", total);
737
+ const filtered = [...table.querySelectorAll("[data-retention-row]")].filter((row) => (
738
+ (historyToggle.checked || row.dataset.history !== "true")
739
+ && (!query || row.textContent.toLowerCase().includes(query))
740
+ ));
741
+ const totalPages = Math.max(1, Math.ceil(filtered.length / LIST_PAGE_SIZE));
742
+ pageNumber = Math.min(pageNumber, totalPages);
743
+ const start = (pageNumber - 1) * LIST_PAGE_SIZE;
744
+ const visible = new Set(filtered.slice(start, start + LIST_PAGE_SIZE));
745
+ table.querySelectorAll("[data-retention-row]").forEach((row) => { row.hidden = !visible.has(row); });
746
+ noResults.hidden = filtered.length !== 0 || total === 0;
747
+ const firstVisible = filtered.length ? start + 1 : 0;
748
+ const lastVisible = Math.min(start + LIST_PAGE_SIZE, filtered.length);
749
+ count.textContent = firstVisible + "–" + lastVisible + " of " + filtered.length + " " + pluralize("row", filtered.length) + (filtered.length === total ? "" : " · " + total + " total");
750
+ pagination.hidden = totalPages === 1;
751
+ pageStatus.textContent = "Page " + pageNumber + " of " + totalPages;
752
+ previous.disabled = pageNumber === 1;
753
+ next.disabled = pageNumber === totalPages;
734
754
  };
735
755
  const updateLocation = () => {
736
756
  const params = new URLSearchParams();
737
757
  if (search.value.trim()) params.set("q", search.value.trim());
738
758
  if (historyToggle.checked) params.set("history", "1");
759
+ if (pageNumber > 1) params.set("page", String(pageNumber));
739
760
  history.replaceState(null, "", "#/retention-schedule" + (params.size ? "?" + params : ""));
740
761
  };
741
- search.addEventListener("input", () => { filter(); updateLocation(); });
742
- historyToggle.addEventListener("change", () => { filter(); updateLocation(); });
762
+ search.addEventListener("input", () => { pageNumber = 1; filter(); updateLocation(); });
763
+ historyToggle.addEventListener("change", () => { pageNumber = 1; filter(); updateLocation(); });
764
+ previous.addEventListener("click", () => { pageNumber -= 1; filter(); updateLocation(); });
765
+ next.addEventListener("click", () => { pageNumber += 1; filter(); updateLocation(); });
743
766
  filter();
744
767
  }
745
768
 
@@ -759,9 +782,16 @@ function renderRetentionScheduleIssues() {
759
782
  }
760
783
 
761
784
  function retentionScheduleDocumentReadiness(documentId) {
762
- return state.programReadiness?.stages
763
- ?.find(({ id }) => id === "policies")
764
- ?.items.find(({ id }) => id === "document-approval-" + documentId) || null;
785
+ const documentIssueCodes = new Set([
786
+ "missing-retention-schedule-document",
787
+ "multiple-retention-schedule-documents",
788
+ "incomplete-retention-schedule-document"
789
+ ]);
790
+ const issue = state.collectionReviews?.["retention-schedule-item"]?.approvalIssues
791
+ ?.find(({ code }) => documentIssueCodes.has(code));
792
+ return issue
793
+ ? { status: "action", message: issue.message }
794
+ : { status: "complete", message: "The document proposal is complete. Final approval follows all completed row proposals." };
765
795
  }
766
796
 
767
797
  function referenceTitle(id) {
@@ -1162,27 +1192,34 @@ function collectionReviewPanel(type, force = false) {
1162
1192
  const current = assessment.status === "current";
1163
1193
  const scheduleReview = type === "retention-schedule-item" && modelSupports("retention-schedule-approval");
1164
1194
  const scheduleBlocker = scheduleReview ? retentionScheduleApprovalBlocker() : null;
1195
+ const proposalBlocker = assessment.incompleteRecordProposals?.length
1196
+ ? "Complete " + assessment.incompleteRecordProposals.length + " record " + (assessment.incompleteRecordProposals.length === 1 ? "proposal" : "proposals") + " before the collection review."
1197
+ : null;
1198
+ const reviewBlocker = scheduleBlocker || proposalBlocker;
1165
1199
  const needsFirstRecord = collectionNeedsFirstRecord(type);
1166
1200
  const reviewerNames = (assessment.review?.reviewedByIds || [])
1167
1201
  .map((id) => state.resources.find(({ record }) => record.id === id)?.record.title || id);
1168
1202
  const reviewNote = assessment.review?.rationale
1169
1203
  ? " Note: " + esc(assessment.review.rationale)
1170
1204
  : "";
1171
- const reviewSummary = current && !scheduleBlocker
1205
+ const reviewSummary = current && !reviewBlocker
1172
1206
  ? '<p class="collection-review-result"><strong>' + esc(properCase(assessment.review.decision)) + '</strong><span>Reviewed ' + esc(formatCalendarDate(assessment.review.reviewedOn)) + (reviewerNames.length ? " by " + esc(reviewerNames.join(", ")) : "") + "." + reviewNote + '</span></p>'
1173
- : '<p class="collection-review-result"><strong>' + (scheduleBlocker ? "Complete schedule first" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(scheduleBlocker || assessment.message) + '</span></p>';
1174
- const compactReviewSummary = current && !scheduleBlocker
1207
+ : '<p class="collection-review-result"><strong>' + (scheduleBlocker ? "Complete schedule first" : proposalBlocker ? "Complete proposals first" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(reviewBlocker || assessment.message) + '</span></p>';
1208
+ const compactReviewSummary = current && !reviewBlocker
1175
1209
  ? '<p class="collection-review-result"><strong>' + esc(properCase(assessment.review.decision)) + '</strong><span>Reviewed ' + esc(formatCalendarDate(assessment.review.reviewedOn)) + (reviewerNames.length ? " by " + esc(reviewerNames.join(", ")) : "") + ".</span></p>"
1176
1210
  : reviewSummary;
1177
1211
  const action = state.readOnly
1178
1212
  ? ""
1179
- : scheduleBlocker
1180
- ? '<button class="button" type="button" disabled title="' + esc(scheduleBlocker) + '">Review and approve schedule</button>'
1213
+ : reviewBlocker
1214
+ ? '<button class="button" type="button" disabled title="' + esc(reviewBlocker) + '">' + (scheduleReview ? "Review and approve schedule" : "Review and confirm") + '</button>'
1181
1215
  : needsFirstRecord
1182
1216
  ? '<a class="button primary" href="#/resources/' + encodeURIComponent(type) + '?new=1">Add first ' + esc(state.model.resources[type].title.toLowerCase()) + '</a>'
1183
1217
  : '<button class="button ' + (current ? "" : "primary") + '" type="button" data-review-collection="' + esc(type) + '">' + (current ? scheduleReview ? "Approve a new revision" : "Review again" : scheduleReview ? "Review and approve schedule" : "Review and confirm") + '</button>';
1184
- const status = scheduleBlocker ? "Not ready" : current ? "Reviewed" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Stale" : "Review required";
1185
- const details = '<details ' + (current ? 'class="collection-review-details"' : "open") + '><summary>' + (current ? scheduleReview ? "Show approved revision" : "Show scope confirmation" : "What to review") + '</summary><div class="collection-review-detail-content"><div class="collection-review-head"><div><p class="kicker">' + (scheduleReview ? "Schedule approval" : "Scope confirmation") + '</p><h3>' + esc(configuration.title) + '</h3><p>' + esc(configuration.description) + '</p></div><span class="badge ' + (current ? "good" : "warn") + '">' + status + '</span></div><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul>' + (current ? reviewSummary : "") + '</div></details>';
1218
+ const status = reviewBlocker ? "Not ready" : current ? "Reviewed" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Stale" : "Review required";
1219
+ const proposalSummary = assessment.recordProposals?.length
1220
+ ? '<p class="collection-review-proposal-summary"><strong>' + assessment.recordProposals.filter(({ complete }) => complete).length + ' of ' + assessment.recordProposals.length + ' record proposals complete.</strong> The collection review follows every proposal.</p>'
1221
+ : "";
1222
+ const details = '<details ' + (current ? 'class="collection-review-details"' : "open") + '><summary>' + (current ? scheduleReview ? "Show approved revision" : "Show scope confirmation" : "What to review") + '</summary><div class="collection-review-detail-content"><div class="collection-review-head"><div><p class="kicker">' + (scheduleReview ? "Schedule approval" : "Scope confirmation") + '</p><h3>' + esc(configuration.title) + '</h3><p>' + esc(configuration.description) + '</p></div><span class="badge ' + (current ? "good" : "warn") + '">' + status + '</span></div>' + proposalSummary + '<ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul>' + (current ? reviewSummary : "") + '</div></details>';
1186
1223
  const summary = current
1187
1224
  ? '<div class="collection-review-current-row"><div class="collection-review-complete-summary"><span class="kicker">' + (scheduleReview ? "Approved schedule" : "Scope confirmation") + '</span>' + compactReviewSummary + '</div>' + details + action + '</div>'
1188
1225
  : details;
@@ -1201,15 +1238,8 @@ function retentionScheduleApprovalBlocker() {
1201
1238
  const document = documents.length === 1 ? documents[0] : null;
1202
1239
  if (documents.length > 1) return "Keep exactly one current program Data Retention Schedule document before approval.";
1203
1240
  if (!document) return "Add the governing Data Retention Schedule document first.";
1204
- if (!["approved", "active"].includes(document.status) || !document.approvedOn || !document.approvedContentRevisions) {
1205
- return "Approve the governing document and bind its exact Markdown revision first.";
1206
- }
1207
- const documentReadiness = retentionScheduleDocumentReadiness(document.id);
1208
- if (documentReadiness?.status !== "complete") {
1209
- return documentReadiness?.message || "Complete the governing document before approving the schedule.";
1210
- }
1211
- const planned = resourcesOfType("retention-schedule-item").find(({ record }) => record.status === "planned")?.record;
1212
- if (planned) return "Complete or retire the planned row: " + planned.title + ".";
1241
+ const approvalIssue = state.collectionReviews?.["retention-schedule-item"]?.approvalIssues?.[0];
1242
+ if (approvalIssue) return approvalIssue.message;
1213
1243
  const incomplete = retentionReviewItems("policies", false).find((item) => (
1214
1244
  item.status === "action" && item.id !== "collection-review-retention-schedule-item"
1215
1245
  ));
@@ -4752,6 +4782,9 @@ function openEditor(type, entry = null, options = {}) {
4752
4782
  Array.isArray(group) ? { fields: group } : group
4753
4783
  ));
4754
4784
  const oneOf = new Set(oneOfGroups.flatMap((group) => group.fields || []).filter((name) => !name.startsWith("$markdown:")));
4785
+ const hiddenFields = type === "retention-schedule-item" && modelSupports("retention-schedule-approval")
4786
+ ? new Set(["approvedByIds", "approvedOn"])
4787
+ : new Set();
4755
4788
  const activeOneOf = new Set(oneOfGroups
4756
4789
  .filter((group) => !group.when || conditionMatches(record, group.when))
4757
4790
  .flatMap((group) => group.fields || []));
@@ -4765,6 +4798,7 @@ function openEditor(type, entry = null, options = {}) {
4765
4798
  ...oneOf
4766
4799
  ])].filter((name) => (
4767
4800
  !["id", "type"].includes(name)
4801
+ && !hiddenFields.has(name)
4768
4802
  && fields[name]
4769
4803
  && !(options.occurrenceReview && ["members", "expectedCount", "completedCount", "conclusion"].includes(name))
4770
4804
  ));
package/src/workflow.js CHANGED
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { modelSupports } from "../model/index.js";
5
5
  import { applicabilityReviewIsCurrent } from "./applicability-scope.js";
6
+ import { retentionScheduleIsAuthoritative } from "./collection-review.js";
6
7
  import { assessRequiredAppointments } from "./appointments.js";
7
8
  import { assessSourceCoverageReadiness } from "./source-coverage.js";
8
9
  import { coverageEnd, coverageStart } from "./coverage.js";
@@ -918,6 +919,7 @@ async function auditLifecycleFindings(loaded, audits, program) {
918
919
  const byId = new Map(loaded.resources.map((record) => [record.id, record]));
919
920
  const retentionRules = loaded.resources.filter((record) => record.type === "retention-schedule-item");
920
921
  const retentionRevisions = await resourceReviewRevisions(loaded, retentionRules.flatMap((rule) => retentionReviewResourceIds(rule, loaded)));
922
+ const retentionScheduleApproved = retentionScheduleIsAuthoritative(loaded, target);
921
923
  const findings = [];
922
924
  for (const audit of audits) {
923
925
  if (!(audit.controlIds || []).length) {
@@ -1045,7 +1047,7 @@ async function auditLifecycleFindings(loaded, audits, program) {
1045
1047
  `${records.length} ${noun} remain open, so this engagement cannot be treated as closed.`
1046
1048
  ));
1047
1049
  }
1048
- const linkedRetentionCurrent = (audit.retentionScheduleItemIds || []).some((id) => {
1050
+ const linkedRetentionCurrent = retentionScheduleApproved && (audit.retentionScheduleItemIds || []).some((id) => {
1049
1051
  const rule = byId.get(id);
1050
1052
  return rule?.type === "retention-schedule-item"
1051
1053
  && rule.status === "active"