filegrc 0.16.16 → 0.16.18
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/agent.js +3 -1
- package/src/cli.js +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-path.js +10 -10
- package/src/program-readiness.js +78 -51
- package/src/validate.js +9 -5
- package/src/web.js +34 -7
- package/src/workflow.js +16 -7
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -122,13 +122,15 @@ export function buildAgentGuide(loaded, type, options = {}) {
|
|
|
122
122
|
oneOf: definition.oneOf ?? [],
|
|
123
123
|
markdown,
|
|
124
124
|
workflow: [
|
|
125
|
-
"Inspect existing records and
|
|
125
|
+
"Inspect the recommended action, existing records, relationships, source Markdown, and available evidence. Do the research, drafting, record edits, and validation you can do yourself.",
|
|
126
126
|
definition.singleton
|
|
127
127
|
? "Open the existing singleton record and update only gaps supported by facts from an authoritative source."
|
|
128
128
|
: "Reuse and update a suitable existing record. Create a scaffold only when no record covers the work, then replace every null value and empty required array with facts from an authoritative source.",
|
|
129
129
|
recommendedMarkdown.length
|
|
130
130
|
? "Keep model fields in JSON and use the recommended Markdown companion for the detailed work, decisions, results, exceptions, and follow-up that apply to this record."
|
|
131
131
|
: "Keep the current facts and lifecycle state in JSON. Add optional Record Markdown only when the model fields cannot explain the record clearly.",
|
|
132
|
+
"If a material fact, external action, approval, or management decision is still missing, ask the user one specific question. State what you inspected, the exact missing input, and what it unlocks. Do not hand off FileGRC commands, routine record entry, or a vague request to confirm. Continue the workflow after the answer without asking for another handoff.",
|
|
133
|
+
...(type === "control" ? ["Separate implementation from operation: document the working configuration, scope, owner, procedure, source, and enabled rules when its operation pattern needs them before marking Implemented. Record dated operating work and evidence only after a real cycle or event occurs; a ready schedule is not proof of operation."] : []),
|
|
132
134
|
...(type === "program" && modelSupports(loaded.model, "program-scope")
|
|
133
135
|
? ["Review Requirement applicability with npx filegrc review-applicability --type requirement --scaffold, then preview and apply the reviewed decisions as one validated batch."]
|
|
134
136
|
: []),
|
package/src/cli.js
CHANGED
|
@@ -1743,7 +1743,7 @@ function agentOverview(model) {
|
|
|
1743
1743
|
commit: "git diff --check && git diff && git add <reviewed-paths> && git commit -m <reason>"
|
|
1744
1744
|
};
|
|
1745
1745
|
return {
|
|
1746
|
-
rule: "Treat data/ as the source of truth. Run guide before creating an unfamiliar type, validate
|
|
1746
|
+
rule: "Treat data/ as the source of truth. Inspect existing records and sources, then do the research, edits, and validation you can do. Ask the user only for a specific material fact, external action, approval, or decision you cannot establish; explain what it unlocks and continue after the answer. Run guide before creating an unfamiliar type, validate every write, review the Git diff, and commit a focused change.",
|
|
1747
1747
|
programPath: buildAgentProgramPath(model),
|
|
1748
1748
|
actions: Object.fromEntries(Object.entries(commands).map(([name, command]) => [
|
|
1749
1749
|
name,
|
|
@@ -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-path.js
CHANGED
|
@@ -4,11 +4,11 @@ export const RESOURCE_INSTRUCTIONS = {
|
|
|
4
4
|
program: "Set the Program goal, owners, Systems, Frameworks, criteria decisions, and risk method.",
|
|
5
5
|
person: "Record the person’s actual job title and current status.",
|
|
6
6
|
appointment: "Assign a named authority to one Person with its start date and scope.",
|
|
7
|
-
team: "
|
|
7
|
+
team: "Use the current Person and Appointment records to set the Team’s members and chair; ask who holds a role only when those sources cannot establish it.",
|
|
8
8
|
system: "Define the service boundary, exclusions, owners, and information it handles.",
|
|
9
9
|
component: "Record a material service or evidence source and its role in each linked System.",
|
|
10
10
|
vendor: "Record each material external provider and the Components it supplies.",
|
|
11
|
-
classification: "
|
|
11
|
+
classification: "Use current Policy content and recorded information needs to propose handling categories, then apply the management decision to information and evidence. Ask about any category those sources do not settle.",
|
|
12
12
|
"information-type": "Name a stable category of information, set its default Classification, and link its uses.",
|
|
13
13
|
framework: "Select the criteria Framework and version used by the Program.",
|
|
14
14
|
requirement: "Review whether this criterion applies to the Program and record the decision and rationale.",
|
|
@@ -22,7 +22,7 @@ export const RESOURCE_INSTRUCTIONS = {
|
|
|
22
22
|
evidence: "Record a real artifact or approved reference, link its source and supported work, then have another person verify it.",
|
|
23
23
|
"risk-assessment": "Assess the in-scope service, record conclusions, and obtain an independent review.",
|
|
24
24
|
risk: "Name the threat and impact, assign an owner, rate the risk, and record its response.",
|
|
25
|
-
obligation: "
|
|
25
|
+
obligation: "Read the linked Policy and Control, fill the owner, cadence or trigger, deadline, and proof from those sources, then enable the rule when its Control is ready. Ask only for an unresolved management choice.",
|
|
26
26
|
"retention-schedule-item": "Set the Information Types, scope, cutoff, period, disposition, and sources for one retention rule.",
|
|
27
27
|
"obligation-event": "Record the actual event date and subject, then complete its linked Work Queue actions.",
|
|
28
28
|
"policy-review": "Record the review date, reviewer, decision, and any required follow-up.",
|
|
@@ -101,11 +101,11 @@ export const RESOURCE_OUTPUTS = {
|
|
|
101
101
|
};
|
|
102
102
|
|
|
103
103
|
export const RESOURCE_PAGE_SUMMARIES = {
|
|
104
|
-
person: "
|
|
104
|
+
person: "Use current people already recorded; ask for a missing person's identity or role only when needed.",
|
|
105
105
|
appointment: "Assign named authority.",
|
|
106
|
-
team: "
|
|
106
|
+
team: "Set shared owners and members from current People and Appointments.",
|
|
107
107
|
program: "Choose the goal, scope, criteria, controls, owners, and risk method.",
|
|
108
|
-
framework: "
|
|
108
|
+
framework: "Select the applicable SOC 2 framework and version from the installed catalog.",
|
|
109
109
|
requirement: "Decide which SOC 2 criteria apply.",
|
|
110
110
|
commitment: "Record customer promises that affect scope.",
|
|
111
111
|
"requirement-mapping": "Review how supplemental promises relate to Requirements and Controls.",
|
|
@@ -134,8 +134,8 @@ export const PROGRAM_PATH = [
|
|
|
134
134
|
description: "Ownership, criteria, and service boundary",
|
|
135
135
|
summary: "A reviewed Program scope with owners, criteria, Systems, and material providers.",
|
|
136
136
|
sections: [
|
|
137
|
-
{ id: "ownership", title: "Program Ownership", description: "
|
|
138
|
-
{ id: "criteria", title: "Program and Criteria", description: "Define the Program,
|
|
137
|
+
{ id: "ownership", title: "Program Ownership", description: "Assign program owners and set the normal security reporting channel and its fallback.", steps: ["Inspect current People and Appointments, then assign the program lead, policy owner, independent reviewer, and oversight team. Ask only for missing assignments.", "Use the actual normal and fallback routes to replace reporting-channel placeholders, then commit the proposal."], types: ["person", "appointment", "team", "reporting-route-set"], defaultOpen: true },
|
|
138
|
+
{ id: "criteria", title: "Program and Criteria", description: "Define the Program, select its Frameworks, record Program-scoped Requirement applicability, and connect customer commitments that shape the System or Control design.", steps: ["Set the Program goal, owners, risk method, and tentative timing from current records; ask for unresolved management choices.", "Compare each selected criterion with the service scope and record its applicability decision.", "Replace the starter Commitment prompt with the actual promise; add only needed supplemental mappings."], types: ["program", "framework", "requirement", "commitment", "requirement-mapping"], defaultOpen: true },
|
|
139
139
|
{ id: "boundary", title: "System Boundary", description: "Start with the bounded System. Add Components that materially deliver the service, support Controls, produce authoritative Evidence, or support relevant operations. Keep Vendor relationships and specific Assets separate.", steps: ["Define the service and exclusions in a bounded System selected by the Program.", "Add material Components and Vendors with their actual System relationships.", "Record the Information Types and Classifications the service uses."], types: ["system", "component", "vendor", "classification", "information-type"], defaultOpen: false }
|
|
140
140
|
],
|
|
141
141
|
resourceTypes: ["person", "appointment", "team", "reporting-route-set", "program", "framework", "requirement", "commitment", "requirement-mapping", "system", "component", "vendor", "classification", "information-type"],
|
|
@@ -160,7 +160,7 @@ export const PROGRAM_PATH = [
|
|
|
160
160
|
description: "Approve governed content and retention decisions",
|
|
161
161
|
summary: "Approved Policies, program Documents, Training content, and retention decisions.",
|
|
162
162
|
sections: [
|
|
163
|
-
{ id: "policy-content", title: "Policies", description: "Approve the program's governing content.", instructions: "Open each Policy, program Document, and Training record.
|
|
163
|
+
{ id: "policy-content", title: "Policies", description: "Approve the program's governing content.", instructions: "Open each Policy, program Document, and Training record. Draft from current approved decisions, replace placeholders, and resolve only material choices with management. A separate person then approves the exact Markdown revision.", output: "Approved content bound to the reviewed revisions.", steps: ["Draft the company's real rules, plans, and training material from current scope and approved decisions.", "Resolve missing owner, separate approver, Control links, audience, or intended values from authoritative records; ask only when a material decision remains.", "Have the independent approver review the exact Markdown revision and record their actual decision and date."], types: [], relatedLinks: [{ type: "policy", label: "Policies", href: "#/policies" }], defaultOpen: true },
|
|
164
164
|
{ id: "retention", title: "Data Retention Schedule", description: "Approve the schedule document and its completed rows together.", steps: ["Review the governing schedule and any legal hold or exception rules.", "Complete a row for each distinct information use, cutoff, retention period, and disposition.", "Have a separate reviewer approve the complete document and row set."], types: [], utility: "retention-schedule", defaultOpen: true }
|
|
165
165
|
],
|
|
166
166
|
resourceTypes: [],
|
|
@@ -184,7 +184,7 @@ export const PROGRAM_PATH = [
|
|
|
184
184
|
description: "Implement and prepare to operate",
|
|
185
185
|
summary: "Implemented Controls with ready evidence sources and enabled work schedules.",
|
|
186
186
|
sections: [
|
|
187
|
-
{ id: "controls", title: "Controls", description: "Put each selected Control into use and record
|
|
187
|
+
{ id: "controls", title: "Controls", description: "Put each selected Control into use and record its working design. Dated operating proof follows when work occurs.", steps: ["Open each Control, linked records, and current missing checks; do the setup that can be established from those sources.", "Configure the activity and record its procedure, Systems, owner, evidence sources, and any work rules its operation pattern requires.", "Mark it Implemented with its real start date when it works. Record later cycles and events separately with dated proof."], types: ["control"], defaultOpen: true },
|
|
188
188
|
{ id: "complementary-controls", title: "Complementary Controls", description: "Record customer or provider responsibilities that form part of the implementation boundary, or confirm there are none.", steps: ["Identify any customer or carved-out provider action an in-scope Control depends on.", "Record each dependency, or confirm that the current scope has none."], types: ["complementary-control"], defaultOpen: true },
|
|
189
189
|
{ id: "evidence-sources", title: "Evidence Sources", description: "Check that every Control points to an authoritative source that can produce its expected evidence.", steps: ["For each Control family, link an active Component with the required source kind, access owners, and retrieval instructions."], types: [], utility: "evidence-sources", defaultOpen: true },
|
|
190
190
|
{ id: "obligations", title: "Obligations", description: "Configure the calendar and event schedules that operate the Controls.", steps: ["Check the owner, trigger or cadence, due window, and required proof for each Obligation.", "Enable the rules that will run the implemented Controls."], types: ["obligation"], defaultOpen: true }
|
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));
|
|
@@ -210,7 +217,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
210
217
|
? item
|
|
211
218
|
: { ...item, status: "blocked", message: `Complete the Control collection review before activation. ${item.message}` };
|
|
212
219
|
}));
|
|
213
|
-
controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, enabled Obligations, and complete authoritative source ${modelSupports(loaded.model, "component-sources") ? "Components" : "Systems"}. When implementation is ready, perform one review of the implemented Control collection. Management then activates the unchanged approved program content as the Step 3 cutover.`;
|
|
220
|
+
controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, enabled Obligations for scheduled or event work, and complete authoritative source ${modelSupports(loaded.model, "component-sources") ? "Components" : "Systems"}. When implementation is ready, perform one review of the implemented Control collection. Management then activates the unchanged approved program content as the Step 3 cutover.`;
|
|
214
221
|
const scopeReadinessStage = scopeStage(
|
|
215
222
|
program,
|
|
216
223
|
scope,
|
|
@@ -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
|
|
@@ -589,7 +596,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
589
596
|
items.push(item(
|
|
590
597
|
"criteria",
|
|
591
598
|
criteriaComplete ? "complete" : "action",
|
|
592
|
-
"
|
|
599
|
+
"Resolve criteria decisions and Control coverage",
|
|
593
600
|
criteriaComplete
|
|
594
601
|
? `${selectedTrustServicesRequirements.length} applicable Trust Services criteria, ${selectedDescriptionRequirements.length} SOC 2 Description Criteria, and ${scope.controls.length} Controls are in scope. Every applicable Trust Services criterion has at least one selected Control; Description Criteria govern the system description and do not map to Controls.`
|
|
595
602
|
: missingRequiredSecurityReferences.length || missingRequiredDescriptionReferences.length
|
|
@@ -985,7 +992,7 @@ function programOwnershipItem(records, byId) {
|
|
|
985
992
|
return item(
|
|
986
993
|
"program-ownership",
|
|
987
994
|
complete ? "complete" : "action",
|
|
988
|
-
"
|
|
995
|
+
"Assign program owners and oversight",
|
|
989
996
|
complete
|
|
990
997
|
? `${currentOwners.size} current ${currentOwners.size === 1 ? "person owns" : "people own"} the program records.${oversight ? " Security and Risk Oversight has a separate current chair." : ""}`
|
|
991
998
|
: detail.join(" "),
|
|
@@ -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"],
|
|
@@ -1603,7 +1619,7 @@ function policyActivationItem(assessment) {
|
|
|
1603
1619
|
[assessment.unresolvedExceptionIds.length, "unresolved Exceptions"]
|
|
1604
1620
|
].filter(([count]) => count).map(([count, label]) => `${count} ${label}`);
|
|
1605
1621
|
const message = assessment.state === "active-and-operating"
|
|
1606
|
-
? `Active and effective ${assessment.effectiveOn}; all ${assessment.linkedControlIds.length} linked Controls are implemented with Components, evidence sources, and enabled Obligations.`
|
|
1622
|
+
? `Active and effective ${assessment.effectiveOn}; all ${assessment.linkedControlIds.length} linked Controls are implemented with Components, evidence sources, and enabled Obligations where scheduled or event work requires them.`
|
|
1607
1623
|
: assessment.state === "ready-to-activate"
|
|
1608
1624
|
? "Implementation checks are complete. Include this approved Policy in the Step 3 cutover when you are ready for it to take effect."
|
|
1609
1625
|
: `${assessment.label}: ${counts.join(", ") || assessment.timingWarnings.join(" ")}. ${assessment.activationWarning || ""}`.trim();
|
|
@@ -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": "Check whether key Systems and Vendor tools can export access lists. Set the first access review and name who removes access after a departure.",
|
|
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,33 +2136,35 @@ 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(
|
|
2128
|
-
|
|
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]
|
|
2157
|
+
|| `Inspect the Systems, people, Vendors, and work linked to ${control.title || control.id}. Compare the actual configuration with this Control's statement and fix the specific gaps.`);
|
|
2129
2158
|
const record = [];
|
|
2130
|
-
if (!checks.owner) record.push("
|
|
2131
|
-
if (!checks.scope) record.push("
|
|
2132
|
-
if (!checks.operationPattern) record.push("
|
|
2133
|
-
if (!checks.procedure) record.push("write
|
|
2134
|
-
if (!checks.evidenceSource) record.push("link the
|
|
2159
|
+
if (!checks.owner) record.push("assign the actual owner from existing People or Appointments");
|
|
2160
|
+
if (!checks.scope) record.push("link the in-scope Systems already in the Program");
|
|
2161
|
+
if (!checks.operationPattern) record.push("record whether it runs continuously, per transaction, on a schedule, or after an event");
|
|
2162
|
+
if (!checks.procedure) record.push("write the current actors, configuration, and procedure in Record Markdown");
|
|
2163
|
+
if (!checks.evidenceSource) record.push("link the authoritative Component that can produce proof");
|
|
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 date the working implementation took effect 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("; ")}.`);
|
|
@@ -2145,18 +2174,16 @@ export function controlImplementationSteps(control, checks, options = {}) {
|
|
|
2145
2174
|
? `Cover the missing evidence families: ${sourceTargets.length ? sourceTargets.join(" and ") : "this Control"}. For each, link an active Component with the matching kind, evidence owners, and report retrieval instructions.`
|
|
2146
2175
|
: null;
|
|
2147
2176
|
const queueStep = checks.workQueue === false
|
|
2148
|
-
? "
|
|
2177
|
+
? "Inspect linked Obligations and their Policy basis. Enable a suitable existing rule with its owner and deadline, or add a rule only if no existing one covers this work. A scheduled rule does not prove a cycle occurred."
|
|
2149
2178
|
: null;
|
|
2150
2179
|
if (!checks.implemented) {
|
|
2151
2180
|
if (sourceStep || queueStep) steps.push([sourceStep, queueStep].filter(Boolean).join(" "));
|
|
2152
|
-
else if (checks.applicability === false) steps.push("
|
|
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.");
|
|
2181
|
+
else if (checks.applicability === false) steps.push("Compare this Control with the current Program scope and record its applicability decision with `review-applicability --scaffold --type control`; ask management only if the decision cannot be established from the scope and commitments.");
|
|
2182
|
+
else steps.push("When these launch conditions hold, mark the Control Implemented with its real effective date. Record dated proof later, when a cycle or event actually occurs.");
|
|
2156
2183
|
} else {
|
|
2157
2184
|
if (sourceStep) steps.push(sourceStep);
|
|
2158
2185
|
if (queueStep) steps.push(queueStep);
|
|
2159
|
-
if (checks.applicability === false) steps.push("
|
|
2186
|
+
if (checks.applicability === false) steps.push("Compare this Control with the current Program scope and record its applicability decision with `review-applicability --scaffold --type control`; ask management only if the decision cannot be established from the scope and commitments.");
|
|
2160
2187
|
}
|
|
2161
2188
|
return steps;
|
|
2162
2189
|
}
|
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: check the live configuration, then record its 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
|
@@ -466,13 +466,17 @@ function recordIncompleteReason(record, loaded, program) {
|
|
|
466
466
|
return {
|
|
467
467
|
state: "ready",
|
|
468
468
|
requiredness: appointmentRequiredness(record, loaded.model),
|
|
469
|
-
message: "
|
|
469
|
+
message: "Check current People and scoped Appointments, assign a qualified holder, and record the actual authority and start date. Ask management only if the holder or independence decision is unresolved."
|
|
470
470
|
};
|
|
471
471
|
}
|
|
472
472
|
const messages = {
|
|
473
473
|
draft: "Add the required details, relationships, and Markdown before review.",
|
|
474
474
|
planned: "Check this planned record against how your program works, then add the missing details.",
|
|
475
|
-
proposed: "
|
|
475
|
+
proposed: ["obligation", "obligation-rule"].includes(record.type)
|
|
476
|
+
? "Read the linked Policy and Control, fill the actual owner, schedule, deadline, and proof requirement, then activate the rule. Ask management only for a choice those sources do not settle."
|
|
477
|
+
: record.type === "reporting-route-set"
|
|
478
|
+
? "Fill the normal and fallback reporting routes from approved contact details, commit the proposal, and obtain the required separate approval before it takes effect. Ask only for missing route or authority decisions."
|
|
479
|
+
: "Inspect the source facts and type guide, complete the proposal, and obtain the required review or approval before activation.",
|
|
476
480
|
"in-review": "Have an independent reviewer approve the exact content revision.",
|
|
477
481
|
open: "Complete this work with the required proof, or formally close it without completion.",
|
|
478
482
|
"in-progress": "Finish the work, record the result, and link the completion proof.",
|
|
@@ -724,7 +728,7 @@ function reconciliationFindings(reconciliation) {
|
|
|
724
728
|
state: "ready",
|
|
725
729
|
severity: "warning",
|
|
726
730
|
requiredness: "conditional",
|
|
727
|
-
title: `
|
|
731
|
+
title: `Resolve ${fieldLabel(candidate.eventType)} transition`,
|
|
728
732
|
message: candidate.message,
|
|
729
733
|
subject: candidate.subject,
|
|
730
734
|
fieldPath: candidate.sourcePath,
|
|
@@ -1139,6 +1143,11 @@ function normalizeFinding(code, item, context) {
|
|
|
1139
1143
|
title: item.title,
|
|
1140
1144
|
message: item.message,
|
|
1141
1145
|
...(item.nextSteps?.length ? { nextSteps: item.nextSteps } : {}),
|
|
1146
|
+
...(item.implementationState ? {
|
|
1147
|
+
implementationState: item.implementationState,
|
|
1148
|
+
launchChecks: item.launchChecks,
|
|
1149
|
+
implementationAssertion: item.implementationAssertion
|
|
1150
|
+
} : {}),
|
|
1142
1151
|
...(item.checks ? { unmetChecks: Object.entries(item.checks)
|
|
1143
1152
|
.filter(([, passed]) => passed === false)
|
|
1144
1153
|
.map(([name]) => name) } : {}),
|
|
@@ -1625,12 +1634,12 @@ function periodFinding(key, title, message, state, subject, actions, dependencie
|
|
|
1625
1634
|
function auditClosureNextStep(status) {
|
|
1626
1635
|
const steps = {
|
|
1627
1636
|
planned: {
|
|
1628
|
-
title: "
|
|
1629
|
-
message: "
|
|
1637
|
+
title: "Record the agreed engagement and start audit preparation",
|
|
1638
|
+
message: "Read the signed engagement terms, link them, record the agreed scope and management acknowledgement, then move the audit to in progress. Ask for missing terms or acknowledgement only if unavailable."
|
|
1630
1639
|
},
|
|
1631
1640
|
"in-progress": {
|
|
1632
1641
|
title: "Begin fieldwork",
|
|
1633
|
-
message: "Finish management preparation,
|
|
1642
|
+
message: "Finish management preparation, record the fieldwork dates agreed with the CPA firm, and move the audit to fieldwork when testing begins. Ask for the dates only if they are not in the engagement correspondence."
|
|
1634
1643
|
},
|
|
1635
1644
|
fieldwork: {
|
|
1636
1645
|
title: "Finish fieldwork and prepare the report",
|
|
@@ -1642,7 +1651,7 @@ function auditClosureNextStep(status) {
|
|
|
1642
1651
|
},
|
|
1643
1652
|
issued: {
|
|
1644
1653
|
title: "Record final report delivery",
|
|
1645
|
-
message: "
|
|
1654
|
+
message: "Read the issued report and delivery record, enter the opinion, report date, authorized recipient, and receipt, then move the audit to delivered. Ask only for missing delivery facts."
|
|
1646
1655
|
},
|
|
1647
1656
|
delivered: {
|
|
1648
1657
|
title: "Close the audit",
|