filegrc 0.16.14 → 0.16.16

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.16.14",
3
+ "version": "0.16.16",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/agent.js CHANGED
@@ -102,6 +102,7 @@ export function buildAgentGuide(loaded, type, options = {}) {
102
102
  allowedDecisions: collectionReview.configuration.decisions,
103
103
  status: collectionReview.status,
104
104
  recordCount: collectionReview.recordCount,
105
+ ...(collectionReview.changesSinceReview ? { changesSinceReview: collectionReview.changesSinceReview } : {}),
105
106
  command: `npx filegrc review-collection ${type} --scaffold`
106
107
  }
107
108
  : null
@@ -0,0 +1,158 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { modelSupports } from "../model/index.js";
3
+ import { authoritativeSourceRevisionValue, collectionRevisionInputs, collectionScopeRevisionFacts, scopedCollectionRecords } from "./collection-scope.js";
4
+ import { collectionRevisionMatches } from "./collection-revision.js";
5
+ import { getFileAtRevision } from "./git.js";
6
+ import { reviewHistoricalWorkspace, reviewHistoryContext } from "./historical-workspace.js";
7
+ import { markdownEntries } from "./resource-markdown.js";
8
+ import { resolveProgram } from "./program.js";
9
+ import { resolveDataPath } from "./paths.js";
10
+ import { canonicalCalculatedRevision } from "./revisions.js";
11
+
12
+ export function collectionChangesSinceReview(loaded, resourceType, review, programId) {
13
+ if (!/^[a-f0-9]{40,64}$/i.test(review?.scopeRevision || "") || !loaded.root) return unavailable("The reviewed Git snapshot is unavailable. Compare the current records with the prior review.");
14
+ const before = reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), review.scopeRevision);
15
+ if (!before) return unavailable("The reviewed Git snapshot is unavailable. Compare the current records with the prior review.");
16
+ const workspaceWide = resourceType === "retention-schedule-item"
17
+ && modelSupports(loaded.model, "retention-schedule-approval");
18
+ let priorProgram = null;
19
+ let currentProgram = null;
20
+ try {
21
+ if (!workspaceWide) {
22
+ priorProgram = resolveProgram(before, programId);
23
+ currentProgram = resolveProgram(loaded, programId);
24
+ }
25
+ } catch {
26
+ return unavailable("The reviewed Program could not be reconstructed. Compare the current scope with the prior review.");
27
+ }
28
+ if (!collectionRevisionMatches(before, resourceType, review.collectionRevision, {
29
+ programId,
30
+ historicalCommit: review.scopeRevision,
31
+ authoritativeSourceId: review.authoritativeComponentId || review.authoritativeSystemId
32
+ })) {
33
+ return unavailable("The stored revision does not match the reviewed Git snapshot. Compare the current records with the prior review.");
34
+ }
35
+ const priorInputs = new Map(collectionRevisionInputs(before, resourceType, priorProgram)
36
+ .map((input) => [input.record.id, input]));
37
+ const currentInputs = new Map(collectionRevisionInputs(loaded, resourceType, currentProgram)
38
+ .map((input) => [input.record.id, input]));
39
+ const authoritativeSourceId = review.authoritativeComponentId || review.authoritativeSystemId;
40
+ for (const [source, inputs] of [[before, priorInputs], [loaded, currentInputs]]) {
41
+ const record = source.resources.find(({ id }) => id === authoritativeSourceId);
42
+ if (record) inputs.set(record.id, {
43
+ record,
44
+ value: authoritativeSourceRevisionValue(record),
45
+ includeContent: true
46
+ });
47
+ }
48
+ const priorMembers = new Set(scopedCollectionRecords(before, resourceType, priorProgram).map(({ id }) => id));
49
+ const currentMembers = new Set(scopedCollectionRecords(loaded, resourceType, currentProgram).map(({ id }) => id));
50
+ const changes = [];
51
+ for (const id of new Set([...priorInputs.keys(), ...currentInputs.keys()])) {
52
+ const prior = priorInputs.get(id);
53
+ const current = currentInputs.get(id);
54
+ const record = current?.record || prior?.record;
55
+ const fields = prior && current ? changedFields(prior.value, current.value, loaded.model, record.type) : [];
56
+ const contentChanged = prior && current && (prior.includeContent || current.includeContent)
57
+ ? changedContent(before, loaded, review.scopeRevision, prior.record, current.record)
58
+ : false;
59
+ const membershipChanged = priorMembers.has(id) !== currentMembers.has(id);
60
+ if (!prior || !current || fields.length || contentChanged || membershipChanged) {
61
+ changes.push({
62
+ id,
63
+ type: record.type,
64
+ title: record.title || id,
65
+ kind: !prior ? "added" : !current ? "removed" : "changed",
66
+ fields,
67
+ ...(contentChanged ? { contentChanged: true } : {}),
68
+ ...(membershipChanged ? { membershipChanged: true } : {})
69
+ });
70
+ }
71
+ }
72
+ const scopeFields = changedFields(
73
+ collectionScopeRevisionFacts(before, resourceType, priorProgram),
74
+ collectionScopeRevisionFacts(loaded, resourceType, currentProgram),
75
+ loaded.model,
76
+ "program"
77
+ );
78
+ if (!changes.length && !scopeFields.length) {
79
+ return unavailable("The stored revision does not match the reviewed Git snapshot. Compare the current records with the prior review.");
80
+ }
81
+ changes.sort((left, right) => left.type.localeCompare(right.type) || left.title.localeCompare(right.title) || left.id.localeCompare(right.id));
82
+ const overview = [
83
+ ...changes.slice(0, 3).map(describeChange),
84
+ ...(changes.length > 3 ? [`${changes.length - 3} more changed records`] : []),
85
+ ...(scopeFields.length ? [`Program scope (${scopeFields.join(", ")})`] : [])
86
+ ].join("; ");
87
+ const headlineParts = [
88
+ ...changes.slice(0, 2).map(describeShortChange),
89
+ ...(changes.length > 2 ? [`${changes.length - 2} more changed records`] : []),
90
+ ...(scopeFields.length ? [`Program scope: ${scopeFields.slice(0, 2).join(", ")}${scopeFields.length > 2 ? ", more" : ""}`] : [])
91
+ ];
92
+ return {
93
+ status: "available",
94
+ summary: `Changed since the last review: ${overview}.`,
95
+ headline: `${headlineParts.join("; ")}. Open the review for details.`,
96
+ changes,
97
+ scopeFields
98
+ };
99
+ }
100
+
101
+ function unavailable(summary) {
102
+ return { status: "unavailable", summary, headline: "Changes could not be reconstructed. Open the review for details.", changes: [], scopeFields: [] };
103
+ }
104
+
105
+ function describeChange(change) {
106
+ const name = `${change.title} (${change.type})`;
107
+ if (change.kind !== "changed") return `${name} ${change.kind === "added" ? "entered review scope" : "left review scope"}`;
108
+ const details = [...change.fields, ...(change.contentChanged ? ["Markdown"] : []), ...(change.membershipChanged ? ["collection membership"] : [])];
109
+ return `${name}: ${details.join(", ")}`;
110
+ }
111
+
112
+ function describeShortChange(change) {
113
+ const name = change.title.length > 44 ? `${change.title.slice(0, 43)}…` : change.title;
114
+ if (change.kind !== "changed") return `${name} ${change.kind === "added" ? "entered scope" : "left scope"}`;
115
+ const details = [...change.fields, ...(change.contentChanged ? ["Markdown"] : []), ...(change.membershipChanged ? ["membership"] : [])];
116
+ return `${name}: ${details.slice(0, 2).join(", ")}${details.length > 2 ? ", more" : ""}`;
117
+ }
118
+
119
+ function changedFields(before, after, model, type) {
120
+ const definitions = { ...model.commonFields, ...model.resources[type]?.fields };
121
+ return [...new Set([...Object.keys(before || {}), ...Object.keys(after || {})])]
122
+ .filter((field) => !sameValue(before?.[field], after?.[field]))
123
+ .map((field) => definitions[field]?.label || field.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/\bIds\b/g, "IDs").replace(/^./, (letter) => letter.toUpperCase()));
124
+ }
125
+
126
+ function sameValue(left, right) {
127
+ if (typeof left === "string" && typeof right === "string") {
128
+ return canonicalCalculatedRevision(left) === canonicalCalculatedRevision(right);
129
+ }
130
+ if (Array.isArray(left) && Array.isArray(right)) {
131
+ if (left.every((value) => typeof value === "string") && right.every((value) => typeof value === "string")) {
132
+ return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
133
+ }
134
+ return left.length === right.length && left.every((value, index) => sameValue(value, right[index]));
135
+ }
136
+ if (left && right && typeof left === "object" && typeof right === "object") {
137
+ const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
138
+ return [...keys].every((key) => sameValue(left[key], right[key]));
139
+ }
140
+ return left === right;
141
+ }
142
+
143
+ function changedContent(before, current, commit, priorRecord, currentRecord) {
144
+ const priorPaths = markdownEntries(before.model, priorRecord).map(({ path }) => path);
145
+ const currentPaths = markdownEntries(current.model, currentRecord).map(({ path }) => path);
146
+ for (const path of new Set([...priorPaths, ...currentPaths])) {
147
+ const priorSource = priorPaths.includes(path)
148
+ ? before.historicalFiles?.get(`data/${path}`) ?? getFileAtRevision(before.root, commit, `data/${path}`)
149
+ : null;
150
+ let currentSource = null;
151
+ if (currentPaths.includes(path)) {
152
+ try { currentSource = readFileSync(resolveDataPath(current.root, path), "utf8"); }
153
+ catch (error) { if (error.code !== "ENOENT") throw error; }
154
+ }
155
+ if (priorSource !== currentSource) return true;
156
+ }
157
+ return false;
158
+ }
@@ -16,8 +16,9 @@ import { currentPartyPeople } from "./parties.js";
16
16
  import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js";
