filegrc 0.16.16 → 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.16",
3
+ "version": "0.16.17",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
@@ -1266,10 +1273,11 @@ function requiredGovernedDocuments(scope, records, byId, model) {
1266
1273
  ));
1267
1274
  }
1268
1275
 
1269
- async function governedContentItems(scope, records, byId, readMarkdown, asOf, model) {
1276
+ async function governedContentItems(scope, records, byId, readMarkdown, asOf, model, asOfTime) {
1270
1277
  const enabledObligations = records.filter((record) => (
1271
1278
  record.type === "obligation" && obligationIsEnabled(record)
1272
1279
  ));
1280
+ const operatingSchedules = enabledObligations.filter((record) => obligationRuleIsEnabled(record, byId, asOfTime));
1273
1281
  const requiredGovernedIds = new Set(enabledObligations.flatMap((record) => [
1274
1282
  ...(record.scopeResourceIds || []),
1275
1283
  ...(record.templateResourceId ? [record.templateResourceId] : [])
@@ -1332,7 +1340,7 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf, mo
1332
1340
  owner: currentPartyPeople(record.ownerIds, byId).size > 0,
1333
1341
  active: record.status === "active",
1334
1342
  requirementsImplemented: linkedControlIds.length > 0 && missingImplementationControlIds.length === 0,
1335
- assignmentScheduled: enabledObligations.some((obligation) => (
1343
+ assignmentScheduled: operatingSchedules.some((obligation) => (
1336
1344
  obligation.templateResourceId === record.id
1337
1345
  || (obligation.scopeResourceIds || []).includes(record.id)
1338
1346
  )),
@@ -1459,7 +1467,7 @@ function documentActivationLabel(state) {
1459
1467
  })[state] || state;
1460
1468
  }
1461
1469
 
1462
- async function assessPolicyActivations(policies, controls, records, byId, readMarkdown, asOf, model) {
1470
+ async function assessPolicyActivations(policies, controls, records, byId, readMarkdown, asOf, model, controlItems, asOfTime) {
1463
1471
  const sourceType = modelSupports(model, "component-sources") ? "component" : "system";
1464
1472
  const sourceField = modelSupports(model, "component-sources") ? "evidenceSourceComponentIds" : "evidenceSourceIds";
1465
1473
  const assessments = [];
@@ -1469,6 +1477,11 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
1469
1477
  const plannedOrPartialControlIds = linkedControls
1470
1478
  .filter((control) => ["planned", "partially-implemented"].includes(control.status))
1471
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);
1472
1485
  const missingComponentControlIds = modelSupports(model, "component-sources")
1473
1486
  ? linkedControls.filter((control) => ![
1474
1487
  ...(control.componentIds || []),
@@ -1497,7 +1510,7 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
1497
1510
  ["scheduled", "event-driven", "mixed"].includes(control.operationPattern)
1498
1511
  && !records.some((record) => (
1499
1512
  record.type === "obligation"
1500
- && obligationIsEnabled(record)
1513
+ && obligationRuleIsEnabled(record, byId, asOfTime)
1501
1514
  && (record.controlIds || []).includes(control.id)
1502
1515
  && (record.policyIds || []).includes(policy.id)
1503
1516
  ))
@@ -1540,6 +1553,7 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
1540
1553
  : null
1541
1554
  ].filter(Boolean);
1542
1555
  const gapCount = plannedOrPartialControlIds.length
1556
+ + missingLaunchControlIds.length
1543
1557
  + missingComponentControlIds.length
1544
1558
  + missingEvidenceSourceControlIds.length
1545
1559
  + missingScheduleControlIds.length
@@ -1564,6 +1578,7 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
1564
1578
  proposedEffectiveOn: policy.proposedEffectiveOn || null,
1565
1579
  linkedControlIds,
1566
1580
  plannedOrPartialControlIds,
1581
+ missingLaunchControlIds,
1567
1582
  missingComponentControlIds,
1568
1583
  missingEvidenceSourceControlIds,
1569
1584
  missingScheduleControlIds,
@@ -1596,6 +1611,7 @@ function policyActivationLabel(state) {
1596
1611
  function policyActivationItem(assessment) {
1597
1612
  const counts = [
1598
1613
  [assessment.plannedOrPartialControlIds.length, "planned or partial Controls"],
1614
+ [assessment.missingLaunchControlIds.length, "implemented Controls with launch gaps"],
1599
1615
  [assessment.missingComponentControlIds.length, "Controls missing active Components"],
1600
1616
  [assessment.missingEvidenceSourceControlIds.length, "Controls missing ready evidence sources"],
1601
1617
  [assessment.missingScheduleControlIds.length, "Controls missing enabled Obligations"],
@@ -1644,7 +1660,7 @@ function legacyPolicyLibraryProposals(records) {
1644
1660
  }];
1645
1661
  }
1646
1662
 
1647
- async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null) {
1663
+ async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null, asOfTime) {
1648
1664
  const items = [];
1649
1665
  const families = selectedControlFamilies(scope.controls, model);
1650
1666
  if (!scope.controls.length) {
@@ -1698,12 +1714,24 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null
1698
1714
  criteriaMapping: (control.requirementIds || []).length > 0,
1699
1715
  ...(["scheduled", "event-driven", "mixed"].includes(control.operationPattern) ? {
1700
1716
  workQueue: queueSchedules.length > 0
1701
- && queueSchedules.some(obligationIsEnabled)
1717
+ && queueSchedules.some((obligation) => obligationRuleIsEnabled(obligation, byId, asOfTime))
1702
1718
  } : {})
1703
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";
1704
1728
  const missing = Object.entries(checks).filter(([, value]) => !value).map(([name]) => controlCheckLabel(name));
1705
1729
  const nextSteps = controlImplementationSteps(control, checks, {
1706
- missingSourceFamilies
1730
+ missingSourceFamilies,
1731
+ targets: [...new Set([
1732
+ ...(control.systemIds || []),
1733
+ ...(control.componentIds || [])
1734
+ ].map((id) => byId.get(id)?.title).filter(Boolean))]
1707
1735
  });
1708
1736
  items.push(item(
1709
1737
  `control-${control.id}`,
@@ -1715,17 +1743,20 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null
1715
1743
  control,
1716
1744
  {
1717
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.",
1718
1749
  nextSteps,
1719
1750
  commands: [`npx filegrc get ${shellArgument(control.id)} --mutation`],
1720
1751
  workQueue: queueSchedules.length ? {
1721
- enabled: queueSchedules.filter(obligationIsEnabled).length,
1722
- 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,
1723
1754
  total: queueSchedules.length
1724
1755
  } : null
1725
1756
  }
1726
1757
  ));
1727
1758
  }
1728
- 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);
1729
1760
  }
1730
1761
 
1731
1762
  async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
@@ -1739,10 +1770,6 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
1739
1770
  .map((id) => byId.get(id))
1740
1771
  .filter((record) => (
1741
1772
  record?.type === sourceType
1742
- && (
1743
- !(record.evidenceSourceKinds || []).length
1744
- || family.sourceKinds.some((kind) => (record.evidenceSourceKinds || []).includes(kind))
1745
- )
1746
1773
  ));
1747
1774
  const completeSources = [];
1748
1775
  const sourceChecks = [];
@@ -1780,7 +1807,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
1780
1807
  const coveredControls = controlMappings.filter(({ complete }) => complete);
1781
1808
  const complete = completeSources.length > 0 && coveredControls.length === family.controls.length;
1782
1809
  const commands = [
1783
- ...(selectedSources.length
1810
+ ...(complete ? [] : selectedSources.length
1784
1811
  ? sourceChecks.filter(({ complete: sourceComplete }) => !sourceComplete).flatMap((sourceCheck) => {
1785
1812
  const sourceId = sourceCheck.sourceComponentId || sourceCheck.sourceSystemId;
1786
1813
  return [
@@ -2087,21 +2114,21 @@ function controlCheckLabel(name) {
2087
2114
  procedureEffective: "procedure effective date",
2088
2115
  policyMapping: "policy mapping",
2089
2116
  criteriaMapping: "criteria mapping",
2090
- workQueue: "running Obligation schedules"
2117
+ workQueue: "enabled Obligation rules (work starts after cutover)"
2091
2118
  })[name] || name;
2092
2119
  }
2093
2120
 
2094
2121
  const starterControlChecks = {
2095
- "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.",
2096
- "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.",
2097
- "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.",
2098
2125
  "control-workforce-expectations": "Review onboarding for roles with sensitive access. Check that required screening, agreements, and Policy acknowledgements happen before access.",
2099
- "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.",
2100
- "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.",
2101
- "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.",
2102
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.",
2103
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.",
2104
- "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.",
2105
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.",
2106
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.",
2107
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.",
@@ -2109,22 +2136,24 @@ const starterControlChecks = {
2109
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.",
2110
2137
  "control-endpoint-protection": "Review devices allowed into company Systems. Check encryption, screen lock, updates, and malware protection against the approved settings.",
2111
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.",
2112
- "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.",
2113
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.",
2114
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.",
2115
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.",
2116
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.",
2117
- "control-incident-exercise": "Send a test alert through a representative incident path. Check acknowledgement, escalation, and fallback, then record participants, failures, and follow-up.",
2118
- "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.",
2119
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.",
2120
- "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.",
2121
- "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.",
2122
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."
2123
2150
  };
2124
2151
 
2125
2152
  export function controlImplementationSteps(control, checks, options = {}) {
2126
2153
  const steps = [];
2127
- 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]
2128
2157
  || "Inspect the systems, people, vendors, or work this Control covers. Compare current practice with its statement and fix any gaps.");
2129
2158
  const record = [];
2130
2159
  if (!checks.owner) record.push("choose an owner");
@@ -2134,8 +2163,8 @@ export function controlImplementationSteps(control, checks, options = {}) {
2134
2163
  if (!checks.evidenceSource) record.push("link the tool or system that can show it happened");
2135
2164
  if (!checks.policyMapping && checks.policyMapping !== undefined) record.push("link the Policy it implements");
2136
2165
  if (!checks.criteriaMapping && checks.criteriaMapping !== undefined) record.push("link the criteria it covers");
2137
- if (checks.implemented && !checks.implementationDate) record.push("enter the date it started working");
2138
- 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) {
2139
2168
  record.push("record the Procedure revision and effective date");
2140
2169
  }
2141
2170
  if (record.length) steps.push(`In this Control, ${record.join("; ")}.`);
@@ -2150,9 +2179,7 @@ export function controlImplementationSteps(control, checks, options = {}) {
2150
2179
  if (!checks.implemented) {
2151
2180
  if (sourceStep || queueStep) steps.push([sourceStep, queueStep].filter(Boolean).join(" "));
2152
2181
  else if (checks.applicability === false) steps.push("Confirm this Control applies to the current Program scope with `review-applicability --scaffold --type control`.");
2153
- else steps.push(checks.procedureRevision === undefined
2154
- ? "When it works, mark this Control Implemented and enter its start date."
2155
- : "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.");
2156
2183
  } else {
2157
2184
  if (sourceStep) steps.push(sourceStep);
2158
2185
  if (queueStep) steps.push(queueStep);
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";
@@ -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(
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>';
@@ -1408,8 +1429,13 @@ function recordWorkflowCell(type, entry, detailContext = "") {
1408
1429
  const item = items[0];
1409
1430
  let href = workflowItemHref(item) || "#/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(entry.record.id);
1410
1431
  if (detailContext && href.startsWith("#/resource/" + encodeURIComponent(type) + "/") && !href.includes("?")) href += detailContext;
1411
- const title = type === "control" ? "Finish implementation" : item.title;
1412
- 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>';
1413
1439
  }
1414
1440
 
1415
1441
  function openCollectionReviewDialog(type) {
@@ -1839,7 +1865,7 @@ function stagePageCard(stage, destination, index) {
1839
1865
  const taskPreview = items.length
1840
1866
  ? '<div class="stage-page-tasks">' + items.slice(0, 3).map((item) => {
1841
1867
  const href = destination.utility === "evidence-sources" ? destination.href : item.href || workflowItemHref(item) || destination.href;
1842
- 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>';
1843
1869
  }).join("") + (items.length > 3 ? '<small class="stage-page-tasks-more">+' + (items.length - 3) + ' more on this page</small>' : "") + '</div>'
1844
1870
  : "";
1845
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>';
@@ -2277,8 +2303,9 @@ function policyEventTrigger(trigger, index, collapsed = false, scope = "events")
2277
2303
  const tooltipId = "policy-event-tooltip-" + scope + "-" + index;
2278
2304
  const proposed = trigger.programStatus === "proposed";
2279
2305
  const unavailable = state.readOnly || proposed;
2306
+ const blocker = trigger.steps.find(({ programBlocker }) => programBlocker)?.programBlocker;
2280
2307
  const availability = proposed
2281
- ? "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."
2282
2309
  : state.readOnly
2283
2310
  ? "Open this workspace in writable mode to trigger the workflow."
2284
2311
  : trigger.steps.length + " " + pluralize("task", trigger.steps.length) + " will be added to the Work Queue.";
@@ -3219,7 +3246,7 @@ function renderList(main, type, params = new URLSearchParams()) {
3219
3246
  const start = (pageNumber - 1) * LIST_PAGE_SIZE;
3220
3247
  const visible = filtered.slice(start, start + LIST_PAGE_SIZE);
3221
3248
  main.querySelector("#result-count").textContent = filtered.length + (filtered.length === 1 ? " record" : " records");
3222
- 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>';
3223
3250
  pagination.hidden = totalPages === 1;
3224
3251
  previous.disabled = pageNumber === 1;
3225
3252
  next.disabled = pageNumber === totalPages;
package/src/workflow.js CHANGED
@@ -1139,6 +1139,11 @@ function normalizeFinding(code, item, context) {
1139
1139
  title: item.title,
1140
1140
  message: item.message,
1141
1141
  ...(item.nextSteps?.length ? { nextSteps: item.nextSteps } : {}),
1142
+ ...(item.implementationState ? {
1143
+ implementationState: item.implementationState,
1144
+ launchChecks: item.launchChecks,
1145
+ implementationAssertion: item.implementationAssertion
1146
+ } : {}),
1142
1147
  ...(item.checks ? { unmetChecks: Object.entries(item.checks)
1143
1148
  .filter(([, passed]) => passed === false)
1144
1149
  .map(([name]) => name) } : {}),