filegrc 0.16.15 → 0.16.17

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.15",
3
+ "version": "0.16.17",
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),
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
 
@@ -0,0 +1,20 @@
1
+ export function obligationRule(obligation, byId, options = {}) {
2
+ const proposedId = options.includeProposed
3
+ ? [...(obligation.ruleIds || [])].reverse().find((id) => ["proposed", "approved"].includes(byId.get(id)?.status))
4
+ : null;
5
+ if (obligation?.scheduleMode !== "rule" && !proposedId) return null;
6
+ let rule = byId.get(obligation.activeRuleId || proposedId);
7
+ if (rule?.status === "active" && rule.effectiveAt && options.now && new Date(rule.effectiveAt) > new Date(options.now)) {
8
+ const prior = byId.get(rule.supersedesId);
9
+ return prior?.type === "obligation-rule"
10
+ && prior.obligationId === obligation.id
11
+ && ["active", "retired"].includes(prior.status)
12
+ ? prior
13
+ : null;
14
+ }
15
+ return rule?.type === "obligation-rule"
16
+ && rule.obligationId === obligation.id
17
+ && (rule.status === "active" || (options.includeProposed && ["proposed", "approved"].includes(rule.status)))
18
+ ? rule
19
+ : null;
20
+ }
@@ -21,7 +21,8 @@ import {
21
21
  } from "./recurrence.js";
22
22
  import { currentCalendarDate, isRfc3339Timestamp, localDateTimeValue, timestampFromLocalDateTime } from "./time.js";
23
23
  import { loadWorkspace } from "./workspace.js";
24
- import { obligationGovernedContent, obligationProgramStatus } from "./program-lifecycle.js";
24
+ import { obligationRule } from "./obligation-rule.js";
25
+ import { governedContentIsOperating, obligationGovernedContent, obligationProgramStatus } from "./program-lifecycle.js";
25
26
  import { resolveProgram } from "./program.js";
26
27
  import { currentPartyPeople } from "./parties.js";
27
28
  import { serializeWorkspaceMutation } from "./mutation.js";
@@ -84,9 +85,9 @@ export function planObligations(resources, options = {}) {
84
85
  throw new Error("Obligation planning requires programId when more than one Program is active.");
85
86
  }
86
87
  const asOf = requireDate(options.asOf ?? currentCalendarDate(workspace?.timezone || "UTC"), "as-of date");
87
- const defaultNow = options.asOf
88
- ? timestampFromLocalDateTime(`${asOf}T23:59:59`, workspace?.timezone || "UTC")
89
- : new Date().toISOString();
88
+ const defaultNow = asOf === currentCalendarDate(workspace?.timezone || "UTC")
89
+ ? new Date().toISOString()
90
+ : timestampFromLocalDateTime(`${asOf}T23:59:59`, workspace?.timezone || "UTC");
90
91
  const now = requireTimestamp(options.now ?? defaultNow, "current timestamp");
91
92
  const through = requireDate(options.through ?? addCalendarDays(asOf, 90), "through date");
92
93
  const requestedFrom = options.from ? requireDate(options.from, "from date") : null;
@@ -102,7 +103,7 @@ export function planObligations(resources, options = {}) {
102
103
  && obligationBelongsToProgram(record, obligationProgram, model)
103
104
  ));
104
105
  const programStatusByObligationId = new Map(obligations.map((obligation) => [
105
- obligation.id, obligationProgramStatus(obligation, byId, asOf, model)
106
+ obligation.id, obligationProgramStatus(obligation, byId, asOf, model, now)
106
107
  ]));
107
108
  const obligationIds = new Set(obligations.map(({ id }) => id));
