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 +1 -1
- package/src/obligation-rule.js +20 -0
- package/src/obligations.js +77 -32
- package/src/program-lifecycle.js +11 -3
- package/src/program-readiness.js +65 -38
- package/src/validate.js +9 -5
- package/src/web.js +34 -7
- package/src/workflow.js +5 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/obligations.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
88
|
-
?
|
|
89
|
-
:
|
|
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:
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
|
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) => (
|
package/src/program-lifecycle.js
CHANGED
|
@@ -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
|
+
}
|
package/src/program-readiness.js
CHANGED
|
@@ -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:
|
|
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
|
-
&&
|
|
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(
|
|
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(
|
|
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
|
|
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: "
|
|
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": "
|
|
2096
|
-
"control-policy-management": "
|
|
2097
|
-
"control-security-communication": "
|
|
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": "
|
|
2100
|
-
"control-risk-assessment": "
|
|
2101
|
-
"control-monitoring-remediation": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
2118
|
-
"control-backup-restoration": "For each important System, inspect backup or alternate recovery settings and failure alerts.
|
|
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": "
|
|
2121
|
-
"control-vendor-monitoring": "
|
|
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(
|
|
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 (
|
|
2138
|
-
if (checks.
|
|
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(
|
|
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(
|
|
1554
|
-
const stopped = schedules.filter((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"
|
|
1412
|
-
|
|
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
|
-
? "
|
|
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) } : {}),
|