filegrc 0.15.0 → 0.15.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { modelSupports } from "../model/index.js";
4
4
  import { applicabilityReviewIsCurrent } from "./applicability-scope.js";
5
+ import { selectDefaultAudit } from "./audit-selection.js";
5
6
  import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
6
7
  import {
7
8
  coverageContains,
@@ -63,20 +64,21 @@ export async function assessAuditPreparation(input, options = {}) {
63
64
  const records = loaded.resources;
64
65
  const byId = new Map(records.map((record) => [record.id, record]));
65
66
  const audits = records.filter((record) => record.type === "audit");
67
+ const calendarAsOf = options.asOf || currentCalendarDate(loaded.workspace?.timezone || "UTC");
66
68
  const audit = options.auditId
67
69
  ? audits.find((record) => record.id === options.auditId)
68
70
  : options.selectDefault === false
69
71
  ? null
70
- : audits.find((record) => !["complete", "closed", "canceled"].includes(record.status)) || audits[0];
72
+ : selectDefaultAudit(audits, calendarAsOf);
71
73
  if (options.auditId && !audit) throw new Error(`Audit "${options.auditId}" was not found.`);
72
74
 
73
- const calendarAsOf = options.asOf || currentCalendarDate(loaded.workspace?.timezone || "UTC");
74
75
  const formalPeriodEnd = audit?.auditKind === "soc-2-type-2" ? coverageEnd(audit.coverage) : null;
75
76
  const readinessAsOf = formalPeriodEnd && formalPeriodEnd < calendarAsOf ? formalPeriodEnd : calendarAsOf;
76
77
  const programReadiness = options.programReadiness || await assessProgramReadiness(loaded, {
77
78
  asOf: readinessAsOf,
78
79
  generatedAt: options.generatedAt,
79
- programId: audit?.programId || options.programId
80
+ programId: audit?.programId || options.programId,
81
+ auditId: audit?.id
80
82
  });
81
83
  const documentActivations = audit && modelSupports(loaded.model, "governed-document-activation")
82
84
  ? await auditDocumentActivationAssessments(loaded, audit, byId, calendarAsOf)
@@ -746,6 +748,12 @@ function canonicalScopeValue(value) {
746
748
  function programFoundationStage(programReadiness, workspace, audit) {
747
749
  const needsOperatingPeriod = audit?.auditKind === "soc-2-type-2";
748
750
  const ready = needsOperatingPeriod ? programReadiness.operating : programReadiness.evidenceReady;
751
+ const setupRemaining = programReadiness.setupProgress?.remaining
752
+ ?? (programReadiness.progress?.mode === "setup-progress" ? programReadiness.progress.remaining : 0);
753
+ const activeWindow = programReadiness.progress?.mode === "operating-window";
754
+ const activeWindowLabel = programReadiness.progress?.source === "audit"
755
+ ? "audit window"
756
+ : "candidate operating period";
749
757
  return stage("program", "Program Readiness", "The management program can be prepared and operated without an audit record or CPA firm.", [
750
758
  item(
751
759
  "evidence-ready",
@@ -755,9 +763,13 @@ function programFoundationStage(programReadiness, workspace, audit) {
755
763
  ? needsOperatingPeriod
756
764
  ? `${programReadiness.target.label} is operating for the selected engagement.`
757
765
  : `${programReadiness.target.label} is evidence-ready.`
766
+ : needsOperatingPeriod && activeWindow
767
+ ? setupRemaining
768
+ ? `The ${activeWindowLabel} is active, but ${setupRemaining} required setup ${setupRemaining === 1 ? "action remains" : "actions remain"} across scope, policies, controls, and evidence preparation.`
769
+ : `The ${activeWindowLabel} is active, but current operating work needs attention before treating the engagement as management-ready.`
758
770
  : needsOperatingPeriod && programReadiness.evidenceReady
759
771
  ? "Start the candidate period and finish the current operating-readiness work before treating a Type 2 engagement as management-ready."
760
- : `${programReadiness.counts.action} program-readiness actions remain across scope, policies, controls, and evidence preparation.`,
772
+ : `${setupRemaining} required setup ${setupRemaining === 1 ? "action remains" : "actions remain"} across scope, policies, controls, and evidence preparation.`,
761
773
  workspace || { type: "workspace" }
762
774
  )
763
775
  ]);
@@ -0,0 +1,33 @@
1
+ export function selectDefaultAudit(audits = [], asOf = "") {
2
+ const terminal = new Set(["complete", "closed", "canceled", "delivered"]);
3
+ const coverageStart = (audit) => audit?.coverage?.startsOn
4
+ || audit?.coverage?.periodStart
5
+ || audit?.coverage?.on
6
+ || "";
7
+ const statusOrder = new Map([
8
+ ["planned", 0],
9
+ ["in-progress", 1],
10
+ ["fieldwork", 2],
11
+ ["report-draft", 3],
12
+ ["issued", 4],
13
+ ["delivered", 5],
14
+ ["complete", 6]
15
+ ]);
16
+ const open = audits.filter((audit) => !terminal.has(audit.status));
17
+ const candidates = [...(open.length ? open : audits)];
18
+ candidates.sort((left, right) => {
19
+ const leftStart = coverageStart(left);
20
+ const rightStart = coverageStart(right);
21
+ const leftStarted = Boolean(leftStart && (!asOf || leftStart <= asOf));
22
+ const rightStarted = Boolean(rightStart && (!asOf || rightStart <= asOf));
23
+ return Number(rightStarted) - Number(leftStarted)
24
+ || (leftStarted && rightStarted
25
+ ? rightStart.localeCompare(leftStart)
26
+ : leftStart && rightStart
27
+ ? leftStart.localeCompare(rightStart)
28
+ : Number(Boolean(rightStart)) - Number(Boolean(leftStart)))
29
+ || (statusOrder.get(right.status) ?? -1) - (statusOrder.get(left.status) ?? -1)
30
+ || String(left.id || "").localeCompare(String(right.id || ""));
31
+ });
32
+ return candidates[0] || null;
33
+ }
package/src/cli.js CHANGED
@@ -447,16 +447,17 @@ export async function runCli(argv = process.argv.slice(2)) {
447
447
  const loaded = await loadWorkspace(root);
448
448
  const result = await assessProgramReadiness(loaded, { asOf: flags["as-of"], programId: flags.program });
449
449
  const output = flags.summary ? summarizeProgramReadiness(result) : result;
450
+ const headlineStatus = result.progress.status || result.status;
450
451
  if (flags.json) console.log(JSON.stringify(output, null, 2));
451
452
  else if (flags.summary) {
452
- console.log(`${result.status.toUpperCase()}: ${result.progress.complete} of ${result.progress.total} program items complete`);
453
+ console.log(`${headlineStatus.toUpperCase()}: ${result.progress.label} ${result.progress.percent}%. ${result.progress.detail}`);
453
454
  for (const stage of output.stages) {
454
- console.log(`${stage.status.toUpperCase()}\t${stage.title}\t${stage.counts.action} actions`);
455
+ console.log(`${stage.status.toUpperCase()}\t${stage.title}\t${stage.counts.action} checks need action`);
455
456
  }
456
457
  if (output.firstAction) console.log(`Next: ${output.firstAction.title}\t${output.firstAction.message}`);
457
458
  }
458
459
  else {
459
- console.log(`${result.status.toUpperCase()}: ${result.progress.complete} of ${result.progress.total} program items complete`);
460
+ console.log(`${headlineStatus.toUpperCase()}: ${result.progress.label} ${result.progress.percent}%. ${result.progress.detail}`);
460
461
  console.log(
461
462
  `${result.target.label}`
462
463
  + (result.target.candidateCoverage?.kind === "range"
@@ -2036,6 +2037,8 @@ function summarizeProgramReadiness(result) {
2036
2037
  suggestedCandidatePeriodStart: result.suggestedCandidatePeriodStart,
2037
2038
  target: result.target,
2038
2039
  progress: result.progress,
2040
+ setupProgress: result.setupProgress,
2041
+ checkProgress: result.checkProgress,
2039
2042
  counts: result.counts,
2040
2043
  scopeCounts: Object.fromEntries(
2041
2044
  Object.entries(result.scope).map(([name, ids]) => [name.replace(/Ids$/, ""), ids.length])
package/src/index.js CHANGED
@@ -86,7 +86,7 @@ export {
86
86
  scaffoldExternalReviewerGovernance,
87
87
  setupExternalReviewerGovernance
88
88
  } from "./external-reviewer.js";
89
- export { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
89
+ export { assessEvidenceMap, assessProgramReadiness, calculateProgramProgress } from "./program-readiness.js";
90
90
  export {
91
91
  approveReportingRouteSet,
92
92
  assessReportingRoutePeriod,
@@ -32,6 +32,94 @@ import { assessSourceCoverageReadiness } from "./source-coverage.js";
32
32
  import { currentCalendarDate, timestampFromLocalDateTime } from "./time.js";
33
33
  import { loadWorkspace } from "./workspace.js";
34
34
 
35
+ export function calculateProgramProgress({
36
+ stages = [],
37
+ target = {},
38
+ evidenceReady = false,
39
+ operating = false,
40
+ asOf,
41
+ window = null
42
+ } = {}) {
43
+ const typeTwo = target.goal === "soc-2-type-2";
44
+ const trackedWindow = normalizeProgressWindow(window || target.candidateCoverage, window ? "audit" : "candidate");
45
+ const windowStarted = Boolean(
46
+ typeTwo
47
+ && trackedWindow
48
+ && asOf
49
+ && trackedWindow.start <= asOf
50
+ );
51
+ const setupProgress = calculateSetupActionProgress(stages, target, Boolean(trackedWindow));
52
+ if (windowStarted) {
53
+ const totalDays = calendarDayDistance(trackedWindow.start, trackedWindow.end) + 1;
54
+ const elapsedDays = Math.min(
55
+ totalDays,
56
+ Math.max(0, calendarDayDistance(trackedWindow.start, asOf) + 1)
57
+ );
58
+ const percent = totalDays ? Math.round((elapsedDays / totalDays) * 100) : 0;
59
+ const complete = elapsedDays >= totalDays;
60
+ const healthy = Boolean(operating);
61
+ return {
62
+ mode: "operating-window",
63
+ label: trackedWindow.source === "audit" ? "Audit window" : "Operating window",
64
+ status: healthy ? complete ? "Window complete" : "Operating" : "Needs attention",
65
+ complete: elapsedDays,
66
+ total: totalDays,
67
+ remaining: Math.max(0, totalDays - elapsedDays),
68
+ percent,
69
+ unit: "day",
70
+ start: trackedWindow.start,
71
+ end: trackedWindow.end,
72
+ source: trackedWindow.source,
73
+ detail: complete
74
+ ? `${totalDays} of ${totalDays} days elapsed.`
75
+ : `Day ${elapsedDays} of ${totalDays}; ${Math.max(0, totalDays - elapsedDays)} days remain.`,
76
+ operating
77
+ };
78
+ }
79
+
80
+ return {
81
+ mode: "setup-progress",
82
+ label: "Program setup",
83
+ status: !setupProgress.remaining
84
+ ? evidenceReady && typeTwo && trackedWindow?.start > asOf
85
+ ? "Scheduled"
86
+ : evidenceReady ? "Evidence ready" : "Ready"
87
+ : evidenceReady && typeTwo ? "Ready to start" : "Needs work",
88
+ ...setupProgress,
89
+ unit: "action",
90
+ detail: setupProgress.remaining
91
+ ? `${setupProgress.complete} of ${setupProgress.total} required actions complete.`
92
+ : `All ${setupProgress.total} required actions are complete.`,
93
+ operating
94
+ };
95
+ }
96
+
97
+ function calculateSetupActionProgress(stages, target, windowMilestoneComplete) {
98
+ const units = new Map();
99
+ for (const currentStage of stages.filter(({ id }) => id !== "operation")) {
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);
106
+ }
107
+ }
108
+ }
109
+
110
+ const setupUnits = [...units.values()];
111
+ const complete = setupUnits.filter(({ status }) => status === "complete").length;
112
+ const total = setupUnits.length + (target.goal === "soc-2-type-2" ? 1 : 0);
113
+ const completed = complete + (windowMilestoneComplete ? 1 : 0);
114
+ const remaining = Math.max(0, total - completed);
115
+ return {
116
+ complete: completed,
117
+ total,
118
+ remaining,
119
+ percent: total ? Math.round((completed / total) * 100) : 0
120
+ };
121
+ }
122
+
35
123
  export async function assessProgramReadiness(input, options = {}) {
36
124
  const loaded = input?.resources && input?.model && input?.entries
37
125
  ? input
@@ -41,6 +129,15 @@ export async function assessProgramReadiness(input, options = {}) {
41
129
  const workspace = loaded.workspace || records.find((record) => record.type === "workspace");
42
130
  const program = resolveProgram(loaded, options.programId);
43
131
  const asOf = options.asOf || currentCalendarDate(workspace?.timezone || "UTC");
132
+ const requestedAudit = options.auditId
133
+ ? records.find((record) => record.type === "audit" && record.id === options.auditId)
134
+ : null;
135
+ const auditWindow = requestedAudit?.auditKind === "soc-2-type-2"
136
+ ? requestedAudit.coverage
137
+ : selectedAuditWindow(records, program, asOf);
138
+ const progressWindow = requestedAudit?.auditKind === "soc-2-type-2"
139
+ ? normalizeProgressWindow(auditWindow, "audit")
140
+ : selectedProgressWindow(program?.candidateCoverage, auditWindow, asOf);
44
141
  const scope = programScope(program, records, byId, loaded.model, loaded);
45
142
  const collectionReviews = assessCollectionReviews(loaded, { programId: program.id });
46
143
  const markdown = new Map();
@@ -123,26 +220,54 @@ export async function assessProgramReadiness(input, options = {}) {
123
220
  const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0);
124
221
  const stages = [
125
222
  ...evidenceGateStages,
126
- await operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model)
223
+ await operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model, progressWindow)
127
224
  ];
128
225
  finalizeStage(stages.at(-1));
129
- const candidateStarted = Boolean(
226
+ const periodStarted = Boolean(
130
227
  program?.assuranceGoal === "soc-2-type-2"
131
- && program.candidateCoverage?.kind === "range"
132
- && coverageStart(program.candidateCoverage) <= asOf
228
+ && progressWindow?.start <= asOf
133
229
  );
134
230
  const obligations = planObligations(records, { programId: program.id, asOf, through: asOf, model: loaded.model });
135
231
  const policyLibrary = await assessPolicyLibraryUpgrades(loaded);
136
- const operating = evidenceReady && candidateStarted && stages.at(-1).counts.action === 0;
232
+ const operating = Boolean(
233
+ evidenceReady
234
+ && periodStarted
235
+ && stages.at(-1).counts.action === 0
236
+ );
137
237
  const canStartCandidatePeriod = Boolean(
138
238
  evidenceReady
139
239
  && program?.assuranceGoal === "soc-2-type-2"
140
240
  && !program.candidateCoverage
241
+ && !periodStarted
141
242
  );
142
243
  const items = stages.flatMap((current) => current.items);
143
244
  const managedItems = items.filter((current) => !["info", "later"].includes(current.status));
144
245
  const complete = managedItems.filter((current) => current.status === "complete").length;
145
246
  const firstAction = items.find((current) => current.status === "action") || null;
247
+ const target = {
248
+ programId: program?.id || null,
249
+ goal: program?.assuranceGoal || "none",
250
+ label: assuranceGoalLabel(program?.assuranceGoal),
251
+ candidateCoverage: program?.candidateCoverage || null
252
+ };
253
+ const checkProgress = {
254
+ complete,
255
+ total: managedItems.length,
256
+ percent: managedItems.length ? Math.round((complete / managedItems.length) * 100) : 0
257
+ };
258
+ const progress = calculateProgramProgress({
259
+ stages,
260
+ target,
261
+ evidenceReady,
262
+ operating,
263
+ asOf,
264
+ window: progressWindow
265
+ });
266
+ const setupProgress = calculateSetupActionProgress(
267
+ stages,
268
+ target,
269
+ Boolean(progressWindow)
270
+ );
146
271
 
147
272
  return {
148
273
  program,
@@ -150,12 +275,7 @@ export async function assessProgramReadiness(input, options = {}) {
150
275
  dataModelVersion: String(loaded.model.modelVersion),
151
276
  generatedAt: options.generatedAt || new Date().toISOString(),
152
277
  asOf,
153
- target: {
154
- programId: program?.id || null,
155
- goal: program?.assuranceGoal || "none",
156
- label: assuranceGoalLabel(program?.assuranceGoal),
157
- candidateCoverage: program?.candidateCoverage || null
158
- },
278
+ target,
159
279
  status: operating ? "operating" : evidenceReady ? "evidence-ready" : "needs-work",
160
280
  evidenceReady,
161
281
  operating,
@@ -168,11 +288,9 @@ export async function assessProgramReadiness(input, options = {}) {
168
288
  ...policyLibrary.proposals,
169
289
  ...legacyPolicyLibraryProposals(records)
170
290
  ],
171
- progress: {
172
- complete,
173
- total: managedItems.length,
174
- percent: managedItems.length ? Math.round((complete / managedItems.length) * 100) : 0
175
- },
291
+ progress,
292
+ setupProgress,
293
+ checkProgress,
176
294
  counts: countStatuses(items),
177
295
  firstAction,
178
296
  scope: {
@@ -724,6 +842,7 @@ async function policiesStage(scope, records, byId, readMarkdown, model) {
724
842
  : "Appoint a reviewer who is separate from the policy owner. The reviewer may be another person in the organization or an external person, and is separate from the CPA firm that may later perform the audit.",
725
843
  appointedReviewer || (reviewerNeedsAssignment ? governedRecords[0] : { type: "person" }),
726
844
  {
845
+ progressUnit: false,
727
846
  commands: [
728
847
  "npx filegrc list appointment --workflow --json",
729
848
  "npx filegrc guide appointment --json",
@@ -1468,7 +1587,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
1468
1587
  return stage("sources", "Control Evidence Sources", `Complete the authoritative ${modelSupports(model, "component-sources") ? "Components" : "Systems"} for every selected control family before marking the Controls implemented.`, items);
1469
1588
  }
1470
1589
 
1471
- async function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
1590
+ async function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model, progressWindow = null) {
1472
1591
  const goal = workspace?.assuranceGoal || "none";
1473
1592
  if (!evidenceReady) {
1474
1593
  return stage("operation", "Operate the Program", "Run the controls and preserve dated evidence after the Evidence Ready gate passes.", [
@@ -1500,12 +1619,9 @@ async function operationStage(loaded, workspace, scope, records, byId, asOf, evi
1500
1619
  }
1501
1620
 
1502
1621
  const obligations = planObligations(records, { programId: workspace.id, asOf, through: asOf, model });
1503
- const start = workspace.candidateCoverage?.kind === "range"
1504
- ? coverageStart(workspace.candidateCoverage)
1505
- : null;
1506
- const end = workspace.candidateCoverage?.kind === "range"
1507
- ? coverageEnd(workspace.candidateCoverage)
1508
- : null;
1622
+ const start = progressWindow?.start || null;
1623
+ const end = progressWindow?.end || null;
1624
+ const formalWindow = progressWindow?.source === "audit";
1509
1625
  const startStatus = !start ? "action" : start <= asOf ? "complete" : "later";
1510
1626
  const sourceCoverage = await assessSourceCoverageReadiness(loaded, scope.controls.map(({ id }) => id), workspace);
1511
1627
  const incompleteSourceCoverage = sourceCoverage.filter(({ complete }) => !complete);
@@ -1517,16 +1633,20 @@ async function operationStage(loaded, workspace, scope, records, byId, asOf, evi
1517
1633
  !start
1518
1634
  ? "Set the management candidate period start when the Evidence Ready gate passes. Do not backdate it."
1519
1635
  : start <= asOf
1520
- ? `Management began the candidate Type 2 evidence period on ${start}. This is not the auditor-agreed report period.`
1636
+ ? formalWindow
1637
+ ? `The auditor-agreed Type 2 period began on ${start}. Keep evidence collection and operating work current through the period.`
1638
+ : `Management began the candidate Type 2 evidence period on ${start}. This is not the auditor-agreed report period.`
1521
1639
  : `Evidence collection is scheduled to begin on ${start}.`,
1522
1640
  workspace
1523
1641
  ),
1524
1642
  item(
1525
1643
  "candidate-period-end",
1526
1644
  end ? "complete" : start ? "later" : "info",
1527
- "Plan the candidate period end",
1645
+ formalWindow ? "Confirm the auditor-agreed period end" : "Plan the candidate period end",
1528
1646
  end
1529
- ? `Management candidate period: ${start || "start not set"} through ${end}. The CPA firm may agree to different dates.`
1647
+ ? formalWindow
1648
+ ? `Auditor-agreed period: ${start || "start not set"} through ${end}.`
1649
+ : `Management candidate period: ${start || "start not set"} through ${end}. The CPA firm may agree to different dates.`
1530
1650
  : "Add the management target end when useful. Starting reliable evidence collection is the immediate milestone.",
1531
1651
  workspace
1532
1652
  ),
@@ -1784,6 +1904,61 @@ function countStatuses(items) {
1784
1904
  return counts;
1785
1905
  }
1786
1906
 
1907
+ export function selectedAuditWindow(records, program, asOf) {
1908
+ if (program?.assuranceGoal !== "soc-2-type-2") return null;
1909
+ const candidates = records.filter((record) => (
1910
+ record.type === "audit"
1911
+ && record.auditKind === "soc-2-type-2"
1912
+ && (!record.programId || record.programId === program.id)
1913
+ && !["canceled", "closed", "complete", "delivered"].includes(record.status)
1914
+ && normalizeProgressWindow(record.coverage)
1915
+ && coverageStart(record.coverage) <= asOf
1916
+ ));
1917
+ const statusOrder = new Map([
1918
+ ["planned", 0],
1919
+ ["in-progress", 1],
1920
+ ["fieldwork", 2],
1921
+ ["report-draft", 3],
1922
+ ["issued", 4]
1923
+ ]);
1924
+ candidates.sort((left, right) => (
1925
+ coverageStart(right.coverage).localeCompare(coverageStart(left.coverage))
1926
+ || (statusOrder.get(right.status) ?? -1) - (statusOrder.get(left.status) ?? -1)
1927
+ || coverageEnd(right.coverage).localeCompare(coverageEnd(left.coverage))
1928
+ || left.id.localeCompare(right.id)
1929
+ ));
1930
+ return candidates[0]?.coverage || null;
1931
+ }
1932
+
1933
+ export function selectedProgressWindow(candidateCoverage, auditCoverage, asOf) {
1934
+ const candidates = [
1935
+ normalizeProgressWindow(candidateCoverage, "candidate"),
1936
+ normalizeProgressWindow(auditCoverage, "audit")
1937
+ ].filter((window) => window && (!asOf || window.start <= asOf));
1938
+ candidates.sort((left, right) => (
1939
+ right.start.localeCompare(left.start)
1940
+ || Number(right.source === "audit") - Number(left.source === "audit")
1941
+ || right.end.localeCompare(left.end)
1942
+ ));
1943
+ if (candidates.length) return candidates[0];
1944
+ return normalizeProgressWindow(candidateCoverage, "candidate")
1945
+ || normalizeProgressWindow(auditCoverage, "audit");
1946
+ }
1947
+
1948
+ function normalizeProgressWindow(coverage, source = "candidate") {
1949
+ const start = coverage?.start || coverageStart(coverage);
1950
+ const end = coverage?.end || coverageEnd(coverage);
1951
+ if (!start || !end || end < start) return null;
1952
+ return { start, end, source: coverage?.source || source };
1953
+ }
1954
+
1955
+ function calendarDayDistance(start, end) {
1956
+ const startTime = Date.parse(`${start}T00:00:00Z`);
1957
+ const endTime = Date.parse(`${end}T00:00:00Z`);
1958
+ if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) return 0;
1959
+ return Math.round((endTime - startTime) / 86_400_000);
1960
+ }
1961
+
1787
1962
  function shiftYear(value, offset) {
1788
1963
  const date = new Date(`${value}T00:00:00Z`);
1789
1964
  date.setUTCFullYear(date.getUTCFullYear() + offset);
package/src/retention.js CHANGED
@@ -32,22 +32,42 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
32
32
  ]
33
33
  }
34
34
  ));
35
- items.push(...uses.map((use) => {
36
- const matches = usableRules.filter((rule) => ruleCoversUse(rule, use, program));
35
+ const usesByInformationType = new Map();
36
+ for (const use of uses) {
37
+ const grouped = usesByInformationType.get(use.informationTypeId) || [];
38
+ grouped.push(use);
39
+ usesByInformationType.set(use.informationTypeId, grouped);
40
+ }
41
+ items.push(...[...usesByInformationType].map(([informationTypeId, informationTypeUses]) => {
42
+ const uncoveredUses = informationTypeUses.filter((use) => (
43
+ !usableRules.some((rule) => ruleCoversUse(rule, use, program))
44
+ ));
45
+ const matches = usableRules.filter((rule) => (
46
+ informationTypeUses.some((use) => ruleCoversUse(rule, use, program))
47
+ ));
48
+ const informationTypeTitle = byId.get(informationTypeId)?.title || informationTypeId;
49
+ const affectedTitles = uncoveredUses.map(({ resource }) => resource.title);
50
+ const affectedSummary = affectedTitles.length > 3
51
+ ? `${affectedTitles.slice(0, 3).join(", ")}, and ${affectedTitles.length - 3} more`
52
+ : affectedTitles.join(", ");
37
53
  return readinessItem(
38
- `retention-use-${use.resource.id}-${use.informationTypeId}`,
39
- matches.length ? "complete" : "action",
40
- `Decide retention for ${byId.get(use.informationTypeId)?.title || use.informationTypeId}`,
41
- matches.length
42
- ? `${use.resource.title} is covered by ${matches.map(({ title }) => title).join(", ")}.`
43
- : `${use.resource.title} uses this Information Type, but no active, current retention schedule item covers both the type and scope. Management must choose the cutoff, period, and disposition.`,
44
- use.resource,
54
+ `retention-use-${informationTypeId}`,
55
+ uncoveredUses.length ? "action" : "complete",
56
+ `Decide retention for ${informationTypeTitle}`,
57
+ uncoveredUses.length
58
+ ? `${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(", ")}.`,
60
+ { type: "retention-schedule-item" },
45
61
  {
46
- informationTypeId: use.informationTypeId,
62
+ informationTypeId,
63
+ affectedResourceIds: uncoveredUses.map(({ resource }) => resource.id),
64
+ retentionScopeResourceIds: uncoveredUses.length === informationTypeUses.length
65
+ ? [program.id]
66
+ : uncoveredUses.map(({ resource }) => resource.id),
47
67
  retentionScheduleItemIds: matches.map(({ id }) => id),
48
68
  commands: [
49
69
  "npx filegrc guide retention-schedule-item --json",
50
- `npx filegrc scaffold retention-schedule-item --title ${shellArgument(`Retention for ${byId.get(use.informationTypeId)?.title || use.informationTypeId}`)}`
70
+ `npx filegrc scaffold retention-schedule-item --title ${shellArgument(`Retention for ${informationTypeTitle}`)}`
51
71
  ]
52
72
  }
53
73
  );
package/src/web.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createResourceId } from "./id.js";
2
+ import { selectDefaultAudit } from "./audit-selection.js";
2
3
  import { MODEL_CAPABILITY_VERSIONS } from "../model/index.js";
3
4
  import {
4
5
  calendarOccurrence,
@@ -14,12 +15,20 @@ import { formatCalendarDate, formatLocalDateTime } from "./time.js";
14
15
 
15
16
  export function dashboardProgramReadiness(programReadiness = {}) {
16
17
  const progress = programReadiness.progress || {};
17
- const lifecycle = programReadiness.operating
18
- ? { status: "Operating", tone: "good" }
19
- : programReadiness.evidenceReady
20
- ? { status: "Evidence ready", tone: "good" }
21
- : { status: "Needs work", tone: "warn" };
18
+ const lifecycle = progress.mode === "operating-window"
19
+ ? {
20
+ status: progress.status || (programReadiness.operating ? "Operating" : "Needs attention"),
21
+ tone: programReadiness.operating ? "good" : "warn"
22
+ }
23
+ : programReadiness.operating
24
+ ? { status: "Operating", tone: "good" }
25
+ : programReadiness.evidenceReady
26
+ ? { status: progress.status || "Evidence ready", tone: "good" }
27
+ : { status: progress.status || "Needs work", tone: "warn" };
22
28
  return {
29
+ mode: progress.mode || "setup-progress",
30
+ label: progress.label || "Program setup",
31
+ detail: progress.detail || `${progress.complete ?? 0} of ${progress.total ?? 0} required actions complete.`,
23
32
  percent: progress.percent ?? 0,
24
33
  complete: progress.complete ?? 0,
25
34
  total: progress.total ?? 0,
@@ -27,6 +36,23 @@ export function dashboardProgramReadiness(programReadiness = {}) {
27
36
  };
28
37
  }
29
38
 
39
+ export function workflowHrefBelongsToPage(destination = {}, href = "") {
40
+ const route = String(href).split("?")[0];
41
+ if (!route) return false;
42
+ if (destination.href === "#/policies") {
43
+ return ["policy", "document", "training"].some((type) => route.startsWith(`#/resource/${type}/`));
44
+ }
45
+ if (destination.type) {
46
+ return route === `#/resources/${destination.type}` || route.startsWith(`#/resource/${destination.type}/`);
47
+ }
48
+ return route === String(destination.href || "").split("?")[0];
49
+ }
50
+
51
+ export function pageActionIdentity(href = "", fallback = "") {
52
+ const route = String(href).split("?")[0];
53
+ return route.startsWith("#/resource/") ? route : fallback || href;
54
+ }
55
+
30
56
  export function renderIndex(state = null) {
31
57
  const snapshot = state
32
58
  ? `<script id="filegrc-data" type="application/json">${safeJson(state)}</script>`
@@ -71,6 +97,9 @@ let READINESS_STAGES = SHARED_PROGRAM_STAGES.map((stage) => ({
71
97
  }));
72
98
  const RESOURCE_GUIDE_INSTRUCTIONS = ${JSON.stringify(RESOURCE_INSTRUCTIONS)};
73
99
  const dashboardProgramReadiness = ${dashboardProgramReadiness.toString()};
100
+ const workflowHrefBelongsToPage = ${workflowHrefBelongsToPage.toString()};
101
+ const pageActionIdentity = ${pageActionIdentity.toString()};
102
+ const selectDefaultAudit = ${selectDefaultAudit.toString()};
74
103
  const STAGE_PAGE_SUMMARIES = ${JSON.stringify({
75
104
  ...RESOURCE_PAGE_SUMMARIES,
76
105
  "utility:evidence-sources": "Check that each Control has an authoritative, retrievable evidence source.",
@@ -93,6 +122,7 @@ let onboardingPendingDraft = false;
93
122
  const resourceDetailRequests = new Map();
94
123
  const stateSectionRequests = new Map();
95
124
  let resourceGuideCleanup = null;
125
+ let recordOverflowCleanup = null;
96
126
  let repositorySyncPollTimer = null;
97
127
  let repositorySyncPollInFlight = false;
98
128
  let mutationStateRefreshInFlight = false;
@@ -165,6 +195,8 @@ function handleRouteChange() {
165
195
  function render() {
166
196
  resourceGuideCleanup?.();
167
197
  resourceGuideCleanup = null;
198
+ recordOverflowCleanup?.();
199
+ recordOverflowCleanup = null;
168
200
  const previousNavigation = root.querySelector(".sidebar-nav");
169
201
  if (previousNavigation) navigationScrollTop = previousNavigation.scrollTop;
170
202
  const route = parseRoute();
@@ -460,8 +492,16 @@ function topbarProgramReadiness() {
460
492
  return '<a class="topbar-readiness' + (loading ? ' is-loading' : '') + '" href="#/"' + (loading ? ' aria-busy="true"' : '') + '><span class="topbar-readiness-copy"><span>Program readiness</span><strong>' + label + '</strong></span><span class="progress" aria-hidden="true"><span style="width:0%"></span></span></a>';
461
493
  }
462
494
  const progress = dashboardProgramReadiness(state.programReadiness);
463
- const detail = progress.complete + " of " + progress.total + " readiness items complete";
464
- return '<a class="topbar-readiness" href="' + nextProgramStageHref() + '" aria-label="' + esc("Program readiness: " + progress.percent + "%. " + detail + ". " + progress.status + ".") + '"><span class="topbar-readiness-copy"><span>Program readiness</span><strong>' + progress.percent + '%</strong></span><span class="progress" aria-hidden="true"><span style="width:' + progress.percent + '%"></span></span></a>';
495
+ const setupRemaining = state.programReadiness.setupProgress?.remaining || 0;
496
+ const setupRegression = progress.mode === "operating-window" && setupRemaining > 0;
497
+ const href = progress.mode === "operating-window" && !setupRegression ? "#/stage/run" : nextProgramStageHref();
498
+ const label = setupRegression
499
+ ? progress.label + " · " + setupRemaining + " setup " + (setupRemaining === 1 ? "action" : "actions")
500
+ : progress.label;
501
+ const detail = setupRegression
502
+ ? progress.detail + " " + setupRemaining + " required setup " + (setupRemaining === 1 ? "action also needs" : "actions also need") + " attention."
503
+ : progress.detail;
504
+ return '<a class="topbar-readiness" href="' + href + '" aria-label="' + esc(label + ": " + progress.percent + "%. " + detail + " " + progress.status + ".") + '"><span class="topbar-readiness-copy"><span>' + esc(label) + '</span><strong>' + progress.percent + '%</strong></span><span class="progress" aria-hidden="true"><span style="width:' + progress.percent + '%"></span></span></a>';
465
505
  }
466
506
 
467
507
  function repositoryStatusTone(status) {
@@ -649,7 +689,7 @@ function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
649
689
  const seed = {
650
690
  ...(params.get("title") ? { title: params.get("title") } : {}),
651
691
  ...(params.get("informationTypeId") ? { informationTypeIds: [params.get("informationTypeId")] } : {}),
652
- ...(params.get("scopeResourceId") ? { scopeResourceIds: [params.get("scopeResourceId")] } : {}),
692
+ ...(params.getAll("scopeResourceId").length ? { scopeResourceIds: params.getAll("scopeResourceId") } : {}),
653
693
  ...(params.get("scheduleDocumentId") ? { scheduleDocumentId: params.get("scheduleDocumentId") } : {})
654
694
  };
655
695
  history.replaceState(null, "", "#/retention-schedule");
@@ -704,18 +744,9 @@ function wireRetentionScheduleTable(total) {
704
744
  }
705
745
 
706
746
  function renderRetentionScheduleIssues() {
707
- const items = retentionReviewItems("policies", false);
708
- const plannedRows = resourcesOfType("retention-schedule-item")
709
- .filter(({ record }) => record.status === "planned")
710
- .map(({ record }) => ({
711
- id: "planned-" + record.id,
712
- title: "Complete or retire " + record.title,
713
- message: "Planned rows are excluded from the approved schedule. Finish this decision or retire the row before schedule approval.",
714
- href: '#/resource/retention-schedule-item/' + encodeURIComponent(record.id) + '?stage=policies'
715
- }));
716
- const actions = [...plannedRows, ...items.filter((item) => (
717
- item.status === "action" && item.id !== "collection-review-retention-schedule-item"
718
- ))];
747
+ const actions = retentionSchedulePageItems().filter((item) => (
748
+ item.id !== "collection-review-retention-schedule-item"
749
+ ));
719
750
  if (!actions.length) return "";
720
751
  return '<section class="retention-issues"><div class="section-head"><div><p class="kicker">Before approval</p><h2>Items to resolve</h2></div></div><div class="retention-issue-list">' + actions.map((item) => {
721
752
  const href = item.href || (item.id?.startsWith("retention-use-")
@@ -888,7 +919,7 @@ function renderDocumentActivationAssessments() {
888
919
 
889
920
  function renderAuditDocumentActivationAssessments() {
890
921
  const audits = resourcesOfType("audit").map(({ record }) => record);
891
- const audit = audits.find(({ status }) => !["complete", "closed", "canceled"].includes(status)) || audits[0];
922
+ const audit = selectDefaultAudit(audits, state.programReadiness?.asOf || currentDate());
892
923
  const preparation = audit ? state.auditPreparations?.[audit.id] : null;
893
924
  const assessments = preparation?.documentActivations || [];
894
925
  if (!audit || !assessments.length) return "";
@@ -1276,8 +1307,7 @@ function recordWorkflowItems(type, id) {
1276
1307
  const activeStates = new Set(["blocked", "due", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
1277
1308
  return [
1278
1309
  ...(state.workflow?.findings || []),
1279
- ...(state.workflow?.workItems || []),
1280
- ...programReadinessWorkflowItems()
1310
+ ...(state.workflow?.workItems || [])
1281
1311
  ].filter((item) => (
1282
1312
  activeStates.has(item.state)
1283
1313
  && (
@@ -1499,7 +1529,10 @@ function workflowItemHref(item) {
1499
1529
  function retentionScheduleItemHref(item) {
1500
1530
  const params = new URLSearchParams({ new: "1" });
1501
1531
  if (item.informationTypeId) params.set("informationTypeId", item.informationTypeId);
1502
- if (item.resourceId || item.subject?.id) params.set("scopeResourceId", item.resourceId || item.subject.id);
1532
+ for (const id of item.retentionScopeResourceIds || []) params.append("scopeResourceId", id);
1533
+ if (!params.has("scopeResourceId") && (item.resourceId || item.subject?.id)) {
1534
+ params.set("scopeResourceId", item.resourceId || item.subject.id);
1535
+ }
1503
1536
  const informationTypeTitle = String(item.title || "").replace(/^Decide retention for\s+/i, "").trim();
1504
1537
  if (informationTypeTitle && informationTypeTitle !== item.title) params.set("title", "Retention for " + informationTypeTitle);
1505
1538
  const scheduleDocuments = resourcesOfType("document").filter(({ record }) => (
@@ -1611,16 +1644,50 @@ function isRetentionScheduleWorkflowItem(item) {
1611
1644
  });
1612
1645
  }
1613
1646
 
1614
- function programReadinessWorkflowItems() {
1615
- if (state.sections?.workflow === "complete") return [];
1616
- return retentionReviewItems()
1617
- .filter((item) => item.status === "action")
1618
- .map((item) => ({
1619
- ...item,
1620
- key: "program-readiness." + item.id,
1647
+ function retentionSchedulePageItems() {
1648
+ const plannedRows = resourcesOfType("retention-schedule-item")
1649
+ .filter(({ record }) => record.status === "planned")
1650
+ .map(({ record }) => ({
1651
+ id: "planned-" + record.id,
1652
+ key: "planned-" + record.id,
1653
+ status: "action",
1621
1654
  state: "ready",
1622
- subject: item.resourceId && item.resourceType ? { type: item.resourceType, id: item.resourceId } : { type: item.resourceType || "unknown" }
1655
+ title: "Complete or retire " + record.title,
1656
+ message: "Planned rows are excluded from the approved schedule. Finish this decision or retire the row before schedule approval.",
1657
+ resourceType: record.type,
1658
+ resourceId: record.id,
1659
+ href: '#/resource/retention-schedule-item/' + encodeURIComponent(record.id) + '?stage=policies'
1623
1660
  }));
1661
+ return distinctPageActionItems([
1662
+ ...plannedRows,
1663
+ ...retentionReviewItems("policies", false)
1664
+ .filter(({ status }) => status === "action")
1665
+ .map((item) => ({
1666
+ ...item,
1667
+ key: item.id,
1668
+ state: "ready",
1669
+ href: item.id === "collection-review-retention-schedule-item"
1670
+ ? "#/retention-schedule"
1671
+ : item.id?.startsWith("retention-use-")
1672
+ ? retentionScheduleItemHref(item)
1673
+ : item.resourceId && item.resourceType
1674
+ ? '#/resource/' + encodeURIComponent(item.resourceType) + '/' + encodeURIComponent(item.resourceId)
1675
+ : workflowItemHref(item) || "#/retention-schedule",
1676
+ subject: item.resourceId && item.resourceType
1677
+ ? { type: item.resourceType, id: item.resourceId }
1678
+ : item.subject
1679
+ }))
1680
+ ]);
1681
+ }
1682
+
1683
+ function distinctPageActionItems(items) {
1684
+ const distinct = new Map();
1685
+ for (const item of items) {
1686
+ const href = item.href || workflowItemHref(item);
1687
+ const key = pageActionIdentity(href, item.key || item.id);
1688
+ if (!distinct.has(key)) distinct.set(key, item);
1689
+ }
1690
+ return [...distinct.values()];
1624
1691
  }
1625
1692
 
1626
1693
  function evidenceSourceCheckLabel(name) {
@@ -1660,11 +1727,11 @@ function stagePageCard(stage, destination, index) {
1660
1727
  const stepLabel = "Step " + stage.number + "." + String.fromCharCode(97 + index);
1661
1728
  const derived = derivedStagePageState(stage, destination);
1662
1729
  const complete = derived.complete;
1663
- const items = stagePageItems(stage, destination);
1730
+ const items = stagePageActionItems(stage, destination);
1664
1731
  const completionState = '<span class="stage-page-completion-state ' + (complete ? "complete" : "") + '">' + esc(derived.label) + '</span>';
1665
1732
  const taskPreview = items.length
1666
1733
  ? '<div class="stage-page-tasks">' + items.slice(0, 3).map((item) => {
1667
- const href = destination.utility === "evidence-sources" ? destination.href : workflowItemHref(item) || destination.href;
1734
+ const href = destination.utility === "evidence-sources" ? destination.href : item.href || workflowItemHref(item) || destination.href;
1668
1735
  return '<a href="' + href + '"><span class="workflow-finding-status ' + esc(item.state) + '">' + esc(properCase(item.state)) + '</span><span><strong>' + esc(item.title) + '</strong><small>' + esc(stagePageItemDetail(item)) + '</small></span></a>';
1669
1736
  }).join("") + (items.length > 3 ? '<small class="stage-page-tasks-more">+' + (items.length - 3) + ' more on this page</small>' : "") + '</div>'
1670
1737
  : "";
@@ -1699,6 +1766,11 @@ function stageProgress(stage) {
1699
1766
  return progressFromCounts(complete, applicable.length, "page");
1700
1767
  }
1701
1768
 
1769
+ function stagePageActionItems(stage, destination) {
1770
+ const deferredStates = new Set(["later", "scheduled", "upcoming", "waiting-external"]);
1771
+ return stagePageItems(stage, destination).filter(({ state }) => !deferredStates.has(state));
1772
+ }
1773
+
1702
1774
  function derivedStagePageState(stage, destination) {
1703
1775
  if (
1704
1776
  stage.id === "audit"
@@ -1716,15 +1788,14 @@ function derivedStagePageState(stage, destination) {
1716
1788
  : { complete: evidenceItems.length > 0, label: evidenceItems.length ? "Ready" : "Not configured", countsTowardProgress: evidenceItems.length > 0 };
1717
1789
  }
1718
1790
  if (destination.utility === "retention-schedule") {
1719
- const assessment = state.collectionReviews?.["retention-schedule-item"];
1720
- const incomplete = retentionReviewItems("policies", false).filter(({ status }) => status !== "complete").length;
1721
- return assessment?.status === "current" && !incomplete
1791
+ const incomplete = retentionSchedulePageItems().length;
1792
+ return !incomplete
1722
1793
  ? { complete: true, label: "Approved" }
1723
1794
  : { complete: false, label: incomplete ? incomplete + " " + pluralize("item", incomplete) + " need work" : "Approval needed" };
1724
1795
  }
1725
1796
  const items = stagePageItems(stage, destination);
1726
1797
  const deferredStates = new Set(["later", "scheduled", "upcoming", "waiting-external"]);
1727
- const blocking = items.filter(({ state }) => !deferredStates.has(state));
1798
+ const blocking = stagePageActionItems(stage, destination);
1728
1799
  if (blocking.length) {
1729
1800
  return { complete: false, label: blocking.length + " " + pluralize("item", blocking.length) + (blocking.length === 1 ? " needs work" : " need work") };
1730
1801
  }
@@ -1749,8 +1820,7 @@ function stagePageItems(stage, destination) {
1749
1820
  const activeStates = new Set(["blocked", "due", "later", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
1750
1821
  const items = [
1751
1822
  ...(state.workflow?.findings || []),
1752
- ...(state.workflow?.workItems || []),
1753
- ...programReadinessWorkflowItems()
1823
+ ...(state.workflow?.workItems || [])
1754
1824
  ].filter((item) => (
1755
1825
  activeStates.has(item.state)
1756
1826
  && (
@@ -1766,18 +1836,9 @@ function stagePageItems(stage, destination) {
1766
1836
  .map((item) => ({ ...item, key: item.id, state: item.status, subject: { type: "source-coverage" } }));
1767
1837
  }
1768
1838
  if (destination.utility === "retention-schedule") {
1769
- return retentionReviewItems("policies", false)
1770
- .filter(({ status }) => status !== "complete")
1771
- .map((item) => ({ ...item, key: item.id, state: item.status, subject: item.resourceId && item.resourceType ? { type: item.resourceType, id: item.resourceId } : { type: item.resourceType || "retention-schedule-item" } }));
1772
- }
1773
- if (stage.id === "policies" && destination.href === "#/policies") {
1774
- return items.filter((item) => !isRetentionScheduleWorkflowItem(item)).sort((left, right) => (
1775
- workflowItemStatePriority(left) - workflowItemStatePriority(right)
1776
- || (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
1777
- || left.key.localeCompare(right.key)
1778
- ));
1839
+ return retentionSchedulePageItems();
1779
1840
  }
1780
- return items.filter((item) => {
1841
+ return distinctPageActionItems(items.filter((item) => {
1781
1842
  if (destination.type === "program" && item.key === "program.scope.criteria") return false;
1782
1843
  if (
1783
1844
  destination.type === "requirement"
@@ -1789,22 +1850,10 @@ function stagePageItems(stage, destination) {
1789
1850
  && ["source-coverage", "system"].includes(item.subject?.type)
1790
1851
  && item.key?.startsWith("evidence-source.")
1791
1852
  ) return false;
1792
- if (
1793
- stage.id === "policies"
1794
- && destination.type === "policy"
1795
- && item.code === "governance.appointment.independent-policy-reviewer"
1796
- ) return true;
1797
- if (
1798
- destination.type
1799
- && (item.subject?.type === destination.type || item.source?.type === destination.type)
1800
- ) return true;
1801
1853
  const href = workflowItemHref(item);
1802
- if (!href) return false;
1803
- const destinationHref = destination.href.split("?")[0];
1804
- return href === destinationHref
1805
- || href.startsWith(destinationHref + "?")
1806
- || Boolean(destination.type && href.startsWith("#/resource/" + destination.type + "/"));
1807
- }).sort((left, right) => (
1854
+ return workflowHrefBelongsToPage(destination, href)
1855
+ && !(destination.href === "#/policies" && isRetentionScheduleWorkflowItem(item));
1856
+ })).sort((left, right) => (
1808
1857
  workflowItemStatePriority(left) - workflowItemStatePriority(right)
1809
1858
  || (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
1810
1859
  || left.key.localeCompare(right.key)
@@ -1820,7 +1869,7 @@ function operationProgress() {
1820
1869
  const goal = program?.target?.goal || activeProgram().assuranceGoal || "none";
1821
1870
  const asOf = program?.asOf || currentDate();
1822
1871
  const candidateStarted = goal === "soc-2-type-2"
1823
- ? Boolean(program?.target?.candidateCoverage?.kind === "range" && program.target.candidateCoverage.startsOn <= asOf)
1872
+ ? Boolean(program?.progress?.mode === "operating-window")
1824
1873
  : goal === "soc-2-type-1"
1825
1874
  ? Boolean(program?.target?.candidateCoverage?.kind === "as-of")
1826
1875
  : Boolean(program?.evidenceReady);
@@ -3092,7 +3141,7 @@ function renderList(main, type, params = new URLSearchParams()) {
3092
3141
  const seed = type === "retention-schedule-item" ? {
3093
3142
  ...(params.get("title") ? { title: params.get("title") } : {}),
3094
3143
  ...(params.get("informationTypeId") ? { informationTypeIds: [params.get("informationTypeId")] } : {}),
3095
- ...(params.get("scopeResourceId") ? { scopeResourceIds: [params.get("scopeResourceId")] } : {}),
3144
+ ...(params.getAll("scopeResourceId").length ? { scopeResourceIds: params.getAll("scopeResourceId") } : {}),
3096
3145
  ...(params.get("scheduleDocumentId") ? { scheduleDocumentId: params.get("scheduleDocumentId") } : {})
3097
3146
  } : type === "document" ? {
3098
3147
  ...(params.get("title") ? { title: params.get("title") } : {}),
@@ -3155,12 +3204,11 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
3155
3204
  const narrative = recordNarrative(entry.record, fields);
3156
3205
  const narrativeNames = new Set(narrative.map(([name]) => name));
3157
3206
  const visible = Object.entries(entry.record).filter(([name]) => (
3158
- !["id", "type", "title"].includes(name)
3207
+ !["id", "type", "title", "extensions"].includes(name)
3159
3208
  && !fields[name]?.content
3160
3209
  && !narrativeNames.has(name)
3161
3210
  ));
3162
3211
  const content = Object.entries(entry.content);
3163
- const sourceMetadata = '<div><dt>Source file</dt><dd><code>' + esc(entry.relativePath) + '</code></dd></div><div><dt>Workspace revision</dt><dd>' + (state.git.available ? '<code>' + esc(state.git.shortCommit) + '</code>' : "Unavailable until the workspace is committed.") + '</dd></div>';
3164
3212
  const narrativeContent = narrative.length
3165
3213
  ? '<div class="content-label"><span>Record</span></div><div class="record-prose">' + narrative.map(([name, value]) => '<section><h3>' + esc(titleCase(fields[name]?.label || humanize(name))) + '</h3><p>' + esc(value) + '</p></section>').join("") + '</div>'
3166
3214
  : "";
@@ -3213,26 +3261,41 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
3213
3261
  ? '<section class="panel detail-main">' + narrativeContent + markdownContent + addRecordContent + '</section>'
3214
3262
  : "";
3215
3263
  const attachmentPanel = type === "evidence" ? evidenceAttachmentPanel(entry) : "";
3216
- const historyPanel = entry.history?.length
3217
- ? '<section class="panel detail-history-panel"><div class="panel-head"><h3>File History</h3></div><div class="history">' + entry.history.map((commit) => '<div><code>' + esc(commit.shortCommit) + '</code><span><strong>' + esc(commit.subject) + '</strong><small>' + esc(commit.author) + ' · ' + esc(formatLocalDateTime(commit.timestamp)) + '</small></span></div>').join("") + '</div></section>'
3264
+ const recordFieldsPanel = visible.length
3265
+ ? '<section class="panel detail-support-panel detail-metadata-panel"><div class="panel-head"><h3>Record details</h3></div><dl class="metadata">' + visible.map(([name, value]) => '<div><dt>' + esc(fields[name]?.label || humanize(name)) + '</dt><dd>' + formatValue(name === "status" ? displayStatus(entry.record) : value, name, type) + '</dd></div>').join("") + '</dl></section>'
3218
3266
  : "";
3219
- const participationPanel = entry.historyLoaded === false
3220
- ? '<section class="panel detail-support-panel" role="status" aria-live="polite" aria-busy="true"><div class="panel-head"><h3>Participation</h3></div><p class="muted is-loading">' + loadingIndicator("Loading participation and file history…") + '</p></section>'
3221
- : entry.historyError
3222
- ? '<section class="panel detail-support-panel" role="status" aria-live="polite"><div class="panel-head"><h3>Participation</h3></div><p class="muted">Participation and file history are unavailable. Reload the record to try again.</p></section>'
3223
- : personParticipation(entry);
3224
3267
  const supportPanels = renderDetailSupport({
3225
3268
  hasRecordBody,
3226
3269
  workflowPanel: workflowGuidance({ type, id, title: "Next steps", workflow: entry.workflow }) || recordCompletionState(entry.record),
3227
- metadataPanel: '<section class="panel detail-support-panel detail-metadata-panel"><div class="panel-head"><h3>Record details</h3></div><dl class="metadata">' + sourceMetadata + visible.map(([name, value]) => '<div><dt>' + esc(fields[name]?.label || humanize(name)) + '</dt><dd>' + formatValue(name === "status" ? displayStatus(entry.record) : value, name, type) + '</dd></div>').join("") + '</dl></section>',
3228
- attachmentPanel,
3229
- participationPanel,
3230
- connectionsPanel: resourceConnections(entry),
3231
- historyPanel
3270
+ metadataPanel: recordFieldsPanel,
3271
+ attachmentPanel
3232
3272
  });
3233
- main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="' + collectionHref + '">' + esc(collectionLabel) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div><div class="actions">' + reportingRouteSetActions + auditCycleAction + auditPopulationCorrectionAction + (type === "audit" ? '<a class="button primary" href="#/audit-packet?auditId=' + encodeURIComponent(entry.record.id) + '">Audit Evidence &amp; Packet</a>' : "") + governanceActions + lifecycleActions + issueActions + addRecordContentAction + (!state.readOnly && !routeSetLocked ? '<button class="button" id="edit-resource">Edit</button>' + (!definition.singleton ? '<button class="button danger" id="delete-resource">Delete</button>' : "") : "") + '</div></div><div class="detail-grid ' + (hasRecordBody ? "" : "detail-grid-structured") + '">' + detailMain + supportPanels + '</div></div>';
3273
+ const mayDelete = !state.readOnly && !routeSetLocked && !definition.singleton;
3274
+ const recordMenu = '<details class="record-overflow"><summary aria-label="More record actions" title="More record actions"><svg viewBox="0 0 18 4" aria-hidden="true"><circle cx="2" cy="2" r="2"></circle><circle cx="9" cy="2" r="2"></circle><circle cx="16" cy="2" r="2"></circle></svg></summary><div class="record-overflow-menu" aria-label="Record actions"><button type="button" data-record-modal="info">Record info</button><button type="button" data-record-modal="connections">Connections</button><button type="button" data-record-modal="history"' + (entry.historyLoaded === false ? ' disabled' : '') + '>File history' + (entry.historyLoaded === false ? ' (loading)' : '') + '</button>' + (mayDelete ? '<div class="record-overflow-separator"></div><button class="danger-text" type="button" data-delete-resource>Delete record</button>' : '') + '</div></details>';
3275
+ main.innerHTML = '<div class="page"><div class="detail-head"><div><div class="breadcrumbs header-breadcrumbs"><a href="' + collectionHref + '">' + esc(collectionLabel) + '</a><span>/</span><span>' + esc(entry.record.title) + '</span></div><h2>' + esc(titleCase(entry.record.title)) + '</h2></div><div class="actions">' + reportingRouteSetActions + auditCycleAction + auditPopulationCorrectionAction + (type === "audit" ? '<a class="button primary" href="#/audit-packet?auditId=' + encodeURIComponent(entry.record.id) + '">Audit Evidence &amp; Packet</a>' : "") + governanceActions + lifecycleActions + issueActions + addRecordContentAction + (!state.readOnly && !routeSetLocked ? '<button class="button" id="edit-resource">Edit</button>' : "") + recordMenu + '</div></div><div class="detail-grid ' + (hasRecordBody ? "" : "detail-grid-structured") + '">' + detailMain + supportPanels + '</div></div>';
3234
3276
  main.querySelectorAll("[data-completion-requirements]").forEach((button) => button.addEventListener("click", () => openCompletionRequirementsDialog(button.dataset.completionRequirements)));
3235
3277
  main.querySelector("#edit-resource")?.addEventListener("click", () => openEditor(type, entry));
3278
+ main.querySelectorAll("[data-record-modal]").forEach((button) => button.addEventListener("click", () => {
3279
+ button.closest("details")?.removeAttribute("open");
3280
+ openRecordUtilityDialog(entry, button.dataset.recordModal);
3281
+ }));
3282
+ const overflowMenu = main.querySelector(".record-overflow");
3283
+ const stopOutsideDismissal = () => document.removeEventListener("click", dismissOnOutsideClick);
3284
+ const dismissOnOutsideClick = (event) => {
3285
+ if (overflowMenu.contains(event.target)) return;
3286
+ overflowMenu.removeAttribute("open");
3287
+ stopOutsideDismissal();
3288
+ };
3289
+ overflowMenu?.addEventListener("toggle", () => {
3290
+ stopOutsideDismissal();
3291
+ if (overflowMenu.open) queueMicrotask(() => document.addEventListener("click", dismissOnOutsideClick));
3292
+ });
3293
+ overflowMenu?.addEventListener("keydown", (event) => {
3294
+ if (event.key !== "Escape" || !overflowMenu.open) return;
3295
+ overflowMenu.removeAttribute("open");
3296
+ overflowMenu.querySelector("summary")?.focus();
3297
+ });
3298
+ recordOverflowCleanup = stopOutsideDismissal;
3236
3299
  main.querySelector("[data-external-reviewer-governance]")?.addEventListener("click", openExternalReviewerGovernanceDialog);
3237
3300
  main.querySelector("[data-next-audit-cycle]")?.addEventListener("click", () => openNextAuditCycleDialog(entry.record));
3238
3301
  main.querySelector("[data-correct-audit-population]")?.addEventListener("click", async () => {
@@ -3343,7 +3406,8 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
3343
3406
  }));
3344
3407
  main.querySelector("#add-record-content")?.addEventListener("click", () => openEditor(type, entry, { addRecordContent: true }));
3345
3408
  main.querySelectorAll("[data-edit-content]").forEach((button) => button.addEventListener("click", () => openContentEditor(entry, button.dataset.editContent)));
3346
- main.querySelector("#delete-resource")?.addEventListener("click", async () => {
3409
+ main.querySelector("[data-delete-resource]")?.addEventListener("click", async () => {
3410
+ main.querySelector(".record-overflow")?.removeAttribute("open");
3347
3411
  const repositoryPrefetch = prefetchRepositoryForReview();
3348
3412
  if (!await confirmAction({
3349
3413
  kicker: "Delete record",
@@ -3369,17 +3433,50 @@ function renderDetail(main, type, id, params = new URLSearchParams()) {
3369
3433
  }
3370
3434
  }
3371
3435
 
3372
- function renderDetailSupport({ hasRecordBody, workflowPanel, metadataPanel, attachmentPanel, participationPanel, connectionsPanel, historyPanel }) {
3373
- const panels = workflowPanel + metadataPanel + attachmentPanel + participationPanel + connectionsPanel + historyPanel;
3436
+ function renderDetailSupport({ hasRecordBody, workflowPanel, metadataPanel, attachmentPanel }) {
3437
+ const panels = workflowPanel + metadataPanel + attachmentPanel;
3374
3438
  if (hasRecordBody) return '<aside>' + panels + '</aside>';
3375
3439
  const stacks = [
3376
3440
  { name: "guidance", content: workflowPanel },
3377
- { name: "record", content: metadataPanel + attachmentPanel + historyPanel },
3378
- { name: "relationships", content: participationPanel + connectionsPanel }
3441
+ { name: "record", content: metadataPanel + attachmentPanel }
3379
3442
  ].filter(({ content }) => content);
3380
3443
  return '<aside class="detail-support-columns">' + stacks.map(({ name, content }) => '<div class="detail-support-stack ' + name + '">' + content + '</div>').join("") + '</aside>';
3381
3444
  }
3382
3445
 
3446
+ function openRecordUtilityDialog(entry, view) {
3447
+ const dialog = document.createElement("dialog");
3448
+ dialog.className = "alert-dialog record-utility-dialog";
3449
+ const definitions = {
3450
+ info: {
3451
+ kicker: "Record info",
3452
+ title: entry.record.title,
3453
+ body: '<dl class="metadata record-info"><div><dt>Record ID</dt><dd><code>' + esc(entry.record.id) + '</code></dd></div><div><dt>Type</dt><dd>' + esc(state.model.resources[entry.record.type].title) + '</dd></div><div><dt>Source file</dt><dd><code>' + esc(entry.relativePath) + '</code></dd></div><div><dt>Workspace revision</dt><dd>' + (state.git.available ? '<code>' + esc(state.git.shortCommit) + '</code>' : "Unavailable until the workspace is committed.") + '</dd></div>' + (entry.record.extensions ? '<div><dt>Extensions</dt><dd>' + formatValue(entry.record.extensions, "extensions", entry.record.type) + '</dd></div>' : '') + '</dl><details class="record-json"><summary>View raw JSON</summary><pre class="compact-json">' + esc(JSON.stringify(entry.record, null, 2)) + '</pre></details>'
3454
+ },
3455
+ connections: {
3456
+ kicker: "Connections",
3457
+ title: entry.record.title,
3458
+ body: (personParticipation(entry, true) + resourceConnections(entry, true)) || '<p class="muted">This record has no connected records.</p>'
3459
+ },
3460
+ history: {
3461
+ kicker: "File history",
3462
+ title: entry.record.title,
3463
+ body: entry.historyError
3464
+ ? '<p class="muted">File history is unavailable. Reload the record to try again.</p>'
3465
+ : entry.history?.length
3466
+ ? '<div class="history">' + entry.history.map((commit) => '<div><code>' + esc(commit.shortCommit) + '</code><span><strong>' + esc(commit.subject) + '</strong><small>' + esc(commit.author) + ' · ' + esc(formatLocalDateTime(commit.timestamp)) + '</small></span></div>').join("") + '</div>'
3467
+ : '<p class="muted">No committed history is available for this record.</p>'
3468
+ }
3469
+ };
3470
+ const definition = definitions[view];
3471
+ if (!definition) return;
3472
+ dialog.setAttribute("aria-labelledby", "record-utility-title");
3473
+ dialog.innerHTML = '<div class="dialog-head"><div><p class="kicker">' + esc(definition.kicker) + '</p><h2 id="record-utility-title">' + esc(titleCase(definition.title)) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><div class="record-utility-body">' + definition.body + '</div><div class="dialog-actions"><button type="button" class="button primary">Close</button></div>';
3474
+ document.body.append(dialog);
3475
+ dialog.querySelectorAll("button").forEach((button) => button.addEventListener("click", () => dialog.close()));
3476
+ dialog.addEventListener("close", () => dialog.remove(), { once: true });
3477
+ dialog.showModal();
3478
+ }
3479
+
3383
3480
  function evidenceAttachmentPanel(entry) {
3384
3481
  const attachments = (entry.record.filePaths || []).map((path) => ({
3385
3482
  path,
@@ -3563,7 +3660,7 @@ function recordContentDefinition(type) {
3563
3660
  };
3564
3661
  }
3565
3662
 
3566
- function personParticipation(entry) {
3663
+ function personParticipation(entry, expanded = false) {
3567
3664
  if (entry.record.type !== "person") return "";
3568
3665
  const personId = entry.record.id;
3569
3666
  const appointments = resourcesOfType("appointment")
@@ -3603,8 +3700,8 @@ function personParticipation(entry) {
3603
3700
  const count = affiliations.length + assignments.length;
3604
3701
  if (!count) return "";
3605
3702
  return '<section class="panel connections-panel"><div class="panel-head"><h3>Participation</h3><span>' + count + '</span></div>'
3606
- + personParticipationGroup("Appointments and teams", affiliations, 8, "more appointments or teams")
3607
- + personParticipationGroup("Assigned records", assignments, 10, "more assigned records")
3703
+ + personParticipationGroup("Appointments and teams", affiliations, expanded ? affiliations.length : 8, "more appointments or teams")
3704
+ + personParticipationGroup("Assigned records", assignments, expanded ? assignments.length : 10, "more assigned records")
3608
3705
  + '</section>';
3609
3706
  }
3610
3707
 
@@ -3617,7 +3714,7 @@ function personParticipationGroup(title, rows, limit, moreLabel) {
3617
3714
  + '</div>';
3618
3715
  }
3619
3716
 
3620
- function resourceConnections(entry) {
3717
+ function resourceConnections(entry, expanded = false) {
3621
3718
  const relatedPeopleOnly = entry.record.type === "person";
3622
3719
  const connections = new Map();
3623
3720
  const entriesById = new Map(state.resources.map((item) => [item.record.id, item]));
@@ -3652,7 +3749,7 @@ function resourceConnections(entry) {
3652
3749
  return firstType - secondType || first.entry.record.title.localeCompare(second.entry.record.title);
3653
3750
  });
3654
3751
  if (!sorted.length) return "";
3655
- const visible = sorted.slice(0, 14);
3752
+ const visible = expanded ? sorted : sorted.slice(0, 14);
3656
3753
  return '<section class="panel connections-panel"><div class="panel-head"><h3>' + (relatedPeopleOnly ? "Related people" : "Connections") + '</h3><span>' + sorted.length + '</span></div><div class="connections">' + visible.map(({ entry: connected, reasons }) => '<a href="#/resource/' + encodeURIComponent(connected.record.type) + '/' + encodeURIComponent(connected.record.id) + '"><strong>' + esc(connected.record.title) + '</strong><small>' + esc([...reasons].join(" · ")) + '</small></a>').join("") + '</div>' + (sorted.length > visible.length ? '<p class="connections-more">' + (sorted.length - visible.length) + ' more connections are available through the linked records.</p>' : "") + '</section>';
3657
3754
  }
3658
3755
 
@@ -6385,7 +6482,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
6385
6482
  .reconciliation-panel>p{margin:0 0 12px;color:var(--muted);font-size:12px;line-height:1.5}.reconciliation-candidates{display:grid;gap:9px}.reconciliation-candidate{display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,.8fr);gap:16px;padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.reconciliation-candidate strong{margin-left:8px;font-size:13.2px}.reconciliation-candidate p{margin:7px 0;color:var(--muted);font-size:12px;line-height:1.5}.reconciliation-candidate small{display:block;overflow-wrap:anywhere;color:var(--muted);font-size:9.6px}.reconciliation-actions{display:grid;gap:8px;justify-items:end;align-content:center}.reconciliation-actions>code{max-width:100%;overflow-wrap:anywhere;color:var(--muted);font-size:9.6px;text-align:right}
6386
6483
  .onboarding-save-status{color:var(--muted);font-size:10.8px;line-height:1.35}.onboarding-save-status:empty{display:none}.onboarding-save-status:not(:empty){order:-1;flex-basis:100%;margin-bottom:4px}.onboarding-retry-sync{margin-top:9px}.detail-loading{color:var(--muted)}
6387
6484
  .save-status{min-height:16px;color:var(--muted);font-size:10.8px;line-height:1.35}
6388
- .page-intro,.detail-head{align-items:center;margin-bottom:12px}.actions{align-items:center}.detail-head>div:first-child{min-width:0}.detail-head h2{margin:7px 0}.detail-head .header-breadcrumbs{margin:0;font-size:10.8px;line-height:normal;min-height:11px;align-items:center}.header-breadcrumbs span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60ch}
6485
+ .page-intro,.detail-head{align-items:center;margin-bottom:12px}.actions{align-items:center;justify-content:flex-end;flex-wrap:wrap}.detail-head>div:first-child{min-width:0}.detail-head h2{margin:7px 0}.detail-head .header-breadcrumbs{margin:0;font-size:10.8px;line-height:normal;min-height:11px;align-items:center}.header-breadcrumbs span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60ch}.record-overflow{position:relative;flex:0 0 auto;margin-left:auto}.record-overflow>summary{display:grid;place-items:center;width:38px;height:38px;border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--ink);cursor:pointer;list-style:none}.record-overflow>summary::-webkit-details-marker{display:none}.record-overflow>summary svg{display:block;width:18px;height:4px;fill:currentColor}.record-overflow>summary:hover,.record-overflow[open]>summary{border-color:var(--accent-light);background:var(--accent-soft);color:var(--accent)}.record-overflow-menu{position:absolute;z-index:12;top:calc(100% + 6px);right:0;display:grid;width:190px;padding:6px;border:1px solid var(--line);border-radius:9px;background:var(--panel);box-shadow:0 14px 36px rgba(0,0,53,.16)}.record-overflow-menu button{width:100%;border:0;border-radius:6px;padding:9px 10px;background:none;color:var(--ink);font-size:13.2px;text-align:left;cursor:pointer}.record-overflow-menu button:hover{background:var(--surface-soft)}.record-overflow-menu button:disabled{cursor:wait;color:var(--muted)}.record-overflow-menu .danger-text{color:var(--red)}.record-overflow-separator{height:1px;margin:5px 4px;background:var(--line)}.record-utility-dialog{width:min(680px,calc(100vw - 30px));max-height:min(760px,calc(100vh - 30px));overflow:auto}.record-utility-body{display:grid;gap:12px;margin:19px 0}.record-utility-body>.panel{box-shadow:none}.record-info>div:first-child{border-top:0}.record-json{border-top:1px solid var(--line);padding-top:13px}.record-json summary{color:var(--accent);font-size:13.2px;font-weight:750;cursor:pointer}.record-json pre{max-height:360px;overflow:auto;margin:12px 0 0}.record-utility-dialog .history{max-height:none}
6389
6486
  .not-found{max-width:620px;padding:28px}.not-found h2{font-family:Georgia,serif;font-size:37.2px;font-weight:500;margin:7px 0}.not-found>p:not(.kicker){margin:0;color:var(--muted);font-size:15.6px}.not-found .page-actions{justify-content:flex-start;margin-top:22px}
6390
6487
  .button.danger-action{background:var(--red);border-color:var(--red);color:#fff}
6391
6488
  @media(max-width:1200px){.readiness-flow{grid-template-columns:repeat(3,minmax(0,1fr))}.audit-engagement{grid-template-columns:1fr 1fr}.audit-engagement .button{grid-column:1/-1;justify-self:start}}
@@ -6396,13 +6493,12 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
6396
6493
  .detail-grid aside .panel{width:100%;max-height:min(520px,65vh);overflow:auto;overscroll-behavior:contain;scrollbar-gutter:stable}.detail-grid aside .workflow-guidance,.detail-grid aside .resource-review-criteria{margin:0}.detail-grid aside .resource-review-criteria{padding:21px}.detail-grid aside .detail-workflow-panel{max-height:min(440px,60vh)}.detail-grid aside .detail-review-panel{max-height:min(300px,50vh)}.detail-grid aside .workflow-findings,.detail-grid aside .resource-review-criteria ul{grid-template-columns:1fr}.detail-support-stack{display:grid;min-width:0;gap:14px;align-content:start}
6397
6494
  @media(max-width:760px){.detail-grid aside{order:-1}}
6398
6495
  @media(max-width:760px){.reconciliation-candidate{grid-template-columns:1fr}.reconciliation-actions{justify-items:start}.reconciliation-actions>code{text-align:left}}
6399
- @media(max-width:760px){.detail-grid-structured .detail-support-columns{display:flex;flex-direction:column}.detail-grid-structured .detail-support-stack{display:contents}.detail-grid-structured .detail-history-panel{order:1}}
6496
+ @media(max-width:760px){.detail-grid-structured .detail-support-columns{display:flex;flex-direction:column}.detail-grid-structured .detail-support-stack{display:contents}}
6400
6497
  @media(max-width:760px){.guide-review ul,.resource-review-criteria ul{grid-template-columns:1fr}.collection-review-head,.collection-review-foot{align-items:stretch;flex-direction:column}.record-workflow-action{min-width:0}}
6401
6498
  @media(max-width:760px){.policy-content-table>.section-head{display:block}.policy-content-tools{justify-content:flex-start;margin-top:15px}.policy-content-tools>label:first-child{flex:1 1 100%;width:100%}.policy-content-tools input[type="search"]{width:100%;min-width:0}}
6402
6499
  @media(max-width:520px){.onboarding-form,.onboarding-sections,.setup-steps{grid-template-columns:1fr}.onboarding-form label.wide{grid-column:auto}.onboarding-actions{flex-wrap:wrap}.onboarding-skip{width:100%;order:3;margin:3px 0 0}.readiness-flow{grid-template-columns:1fr}.obligation-card-foot{align-items:flex-start;flex-direction:column}.obligation-action{align-self:flex-start}}
6403
6500
  @media(min-width:761px){.detail-grid{grid-template-columns:minmax(270px,1fr) minmax(0,2fr)}.detail-grid aside{grid-column:1;grid-row:1}.detail-main{grid-column:2;grid-row:1}}
6404
6501
  @media(min-width:761px){.detail-grid.detail-grid-structured{grid-template-columns:1fr}.detail-grid-structured aside{grid-column:1;grid-row:1}.detail-grid-structured .detail-support-columns{grid-template-columns:repeat(2,minmax(0,1fr))}}
6405
- @media(min-width:1300px){.detail-grid-structured .detail-support-columns{grid-template-columns:repeat(3,minmax(0,1fr))}}
6406
6502
  @media(max-width:760px){.sidebar{visibility:hidden;transition:transform .2s,visibility 0s .2s}.sidebar.shown{visibility:visible;transition-delay:0s}.nav-close{display:grid;place-items:center;position:absolute;top:25px;right:18px;width:34px;height:34px;border:1px solid #5966a4;border-radius:50%;background:#11174a;color:#eef1ff;font-size:24px;cursor:pointer}.nav-scrim{display:block;position:fixed;inset:0;border:0;background:rgba(0,0,24,.38);opacity:0;pointer-events:none;transition:opacity .2s;z-index:15}.sidebar.shown+.nav-scrim{opacity:1;pointer-events:auto}.pagination{justify-content:space-between;gap:8px}.page-status{min-width:0}}
6407
6503
  @media(max-width:760px){.topbar{height:56px}.topbar>div:first-of-type{display:none}.topbar-readiness{flex:0 1 220px;min-width:120px}.nav-close{font-size:0}.nav-close:before,.nav-close:after{content:"";position:absolute;width:13px;height:2px;border-radius:2px;background:currentColor;transform:rotate(45deg)}.nav-close:after{transform:rotate(-45deg)}}
6408
6504
 
package/src/workflow.js CHANGED
@@ -1125,6 +1125,8 @@ function normalizeFinding(code, item, context) {
1125
1125
  ...(item.createResourceType ? { createResourceType: item.createResourceType } : {}),
1126
1126
  ...(item.sourceResourceIds ? { sourceResourceIds: item.sourceResourceIds } : {}),
1127
1127
  ...(item.informationTypeId ? { informationTypeId: item.informationTypeId } : {}),
1128
+ ...(item.affectedResourceIds ? { affectedResourceIds: item.affectedResourceIds } : {}),
1129
+ ...(item.retentionScopeResourceIds ? { retentionScopeResourceIds: item.retentionScopeResourceIds } : {}),
1128
1130
  ...(item.retentionScheduleItemIds ? { retentionScheduleItemIds: item.retentionScheduleItemIds } : {}),
1129
1131
  ...(item.staleResourceIds ? { staleResourceIds: item.staleResourceIds } : {}),
1130
1132
  ...(subject ? { subject } : {}),