17
17
  import { markdownEntries } from "./resource-markdown.js";
18
18
  import { resolveDataPath } from "./paths.js";
19
- import { calculateRevision, calculatedRevisionDiagnostic, revisionsMatch } from "./revisions.js";
19
+ import { calculateRevision, calculatedRevisionDiagnostic, displayRevision, revisionsMatch } from "./revisions.js";
20
20
  import { personWasActiveOn } from "./soc2.js";
21
+ import { collectionChangesSinceReview } from "./collection-changes.js";
21
22
 
22
23
  export { collectionRevision };
23
24
 
@@ -123,6 +124,9 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
123
124
  && review.collectionRevision
124
125
  && (!revisionMatches || !reviewScopeMatches)
125
126
  );
127
+ const changesSinceReview = stale
128
+ ? collectionChangesSinceReview(loaded, resourceType, review, program.id)
129
+ : null;
126
130
  return {
127
131
  resourceType,
128
132
  configuration,
@@ -134,6 +138,7 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
134
138
  : null,
135
139
  reviewEntries,
136
140
  collectionRevision: currentRevision,
141
+ changesSinceReview,
137
142
  revisionDiagnostic: calculatedRevisionDiagnostic("collection", review?.collectionRevision, currentRevision),
138
143
  status: complete ? "current" : stale ? "stale" : "review-required",
139
144
  complete,
@@ -143,7 +148,7 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
143
148
  message: complete
144
149
  ? `${configuration.title} were reviewed on ${review.reviewedOn}.`
145
150
  : approvalIssues.length
146
- ? approvalIssues[0].message
151
+ ? `${approvalIssues[0].message}${changesSinceReview ? ` ${changesSinceReview.summary}` : ""}`
147
152
  : incompleteRecordProposals.length
148
153
  ? `Complete ${incompleteRecordProposals.length} ${configuration.title.toLowerCase()} ${incompleteRecordProposals.length === 1 ? "record proposal" : "record proposals"} before the collection review.`
149
154
  : revisionMatches && review?.status === "active" && !reviewersEligible
@@ -151,7 +156,7 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
151
156
  ? "Review the Data Retention Schedule again with a reviewer who does not own its governing document or an included schedule row."
152
157
  : "Review the Control collection again with a reviewer who does not own an included Control or its enabled Obligation."
153
158
  : stale
154
- ? `${configuration.title} changed after the last confirmation. Stored revision: ${review.collectionRevision}. Current revision: ${currentRevision}. Review the current records again.`
159
+ ? `${configuration.title} changed after the last confirmation. ${changesSinceReview.summary} Stored revision: ${displayRevision(review.collectionRevision)}. Current revision: ${displayRevision(currentRevision)}. Review the current records again.`
155
160
  : !records.length && !allowsEmptyCollection
156
161
  ? `Add at least one ${loaded.model.resources[resourceType].title.toLowerCase()} before confirming this collection.`
157
162
  : `Review ${configuration.title.toLowerCase()} before this page can be ready.`
@@ -190,6 +195,7 @@ export async function scaffoldCollectionReview(input = process.cwd(), options =
190
195
  : null;
191
196
  return {
192
197
  resourceType,
198
+ ...(assessment.changesSinceReview ? { changesSinceReview: assessment.changesSinceReview } : {}),
193
199
  decision: priorDecisionStillValid ? priorReview.decision : (assessment.records.length
194
200
  ? "complete"
195
201
  : allowedDecisions.includes("zero-population") ? "zero-population" : null),
@@ -167,7 +167,9 @@ function scopedProgramPeople(loaded, program = {}) {
167
167
  const systemIds = new Set(program.systemIds || []);
168
168
  const controlIds = new Set(program.controlIds || []);
169
169
  const policyIds = new Set(loaded.resources
170
- .filter(({ type, status, programRole }) => type === "policy" && status !== "retired" && programRole !== "reference")
170
+ .filter(({ type, status, programRole }) => (
171
+ type === "policy" && !["superseded", "retired"].includes(status) && programRole !== "reference"
172
+ ))
171
173
  .map(({ id }) => id));
172
174
  const components = programComponents(loaded, program);
173
175
  const componentIds = new Set(components.map(({ id }) => id));
@@ -177,13 +179,13 @@ function scopedProgramPeople(loaded, program = {}) {
177
179
  ]);
178
180
  const sources = loaded.resources.filter((record) => (
179
181
  ["workspace", "program", "appointment", "team"].includes(record.type)
180
- || record.type === "system" && systemIds.has(record.id)
182
+ || record.type === "system" && systemIds.has(record.id) && record.status !== "retired"
181
183
  || record.type === "component" && componentIds.has(record.id)
182
- || record.type === "vendor" && vendorIds.has(record.id)
183
- || record.type === "control" && controlIds.has(record.id)
184
+ || record.type === "vendor" && vendorIds.has(record.id) && !["deprecated", "terminated"].includes(record.status)
185
+ || record.type === "control" && controlIds.has(record.id) && record.status !== "retired"
184
186
  || record.type === "policy" && policyIds.has(record.id)
185
- || record.type === "document" && record.workflowScope !== "engagement"
186
- || record.type === "obligation" && (
187
+ || record.type === "document" && record.workflowScope !== "engagement" && !["superseded", "retired"].includes(record.status)
188
+ || record.type === "obligation" && record.status !== "retired" && (
187
189
  (record.controlIds || []).some((id) => controlIds.has(id))
188
190
  || (record.policyIds || []).some((id) => policyIds.has(id))
189
191
  )
package/src/files.js CHANGED
@@ -12,7 +12,7 @@ import { measureTiming } from "./timing.js";
12
12
  import { currentCalendarDate, timestampFromLocalDateTime } from "./time.js";
13
13
  import { loadWorkspace } from "./workspace.js";
14
14
  import { validateWorkspace } from "./validate.js";
15
- import { calculateRevision, revisionsMatch } from "./revisions.js";
15
+ import { calculateRevision, displayRevision, revisionsMatch } from "./revisions.js";
16
16
 
17
17
  export const INTERNAL_WORKFLOW_CAPABILITIES = Object.freeze({
18
18
  auditManagementReconciliation: Symbol("audit-management-reconciliation"),
@@ -1165,7 +1165,7 @@ async function prepareContentWrites(loaded, record, content, options = {}) {
1165
1165
  function assertRevision(source, expected, label) {
1166
1166
  const current = contentRevision(source);
1167
1167
  if (expected && !revisionsMatch("content", expected, current)) {
1168
- throw new Error(`${label} changed after you opened it. Stored revision: ${expected}. Current revision: ${current}. Reload the workspace and apply your change again.`);
1168
+ throw new Error(`${label} changed after you opened it. Stored revision: ${displayRevision(expected)}. Current revision: ${displayRevision(current)}. Reload the workspace and apply your change again.`);
1169
1169
  }
1170
1170
  }
1171
1171
 
@@ -794,7 +794,9 @@ export function collectionReviewReadinessItem(assessment) {
794
794
  : `Review ${assessment.configuration.title.toLowerCase()}`,
795
795
  proposalAction
796
796
  ? `Complete ${firstIncomplete.title} before reviewing ${assessment.configuration.title.toLowerCase()}.`
797
- : assessment.message,
797
+ : assessment.approvalIssues?.length || pendingRetentionRow || needsFirstRecord
798
+ ? assessment.message
799
+ : assessment.changesSinceReview?.headline || assessment.message,
798
800
  proposalAction
799
801
  ? { id: firstIncomplete.resourceId, type: assessment.resourceType }
800
802
  : needsFirstRecord ? { type: assessment.resourceType }
@@ -1239,7 +1241,7 @@ function requiredGovernedDocuments(scope, records, byId, model) {
1239
1241
  const selectedControlIds = new Set(scope.controls.map(({ id }) => id));
1240
1242
  const linkedDocumentIds = new Set(requiredPolicies(scope, byId).flatMap((policy) => policy.relatedDocumentIds || []));
1241
1243
  const obligationDocumentIds = new Set(records
1242
- .filter((record) => record.type === "obligation")
1244
+ .filter((record) => record.type === "obligation" && record.status !== "retired")
1243
1245
  .flatMap((record) => [
1244
1246
  ...(record.scopeResourceIds || []),
1245
1247
  ...(record.templateResourceId ? [record.templateResourceId] : [])
package/src/revisions.js CHANGED
@@ -42,6 +42,12 @@ export function revisionDigest(kind, value) {
42
42
  return null;
43
43
  }
44
44
 
45
+ export function displayRevision(value) {
46
+ const text = String(value || "");
47
+ const match = /^(?:filegrc:[a-z-]+:v\d+:sha256:|scope:)?([a-f0-9]{64})$/.exec(text);
48
+ return match ? match[1].slice(0, 12) : text;
49
+ }
50
+
45
51
  export function revisionsMatch(kind, stored, current) {
46
52
  const storedDigest = revisionDigest(kind, stored);
47
53
  const currentDigest = revisionDigest(kind, current);
package/src/state.js CHANGED
@@ -344,7 +344,10 @@ async function createAppStateUnlocked(input, options) {
344
344
  function assessApplicabilityReviewStatuses(loaded, program) {
345
345
  const decisions = new Map((program.requirementApplicability || []).map((review) => [review.requirementId, review]));
346
346
  return Object.fromEntries(loaded.resources
347
- .filter(({ type }) => ["requirement", "control", "commitment", "complementary-control"].includes(type))
347
+ .filter(({ type, status }) => (
348
+ ["requirement", "control", "commitment", "complementary-control"].includes(type)
349
+ && !["retired", "superseded"].includes(status)
350
+ ))
348
351
  .map((record) => {
349
352
  const review = record.type === "requirement" && modelSupports(loaded.model, "program-scope")
350
353
  ? decisions.get(record.id)
@@ -367,6 +370,7 @@ function serializeCollectionReviewAssessment(assessment) {
367
370
  status: assessment.status,
368
371
  complete: assessment.complete,
369
372
  message: assessment.message,
373
+ ...(assessment.changesSinceReview ? { changesSinceReview: assessment.changesSinceReview } : {}),
370
374
  ...(assessment.recordProposals ? { recordProposals: assessment.recordProposals } : {}),
371
375
  ...(assessment.incompleteRecordProposals ? { incompleteRecordProposals: assessment.incompleteRecordProposals } : {}),
372
376
  ...(assessment.eligibleReviewerIds ? { eligibleReviewerIds: assessment.eligibleReviewerIds } : {}),
package/src/validate.js CHANGED
@@ -46,7 +46,7 @@ import {
46
46
  } from "./reporting-route-integrity.js";
47
47
  import { validateWorkflowHistoryIntegrity } from "./workflow-history-integrity.js";
48
48
  import { occurrenceMemberExceptionIsValid } from "./obligation-members.js";
49
- import { calculateRevision, revisionDigest, revisionsMatch } from "./revisions.js";
49
+ import { calculateRevision, displayRevision, revisionDigest, revisionsMatch } from "./revisions.js";
50
50
 
51
51
  let fingerprintFileReadObserver = null;
52
52
 
@@ -2028,7 +2028,7 @@ function validateRequirementMapping(record, loaded, currentReviewRevisions, path
2028
2028
 
2029
2029
  function revisionMismatchDetails(ids, stored, current) {
2030
2030
  return ids.map((id) => (
2031
- `${id} (stored: ${stored[id] || "missing"}; current: ${current.get(id) || "missing"})`
2031
+ `${id} (stored: ${displayRevision(stored[id]) || "missing"}; current: ${displayRevision(current.get(id)) || "missing"})`
2032
2032
  )).join(", ");
2033
2033
  }
2034
2034
 
package/src/web.js CHANGED
@@ -1251,7 +1251,7 @@ function collectionReviewPanel(type, force = false) {
1251
1251
  : "";
1252
1252
  const reviewSummary = current && !reviewBlocker
1253
1253
  ? '<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>'
1254
- : '<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>';
1254
+ : '<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.status === "stale" && assessment.changesSinceReview ? "Check what changed since the last review, then confirm the current records." : assessment.message)) + '</span></p>';
1255
1255
  const compactReviewSummary = current && !reviewBlocker
1256
1256
  ? '<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>"
1257
1257
  : reviewSummary;
@@ -1270,8 +1270,11 @@ function collectionReviewPanel(type, force = false) {
1270
1270
  const summary = current
1271
1271
  ? '<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>'
1272
1272
  : details;
1273
+ const changes = !current && assessment.status === "stale" && !reviewBlocker
1274
+ ? collectionReviewChangesHtml(assessment.changesSinceReview)
1275
+ : "";
1273
1276
  return '<section class="collection-review-panel panel ' + (current ? "current" : "required") + '">' + summary +
1274
- (current ? "" : '<div class="collection-review-foot">' + reviewSummary + action + '</div>') + '</section>';
1277
+ changes + (current ? "" : '<div class="collection-review-foot">' + reviewSummary + action + '</div>') + '</section>';
1275
1278
  }
1276
1279
 
1277
1280
  function retentionScheduleApprovalBlocker() {
@@ -1452,9 +1455,10 @@ function openCollectionReviewDialog(type) {
1452
1455
  const saveDescription = scheduleReview
1453
1456
  ? 'Approve the governing document and ' + assessment.recordCount + ' current ' + pluralize("schedule row", assessment.recordCount) + ' as one revision. A later document, row, or material scope change requires another approval.'
1454
1457
  : 'Confirm ' + assessment.recordCount + ' current ' + pluralize("record", assessment.recordCount) + '. If the collection or material scope changes, FileGRC will ask for another review.';
1458
+ const changes = collectionReviewChangesHtml(assessment.changesSinceReview);
1455
1459
  const confirmSubmit = '<button type="submit" class="button primary">Confirm and save</button>';
1456
1460
  const submit = scheduleReview ? '<button type="submit" class="button primary">Approve schedule</button>' : confirmSubmit;
1457
- dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">' + dialogKicker + '</p><h2 id="collection-review-dialog-title">' + esc(dialogTitle) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + esc(configuration.description) + '</p><section class="event-dialog-steps collection-review-checks"><strong>' + checklistLabel + '</strong><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section>' + reviewerHelp + '<div class="form-grid"><label><span>Conclusion</span><select name="decision" required>' + decisions + '</select></label><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + ((assessment.review?.reviewedByIds || []).includes(record.id) ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label><label data-authoritative-system><span>Authoritative ' + (v4 ? "Component" : "System") + '</span><select name="authoritativeSourceId"><option value="">Select</option>' + systems.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (preservedAuthoritativeSourceId === record.id ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label class="full"><span>Review notes</span><textarea name="rationale" rows="3" required placeholder="Note what you confirmed and any scope decision that needs context.">' + esc(assessment.review?.rationale || "") + '</textarea></label></div><div class="workflow-preview"><strong>What this saves</strong><p>' + esc(saveDescription) + '</p></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status review-save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button>' + submit + '</div></form>';
1461
+ dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">' + dialogKicker + '</p><h2 id="collection-review-dialog-title">' + esc(dialogTitle) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + esc(configuration.description) + '</p>' + changes + '<section class="event-dialog-steps collection-review-checks"><strong>' + checklistLabel + '</strong><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section>' + reviewerHelp + '<div class="form-grid"><label><span>Conclusion</span><select name="decision" required>' + decisions + '</select></label><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + ((assessment.review?.reviewedByIds || []).includes(record.id) ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label><label data-authoritative-system><span>Authoritative ' + (v4 ? "Component" : "System") + '</span><select name="authoritativeSourceId"><option value="">Select</option>' + systems.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (preservedAuthoritativeSourceId === record.id ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label class="full"><span>Review notes</span><textarea name="rationale" rows="3" required placeholder="Note what you confirmed and any scope decision that needs context.">' + esc(assessment.review?.rationale || "") + '</textarea></label></div><div class="workflow-preview"><strong>What this saves</strong><p>' + esc(saveDescription) + '</p></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status review-save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button>' + submit + '</div></form>';
1458
1462
  document.body.append(dialog);
1459
1463
  dialog.showModal();
1460
1464
  const form = dialog.querySelector("form");
@@ -1516,6 +1520,23 @@ function openCollectionReviewDialog(type) {
1516
1520
  });
1517
1521
  }
1518
1522
 
1523
+ function collectionReviewChangesHtml(changes) {
1524
+ if (!changes) return "";
1525
+ if (changes.status !== "available") {
1526
+ return '<section class="collection-review-change-list"><strong>What changed since the last review</strong><p>' + esc(changes.summary) + '</p></section>';
1527
+ }
1528
+ const rows = changes.changes.map((change) => {
1529
+ const details = change.kind !== "changed" ? change.kind === "added" ? "entered review scope" : "left review scope" : [
1530
+ ...change.fields,
1531
+ ...(change.contentChanged ? ["Markdown"] : []),
1532
+ ...(change.membershipChanged ? ["collection membership"] : [])
1533
+ ].join(", ");
1534
+ return '<li><strong>' + esc(change.title) + '</strong> <span>(' + esc(change.type) + ')</span>: ' + esc(details) + '</li>';
1535
+ });
1536
+ if (changes.scopeFields?.length) rows.push('<li><strong>Program scope</strong>: ' + esc(changes.scopeFields.join(", ")) + '</li>');
1537
+ return '<section class="collection-review-change-list"><strong>What changed since the last review</strong><p>Check these changes before recording a new conclusion.</p><ul>' + rows.join("") + '</ul></section>';
1538
+ }
1539
+
1519
1540
  function workflowItemHref(item) {
1520
1541
  const destinationOptions = {
1521
1542
  retentionScheduleApproval: modelSupports("retention-schedule-approval"),
@@ -1897,11 +1918,13 @@ function derivedStagePageState(stage, destination) {
1897
1918
  : { complete: false, label: "Review scope" };
1898
1919
  }
1899
1920
  if (stage.id === "audit" && destination.type === "document" && destination.href.includes("documentScope=audit")) {
1900
- if (!resourcesOfType("document").some(({ record }) => auditSpecificDocument(record))) {
1921
+ if (!resourcesOfType("document").some(({ record }) => (
1922
+ auditSpecificDocument(record) && !["retired", "superseded"].includes(record.status)
1923
+ ))) {
1901
1924
  return { complete: false, countsTowardProgress: false, label: "Only if needed" };
1902
1925
  }
1903
1926
  }
1904
- if (destination.type && resourcesOfType(destination.type).length === 0) {
1927
+ if (destination.type && !resourcesOfType(destination.type).some(({ record }) => !["retired", "superseded"].includes(record.status))) {
1905
1928
  return { complete: false, countsTowardProgress: false, label: "Only if needed" };
1906
1929
  }
1907
1930
  return { complete: true, label: "Ready" };
@@ -2792,6 +2815,7 @@ function openNextAuditCycleDialog(prior) {
2792
2815
  }
2793
2816
 
2794
2817
  function controlApplicabilityPending(record) {
2818
+ if (["retired", "superseded"].includes(record.status)) return false;
2795
2819
  if (state.applicabilityReviewStatuses?.[record.id]) {
2796
2820
  return state.applicabilityReviewStatuses[record.id] !== "current";
2797
2821
  }
@@ -2802,6 +2826,7 @@ function controlApplicabilityPending(record) {
2802
2826
  }
2803
2827
 
2804
2828
  function applicabilityPending(record) {
2829
+ if (["retired", "superseded"].includes(record.status)) return false;
2805
2830
  if (state.applicabilityReviewStatuses?.[record.id]) {
2806
2831
  return state.applicabilityReviewStatuses[record.id] !== "current";
2807
2832
  }
@@ -6580,6 +6605,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
6580
6605
  .stage-page-card-actions{display:flex;align-items:center;gap:10px}.stage-page-card-actions .stage-page-open{white-space:nowrap}.stage-page-open::after{content:"";position:absolute;inset:0;z-index:1;border-radius:10px}.stage-page-open:focus-visible::after{outline:2px solid var(--focus);outline-offset:2px}.stage-page-open>span{position:relative;z-index:2}
6581
6606
  .workflow-findings>a:hover{border-color:var(--accent-light);box-shadow:0 3px 9px rgba(21,40,33,.05)}.workflow-findings>a:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.stage-page-card-head{align-items:flex-start}.stage-page-completion-state{flex:0 0 auto;max-width:150px;padding:5px 8px;border-radius:99px;background:#f7e9cf;color:#855717;font-size:9.6px;font-weight:750;line-height:1.25;text-align:right}.stage-page-completion-state.complete{background:#ddefe5;color:#176143}.obligation-card.workflow-target{outline:2px solid var(--focus);outline-offset:3px}.obligation-card-foot{flex-wrap:wrap}.obligation-card-foot .obligation-links{flex:1 1 120px}
6582
6607
  .collection-review-panel.current details{margin-top:8px}.collection-review-detail-content{margin-top:10px}.collection-review-complete-summary{display:flex;align-items:baseline;gap:10px}.collection-review-complete-summary>.kicker{margin:0;white-space:nowrap}.collection-review-panel.current .collection-review-foot{justify-content:flex-end;margin-top:8px}
6608
+ .collection-review-change-list{margin:14px 0;padding:12px 14px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-change-list>strong{font-size:11px}.collection-review-change-list p{margin:7px 0 0;color:var(--muted);font-size:11px;line-height:1.5}.collection-review-change-list ul{max-height:180px;overflow:auto;margin:9px 0 0;padding-left:20px;font-size:11px;line-height:1.55}.collection-review-change-list li+li{margin-top:5px}.collection-review-change-list li span{color:var(--muted)}
6583
6609
  .collection-review-current-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.collection-review-panel.current .collection-review-details{flex:0 0 auto;margin:0;padding:0;border:0;background:transparent}.collection-review-panel.current .collection-review-details>summary{color:var(--accent);white-space:nowrap}.collection-review-panel.current .collection-review-details[open]{flex-basis:100%;order:3;padding:10px 12px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-panel.current .collection-review-details[open]>summary{margin-bottom:10px}.collection-review-panel.current .collection-review-details[open] .collection-review-detail-content{margin-top:0}.collection-review-panel.current .button{margin-left:auto}
6584
6610
  @media(max-width:520px){.collection-review-complete-summary{flex:1 1 180px;min-width:0;flex-wrap:wrap}.collection-review-complete-summary .collection-review-result{min-width:0;flex-wrap:wrap}.collection-review-complete-summary .collection-review-result span{overflow-wrap:anywhere}.collection-review-panel.current .button{margin-left:0}}
6585
6611
  @media(max-width:520px){.stage-page-card-head{align-items:stretch;flex-direction:column;gap:10px}.stage-page-card-actions{justify-content:space-between}}
package/src/workflow.js CHANGED
@@ -26,6 +26,7 @@ import { assessAuditPreparation } from "./audit-preparation.js";
26
26
  import { assessProgramReadiness } from "./program-readiness.js";
27
27
  import { resolveProgram } from "./program.js";
28
28
  import { resourceReviewRevisions, retentionReviewResourceIds, retentionRuleIsCurrent } from "./retention.js";
29
+ import { displayRevision } from "./revisions.js";
29
30
  import { signatoryAppointmentIssue, soc2ReportEvidenceIssue, subsequentEventsReviewIssue } from "./soc2.js";
30
31
  import { planReconciliation } from "./reconciliation.js";
31
32
  import { documentIsAuditSpecific } from "./program-lifecycle.js";
@@ -393,6 +394,7 @@ function recordFinalizationFindings(loaded, program) {
393
394
  const findings = [];
394
395
  for (const record of loaded.resources) {
395
396
  if (["workspace", "renderer-settings"].includes(record.type)) continue;
397
+ if (["retired", "superseded"].includes(record.status)) continue;
396
398
  const incomplete = recordIncompleteReason(record, loaded, program);
397
399
  if (incomplete) {
398
400
  findings.push(finalizationFinding(record, incomplete, loaded.model));
@@ -543,8 +545,8 @@ function finalizationFields(record, model, resources, program, root) {
543
545
  field: "applicabilityReview",
544
546
  requiredness: "required",
545
547
  message: record.applicabilityReview?.scopeRevision
546
- ? `Review applicability against the current service scope. Stored revision: ${record.applicabilityReview.scopeRevision}. Current revision: ${currentRevision}. Then record the decision, rationale, reviewer, and date.`
547
- : `Review applicability against the current service scope. Current revision: ${currentRevision}. Then record the decision, rationale, reviewer, and date.`
548
+ ? `Review applicability against the current service scope. Stored revision: ${displayRevision(record.applicabilityReview.scopeRevision)}. Current revision: ${displayRevision(currentRevision)}. Then record the decision, rationale, reviewer, and date.`
549
+ : `Review applicability against the current service scope. Current revision: ${displayRevision(currentRevision)}. Then record the decision, rationale, reviewer, and date.`
548
550
  });
549
551
  }
550
552
  if (record.type === "policy" && model.resources.policy?.fields?.programRole && !record.programRole) {
@@ -815,6 +817,9 @@ async function assessPeriodHealth(loaded, options) {
815
817
  .map(({ id }) => id));
816
818
  for (const record of loaded.resources) {
817
819
  if (record.type === "policy" && ["required", "alternative"].includes(record.programRole)) {
820
+ const historical = ["retired", "superseded"].includes(record.status);
821
+ const changedOn = record.statusTransition?.changedOn;
822
+ if (historical && changedOn && changedOn <= coverage.start) continue;
818
823
  if (record.status !== "active" || !record.effectiveOn || record.effectiveOn > coverage.start) {
819
824
  findings.push(periodGap(record, "policy", record.effectiveOn, coverage));
820
825
  }