108
109
  if (obligations.length > MAX_PLANNED_ITEMS) {
@@ -120,14 +121,17 @@ export function planObligations(resources, options = {}) {
120
121
  };
121
122
 
122
123
  for (const obligation of obligations) {
124
+ const selectedRule = byId.get(obligation.activeRuleId);
123
125
  const rule = obligation.scheduleMode === "rule"
124
126
  ? obligationRule(obligation, byId, { now, includeProposed: true })
127
+ || (selectedRule?.status === "active" && selectedRule.effectiveAt
128
+ && new Date(selectedRule.effectiveAt) > new Date(now) ? selectedRule : null)
125
129
  : null;
126
130
  const schedule = rule || obligation;
127
131
  const activity = obligationActivity(model, obligation);
128
132
  const expectedCompletionTypes = activity.completionResourceTypes;
129
133
  const programStatus = programStatusByObligationId.get(obligation.id);
130
- const programBlocker = programStatus === "proposed" ? obligationProgramBlocker(obligation, byId, asOf) : null;
134
+ const programBlocker = programStatus === "proposed" ? obligationProgramBlocker(obligation, byId, asOf, model, now) : null;
131
135
  if (schedule.recurrence?.mode === "event" && schedule.recurrence.eventType) {
132
136
  const eventType = schedule.recurrence.eventType;
133
137
  const group = triggerGroups.get(eventType) ?? {
@@ -298,7 +302,7 @@ export function planObligations(resources, options = {}) {
298
302
  const overdueOn = addCalendarDays(dueWindowEnd, 1);
299
303
  const window = { dueWindowStart, dueWindowEnd, overdueOn };
300
304
  const programStatus = programStatusByObligationId.get(obligation.id);
301
- const programBlocker = programStatus === "proposed" ? obligationProgramBlocker(obligation, byId, asOf) : null;
305
+ const programBlocker = programStatus === "proposed" ? obligationProgramBlocker(obligation, byId, asOf, model, now) : null;
302
306
  const activity = obligationActivity(model, obligation);
303
307
  const completedMemberIds = (occurrence.members || [])
304
308
  .filter(({ disposition, result }) => disposition === "expected" && result === "passed")
@@ -420,10 +424,17 @@ export function planObligations(resources, options = {}) {
420
424
  };
421
425
  }
422
426
 
423
- function obligationProgramBlocker(obligation, byId, asOf) {
427
+ function obligationProgramBlocker(obligation, byId, asOf, model, now = `${asOf}T23:59:59Z`) {
424
428
  if (obligation.status !== "active") {
425
429
  return { type: "obligation", id: obligation.id, label: "Activate obligation" };
426
430
  }
431
+ if (obligation.scheduleMode === "rule" && !obligationRule(obligation, byId, { now })) {
432
+ const selectedRule = byId.get(obligation.activeRuleId);
433
+ return {
434
+ type: "obligation-rule", id: obligation.activeRuleId,
435
+ label: selectedRule?.status === "active" ? "Wait for rule effective time" : "Activate rule"
436
+ };
437
+ }
427
438
  if (currentPartyPeople(obligation.ownerIds || [], byId).size === 0) {
428
439
  return { type: "obligation", id: obligation.id, label: "Assign current owner" };
429
440
  }
@@ -433,6 +444,11 @@ function obligationProgramBlocker(obligation, byId, asOf) {
433
444
  return { type: "policy", id, label: "Activate policy" };
434
445
  }
435
446
  }
447
+ const inactiveContent = obligationGovernedContent(obligation, byId, model)
448
+ .find((record) => !governedContentIsOperating(record, asOf, model));
449
+ if (inactiveContent) {
450
+ return { type: inactiveContent.type, id: inactiveContent.id, label: `Activate ${inactiveContent.type}` };
451
+ }
436
452
  const controls = (obligation.controlIds || []).map((id) => byId.get(id)).filter(Boolean);
437
453
  if (controls.length && !controls.some(({ status }) => status === "implemented")) {
438
454
  return { type: "control", id: controls[0].id, label: "Implement control" };
@@ -466,6 +482,27 @@ async function createObligationEventUnlocked(input, options) {
466
482
  if (riskLevel && !["normal", "high"].includes(riskLevel)) {
467
483
  throw new Error("A departure risk level must be normal or high.");
468
484
  }
485
+ if (!occurredAt) {
486
+ const intradayCutover = records.find((record) => {
487
+ if (
488
+ record.type !== "obligation"
489
+ || !["active", "proposed"].includes(record.status)
490
+ || (program && !obligationBelongsToProgram(record, program, loaded.model))
491
+ || (Array.isArray(record.eventRiskLevels) && !record.eventRiskLevels.includes(riskLevel))
492
+ ) return false;
493
+ const rule = byId.get(record.activeRuleId);
494
+ return rule?.type === "obligation-rule"
495
+ && rule.status === "active"
496
+ && rule.recurrence?.mode === "event"
497
+ && rule.recurrence.eventType === eventType
498
+ && rule.effectiveAt
499
+ && timestampCalendarDate(rule.effectiveAt, loaded.workspace.timezone) === occurredOn;
500
+ });
501
+ if (intradayCutover) {
502
+ throw new Error(`Event type "${eventType}" requires occurredAt on ${occurredOn} because a linked rule takes effect during that date. Record the real event time with its timezone.`);
503
+ }
504
+ }
505
+ const eventTime = occurredAt || `${occurredOn}T23:59:59Z`;
469
506
  const eventSchedules = new Map();
470
507
  const templates = records.filter((record) => {
471
508
  if (
@@ -473,16 +510,43 @@ async function createObligationEventUnlocked(input, options) {
473
510
  || record.status !== "active"
474
511
  || (program && !obligationBelongsToProgram(record, program, loaded.model))
475
512
  ) return false;
476
- const schedule = obligationRule(record, byId, { now: occurredAt || `${occurredOn}T23:59:59Z` }) || record;
513
+ const schedule = obligationRule(record, byId, { now: eventTime }) || record;
477
514
  const matches = schedule.recurrence?.mode === "event"
478
515
  && schedule.recurrence.eventType === eventType
479
516
  && (!Array.isArray(record.eventRiskLevels) || record.eventRiskLevels.includes(riskLevel));
480
517
  if (matches) eventSchedules.set(record.id, schedule);
481
518
  return matches;
482
519
  });
483
- if (!eventType || templates.length === 0) throw new Error(`No active obligations use event type "${eventType}".`);
484
- if (templates.some((record) => obligationProgramStatus(record, byId, occurredOn, loaded.model) === "proposed")) {
485
- throw new Error(`Event type "${eventType}" still has starter proposals. Make every governing Policy and required governed-content record active and effective, then implement at least one linked Control before starting this workflow.`);
520
+ const proposed = records.find((record) => {
521
+ if (
522
+ record.type !== "obligation"
523
+ || !["active", "proposed"].includes(record.status)
524
+ || (program && !obligationBelongsToProgram(record, program, loaded.model))
525
+ || (Array.isArray(record.eventRiskLevels) && !record.eventRiskLevels.includes(riskLevel))
526
+ ) return false;
527
+ const rule = obligationRule(record, byId, { now: eventTime, includeProposed: true });
528
+ const selectedRule = byId.get(record.activeRuleId);
529
+ const futureRule = selectedRule?.status === "active" && selectedRule.effectiveAt
530
+ && new Date(selectedRule.effectiveAt) > new Date(eventTime);
531
+ const schedule = rule || (futureRule ? selectedRule : record);
532
+ return schedule.recurrence?.mode === "event"
533
+ && schedule.recurrence.eventType === eventType
534
+ && (record.status === "proposed" || (rule && rule.status !== "active") || (futureRule && !rule));
535
+ });
536
+ if (!eventType || templates.length === 0 || proposed) {
537
+ const selectedRule = byId.get(proposed?.activeRuleId);
538
+ const futureRule = selectedRule?.status === "active" && selectedRule.effectiveAt
539
+ && new Date(selectedRule.effectiveAt) > new Date(eventTime);
540
+ throw new Error(futureRule
541
+ ? `Event type "${eventType}" is dormant until rule ${selectedRule.id} takes effect at ${selectedRule.effectiveAt}. Trigger only a real event after that time.`
542
+ : proposed
543
+ ? `Event type "${eventType}" is still a proposed workflow (${proposed.id}). Confirm its owner, deadline, and required proof, then enable its rule in Step 3. It stays dormant until governing content is effective; trigger only a real event after cutover.`
544
+ : `No active obligations use event type "${eventType}".`);
545
+ }
546
+ const dormant = templates.find((record) => obligationProgramStatus(record, byId, occurredOn, loaded.model, eventTime) === "proposed");
547
+ if (dormant) {
548
+ const blocker = obligationProgramBlocker(dormant, byId, occurredOn, loaded.model, eventTime);
549
+ throw new Error(`Event type "${eventType}" is dormant until ${blocker?.label?.toLowerCase() || "its prerequisites are ready"}${blocker?.id ? ` (${blocker.id})` : ""}. Complete the cutover before starting event work; do not create a past event to prove a prospective Control.`);
486
550
  }
487
551
  if (templates.some((record) => normalizedEventWindow(eventSchedules.get(record.id)?.window).precision === "timestamp") && !occurredAt) {
488
552
  throw new Error(`Event type "${eventType}" has hour-based deadlines and requires an RFC 3339 occurredAt timestamp.`);
@@ -1893,26 +1957,7 @@ function obligationActivity(model, obligation) {
1893
1957
  };
1894
1958
  }
1895
1959
 
1896
- export function obligationRule(obligation, byId, options = {}) {
1897
- const proposedId = options.includeProposed
1898
- ? [...(obligation.ruleIds || [])].reverse().find((id) => ["proposed", "approved"].includes(byId.get(id)?.status))
1899
- : null;
1900
- if (obligation?.scheduleMode !== "rule" && !proposedId) return null;
1901
- let rule = byId.get(obligation.activeRuleId || proposedId);
1902
- if (rule?.status === "active" && rule.effectiveAt && options.now && new Date(rule.effectiveAt) > new Date(options.now)) {
1903
- const prior = byId.get(rule.supersedesId);
1904
- return prior?.type === "obligation-rule"
1905
- && prior.obligationId === obligation.id
1906
- && ["active", "retired"].includes(prior.status)
1907
- ? prior
1908
- : null;
1909
- }
1910
- return rule?.type === "obligation-rule"
1911
- && rule.obligationId === obligation.id
1912
- && (rule.status === "active" || (options.includeProposed && ["proposed", "approved"].includes(rule.status)))
1913
- ? rule
1914
- : null;
1915
- }
1960
+ export { obligationRule } from "./obligation-rule.js";
1916
1961
 
1917
1962
  function currentOccurrence(records, occurrenceKey, obligationId, ruleId, window) {
1918
1963
  return records.find((record) => (
@@ -1,6 +1,7 @@
1
1
  import { currentPartyPeople } from "./parties.js";
2
2
  import { modelSupports } from "../model/index.js";
3
3
  import { revisionsMatch } from "./revisions.js";
4
+ import { obligationRule } from "./obligation-rule.js";
4
5
 
5
6
  const requiredDocumentsByControlCache = new WeakMap();
6
7
 
@@ -131,8 +132,9 @@ function indexRequiredDocumentsByControl(byId, model) {
131
132
  return index;
132
133
  }
133
134
 
134
- export function obligationProgramStatus(obligation, byId, asOf, model) {
135
+ export function obligationProgramStatus(obligation, byId, asOf, model, now = `${asOf}T23:59:59Z`) {
135
136
  if (obligation.status !== "active") return "proposed";
137
+ if (!obligationRuleIsEnabled(obligation, byId, now)) return "proposed";
136
138
  if (currentPartyPeople(obligation.ownerIds || [], byId).size === 0) return "proposed";
137
139
  const policyIds = obligation.policyIds || [];
138
140
  const policiesReady = policyIds.every((id) => {
@@ -153,12 +155,18 @@ export function obligationProgramStatus(obligation, byId, asOf, model) {
153
155
  : "proposed";
154
156
  }
155
157
 
156
- export function obligationIsRunning(obligation, byId, asOf, model) {
158
+ export function obligationIsRunning(obligation, byId, asOf, model, now) {
157
159
  return obligation?.type === "obligation"
158
160
  && obligation.status === "active"
159
- && obligationProgramStatus(obligation, byId, asOf, model) === "accepted";
161
+ && obligationProgramStatus(obligation, byId, asOf, model, now) === "accepted";
160
162
  }
161
163
 
162
164
  export function obligationIsEnabled(obligation) {
163
165
  return obligation?.type === "obligation" && obligation.status === "active";
164
166
  }
167
+
168
+ export function obligationRuleIsEnabled(obligation, byId, now) {
169
+ return obligationIsEnabled(obligation)
170
+ && (obligation.scheduleMode !== "rule"
171
+ || obligationRule(obligation, byId, { now }) !== null);
172
+ }
@@ -13,6 +13,7 @@ import {
13
13
  documentIsAuditSpecific,
14
14
  governedDocumentIsOperating,
15
15
  obligationIsEnabled,
16
+ obligationRuleIsEnabled,
16
17
  obligationIsRunning
17
18
  } from "./program-lifecycle.js";
18
19
  import { currentPartyPeople, partiesIndependent, partyPeople } from "./parties.js";
@@ -136,6 +137,10 @@ export async function assessProgramReadiness(input, options = {}) {
136
137
  const workspace = loaded.workspace || records.find((record) => record.type === "workspace");
137
138
  const program = resolveProgram(loaded, options.programId);
138
139
  const asOf = options.asOf || currentCalendarDate(workspace?.timezone || "UTC");
140
+ const currentDate = currentCalendarDate(workspace?.timezone || "UTC");
141
+ const asOfTime = asOf === currentDate
142
+ ? options.generatedAt || new Date().toISOString()
143
+ : timestampFromLocalDateTime(`${asOf}T23:59:59`, workspace?.timezone || "UTC");
139
144
  const requestedAudit = options.auditId
140
145
  ? records.find((record) => record.type === "audit" && record.id === options.auditId)
141
146
  : null;
@@ -155,7 +160,7 @@ export async function assessProgramReadiness(input, options = {}) {
155
160
  };
156
161
 
157
162
  const policyStage = await policiesStage(scope, records, byId, readMarkdown, loaded.model);
158
- const controlStage = await controlsStage(scope, byId, readMarkdown, asOf, loaded.model, loaded.root);
163
+ const controlStage = await controlsStage(scope, byId, readMarkdown, asOf, loaded.model, loaded.root, asOfTime);
159
164
  controlStage.items.unshift(...collectionReviews
160
165
  .filter(({ resourceType }) => resourceType === "complementary-control")
161
166
  .map(collectionReviewReadinessItem));
@@ -185,7 +190,7 @@ export async function assessProgramReadiness(input, options = {}) {
185
190
  } else {
186
191
  controlStage.items.push(...retentionItems, ...retentionReviewItems);
187
192
  }
188
- const governedContent = await governedContentItems(scope, records, byId, readMarkdown, asOf, loaded.model);
193
+ const governedContent = await governedContentItems(scope, records, byId, readMarkdown, asOf, loaded.model, asOfTime);
189
194
  const policyActivations = await assessPolicyActivations(
190
195
  requiredPolicies(scope, byId),
191
196
  scope.controls,
@@ -193,7 +198,9 @@ export async function assessProgramReadiness(input, options = {}) {
193
198
  byId,
194
199
  readMarkdown,
195
200
  asOf,
196
- loaded.model
201
+ loaded.model,
202
+ controlStage.items,
203
+ asOfTime
197
204
  );
198
205
  const controlOversight = collectionReviews.find(({ resourceType }) => resourceType === "control");
199
206
  const oversightEligible = Boolean(controlOversight && controlOversightEligible(controlStage.items));
@@ -268,7 +275,7 @@ export async function assessProgramReadiness(input, options = {}) {
268
275
  program?.assuranceGoal === "soc-2-type-2"
269
276
  && progressWindow?.start <= asOf
270
277
  );
271
- const obligations = planObligations(records, { programId: program.id, asOf, through: asOf, model: loaded.model });
278
+ const obligations = planObligations(records, { programId: program.id, asOf, through: asOf, now: asOfTime, model: loaded.model });
272
279
  const policyLibrary = await assessPolicyLibraryUpgrades(loaded);
273
280
  const operating = Boolean(
274
281
  evidenceReady
@@ -794,7 +801,9 @@ export function collectionReviewReadinessItem(assessment) {
794
801
  : `Review ${assessment.configuration.title.toLowerCase()}`,
795
802
  proposalAction
796
803
  ? `Complete ${firstIncomplete.title} before reviewing ${assessment.configuration.title.toLowerCase()}.`
797
- : assessment.message,
804
+ : assessment.approvalIssues?.length || pendingRetentionRow || needsFirstRecord
805
+ ? assessment.message
806
+ : assessment.changesSinceReview?.headline || assessment.message,
798
807
  proposalAction
799
808
  ? { id: firstIncomplete.resourceId, type: assessment.resourceType }
800
809
  : needsFirstRecord ? { type: assessment.resourceType }
@@ -1264,10 +1273,11 @@ function requiredGovernedDocuments(scope, records, byId, model) {
1264
1273
  ));
1265
1274
  }
1266
1275
 
1267
- async function governedContentItems(scope, records, byId, readMarkdown, asOf, model) {
1276
+ async function governedContentItems(scope, records, byId, readMarkdown, asOf, model, asOfTime) {
1268
1277
  const enabledObligations = records.filter((record) => (
1269
1278
  record.type === "obligation" && obligationIsEnabled(record)
1270
1279
  ));
1280
+ const operatingSchedules = enabledObligations.filter((record) => obligationRuleIsEnabled(record, byId, asOfTime));
1271
1281
  const requiredGovernedIds = new Set(enabledObligations.flatMap((record) => [
1272
1282
  ...(record.scopeResourceIds || []),
1273
1283
  ...(record.templateResourceId ? [record.templateResourceId] : [])
@@ -1330,7 +1340,7 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf, mo
1330
1340
  owner: currentPartyPeople(record.ownerIds, byId).size > 0,
1331
1341
  active: record.status === "active",
1332
1342
  requirementsImplemented: linkedControlIds.length > 0 && missingImplementationControlIds.length === 0,
1333
- assignmentScheduled: enabledObligations.some((obligation) => (
1343
+ assignmentScheduled: operatingSchedules.some((obligation) => (
1334
1344
  obligation.templateResourceId === record.id
1335
1345
  || (obligation.scopeResourceIds || []).includes(record.id)
1336
1346
  )),
@@ -1457,7 +1467,7 @@ function documentActivationLabel(state) {
1457
1467
  })[state] || state;
1458
1468
  }
1459
1469
 
1460
- async function assessPolicyActivations(policies, controls, records, byId, readMarkdown, asOf, model) {
1470
+ async function assessPolicyActivations(policies, controls, records, byId, readMarkdown, asOf, model, controlItems, asOfTime) {
1461
1471
  const sourceType = modelSupports(model, "component-sources") ? "component" : "system";
1462
1472
  const sourceField = modelSupports(model, "component-sources") ? "evidenceSourceComponentIds" : "evidenceSourceIds";
1463
1473
  const assessments = [];
@@ -1467,6 +1477,11 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
1467
1477
  const plannedOrPartialControlIds = linkedControls
1468
1478
  .filter((control) => ["planned", "partially-implemented"].includes(control.status))
1469
1479
  .map(({ id }) => id);
1480
+ const missingLaunchControlIds = linkedControls
1481
+ .filter((control) => controlItems.some((item) => (
1482
+ item.id === `control-${control.id}` && item.implementationState === "implemented-with-gaps"
1483
+ )))
1484
+ .map(({ id }) => id);
1470
1485
  const missingComponentControlIds = modelSupports(model, "component-sources")
1471
1486
  ? linkedControls.filter((control) => ![
1472
1487
  ...(control.componentIds || []),
@@ -1495,7 +1510,7 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
1495
1510
  ["scheduled", "event-driven", "mixed"].includes(control.operationPattern)
1496
1511
  && !records.some((record) => (
1497
1512
  record.type === "obligation"
1498
- && obligationIsEnabled(record)
1513
+ && obligationRuleIsEnabled(record, byId, asOfTime)
1499
1514
  && (record.controlIds || []).includes(control.id)
1500
1515
  && (record.policyIds || []).includes(policy.id)
1501
1516
  ))
@@ -1538,6 +1553,7 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
1538
1553
  : null
1539
1554
  ].filter(Boolean);
1540
1555
  const gapCount = plannedOrPartialControlIds.length
1556
+ + missingLaunchControlIds.length
1541
1557
  + missingComponentControlIds.length
1542
1558
  + missingEvidenceSourceControlIds.length
1543
1559
  + missingScheduleControlIds.length
@@ -1562,6 +1578,7 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
1562
1578
  proposedEffectiveOn: policy.proposedEffectiveOn || null,
1563
1579
  linkedControlIds,
1564
1580
  plannedOrPartialControlIds,
1581
+ missingLaunchControlIds,
1565
1582
  missingComponentControlIds,
1566
1583
  missingEvidenceSourceControlIds,
1567
1584
  missingScheduleControlIds,
@@ -1594,6 +1611,7 @@ function policyActivationLabel(state) {
1594
1611
  function policyActivationItem(assessment) {
1595
1612
  const counts = [
1596
1613
  [assessment.plannedOrPartialControlIds.length, "planned or partial Controls"],
1614
+ [assessment.missingLaunchControlIds.length, "implemented Controls with launch gaps"],
1597
1615
  [assessment.missingComponentControlIds.length, "Controls missing active Components"],
1598
1616
  [assessment.missingEvidenceSourceControlIds.length, "Controls missing ready evidence sources"],
1599
1617
  [assessment.missingScheduleControlIds.length, "Controls missing enabled Obligations"],
@@ -1642,7 +1660,7 @@ function legacyPolicyLibraryProposals(records) {
1642
1660
  }];
1643
1661
  }
1644
1662
 
1645
- async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null) {
1663
+ async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null, asOfTime) {
1646
1664
  const items = [];
1647
1665
  const families = selectedControlFamilies(scope.controls, model);
1648
1666
  if (!scope.controls.length) {
@@ -1696,12 +1714,24 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null
1696
1714
  criteriaMapping: (control.requirementIds || []).length > 0,
1697
1715
  ...(["scheduled", "event-driven", "mixed"].includes(control.operationPattern) ? {
1698
1716
  workQueue: queueSchedules.length > 0
1699
- && queueSchedules.some(obligationIsEnabled)
1717
+ && queueSchedules.some((obligation) => obligationRuleIsEnabled(obligation, byId, asOfTime))
1700
1718
  } : {})
1701
1719
  };
1720
+ const launchChecks = Object.fromEntries(Object.entries(checks).filter(([name]) => ![
1721
+ "implemented", "implementationDate"
1722
+ ].includes(name)));
1723
+ const launchReady = Object.values(launchChecks).every(Boolean);
1724
+ const implementationState = control.status === "implemented"
1725
+ ? Object.values(checks).every(Boolean) ? "implemented" : "implemented-with-gaps"
1726
+ : control.status === "partially-implemented" ? "partial"
1727
+ : launchReady ? "ready-to-implement" : "planned";
1702
1728
  const missing = Object.entries(checks).filter(([, value]) => !value).map(([name]) => controlCheckLabel(name));
1703
1729
  const nextSteps = controlImplementationSteps(control, checks, {
1704
- missingSourceFamilies
1730
+ missingSourceFamilies,
1731
+ targets: [...new Set([
1732
+ ...(control.systemIds || []),
1733
+ ...(control.componentIds || [])
1734
+ ].map((id) => byId.get(id)?.title).filter(Boolean))]
1705
1735
  });
1706
1736
  items.push(item(
1707
1737
  `control-${control.id}`,
@@ -1713,17 +1743,20 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null
1713
1743
  control,
1714
1744
  {
1715
1745
  checks,
1746
+ launchChecks,
1747
+ implementationState,
1748
+ implementationAssertion: "Implemented means the stated control works for the named Systems, its owner and procedure match current practice, and its evidence sources can produce proof. Scheduled or event-driven Controls have at least one enabled linked Obligation. It does not assert that a scheduled cycle or event has already occurred. Record dated operating proof when it does.",
1716
1749
  nextSteps,
1717
1750
  commands: [`npx filegrc get ${shellArgument(control.id)} --mutation`],
1718
1751
  workQueue: queueSchedules.length ? {
1719
- enabled: queueSchedules.filter(obligationIsEnabled).length,
1720
- running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf, model)).length,
1752
+ enabled: queueSchedules.filter((obligation) => obligationRuleIsEnabled(obligation, byId, asOfTime)).length,
1753
+ running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf, model, asOfTime)).length,
1721
1754
  total: queueSchedules.length
1722
1755
  } : null
1723
1756
  }
1724
1757
  ));
1725
1758
  }
1726
- return stage("controls", "Implement Controls", "Put each selected Control in place, record how it works, and mark it Implemented when it is working.", items);
1759
+ return stage("controls", "Implement Controls", "Put each selected Control in place. Check its launch conditions, then mark it Implemented when it is working. Dated proof follows during operation.", items);
1727
1760
  }
1728
1761
 
1729
1762
  async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
@@ -1737,10 +1770,6 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
1737
1770
  .map((id) => byId.get(id))
1738
1771
  .filter((record) => (
1739
1772
  record?.type === sourceType
1740
- && (
1741
- !(record.evidenceSourceKinds || []).length
1742
- || family.sourceKinds.some((kind) => (record.evidenceSourceKinds || []).includes(kind))
1743
- )
1744
1773
  ));
1745
1774
  const completeSources = [];
1746
1775
  const sourceChecks = [];
@@ -1778,7 +1807,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
1778
1807
  const coveredControls = controlMappings.filter(({ complete }) => complete);
1779
1808
  const complete = completeSources.length > 0 && coveredControls.length === family.controls.length;
1780
1809
  const commands = [
1781
- ...(selectedSources.length
1810
+ ...(complete ? [] : selectedSources.length
1782
1811
  ? sourceChecks.filter(({ complete: sourceComplete }) => !sourceComplete).flatMap((sourceCheck) => {
1783
1812
  const sourceId = sourceCheck.sourceComponentId || sourceCheck.sourceSystemId;
1784
1813
  return [
@@ -2085,21 +2114,21 @@ function controlCheckLabel(name) {
2085
2114
  procedureEffective: "procedure effective date",
2086
2115
  policyMapping: "policy mapping",
2087
2116
  criteriaMapping: "criteria mapping",
2088
- workQueue: "running Obligation schedules"
2117
+ workQueue: "enabled Obligation rules (work starts after cutover)"
2089
2118
  })[name] || name;
2090
2119
  }
2091
2120
 
2092
2121
  const starterControlChecks = {
2093
- "control-security-governance": "Open the security review calendar and attendee roles. Check a reviewer separate from owners and operators examines risks, incidents, findings, and overdue work on the approved schedule.",
2094
- "control-policy-management": "Open the Control inventory and a recently revised Policy. Check owners are named, the approver is separate, and the change went through review before approval.",
2095
- "control-security-communication": "Trace how a security notice or Policy change reaches staff and affected outside parties. Check that the approved reporting routes can be used.",
2122
+ "control-security-governance": "Set a security review calendar and name a reviewer separate from the owners. Make sure the agenda covers risks, incidents, findings, and overdue work when those records exist.",
2123
+ "control-policy-management": "Name Control owners and a separate Policy approver. Set the path for reviewing and approving future Policy changes before they take effect.",
2124
+ "control-security-communication": "Set working normal and fallback routes for security notices and Policy changes to staff and affected outside parties.",
2096
2125
  "control-workforce-expectations": "Review onboarding for roles with sensitive access. Check that required screening, agreements, and Policy acknowledgements happen before access.",
2097
- "control-security-training": "Compare the worker roster with training assignments. Check starters and role changes get training on time, with repeats tied to current content and the approved schedule.",
2098
- "control-risk-assessment": "Open the risk assessment against in-scope Systems and Vendors. Check threats, fraud, dependencies, and changes have owners and high risks get the required review.",
2099
- "control-monitoring-remediation": "Open the latest Control review, or schedule the first. Check incidents, test results, Exceptions, and overdue work, with owners and verified closure for gaps.",
2126
+ "control-security-training": "Set up the worker roster, training audience, assignment rule, and owner. Check that starters and role changes will receive the approved content on time.",
2127
+ "control-risk-assessment": "Set the risk assessment scope for the in-scope Systems and Vendors. Name who reviews threats, fraud, dependencies, changes, and high risks.",
2128
+ "control-monitoring-remediation": "Schedule the first Control review. Name who will inspect incidents, tests, Exceptions, overdue work, and follow-up closure when those records exist.",
2100
2129
  "control-access-authorization": "Open access lists for in-scope Systems and Vendor tools. Check that accounts use unique identities and only needed permissions, and new grants require a business reason and approval.",
2101
2130
  "control-strong-authentication": "Open the System and Vendor lists. For each tool used for production, source code, email, identity, or sensitive data, check MFA, unique accounts, default credentials, and admin roles.",
2102
- "control-access-review-offboarding": "Export access lists from key Systems and Vendor tools. Check privileged and other access reviews meet their approved schedules, and departures lose access within the required window.",
2131
+ "control-access-review-offboarding": "Confirm access lists can be exported from key Systems and Vendor tools. Assign the first access review and the owner who will remove access when a departure occurs.",
2103
2132
  "control-physical-workspace-security": "Walk through actual work areas and visitor access. Check who can enter nonpublic spaces and how devices, papers, screens, and conversations are protected.",
2104
2133
  "control-data-classification-inventory": "List important data stores in Systems and Vendor tools. Check each has an owner, classification, approved purpose, and recorded location.",
2105
2134
  "control-encryption-transmission": "For Systems, Vendor tools, and devices holding Confidential or Restricted data, check encryption at rest and in transit and who can manage the keys.",
@@ -2107,22 +2136,24 @@ const starterControlChecks = {
2107
2136
  "control-inventory-configuration": "Compare actual services, devices, software, service accounts, Vendors, and data stores with the inventory. Check owners, secure settings, and unsupported assets.",
2108
2137
  "control-endpoint-protection": "Review devices allowed into company Systems. Check encryption, screen lock, updates, and malware protection against the approved settings.",
2109
2138
  "control-network-security": "Open production network and remote-access rules. Check each path has a business reason, approved authentication, and separation; identify rules due for review.",
2110
- "control-change-management": "Follow a software or infrastructure change from request to deployment. Check the path includes risk review, testing, approval, and a way to recover.",
2139
+ "control-change-management": "Set the path for software and infrastructure changes: risk review, testing, approval, deployment, and recovery. Check teams use it for the next real change.",
2111
2140
  "control-vulnerability-management": "List internet-facing and important Systems. Check each has vulnerability scan coverage or another approved check, and findings have owners and target dates.",
2112
2141
  "control-penetration-testing": "Review the service's exposure, major changes, and customer commitments. Decide whether independent testing is needed and, if so, set its scope and cadence.",
2113
2142
  "control-logging-monitoring": "For each important System, open security logs, alert rules, and service-health checks where needed. Check retention and whether test alerts reach an owner.",
2114
2143
  "control-incident-response": "Walk through how a worker reports an incident. Check who triages, escalates, contains, communicates, and closes it using the approved plan.",
2115
- "control-incident-exercise": "Send a test alert through a representative incident path. Check acknowledgement, escalation, and fallback, then record participants, failures, and follow-up.",
2116
- "control-backup-restoration": "For each important System, inspect backup or alternate recovery settings and failure alerts. Run or review a restore test against its recovery needs.",
2144
+ "control-incident-exercise": "Set the incident exercise owner, scenario, and schedule. Check the reporting, escalation, and fallback paths are ready for the first exercise.",
2145
+ "control-backup-restoration": "For each important System, inspect backup or alternate recovery settings and failure alerts. Schedule the first restore test against its recovery needs.",
2117
2146
  "control-continuity-exercise": "Review recovery priorities, contacts, and owners. Check the plan covers loss of an important System or provider and has an exercise date.",
2118
- "control-vendor-due-diligence": "Open the Vendor list. For each with sensitive data or a material service role, check its security review and contract terms; review older Vendors already in use.",
2119
- "control-vendor-monitoring": "Open critical and high-risk Vendors. Check each has a current review, an owner for open issues, and timely reassessment after a material change or incident.",
2147
+ "control-vendor-due-diligence": "Identify Vendors with sensitive data or a material service role. Set the review and contract checks before approving a new Vendor; triage existing Vendors for the first review.",
2148
+ "control-vendor-monitoring": "Identify critical and high-risk Vendors, assign review owners and cadence, and set a reassessment trigger for material changes or incidents.",
2120
2149
  "control-security-exceptions": "Review departures from security rules. Check each has a reason, owner, risk assessment, compensating measure, approval, and expiry or review date."
2121
2150
  };
2122
2151
 
2123
2152
  export function controlImplementationSteps(control, checks, options = {}) {
2124
2153
  const steps = [];
2125
- if (!checks.implemented) steps.push(starterControlChecks[control.id]
2154
+ if (!checks.implemented) steps.push(control.id === "control-strong-authentication" && options.targets?.length
2155
+ ? `Ensure MFA and unique accounts are enabled for ${options.targets.join(", ")}; check default credentials and admin roles.`
2156
+ : starterControlChecks[control.id]
2126
2157
  || "Inspect the systems, people, vendors, or work this Control covers. Compare current practice with its statement and fix any gaps.");
2127
2158
  const record = [];
2128
2159
  if (!checks.owner) record.push("choose an owner");
@@ -2132,8 +2163,8 @@ export function controlImplementationSteps(control, checks, options = {}) {
2132
2163
  if (!checks.evidenceSource) record.push("link the tool or system that can show it happened");
2133
2164
  if (!checks.policyMapping && checks.policyMapping !== undefined) record.push("link the Policy it implements");
2134
2165
  if (!checks.criteriaMapping && checks.criteriaMapping !== undefined) record.push("link the criteria it covers");
2135
- if (checks.implemented && !checks.implementationDate) record.push("enter the date it started working");
2136
- if (checks.implemented && (checks.procedureRevision === false || checks.procedureEffective === false)) {
2166
+ if (!checks.implementationDate) record.push("record the actual date it starts working when marking Implemented");
2167
+ if (checks.procedureRevision === false || checks.procedureEffective === false) {
2137
2168
  record.push("record the Procedure revision and effective date");
2138
2169
  }
2139
2170
  if (record.length) steps.push(`In this Control, ${record.join("; ")}.`);
@@ -2148,9 +2179,7 @@ export function controlImplementationSteps(control, checks, options = {}) {
2148
2179
  if (!checks.implemented) {
2149
2180
  if (sourceStep || queueStep) steps.push([sourceStep, queueStep].filter(Boolean).join(" "));
2150
2181
  else if (checks.applicability === false) steps.push("Confirm this Control applies to the current Program scope with `review-applicability --scaffold --type control`.");
2151
- else steps.push(checks.procedureRevision === undefined
2152
- ? "When it works, mark this Control Implemented and enter its start date."
2153
- : "When it works, mark this Control Implemented. Enter its start date and the Procedure revision and effective date.");
2182
+ else steps.push("When these launch conditions hold, mark the Control Implemented. This asserts the practice works now; record proof from future cycles and events as they occur.");
2154
2183
  } else {
2155
2184
  if (sourceStep) steps.push(sourceStep);
2156
2185
  if (queueStep) steps.push(queueStep);
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
@@ -370,6 +370,7 @@ function serializeCollectionReviewAssessment(assessment) {
370
370
  status: assessment.status,
371
371
  complete: assessment.complete,
372
372
  message: assessment.message,
373
+ ...(assessment.changesSinceReview ? { changesSinceReview: assessment.changesSinceReview } : {}),
373
374
  ...(assessment.recordProposals ? { recordProposals: assessment.recordProposals } : {}),
374
375
  ...(assessment.incompleteRecordProposals ? { incompleteRecordProposals: assessment.incompleteRecordProposals } : {}),
375
376
  ...(assessment.eligibleReviewerIds ? { eligibleReviewerIds: assessment.eligibleReviewerIds } : {}),
package/src/validate.js CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  } from "./recurrence.js";
22
22
  import { resourceReviewRevisionMatches, resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
23
23
  import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js";
24
- import { obligationIsEnabled } from "./program-lifecycle.js";
24
+ import { obligationIsEnabled, obligationRuleIsEnabled } from "./program-lifecycle.js";
25
25
  import { currentPartyPeople, partyPeople } from "./parties.js";
26
26
  import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
27
27
  import { currentCalendarDate, isRfc3339Timestamp, localDateTimeValue, timestampFromLocalDateTime } from "./time.js";
@@ -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
 
@@ -222,7 +222,7 @@ async function validateWorkspaceUnmeasured(input) {
222
222
  validateIndependentApproval(record, byId, displayPath, diagnostics);
223
223
  validateCompletedObligationEvent(record, byId, loaded.model, displayPath, diagnostics);
224
224
  validateActionObligationRule(record, byId, displayPath, diagnostics);
225
- validateImplementedControlSchedules(record, obligationsByControl, displayPath, diagnostics);
225
+ validateImplementedControlSchedules(record, obligationsByControl, byId, new Date().toISOString(), displayPath, diagnostics);
226
226
  serialContentDiagnosticTasks.push(async () => {
227
227
  const markdownDiagnostics = [];
228
228
  const approvalDiagnostics = [];
@@ -1538,7 +1538,7 @@ function assertFingerprintBudget(options, uncachedBytes) {
1538
1538
  }
1539
1539
  }
1540
1540
 
1541
- function validateImplementedControlSchedules(record, obligationsByControl, path, diagnostics) {
1541
+ function validateImplementedControlSchedules(record, obligationsByControl, byId, now, path, diagnostics) {
1542
1542
  if (record.type !== "control" || record.status !== "implemented") return;
1543
1543
  const schedules = obligationsByControl.get(record.id) || [];
1544
1544
  if (!schedules.length) {
@@ -1550,16 +1550,20 @@ function validateImplementedControlSchedules(record, obligationsByControl, path,
1550
1550
  ));
1551
1551
  return;
1552
1552
  }
1553
- if (schedules.some(obligationIsEnabled)) return;
1554
- const stopped = schedules.filter((obligation) => !obligationIsEnabled(obligation));
1553
+ if (schedules.some((obligation) => obligationRuleIsEnabled(obligation, byId, now))) return;
1554
+ const stopped = schedules.filter((obligation) => !obligationRuleIsEnabled(obligation, byId, now));
1555
1555
  const paused = stopped.filter((obligation) => obligation.status === "paused");
1556
1556
  const proposed = stopped.filter((obligation) => obligation.status === "proposed");
1557
+ const pendingRules = stopped.filter((obligation) => obligationIsEnabled(obligation) && obligation.scheduleMode === "rule");
1557
1558
  const reasons = [
1558
1559
  proposed.length
1559
1560
  ? `${proposed.length} linked ${proposed.length === 1 ? "schedule is" : "schedules are"} still proposed. Enable ${proposed.length === 1 ? "it" : "them"} before implementing the control; the schedule will remain dormant until its governing Policy is active and effective.`
1560
1561
  : "",
1561
1562
  paused.length
1562
1563
  ? `${paused.length} linked ${paused.length === 1 ? "schedule is" : "schedules are"} paused. Enable ${paused.length === 1 ? "it" : "them"} before implementing the control.`
1564
+ : "",
1565
+ pendingRules.length
1566
+ ? `${pendingRules.length} linked ${pendingRules.length === 1 ? "rule is" : "rules are"} not active. Activate the selected rule before implementing the control.`
1563
1567
  : ""
1564
1568
  ].filter(Boolean).join(" ");
1565
1569
  diagnostics.push(error(
@@ -2028,7 +2032,7 @@ function validateRequirementMapping(record, loaded, currentReviewRevisions, path
2028
2032
 
2029
2033
  function revisionMismatchDetails(ids, stored, current) {
2030
2034
  return ids.map((id) => (
2031
- `${id} (stored: ${stored[id] || "missing"}; current: ${current.get(id) || "missing"})`
2035
+ `${id} (stored: ${displayRevision(stored[id]) || "missing"}; current: ${displayRevision(current.get(id)) || "missing"})`
2032
2036
  )).join(", ");
2033
2037
  }
2034
2038
 
package/src/web.js CHANGED
@@ -1098,6 +1098,7 @@ function renderPolicyActivationAssessments() {
1098
1098
  return '<article class="policy-activation-card ' + esc(assessment.state) + '"><div class="evidence-map-card-head"><div><span class="badge ' + (operating ? "good" : "warn") + '">' + esc(assessment.label) + '</span><h3><a href="#/resource/policy/' + encodeURIComponent(assessment.policyId) + '">' + esc(assessment.title) + '</a></h3></div><small>' + assessment.gapCount + ' ' + pluralize("gap", assessment.gapCount) + '</small></div>' + warnings +
1099
1099
  '<div class="policy-activation-gaps">' +
1100
1100
  gapGroup("Planned or partial Controls", assessment.plannedOrPartialControlIds, "control") +
1101
+ gapGroup("Implemented Controls with launch gaps", assessment.missingLaunchControlIds || [], "control") +
1101
1102
  gapGroup("Missing active Components", assessment.missingComponentControlIds, "control") +
1102
1103
  gapGroup("Missing ready evidence sources", assessment.missingEvidenceSourceControlIds, "control") +
1103
1104
  gapGroup("Missing enabled Obligations", assessment.missingScheduleControlIds, "control") +
@@ -1212,9 +1213,9 @@ function workflowGuidance(options = {}) {
1212
1213
  const renderRow = (item) => {
1213
1214
  const href = workflowItemHref(item);
1214
1215
  const guidance = options.type === "control" && item.nextSteps?.length
1215
- ? '<ol class="control-next-steps">' + item.nextSteps.map((step) => '<li>' + esc(step) + '</li>').join("") + '</ol>'
1216
+ ? '<p class="workflow-status">' + esc(controlLaunchLabel(item.implementationState)) + '</p><ol class="control-next-steps">' + item.nextSteps.map((step) => '<li>' + esc(step) + '</li>').join("") + '</ol><small>' + esc(item.implementationAssertion || "") + '</small>'
1216
1217
  : '<small>' + esc(item.message || workflowItemDetail(item)) + '</small>';
1217
- const body = '<span class="workflow-finding-status ' + esc(item.state) + '">' + esc(properCase(item.state)) + '</span><span><strong>' + esc(item.title) + '</strong>' + guidance + '</span>';
1218
+ const body = '<span class="workflow-finding-status ' + esc(item.state) + '">' + esc(options.type === "control" && item.implementationState ? controlLaunchLabel(item.implementationState).split(":")[0] : properCase(item.state)) + '</span><span><strong>' + esc(item.title) + '</strong>' + guidance + '</span>';
1218
1219
  return href ? '<a href="' + href + '">' + body + '</a>' : '<div>' + body + '</div>';
1219
1220
  };
1220
1221
  const rows = visible.map(renderRow).join("");
@@ -1226,7 +1227,27 @@ function workflowGuidance(options = {}) {
1226
1227
  return '<section class="workflow-guidance panel detail-support-panel detail-workflow-panel"><div class="panel-head"><div><p class="kicker">To-do</p><h3>' + esc(options.title || "Checklist") + '</h3></div><span class="badge ' + (blocking.length ? "warn" : "good") + '">' + (blocking.length ? "Needs work" : "Current") + '</span></div><p class="workflow-status">' + esc(status) + (completionRequirements ? '. ' + completionRequirements : "") + '</p><div class="workflow-findings">' + rows + '</div>' + more + '</section>';
1227
1228
  }
1228
1229
 
1230
+ function controlLaunchLabel(state) {
1231
+ return ({
1232
+ planned: "Planned: launch conditions remain",
1233
+ partial: "Partially implemented: launch conditions remain",
1234
+ "ready-to-implement": "Launch checks ready: confirm it works, then record the start date",
1235
+ "implemented-with-gaps": "Implemented with gaps: fix the launch checks",
1236
+ implemented: "Implemented"
1237
+ })[state] || "Control launch";
1238
+ }
1239
+
1240
+ function controlLaunchState(id) {
1241
+ return state.programReadiness?.stages?.find(({ id: stageId }) => stageId === "controls")?.items
1242
+ .find(({ id: itemId }) => itemId === "control-" + id)?.implementationState;
1243
+ }
1244
+
1229
1245
  function recordCompletionState(record) {
1246
+ if (record.type === "control") {
1247
+ const item = state.programReadiness?.stages?.find(({ id }) => id === "controls")?.items
1248
+ .find(({ id }) => id === "control-" + record.id);
1249
+ if (item?.implementationState === "implemented") return '<section class="record-completion-state panel detail-support-panel"><p class="kicker">Control launch</p><h3>Implemented</h3><p>' + esc(item.implementationAssertion) + '</p><p>Dated evidence belongs to the operating work that produces it.</p></section>';
1250
+ }
1230
1251
  const completeStatuses = new Set(["approved", "active", "complete", "completed", "closed", "done", "reconciled"]);
1231
1252
  if (!completeStatuses.has(record.status)) return "";
1232
1253
  return '<section class="record-completion-state panel detail-support-panel"><p class="kicker">Record status</p><h3>' + esc(properCase(record.status)) + '</h3><p>This record has no direct next step.</p></section>';
@@ -1251,7 +1272,7 @@ function collectionReviewPanel(type, force = false) {
1251
1272
  : "";
1252
1273
  const reviewSummary = current && !reviewBlocker
1253
1274
  ? '<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>';
1275
+ : '<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
1276
  const compactReviewSummary = current && !reviewBlocker
1256
1277
  ? '<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
1278
  : reviewSummary;
@@ -1270,8 +1291,11 @@ function collectionReviewPanel(type, force = false) {
1270
1291
  const summary = current
1271
1292
  ? '<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
1293
  : details;
1294
+ const changes = !current && assessment.status === "stale" && !reviewBlocker
1295
+ ? collectionReviewChangesHtml(assessment.changesSinceReview)
1296
+ : "";
1273
1297
  return '<section class="collection-review-panel panel ' + (current ? "current" : "required") + '">' + summary +
1274
- (current ? "" : '<div class="collection-review-foot">' + reviewSummary + action + '</div>') + '</section>';
1298
+ changes + (current ? "" : '<div class="collection-review-foot">' + reviewSummary + action + '</div>') + '</section>';
1275
1299
  }
1276
1300
 
1277
1301
  function retentionScheduleApprovalBlocker() {
@@ -1405,8 +1429,13 @@ function recordWorkflowCell(type, entry, detailContext = "") {
1405
1429
  const item = items[0];
1406
1430
  let href = workflowItemHref(item) || "#/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(entry.record.id);
1407
1431
  if (detailContext && href.startsWith("#/resource/" + encodeURIComponent(type) + "/") && !href.includes("?")) href += detailContext;
1408
- const title = type === "control" ? "Finish implementation" : item.title;
1409
- return '<a class="record-workflow-action" href="' + href + '"><span class="workflow-finding-status ' + esc(item.state) + '">' + esc(properCase(item.state)) + '</span><span><strong>' + esc(title) + '</strong><small>' + esc(stagePageItemDetail(item)) + (items.length > 1 ? " +" + (items.length - 1) + " more" : "") + '</small></span></a>';
1432
+ const title = type === "control"
1433
+ ? item.implementationState === "implemented-with-gaps" ? "Fix Control launch gaps" : "Finish implementation"
1434
+ : item.title;
1435
+ const statusLabel = type === "control" && item.implementationState
1436
+ ? controlLaunchLabel(item.implementationState).split(":")[0]
1437
+ : properCase(item.state);
1438
+ return '<a class="record-workflow-action" href="' + href + '"><span class="workflow-finding-status ' + esc(item.state) + '">' + esc(statusLabel) + '</span><span><strong>' + esc(title) + '</strong><small>' + esc(stagePageItemDetail(item)) + (items.length > 1 ? " +" + (items.length - 1) + " more" : "") + '</small></span></a>';
1410
1439
  }
1411
1440
 
1412
1441
  function openCollectionReviewDialog(type) {
@@ -1452,9 +1481,10 @@ function openCollectionReviewDialog(type) {
1452
1481
  const saveDescription = scheduleReview
1453
1482
  ? '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
1483
  : 'Confirm ' + assessment.recordCount + ' current ' + pluralize("record", assessment.recordCount) + '. If the collection or material scope changes, FileGRC will ask for another review.';
1484
+ const changes = collectionReviewChangesHtml(assessment.changesSinceReview);
1455
1485
  const confirmSubmit = '<button type="submit" class="button primary">Confirm and save</button>';
1456
1486
  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>';
1487
+ 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
1488
  document.body.append(dialog);
1459
1489
  dialog.showModal();
1460
1490
  const form = dialog.querySelector("form");
@@ -1516,6 +1546,23 @@ function openCollectionReviewDialog(type) {
1516
1546
  });
1517
1547
  }
1518
1548
 
1549
+ function collectionReviewChangesHtml(changes) {
1550
+ if (!changes) return "";
1551
+ if (changes.status !== "available") {
1552
+ return '<section class="collection-review-change-list"><strong>What changed since the last review</strong><p>' + esc(changes.summary) + '</p></section>';
1553
+ }
1554
+ const rows = changes.changes.map((change) => {
1555
+ const details = change.kind !== "changed" ? change.kind === "added" ? "entered review scope" : "left review scope" : [
1556
+ ...change.fields,
1557
+ ...(change.contentChanged ? ["Markdown"] : []),
1558
+ ...(change.membershipChanged ? ["collection membership"] : [])
1559
+ ].join(", ");
1560
+ return '<li><strong>' + esc(change.title) + '</strong> <span>(' + esc(change.type) + ')</span>: ' + esc(details) + '</li>';
1561
+ });
1562
+ if (changes.scopeFields?.length) rows.push('<li><strong>Program scope</strong>: ' + esc(changes.scopeFields.join(", ")) + '</li>');
1563
+ 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>';
1564
+ }
1565
+
1519
1566
  function workflowItemHref(item) {
1520
1567
  const destinationOptions = {
1521
1568
  retentionScheduleApproval: modelSupports("retention-schedule-approval"),
@@ -1818,7 +1865,7 @@ function stagePageCard(stage, destination, index) {
1818
1865
  const taskPreview = items.length
1819
1866
  ? '<div class="stage-page-tasks">' + items.slice(0, 3).map((item) => {
1820
1867
  const href = destination.utility === "evidence-sources" ? destination.href : item.href || workflowItemHref(item) || destination.href;
1821
- 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>';
1868
+ return '<a href="' + href + '"><span class="workflow-finding-status ' + esc(item.state) + '">' + esc(item.implementationState ? controlLaunchLabel(item.implementationState).split(":")[0] : properCase(item.state)) + '</span><span><strong>' + esc(item.title) + '</strong><small>' + esc(stagePageItemDetail(item)) + '</small></span></a>';
1822
1869
  }).join("") + (items.length > 3 ? '<small class="stage-page-tasks-more">+' + (items.length - 3) + ' more on this page</small>' : "") + '</div>'
1823
1870
  : "";
1824
1871
  return '<article class="stage-page-card ' + (complete ? "complete" : "") + '"><div class="stage-page-card-head"><div><small>' + esc(stepLabel) + '</small><h3>' + esc(destination.label) + '</h3></div><div class="stage-page-card-actions">' + completionState + '<a class="stage-page-open" href="' + destination.href + '" aria-label="Open ' + esc(destination.label) + '"><span>Open ›</span></a></div></div><p>' + esc(summary) + '</p>' + taskPreview + '</article>';
@@ -2256,8 +2303,9 @@ function policyEventTrigger(trigger, index, collapsed = false, scope = "events")
2256
2303
  const tooltipId = "policy-event-tooltip-" + scope + "-" + index;
2257
2304
  const proposed = trigger.programStatus === "proposed";
2258
2305
  const unavailable = state.readOnly || proposed;
2306
+ const blocker = trigger.steps.find(({ programBlocker }) => programBlocker)?.programBlocker;
2259
2307
  const availability = proposed
2260
- ? "Available after its governing policies are effective, at least one linked control is implemented, and every task has a current owner."
2308
+ ? "Dormant until " + (blocker ? blocker.label.toLowerCase() + " (" + blocker.id + ")" : "its governing content is active and effective") + ". Use this workflow for the first real event after cutover."
2261
2309
  : state.readOnly
2262
2310
  ? "Open this workspace in writable mode to trigger the workflow."
2263
2311
  : trigger.steps.length + " " + pluralize("task", trigger.steps.length) + " will be added to the Work Queue.";
@@ -3198,7 +3246,7 @@ function renderList(main, type, params = new URLSearchParams()) {
3198
3246
  const start = (pageNumber - 1) * LIST_PAGE_SIZE;
3199
3247
  const visible = filtered.slice(start, start + LIST_PAGE_SIZE);
3200
3248
  main.querySelector("#result-count").textContent = filtered.length + (filtered.length === 1 ? " record" : " records");
3201
- main.querySelector("#record-rows").innerHTML = filtered.length ? visible.map((entry) => '<tr><td data-label="' + esc(fieldLabel(type, "title")) + '" data-primary-field><a class="record-title" href="#/resource/' + encodeURIComponent(type) + '/' + encodeURIComponent(entry.record.id) + detailContext + '">' + esc(entry.record.title) + '</a></td>' + fields.map((name) => '<td data-label="' + esc(fieldLabel(type, name)) + '">' + (name === "$workQueueStatus" ? obligationWorkQueueStatus(entry.record) : formatValue(name === "status" ? displayStatus(entry.record) : entry.record[name], name, type, true)) + '</td>').join("") + '<td data-label="Next action">' + recordWorkflowCell(type, entry) + '</td><td data-label="Git file"><code>' + esc(entry.relativePath.replace(/^data\//, "")) + '</code></td></tr>').join("") : '<tr><td colspan="' + (fields.length + 3) + '">' + empty(entries.length ? "No records match this filter." : collectionEmptyState(type, definition)) + '</td></tr>';
3249
+ main.querySelector("#record-rows").innerHTML = filtered.length ? visible.map((entry) => '<tr><td data-label="' + esc(fieldLabel(type, "title")) + '" data-primary-field><a class="record-title" href="#/resource/' + encodeURIComponent(type) + '/' + encodeURIComponent(entry.record.id) + detailContext + '">' + esc(entry.record.title) + '</a></td>' + fields.map((name) => '<td data-label="' + esc(fieldLabel(type, name)) + '">' + (name === "$workQueueStatus" ? obligationWorkQueueStatus(entry.record) : formatValue(name === "status" ? type === "control" ? controlLaunchState(entry.record.id) || displayStatus(entry.record) : displayStatus(entry.record) : entry.record[name], name, type, true)) + '</td>').join("") + '<td data-label="Next action">' + recordWorkflowCell(type, entry) + '</td><td data-label="Git file"><code>' + esc(entry.relativePath.replace(/^data\//, "")) + '</code></td></tr>').join("") : '<tr><td colspan="' + (fields.length + 3) + '">' + empty(entries.length ? "No records match this filter." : collectionEmptyState(type, definition)) + '</td></tr>';
3202
3250
  pagination.hidden = totalPages === 1;
3203
3251
  previous.disabled = pageNumber === 1;
3204
3252
  next.disabled = pageNumber === totalPages;
@@ -6584,6 +6632,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
6584
6632
  .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}
6585
6633
  .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}
6586
6634
  .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}
6635
+ .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)}
6587
6636
  .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}
6588
6637
  @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}}
6589
6638
  @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";
@@ -544,8 +545,8 @@ function finalizationFields(record, model, resources, program, root) {
544
545
  field: "applicabilityReview",
545
546
  requiredness: "required",
546
547
  message: record.applicabilityReview?.scopeRevision
547
- ? `Review applicability against the current service scope. Stored revision: ${record.applicabilityReview.scopeRevision}. Current revision: ${currentRevision}. Then record the decision, rationale, reviewer, and date.`
548
- : `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.`
549
550
  });
550
551
  }
551
552
  if (record.type === "policy" && model.resources.policy?.fields?.programRole && !record.programRole) {
@@ -1138,6 +1139,11 @@ function normalizeFinding(code, item, context) {
1138
1139
  title: item.title,
1139
1140
  message: item.message,
1140
1141
  ...(item.nextSteps?.length ? { nextSteps: item.nextSteps } : {}),
1142
+ ...(item.implementationState ? {
1143
+ implementationState: item.implementationState,
1144
+ launchChecks: item.launchChecks,
1145
+ implementationAssertion: item.implementationAssertion
1146
+ } : {}),
1141
1147
  ...(item.checks ? { unmetChecks: Object.entries(item.checks)
1142
1148
  .filter(([, passed]) => passed === false)
1143
1149
  .map(([name]) => name) } : {}),