filegrc 0.16.15 → 0.16.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent.js +1 -0
- package/src/collection-changes.js +158 -0
- package/src/collection-review.js +9 -3
- package/src/files.js +2 -2
- 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 +68 -39
- package/src/revisions.js +6 -0
- package/src/state.js +1 -0
- package/src/validate.js +11 -7
- package/src/web.js +59 -10
- package/src/workflow.js +8 -2
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -102,6 +102,7 @@ export function buildAgentGuide(loaded, type, options = {}) {
|
|
|
102
102
|
allowedDecisions: collectionReview.configuration.decisions,
|
|
103
103
|
status: collectionReview.status,
|
|
104
104
|
recordCount: collectionReview.recordCount,
|
|
105
|
+
...(collectionReview.changesSinceReview ? { changesSinceReview: collectionReview.changesSinceReview } : {}),
|
|
105
106
|
command: `npx filegrc review-collection ${type} --scaffold`
|
|
106
107
|
}
|
|
107
108
|
: null
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { modelSupports } from "../model/index.js";
|
|
3
|
+
import { authoritativeSourceRevisionValue, collectionRevisionInputs, collectionScopeRevisionFacts, scopedCollectionRecords } from "./collection-scope.js";
|
|
4
|
+
import { collectionRevisionMatches } from "./collection-revision.js";
|
|
5
|
+
import { getFileAtRevision } from "./git.js";
|
|
6
|
+
import { reviewHistoricalWorkspace, reviewHistoryContext } from "./historical-workspace.js";
|
|
7
|
+
import { markdownEntries } from "./resource-markdown.js";
|
|
8
|
+
import { resolveProgram } from "./program.js";
|
|
9
|
+
import { resolveDataPath } from "./paths.js";
|
|
10
|
+
import { canonicalCalculatedRevision } from "./revisions.js";
|
|
11
|
+
|
|
12
|
+
export function collectionChangesSinceReview(loaded, resourceType, review, programId) {
|
|
13
|
+
if (!/^[a-f0-9]{40,64}$/i.test(review?.scopeRevision || "") || !loaded.root) return unavailable("The reviewed Git snapshot is unavailable. Compare the current records with the prior review.");
|
|
14
|
+
const before = reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), review.scopeRevision);
|
|
15
|
+
if (!before) return unavailable("The reviewed Git snapshot is unavailable. Compare the current records with the prior review.");
|
|
16
|
+
const workspaceWide = resourceType === "retention-schedule-item"
|
|
17
|
+
&& modelSupports(loaded.model, "retention-schedule-approval");
|
|
18
|
+
let priorProgram = null;
|
|
19
|
+
let currentProgram = null;
|
|
20
|
+
try {
|
|
21
|
+
if (!workspaceWide) {
|
|
22
|
+
priorProgram = resolveProgram(before, programId);
|
|
23
|
+
currentProgram = resolveProgram(loaded, programId);
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
return unavailable("The reviewed Program could not be reconstructed. Compare the current scope with the prior review.");
|
|
27
|
+
}
|
|
28
|
+
if (!collectionRevisionMatches(before, resourceType, review.collectionRevision, {
|
|
29
|
+
programId,
|
|
30
|
+
historicalCommit: review.scopeRevision,
|
|
31
|
+
authoritativeSourceId: review.authoritativeComponentId || review.authoritativeSystemId
|
|
32
|
+
})) {
|
|
33
|
+
return unavailable("The stored revision does not match the reviewed Git snapshot. Compare the current records with the prior review.");
|
|
34
|
+
}
|
|
35
|
+
const priorInputs = new Map(collectionRevisionInputs(before, resourceType, priorProgram)
|
|
36
|
+
.map((input) => [input.record.id, input]));
|
|
37
|
+
const currentInputs = new Map(collectionRevisionInputs(loaded, resourceType, currentProgram)
|
|
38
|
+
.map((input) => [input.record.id, input]));
|
|
39
|
+
const authoritativeSourceId = review.authoritativeComponentId || review.authoritativeSystemId;
|
|
40
|
+
for (const [source, inputs] of [[before, priorInputs], [loaded, currentInputs]]) {
|
|
41
|
+
const record = source.resources.find(({ id }) => id === authoritativeSourceId);
|
|
42
|
+
if (record) inputs.set(record.id, {
|
|
43
|
+
record,
|
|
44
|
+
value: authoritativeSourceRevisionValue(record),
|
|
45
|
+
includeContent: true
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const priorMembers = new Set(scopedCollectionRecords(before, resourceType, priorProgram).map(({ id }) => id));
|
|
49
|
+
const currentMembers = new Set(scopedCollectionRecords(loaded, resourceType, currentProgram).map(({ id }) => id));
|
|
50
|
+
const changes = [];
|
|
51
|
+
for (const id of new Set([...priorInputs.keys(), ...currentInputs.keys()])) {
|
|
52
|
+
const prior = priorInputs.get(id);
|
|
53
|
+
const current = currentInputs.get(id);
|
|
54
|
+
const record = current?.record || prior?.record;
|
|
55
|
+
const fields = prior && current ? changedFields(prior.value, current.value, loaded.model, record.type) : [];
|
|
56
|
+
const contentChanged = prior && current && (prior.includeContent || current.includeContent)
|
|
57
|
+
? changedContent(before, loaded, review.scopeRevision, prior.record, current.record)
|
|
58
|
+
: false;
|
|
59
|
+
const membershipChanged = priorMembers.has(id) !== currentMembers.has(id);
|
|
60
|
+
if (!prior || !current || fields.length || contentChanged || membershipChanged) {
|
|
61
|
+
changes.push({
|
|
62
|
+
id,
|
|
63
|
+
type: record.type,
|
|
64
|
+
title: record.title || id,
|
|
65
|
+
kind: !prior ? "added" : !current ? "removed" : "changed",
|
|
66
|
+
fields,
|
|
67
|
+
...(contentChanged ? { contentChanged: true } : {}),
|
|
68
|
+
...(membershipChanged ? { membershipChanged: true } : {})
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const scopeFields = changedFields(
|
|
73
|
+
collectionScopeRevisionFacts(before, resourceType, priorProgram),
|
|
74
|
+
collectionScopeRevisionFacts(loaded, resourceType, currentProgram),
|
|
75
|
+
loaded.model,
|
|
76
|
+
"program"
|
|
77
|
+
);
|
|
78
|
+
if (!changes.length && !scopeFields.length) {
|
|
79
|
+
return unavailable("The stored revision does not match the reviewed Git snapshot. Compare the current records with the prior review.");
|
|
80
|
+
}
|
|
81
|
+
changes.sort((left, right) => left.type.localeCompare(right.type) || left.title.localeCompare(right.title) || left.id.localeCompare(right.id));
|
|
82
|
+
const overview = [
|
|
83
|
+
...changes.slice(0, 3).map(describeChange),
|
|
84
|
+
...(changes.length > 3 ? [`${changes.length - 3} more changed records`] : []),
|
|
85
|
+
...(scopeFields.length ? [`Program scope (${scopeFields.join(", ")})`] : [])
|
|
86
|
+
].join("; ");
|
|
87
|
+
const headlineParts = [
|
|
88
|
+
...changes.slice(0, 2).map(describeShortChange),
|
|
89
|
+
...(changes.length > 2 ? [`${changes.length - 2} more changed records`] : []),
|
|
90
|
+
...(scopeFields.length ? [`Program scope: ${scopeFields.slice(0, 2).join(", ")}${scopeFields.length > 2 ? ", more" : ""}`] : [])
|
|
91
|
+
];
|
|
92
|
+
return {
|
|
93
|
+
status: "available",
|
|
94
|
+
summary: `Changed since the last review: ${overview}.`,
|
|
95
|
+
headline: `${headlineParts.join("; ")}. Open the review for details.`,
|
|
96
|
+
changes,
|
|
97
|
+
scopeFields
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function unavailable(summary) {
|
|
102
|
+
return { status: "unavailable", summary, headline: "Changes could not be reconstructed. Open the review for details.", changes: [], scopeFields: [] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function describeChange(change) {
|
|
106
|
+
const name = `${change.title} (${change.type})`;
|
|
107
|
+
if (change.kind !== "changed") return `${name} ${change.kind === "added" ? "entered review scope" : "left review scope"}`;
|
|
108
|
+
const details = [...change.fields, ...(change.contentChanged ? ["Markdown"] : []), ...(change.membershipChanged ? ["collection membership"] : [])];
|
|
109
|
+
return `${name}: ${details.join(", ")}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function describeShortChange(change) {
|
|
113
|
+
const name = change.title.length > 44 ? `${change.title.slice(0, 43)}…` : change.title;
|
|
114
|
+
if (change.kind !== "changed") return `${name} ${change.kind === "added" ? "entered scope" : "left scope"}`;
|
|
115
|
+
const details = [...change.fields, ...(change.contentChanged ? ["Markdown"] : []), ...(change.membershipChanged ? ["membership"] : [])];
|
|
116
|
+
return `${name}: ${details.slice(0, 2).join(", ")}${details.length > 2 ? ", more" : ""}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function changedFields(before, after, model, type) {
|
|
120
|
+
const definitions = { ...model.commonFields, ...model.resources[type]?.fields };
|
|
121
|
+
return [...new Set([...Object.keys(before || {}), ...Object.keys(after || {})])]
|
|
122
|
+
.filter((field) => !sameValue(before?.[field], after?.[field]))
|
|
123
|
+
.map((field) => definitions[field]?.label || field.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/\bIds\b/g, "IDs").replace(/^./, (letter) => letter.toUpperCase()));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sameValue(left, right) {
|
|
127
|
+
if (typeof left === "string" && typeof right === "string") {
|
|
128
|
+
return canonicalCalculatedRevision(left) === canonicalCalculatedRevision(right);
|
|
129
|
+
}
|
|
130
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
131
|
+
if (left.every((value) => typeof value === "string") && right.every((value) => typeof value === "string")) {
|
|
132
|
+
return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
|
|
133
|
+
}
|
|
134
|
+
return left.length === right.length && left.every((value, index) => sameValue(value, right[index]));
|
|
135
|
+
}
|
|
136
|
+
if (left && right && typeof left === "object" && typeof right === "object") {
|
|
137
|
+
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
138
|
+
return [...keys].every((key) => sameValue(left[key], right[key]));
|
|
139
|
+
}
|
|
140
|
+
return left === right;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function changedContent(before, current, commit, priorRecord, currentRecord) {
|
|
144
|
+
const priorPaths = markdownEntries(before.model, priorRecord).map(({ path }) => path);
|
|
145
|
+
const currentPaths = markdownEntries(current.model, currentRecord).map(({ path }) => path);
|
|
146
|
+
for (const path of new Set([...priorPaths, ...currentPaths])) {
|
|
147
|
+
const priorSource = priorPaths.includes(path)
|
|
148
|
+
? before.historicalFiles?.get(`data/${path}`) ?? getFileAtRevision(before.root, commit, `data/${path}`)
|
|
149
|
+
: null;
|
|
150
|
+
let currentSource = null;
|
|
151
|
+
if (currentPaths.includes(path)) {
|
|
152
|
+
try { currentSource = readFileSync(resolveDataPath(current.root, path), "utf8"); }
|
|
153
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
154
|
+
}
|
|
155
|
+
if (priorSource !== currentSource) return true;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
package/src/collection-review.js
CHANGED
|
@@ -16,8 +16,9 @@ import { currentPartyPeople } from "./parties.js";
|
|
|
16
16
|
import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js";
|
|
17
17
|
import { markdownEntries } from "./resource-markdown.js";
|
|
18
18
|
import { resolveDataPath } from "./paths.js";
|
|
19
|
-
import { calculateRevision, calculatedRevisionDiagnostic, revisionsMatch } from "./revisions.js";
|
|
19
|
+
import { calculateRevision, calculatedRevisionDiagnostic, displayRevision, revisionsMatch } from "./revisions.js";
|
|
20
20
|
import { personWasActiveOn } from "./soc2.js";
|
|
21
|
+
import { collectionChangesSinceReview } from "./collection-changes.js";
|
|
21
22
|
|
|
22
23
|
export { collectionRevision };
|
|
23
24
|
|
|
@@ -123,6 +124,9 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
123
124
|
&& review.collectionRevision
|
|
124
125
|
&& (!revisionMatches || !reviewScopeMatches)
|
|
125
126
|
);
|
|
127
|
+
const changesSinceReview = stale
|
|
128
|
+
? collectionChangesSinceReview(loaded, resourceType, review, program.id)
|
|
129
|
+
: null;
|
|
126
130
|
return {
|
|
127
131
|
resourceType,
|
|
128
132
|
configuration,
|
|
@@ -134,6 +138,7 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
134
138
|
: null,
|
|
135
139
|
reviewEntries,
|
|
136
140
|
collectionRevision: currentRevision,
|
|
141
|
+
changesSinceReview,
|
|
137
142
|
revisionDiagnostic: calculatedRevisionDiagnostic("collection", review?.collectionRevision, currentRevision),
|
|
138
143
|
status: complete ? "current" : stale ? "stale" : "review-required",
|
|
139
144
|
complete,
|
|
@@ -143,7 +148,7 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
143
148
|
message: complete
|
|
144
149
|
? `${configuration.title} were reviewed on ${review.reviewedOn}.`
|
|
145
150
|
: approvalIssues.length
|
|
146
|
-
? approvalIssues[0].message
|
|
151
|
+
? `${approvalIssues[0].message}${changesSinceReview ? ` ${changesSinceReview.summary}` : ""}`
|
|
147
152
|
: incompleteRecordProposals.length
|
|
148
153
|
? `Complete ${incompleteRecordProposals.length} ${configuration.title.toLowerCase()} ${incompleteRecordProposals.length === 1 ? "record proposal" : "record proposals"} before the collection review.`
|
|
149
154
|
: revisionMatches && review?.status === "active" && !reviewersEligible
|
|
@@ -151,7 +156,7 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
|
|
|
151
156
|
? "Review the Data Retention Schedule again with a reviewer who does not own its governing document or an included schedule row."
|
|
152
157
|
: "Review the Control collection again with a reviewer who does not own an included Control or its enabled Obligation."
|
|
153
158
|
: stale
|
|
154
|
-
? `${configuration.title} changed after the last confirmation. Stored revision: ${review.collectionRevision}. Current revision: ${currentRevision}. Review the current records again.`
|
|
159
|
+
? `${configuration.title} changed after the last confirmation. ${changesSinceReview.summary} Stored revision: ${displayRevision(review.collectionRevision)}. Current revision: ${displayRevision(currentRevision)}. Review the current records again.`
|
|
155
160
|
: !records.length && !allowsEmptyCollection
|
|
156
161
|
? `Add at least one ${loaded.model.resources[resourceType].title.toLowerCase()} before confirming this collection.`
|
|
157
162
|
: `Review ${configuration.title.toLowerCase()} before this page can be ready.`
|
|
@@ -190,6 +195,7 @@ export async function scaffoldCollectionReview(input = process.cwd(), options =
|
|
|
190
195
|
: null;
|
|
191
196
|
return {
|
|
192
197
|
resourceType,
|
|
198
|
+
...(assessment.changesSinceReview ? { changesSinceReview: assessment.changesSinceReview } : {}),
|
|
193
199
|
decision: priorDecisionStillValid ? priorReview.decision : (assessment.records.length
|
|
194
200
|
? "complete"
|
|
195
201
|
: allowedDecisions.includes("zero-population") ? "zero-population" : null),
|
package/src/files.js
CHANGED
|
@@ -12,7 +12,7 @@ import { measureTiming } from "./timing.js";
|
|
|
12
12
|
import { currentCalendarDate, timestampFromLocalDateTime } from "./time.js";
|
|
13
13
|
import { loadWorkspace } from "./workspace.js";
|
|
14
14
|
import { validateWorkspace } from "./validate.js";
|
|
15
|
-
import { calculateRevision, revisionsMatch } from "./revisions.js";
|
|
15
|
+
import { calculateRevision, displayRevision, revisionsMatch } from "./revisions.js";
|
|
16
16
|
|
|
17
17
|
export const INTERNAL_WORKFLOW_CAPABILITIES = Object.freeze({
|
|
18
18
|
auditManagementReconciliation: Symbol("audit-management-reconciliation"),
|
|
@@ -1165,7 +1165,7 @@ async function prepareContentWrites(loaded, record, content, options = {}) {
|
|
|
1165
1165
|
function assertRevision(source, expected, label) {
|
|
1166
1166
|
const current = contentRevision(source);
|
|
1167
1167
|
if (expected && !revisionsMatch("content", expected, current)) {
|
|
1168
|
-
throw new Error(`${label} changed after you opened it. Stored revision: ${expected}. Current revision: ${current}. Reload the workspace and apply your change again.`);
|
|
1168
|
+
throw new Error(`${label} changed after you opened it. Stored revision: ${displayRevision(expected)}. Current revision: ${displayRevision(current)}. Reload the workspace and apply your change again.`);
|
|
1169
1169
|
}
|
|
1170
1170
|
}
|
|
1171
1171
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function obligationRule(obligation, byId, options = {}) {
|
|
2
|
+
const proposedId = options.includeProposed
|
|
3
|
+
? [...(obligation.ruleIds || [])].reverse().find((id) => ["proposed", "approved"].includes(byId.get(id)?.status))
|
|
4
|
+
: null;
|
|
5
|
+
if (obligation?.scheduleMode !== "rule" && !proposedId) return null;
|
|
6
|
+
let rule = byId.get(obligation.activeRuleId || proposedId);
|
|
7
|
+
if (rule?.status === "active" && rule.effectiveAt && options.now && new Date(rule.effectiveAt) > new Date(options.now)) {
|
|
8
|
+
const prior = byId.get(rule.supersedesId);
|
|
9
|
+
return prior?.type === "obligation-rule"
|
|
10
|
+
&& prior.obligationId === obligation.id
|
|
11
|
+
&& ["active", "retired"].includes(prior.status)
|
|
12
|
+
? prior
|
|
13
|
+
: null;
|
|
14
|
+
}
|
|
15
|
+
return rule?.type === "obligation-rule"
|
|
16
|
+
&& rule.obligationId === obligation.id
|
|
17
|
+
&& (rule.status === "active" || (options.includeProposed && ["proposed", "approved"].includes(rule.status)))
|
|
18
|
+
? rule
|
|
19
|
+
: null;
|
|
20
|
+
}
|
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
|
|
@@ -794,7 +801,9 @@ export function collectionReviewReadinessItem(assessment) {
|
|
|
794
801
|
: `Review ${assessment.configuration.title.toLowerCase()}`,
|
|
795
802
|
proposalAction
|
|
796
803
|
? `Complete ${firstIncomplete.title} before reviewing ${assessment.configuration.title.toLowerCase()}.`
|
|
797
|
-
: assessment.
|
|
804
|
+
: assessment.approvalIssues?.length || pendingRetentionRow || needsFirstRecord
|
|
805
|
+
? assessment.message
|
|
806
|
+
: assessment.changesSinceReview?.headline || assessment.message,
|
|
798
807
|
proposalAction
|
|
799
808
|
? { id: firstIncomplete.resourceId, type: assessment.resourceType }
|
|
800
809
|
: needsFirstRecord ? { type: assessment.resourceType }
|
|
@@ -1264,10 +1273,11 @@ function requiredGovernedDocuments(scope, records, byId, model) {
|
|
|
1264
1273
|
));
|
|
1265
1274
|
}
|
|
1266
1275
|
|
|
1267
|
-
async function governedContentItems(scope, records, byId, readMarkdown, asOf, model) {
|
|
1276
|
+
async function governedContentItems(scope, records, byId, readMarkdown, asOf, model, asOfTime) {
|
|
1268
1277
|
const enabledObligations = records.filter((record) => (
|
|
1269
1278
|
record.type === "obligation" && obligationIsEnabled(record)
|
|
1270
1279
|
));
|
|
1280
|
+
const operatingSchedules = enabledObligations.filter((record) => obligationRuleIsEnabled(record, byId, asOfTime));
|
|
1271
1281
|
const requiredGovernedIds = new Set(enabledObligations.flatMap((record) => [
|
|
1272
1282
|
...(record.scopeResourceIds || []),
|
|
1273
1283
|
...(record.templateResourceId ? [record.templateResourceId] : [])
|
|
@@ -1330,7 +1340,7 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf, mo
|
|
|
1330
1340
|
owner: currentPartyPeople(record.ownerIds, byId).size > 0,
|
|
1331
1341
|
active: record.status === "active",
|
|
1332
1342
|
requirementsImplemented: linkedControlIds.length > 0 && missingImplementationControlIds.length === 0,
|
|
1333
|
-
assignmentScheduled:
|
|
1343
|
+
assignmentScheduled: operatingSchedules.some((obligation) => (
|
|
1334
1344
|
obligation.templateResourceId === record.id
|
|
1335
1345
|
|| (obligation.scopeResourceIds || []).includes(record.id)
|
|
1336
1346
|
)),
|
|
@@ -1457,7 +1467,7 @@ function documentActivationLabel(state) {
|
|
|
1457
1467
|
})[state] || state;
|
|
1458
1468
|
}
|
|
1459
1469
|
|
|
1460
|
-
async function assessPolicyActivations(policies, controls, records, byId, readMarkdown, asOf, model) {
|
|
1470
|
+
async function assessPolicyActivations(policies, controls, records, byId, readMarkdown, asOf, model, controlItems, asOfTime) {
|
|
1461
1471
|
const sourceType = modelSupports(model, "component-sources") ? "component" : "system";
|
|
1462
1472
|
const sourceField = modelSupports(model, "component-sources") ? "evidenceSourceComponentIds" : "evidenceSourceIds";
|
|
1463
1473
|
const assessments = [];
|
|
@@ -1467,6 +1477,11 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
|
|
|
1467
1477
|
const plannedOrPartialControlIds = linkedControls
|
|
1468
1478
|
.filter((control) => ["planned", "partially-implemented"].includes(control.status))
|
|
1469
1479
|
.map(({ id }) => id);
|
|
1480
|
+
const missingLaunchControlIds = linkedControls
|
|
1481
|
+
.filter((control) => controlItems.some((item) => (
|
|
1482
|
+
item.id === `control-${control.id}` && item.implementationState === "implemented-with-gaps"
|
|
1483
|
+
)))
|
|
1484
|
+
.map(({ id }) => id);
|
|
1470
1485
|
const missingComponentControlIds = modelSupports(model, "component-sources")
|
|
1471
1486
|
? linkedControls.filter((control) => ![
|
|
1472
1487
|
...(control.componentIds || []),
|
|
@@ -1495,7 +1510,7 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
|
|
|
1495
1510
|
["scheduled", "event-driven", "mixed"].includes(control.operationPattern)
|
|
1496
1511
|
&& !records.some((record) => (
|
|
1497
1512
|
record.type === "obligation"
|
|
1498
|
-
&&
|
|
1513
|
+
&& obligationRuleIsEnabled(record, byId, asOfTime)
|
|
1499
1514
|
&& (record.controlIds || []).includes(control.id)
|
|
1500
1515
|
&& (record.policyIds || []).includes(policy.id)
|
|
1501
1516
|
))
|
|
@@ -1538,6 +1553,7 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
|
|
|
1538
1553
|
: null
|
|
1539
1554
|
].filter(Boolean);
|
|
1540
1555
|
const gapCount = plannedOrPartialControlIds.length
|
|
1556
|
+
+ missingLaunchControlIds.length
|
|
1541
1557
|
+ missingComponentControlIds.length
|
|
1542
1558
|
+ missingEvidenceSourceControlIds.length
|
|
1543
1559
|
+ missingScheduleControlIds.length
|
|
@@ -1562,6 +1578,7 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
|
|
|
1562
1578
|
proposedEffectiveOn: policy.proposedEffectiveOn || null,
|
|
1563
1579
|
linkedControlIds,
|
|
1564
1580
|
plannedOrPartialControlIds,
|
|
1581
|
+
missingLaunchControlIds,
|
|
1565
1582
|
missingComponentControlIds,
|
|
1566
1583
|
missingEvidenceSourceControlIds,
|
|
1567
1584
|
missingScheduleControlIds,
|
|
@@ -1594,6 +1611,7 @@ function policyActivationLabel(state) {
|
|
|
1594
1611
|
function policyActivationItem(assessment) {
|
|
1595
1612
|
const counts = [
|
|
1596
1613
|
[assessment.plannedOrPartialControlIds.length, "planned or partial Controls"],
|
|
1614
|
+
[assessment.missingLaunchControlIds.length, "implemented Controls with launch gaps"],
|
|
1597
1615
|
[assessment.missingComponentControlIds.length, "Controls missing active Components"],
|
|
1598
1616
|
[assessment.missingEvidenceSourceControlIds.length, "Controls missing ready evidence sources"],
|
|
1599
1617
|
[assessment.missingScheduleControlIds.length, "Controls missing enabled Obligations"],
|
|
@@ -1642,7 +1660,7 @@ function legacyPolicyLibraryProposals(records) {
|
|
|
1642
1660
|
}];
|
|
1643
1661
|
}
|
|
1644
1662
|
|
|
1645
|
-
async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null) {
|
|
1663
|
+
async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null, asOfTime) {
|
|
1646
1664
|
const items = [];
|
|
1647
1665
|
const families = selectedControlFamilies(scope.controls, model);
|
|
1648
1666
|
if (!scope.controls.length) {
|
|
@@ -1696,12 +1714,24 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null
|
|
|
1696
1714
|
criteriaMapping: (control.requirementIds || []).length > 0,
|
|
1697
1715
|
...(["scheduled", "event-driven", "mixed"].includes(control.operationPattern) ? {
|
|
1698
1716
|
workQueue: queueSchedules.length > 0
|
|
1699
|
-
&& queueSchedules.some(
|
|
1717
|
+
&& queueSchedules.some((obligation) => obligationRuleIsEnabled(obligation, byId, asOfTime))
|
|
1700
1718
|
} : {})
|
|
1701
1719
|
};
|
|
1720
|
+
const launchChecks = Object.fromEntries(Object.entries(checks).filter(([name]) => ![
|
|
1721
|
+
"implemented", "implementationDate"
|
|
1722
|
+
].includes(name)));
|
|
1723
|
+
const launchReady = Object.values(launchChecks).every(Boolean);
|
|
1724
|
+
const implementationState = control.status === "implemented"
|
|
1725
|
+
? Object.values(checks).every(Boolean) ? "implemented" : "implemented-with-gaps"
|
|
1726
|
+
: control.status === "partially-implemented" ? "partial"
|
|
1727
|
+
: launchReady ? "ready-to-implement" : "planned";
|
|
1702
1728
|
const missing = Object.entries(checks).filter(([, value]) => !value).map(([name]) => controlCheckLabel(name));
|
|
1703
1729
|
const nextSteps = controlImplementationSteps(control, checks, {
|
|
1704
|
-
missingSourceFamilies
|
|
1730
|
+
missingSourceFamilies,
|
|
1731
|
+
targets: [...new Set([
|
|
1732
|
+
...(control.systemIds || []),
|
|
1733
|
+
...(control.componentIds || [])
|
|
1734
|
+
].map((id) => byId.get(id)?.title).filter(Boolean))]
|
|
1705
1735
|
});
|
|
1706
1736
|
items.push(item(
|
|
1707
1737
|
`control-${control.id}`,
|
|
@@ -1713,17 +1743,20 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null
|
|
|
1713
1743
|
control,
|
|
1714
1744
|
{
|
|
1715
1745
|
checks,
|
|
1746
|
+
launchChecks,
|
|
1747
|
+
implementationState,
|
|
1748
|
+
implementationAssertion: "Implemented means the stated control works for the named Systems, its owner and procedure match current practice, and its evidence sources can produce proof. Scheduled or event-driven Controls have at least one enabled linked Obligation. It does not assert that a scheduled cycle or event has already occurred. Record dated operating proof when it does.",
|
|
1716
1749
|
nextSteps,
|
|
1717
1750
|
commands: [`npx filegrc get ${shellArgument(control.id)} --mutation`],
|
|
1718
1751
|
workQueue: queueSchedules.length ? {
|
|
1719
|
-
enabled: queueSchedules.filter(
|
|
1720
|
-
running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf, model)).length,
|
|
1752
|
+
enabled: queueSchedules.filter((obligation) => obligationRuleIsEnabled(obligation, byId, asOfTime)).length,
|
|
1753
|
+
running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf, model, asOfTime)).length,
|
|
1721
1754
|
total: queueSchedules.length
|
|
1722
1755
|
} : null
|
|
1723
1756
|
}
|
|
1724
1757
|
));
|
|
1725
1758
|
}
|
|
1726
|
-
return stage("controls", "Implement Controls", "Put each selected Control in place
|
|
1759
|
+
return stage("controls", "Implement Controls", "Put each selected Control in place. Check its launch conditions, then mark it Implemented when it is working. Dated proof follows during operation.", items);
|
|
1727
1760
|
}
|
|
1728
1761
|
|
|
1729
1762
|
async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
@@ -1737,10 +1770,6 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
|
1737
1770
|
.map((id) => byId.get(id))
|
|
1738
1771
|
.filter((record) => (
|
|
1739
1772
|
record?.type === sourceType
|
|
1740
|
-
&& (
|
|
1741
|
-
!(record.evidenceSourceKinds || []).length
|
|
1742
|
-
|| family.sourceKinds.some((kind) => (record.evidenceSourceKinds || []).includes(kind))
|
|
1743
|
-
)
|
|
1744
1773
|
));
|
|
1745
1774
|
const completeSources = [];
|
|
1746
1775
|
const sourceChecks = [];
|
|
@@ -1778,7 +1807,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
|
|
|
1778
1807
|
const coveredControls = controlMappings.filter(({ complete }) => complete);
|
|
1779
1808
|
const complete = completeSources.length > 0 && coveredControls.length === family.controls.length;
|
|
1780
1809
|
const commands = [
|
|
1781
|
-
...(selectedSources.length
|
|
1810
|
+
...(complete ? [] : selectedSources.length
|
|
1782
1811
|
? sourceChecks.filter(({ complete: sourceComplete }) => !sourceComplete).flatMap((sourceCheck) => {
|
|
1783
1812
|
const sourceId = sourceCheck.sourceComponentId || sourceCheck.sourceSystemId;
|
|
1784
1813
|
return [
|
|
@@ -2085,21 +2114,21 @@ function controlCheckLabel(name) {
|
|
|
2085
2114
|
procedureEffective: "procedure effective date",
|
|
2086
2115
|
policyMapping: "policy mapping",
|
|
2087
2116
|
criteriaMapping: "criteria mapping",
|
|
2088
|
-
workQueue: "
|
|
2117
|
+
workQueue: "enabled Obligation rules (work starts after cutover)"
|
|
2089
2118
|
})[name] || name;
|
|
2090
2119
|
}
|
|
2091
2120
|
|
|
2092
2121
|
const starterControlChecks = {
|
|
2093
|
-
"control-security-governance": "
|
|
2094
|
-
"control-policy-management": "
|
|
2095
|
-
"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.",
|
|
2096
2125
|
"control-workforce-expectations": "Review onboarding for roles with sensitive access. Check that required screening, agreements, and Policy acknowledgements happen before access.",
|
|
2097
|
-
"control-security-training": "
|
|
2098
|
-
"control-risk-assessment": "
|
|
2099
|
-
"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.",
|
|
2100
2129
|
"control-access-authorization": "Open access lists for in-scope Systems and Vendor tools. Check that accounts use unique identities and only needed permissions, and new grants require a business reason and approval.",
|
|
2101
2130
|
"control-strong-authentication": "Open the System and Vendor lists. For each tool used for production, source code, email, identity, or sensitive data, check MFA, unique accounts, default credentials, and admin roles.",
|
|
2102
|
-
"control-access-review-offboarding": "
|
|
2131
|
+
"control-access-review-offboarding": "Confirm access lists can be exported from key Systems and Vendor tools. Assign the first access review and the owner who will remove access when a departure occurs.",
|
|
2103
2132
|
"control-physical-workspace-security": "Walk through actual work areas and visitor access. Check who can enter nonpublic spaces and how devices, papers, screens, and conversations are protected.",
|
|
2104
2133
|
"control-data-classification-inventory": "List important data stores in Systems and Vendor tools. Check each has an owner, classification, approved purpose, and recorded location.",
|
|
2105
2134
|
"control-encryption-transmission": "For Systems, Vendor tools, and devices holding Confidential or Restricted data, check encryption at rest and in transit and who can manage the keys.",
|
|
@@ -2107,22 +2136,24 @@ const starterControlChecks = {
|
|
|
2107
2136
|
"control-inventory-configuration": "Compare actual services, devices, software, service accounts, Vendors, and data stores with the inventory. Check owners, secure settings, and unsupported assets.",
|
|
2108
2137
|
"control-endpoint-protection": "Review devices allowed into company Systems. Check encryption, screen lock, updates, and malware protection against the approved settings.",
|
|
2109
2138
|
"control-network-security": "Open production network and remote-access rules. Check each path has a business reason, approved authentication, and separation; identify rules due for review.",
|
|
2110
|
-
"control-change-management": "
|
|
2139
|
+
"control-change-management": "Set the path for software and infrastructure changes: risk review, testing, approval, deployment, and recovery. Check teams use it for the next real change.",
|
|
2111
2140
|
"control-vulnerability-management": "List internet-facing and important Systems. Check each has vulnerability scan coverage or another approved check, and findings have owners and target dates.",
|
|
2112
2141
|
"control-penetration-testing": "Review the service's exposure, major changes, and customer commitments. Decide whether independent testing is needed and, if so, set its scope and cadence.",
|
|
2113
2142
|
"control-logging-monitoring": "For each important System, open security logs, alert rules, and service-health checks where needed. Check retention and whether test alerts reach an owner.",
|
|
2114
2143
|
"control-incident-response": "Walk through how a worker reports an incident. Check who triages, escalates, contains, communicates, and closes it using the approved plan.",
|
|
2115
|
-
"control-incident-exercise": "
|
|
2116
|
-
"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.",
|
|
2117
2146
|
"control-continuity-exercise": "Review recovery priorities, contacts, and owners. Check the plan covers loss of an important System or provider and has an exercise date.",
|
|
2118
|
-
"control-vendor-due-diligence": "
|
|
2119
|
-
"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.",
|
|
2120
2149
|
"control-security-exceptions": "Review departures from security rules. Check each has a reason, owner, risk assessment, compensating measure, approval, and expiry or review date."
|
|
2121
2150
|
};
|
|
2122
2151
|
|
|
2123
2152
|
export function controlImplementationSteps(control, checks, options = {}) {
|
|
2124
2153
|
const steps = [];
|
|
2125
|
-
if (!checks.implemented) steps.push(
|
|
2154
|
+
if (!checks.implemented) steps.push(control.id === "control-strong-authentication" && options.targets?.length
|
|
2155
|
+
? `Ensure MFA and unique accounts are enabled for ${options.targets.join(", ")}; check default credentials and admin roles.`
|
|
2156
|
+
: starterControlChecks[control.id]
|
|
2126
2157
|
|| "Inspect the systems, people, vendors, or work this Control covers. Compare current practice with its statement and fix any gaps.");
|
|
2127
2158
|
const record = [];
|
|
2128
2159
|
if (!checks.owner) record.push("choose an owner");
|
|
@@ -2132,8 +2163,8 @@ export function controlImplementationSteps(control, checks, options = {}) {
|
|
|
2132
2163
|
if (!checks.evidenceSource) record.push("link the tool or system that can show it happened");
|
|
2133
2164
|
if (!checks.policyMapping && checks.policyMapping !== undefined) record.push("link the Policy it implements");
|
|
2134
2165
|
if (!checks.criteriaMapping && checks.criteriaMapping !== undefined) record.push("link the criteria it covers");
|
|
2135
|
-
if (
|
|
2136
|
-
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) {
|
|
2137
2168
|
record.push("record the Procedure revision and effective date");
|
|
2138
2169
|
}
|
|
2139
2170
|
if (record.length) steps.push(`In this Control, ${record.join("; ")}.`);
|
|
@@ -2148,9 +2179,7 @@ export function controlImplementationSteps(control, checks, options = {}) {
|
|
|
2148
2179
|
if (!checks.implemented) {
|
|
2149
2180
|
if (sourceStep || queueStep) steps.push([sourceStep, queueStep].filter(Boolean).join(" "));
|
|
2150
2181
|
else if (checks.applicability === false) steps.push("Confirm this Control applies to the current Program scope with `review-applicability --scaffold --type control`.");
|
|
2151
|
-
else steps.push(
|
|
2152
|
-
? "When it works, mark this Control Implemented and enter its start date."
|
|
2153
|
-
: "When it works, mark this Control Implemented. Enter its start date and the Procedure revision and effective date.");
|
|
2182
|
+
else steps.push("When these launch conditions hold, mark the Control Implemented. This asserts the practice works now; record proof from future cycles and events as they occur.");
|
|
2154
2183
|
} else {
|
|
2155
2184
|
if (sourceStep) steps.push(sourceStep);
|
|
2156
2185
|
if (queueStep) steps.push(queueStep);
|
package/src/revisions.js
CHANGED
|
@@ -42,6 +42,12 @@ export function revisionDigest(kind, value) {
|
|
|
42
42
|
return null;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export function displayRevision(value) {
|
|
46
|
+
const text = String(value || "");
|
|
47
|
+
const match = /^(?:filegrc:[a-z-]+:v\d+:sha256:|scope:)?([a-f0-9]{64})$/.exec(text);
|
|
48
|
+
return match ? match[1].slice(0, 12) : text;
|
|
49
|
+
}
|
|
50
|
+
|
|
45
51
|
export function revisionsMatch(kind, stored, current) {
|
|
46
52
|
const storedDigest = revisionDigest(kind, stored);
|
|
47
53
|
const currentDigest = revisionDigest(kind, current);
|
package/src/state.js
CHANGED
|
@@ -370,6 +370,7 @@ function serializeCollectionReviewAssessment(assessment) {
|
|
|
370
370
|
status: assessment.status,
|
|
371
371
|
complete: assessment.complete,
|
|
372
372
|
message: assessment.message,
|
|
373
|
+
...(assessment.changesSinceReview ? { changesSinceReview: assessment.changesSinceReview } : {}),
|
|
373
374
|
...(assessment.recordProposals ? { recordProposals: assessment.recordProposals } : {}),
|
|
374
375
|
...(assessment.incompleteRecordProposals ? { incompleteRecordProposals: assessment.incompleteRecordProposals } : {}),
|
|
375
376
|
...(assessment.eligibleReviewerIds ? { eligibleReviewerIds: assessment.eligibleReviewerIds } : {}),
|
package/src/validate.js
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from "./recurrence.js";
|
|
22
22
|
import { resourceReviewRevisionMatches, resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
|
|
23
23
|
import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js";
|
|
24
|
-
import { obligationIsEnabled } from "./program-lifecycle.js";
|
|
24
|
+
import { obligationIsEnabled, obligationRuleIsEnabled } from "./program-lifecycle.js";
|
|
25
25
|
import { currentPartyPeople, partyPeople } from "./parties.js";
|
|
26
26
|
import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
|
|
27
27
|
import { currentCalendarDate, isRfc3339Timestamp, localDateTimeValue, timestampFromLocalDateTime } from "./time.js";
|
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
} from "./reporting-route-integrity.js";
|
|
47
47
|
import { validateWorkflowHistoryIntegrity } from "./workflow-history-integrity.js";
|
|
48
48
|
import { occurrenceMemberExceptionIsValid } from "./obligation-members.js";
|
|
49
|
-
import { calculateRevision, revisionDigest, revisionsMatch } from "./revisions.js";
|
|
49
|
+
import { calculateRevision, displayRevision, revisionDigest, revisionsMatch } from "./revisions.js";
|
|
50
50
|
|
|
51
51
|
let fingerprintFileReadObserver = null;
|
|
52
52
|
|
|
@@ -222,7 +222,7 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
222
222
|
validateIndependentApproval(record, byId, displayPath, diagnostics);
|
|
223
223
|
validateCompletedObligationEvent(record, byId, loaded.model, displayPath, diagnostics);
|
|
224
224
|
validateActionObligationRule(record, byId, displayPath, diagnostics);
|
|
225
|
-
validateImplementedControlSchedules(record, obligationsByControl, displayPath, diagnostics);
|
|
225
|
+
validateImplementedControlSchedules(record, obligationsByControl, byId, new Date().toISOString(), displayPath, diagnostics);
|
|
226
226
|
serialContentDiagnosticTasks.push(async () => {
|
|
227
227
|
const markdownDiagnostics = [];
|
|
228
228
|
const approvalDiagnostics = [];
|
|
@@ -1538,7 +1538,7 @@ function assertFingerprintBudget(options, uncachedBytes) {
|
|
|
1538
1538
|
}
|
|
1539
1539
|
}
|
|
1540
1540
|
|
|
1541
|
-
function validateImplementedControlSchedules(record, obligationsByControl, path, diagnostics) {
|
|
1541
|
+
function validateImplementedControlSchedules(record, obligationsByControl, byId, now, path, diagnostics) {
|
|
1542
1542
|
if (record.type !== "control" || record.status !== "implemented") return;
|
|
1543
1543
|
const schedules = obligationsByControl.get(record.id) || [];
|
|
1544
1544
|
if (!schedules.length) {
|
|
@@ -1550,16 +1550,20 @@ function validateImplementedControlSchedules(record, obligationsByControl, path,
|
|
|
1550
1550
|
));
|
|
1551
1551
|
return;
|
|
1552
1552
|
}
|
|
1553
|
-
if (schedules.some(
|
|
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(
|
|
@@ -2028,7 +2032,7 @@ function validateRequirementMapping(record, loaded, currentReviewRevisions, path
|
|
|
2028
2032
|
|
|
2029
2033
|
function revisionMismatchDetails(ids, stored, current) {
|
|
2030
2034
|
return ids.map((id) => (
|
|
2031
|
-
`${id} (stored: ${stored[id] || "missing"}; current: ${current.get(id) || "missing"})`
|
|
2035
|
+
`${id} (stored: ${displayRevision(stored[id]) || "missing"}; current: ${displayRevision(current.get(id)) || "missing"})`
|
|
2032
2036
|
)).join(", ");
|
|
2033
2037
|
}
|
|
2034
2038
|
|
package/src/web.js
CHANGED
|
@@ -1098,6 +1098,7 @@ function renderPolicyActivationAssessments() {
|
|
|
1098
1098
|
return '<article class="policy-activation-card ' + esc(assessment.state) + '"><div class="evidence-map-card-head"><div><span class="badge ' + (operating ? "good" : "warn") + '">' + esc(assessment.label) + '</span><h3><a href="#/resource/policy/' + encodeURIComponent(assessment.policyId) + '">' + esc(assessment.title) + '</a></h3></div><small>' + assessment.gapCount + ' ' + pluralize("gap", assessment.gapCount) + '</small></div>' + warnings +
|
|
1099
1099
|
'<div class="policy-activation-gaps">' +
|
|
1100
1100
|
gapGroup("Planned or partial Controls", assessment.plannedOrPartialControlIds, "control") +
|
|
1101
|
+
gapGroup("Implemented Controls with launch gaps", assessment.missingLaunchControlIds || [], "control") +
|
|
1101
1102
|
gapGroup("Missing active Components", assessment.missingComponentControlIds, "control") +
|
|
1102
1103
|
gapGroup("Missing ready evidence sources", assessment.missingEvidenceSourceControlIds, "control") +
|
|
1103
1104
|
gapGroup("Missing enabled Obligations", assessment.missingScheduleControlIds, "control") +
|
|
@@ -1212,9 +1213,9 @@ function workflowGuidance(options = {}) {
|
|
|
1212
1213
|
const renderRow = (item) => {
|
|
1213
1214
|
const href = workflowItemHref(item);
|
|
1214
1215
|
const guidance = options.type === "control" && item.nextSteps?.length
|
|
1215
|
-
? '<ol class="control-next-steps">' + item.nextSteps.map((step) => '<li>' + esc(step) + '</li>').join("") + '</ol>'
|
|
1216
|
+
? '<p class="workflow-status">' + esc(controlLaunchLabel(item.implementationState)) + '</p><ol class="control-next-steps">' + item.nextSteps.map((step) => '<li>' + esc(step) + '</li>').join("") + '</ol><small>' + esc(item.implementationAssertion || "") + '</small>'
|
|
1216
1217
|
: '<small>' + esc(item.message || workflowItemDetail(item)) + '</small>';
|
|
1217
|
-
const body = '<span class="workflow-finding-status ' + esc(item.state) + '">' + esc(properCase(item.state)) + '</span><span><strong>' + esc(item.title) + '</strong>' + guidance + '</span>';
|
|
1218
|
+
const body = '<span class="workflow-finding-status ' + esc(item.state) + '">' + esc(options.type === "control" && item.implementationState ? controlLaunchLabel(item.implementationState).split(":")[0] : properCase(item.state)) + '</span><span><strong>' + esc(item.title) + '</strong>' + guidance + '</span>';
|
|
1218
1219
|
return href ? '<a href="' + href + '">' + body + '</a>' : '<div>' + body + '</div>';
|
|
1219
1220
|
};
|
|
1220
1221
|
const rows = visible.map(renderRow).join("");
|
|
@@ -1226,7 +1227,27 @@ function workflowGuidance(options = {}) {
|
|
|
1226
1227
|
return '<section class="workflow-guidance panel detail-support-panel detail-workflow-panel"><div class="panel-head"><div><p class="kicker">To-do</p><h3>' + esc(options.title || "Checklist") + '</h3></div><span class="badge ' + (blocking.length ? "warn" : "good") + '">' + (blocking.length ? "Needs work" : "Current") + '</span></div><p class="workflow-status">' + esc(status) + (completionRequirements ? '. ' + completionRequirements : "") + '</p><div class="workflow-findings">' + rows + '</div>' + more + '</section>';
|
|
1227
1228
|
}
|
|
1228
1229
|
|
|
1230
|
+
function controlLaunchLabel(state) {
|
|
1231
|
+
return ({
|
|
1232
|
+
planned: "Planned: launch conditions remain",
|
|
1233
|
+
partial: "Partially implemented: launch conditions remain",
|
|
1234
|
+
"ready-to-implement": "Launch checks ready: confirm it works, then record the start date",
|
|
1235
|
+
"implemented-with-gaps": "Implemented with gaps: fix the launch checks",
|
|
1236
|
+
implemented: "Implemented"
|
|
1237
|
+
})[state] || "Control launch";
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function controlLaunchState(id) {
|
|
1241
|
+
return state.programReadiness?.stages?.find(({ id: stageId }) => stageId === "controls")?.items
|
|
1242
|
+
.find(({ id: itemId }) => itemId === "control-" + id)?.implementationState;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1229
1245
|
function recordCompletionState(record) {
|
|
1246
|
+
if (record.type === "control") {
|
|
1247
|
+
const item = state.programReadiness?.stages?.find(({ id }) => id === "controls")?.items
|
|
1248
|
+
.find(({ id }) => id === "control-" + record.id);
|
|
1249
|
+
if (item?.implementationState === "implemented") return '<section class="record-completion-state panel detail-support-panel"><p class="kicker">Control launch</p><h3>Implemented</h3><p>' + esc(item.implementationAssertion) + '</p><p>Dated evidence belongs to the operating work that produces it.</p></section>';
|
|
1250
|
+
}
|
|
1230
1251
|
const completeStatuses = new Set(["approved", "active", "complete", "completed", "closed", "done", "reconciled"]);
|
|
1231
1252
|
if (!completeStatuses.has(record.status)) return "";
|
|
1232
1253
|
return '<section class="record-completion-state panel detail-support-panel"><p class="kicker">Record status</p><h3>' + esc(properCase(record.status)) + '</h3><p>This record has no direct next step.</p></section>';
|
|
@@ -1251,7 +1272,7 @@ function collectionReviewPanel(type, force = false) {
|
|
|
1251
1272
|
: "";
|
|
1252
1273
|
const reviewSummary = current && !reviewBlocker
|
|
1253
1274
|
? '<p class="collection-review-result"><strong>' + esc(properCase(assessment.review.decision)) + '</strong><span>Reviewed ' + esc(formatCalendarDate(assessment.review.reviewedOn)) + (reviewerNames.length ? " by " + esc(reviewerNames.join(", ")) : "") + "." + reviewNote + '</span></p>'
|
|
1254
|
-
: '<p class="collection-review-result"><strong>' + (scheduleBlocker ? "Complete schedule first" : proposalBlocker ? "Complete proposals first" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(reviewBlocker || assessment.message) + '</span></p>';
|
|
1275
|
+
: '<p class="collection-review-result"><strong>' + (scheduleBlocker ? "Complete schedule first" : proposalBlocker ? "Complete proposals first" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(reviewBlocker || (assessment.status === "stale" && assessment.changesSinceReview ? "Check what changed since the last review, then confirm the current records." : assessment.message)) + '</span></p>';
|
|
1255
1276
|
const compactReviewSummary = current && !reviewBlocker
|
|
1256
1277
|
? '<p class="collection-review-result"><strong>' + esc(properCase(assessment.review.decision)) + '</strong><span>Reviewed ' + esc(formatCalendarDate(assessment.review.reviewedOn)) + (reviewerNames.length ? " by " + esc(reviewerNames.join(", ")) : "") + ".</span></p>"
|
|
1257
1278
|
: reviewSummary;
|
|
@@ -1270,8 +1291,11 @@ function collectionReviewPanel(type, force = false) {
|
|
|
1270
1291
|
const summary = current
|
|
1271
1292
|
? '<div class="collection-review-current-row"><div class="collection-review-complete-summary"><span class="kicker">' + (scheduleReview ? "Approved schedule" : "Scope confirmation") + '</span>' + compactReviewSummary + '</div>' + details + action + '</div>'
|
|
1272
1293
|
: details;
|
|
1294
|
+
const changes = !current && assessment.status === "stale" && !reviewBlocker
|
|
1295
|
+
? collectionReviewChangesHtml(assessment.changesSinceReview)
|
|
1296
|
+
: "";
|
|
1273
1297
|
return '<section class="collection-review-panel panel ' + (current ? "current" : "required") + '">' + summary +
|
|
1274
|
-
(current ? "" : '<div class="collection-review-foot">' + reviewSummary + action + '</div>') + '</section>';
|
|
1298
|
+
changes + (current ? "" : '<div class="collection-review-foot">' + reviewSummary + action + '</div>') + '</section>';
|
|
1275
1299
|
}
|
|
1276
1300
|
|
|
1277
1301
|
function retentionScheduleApprovalBlocker() {
|
|
@@ -1405,8 +1429,13 @@ function recordWorkflowCell(type, entry, detailContext = "") {
|
|
|
1405
1429
|
const item = items[0];
|
|
1406
1430
|
let href = workflowItemHref(item) || "#/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(entry.record.id);
|
|
1407
1431
|
if (detailContext && href.startsWith("#/resource/" + encodeURIComponent(type) + "/") && !href.includes("?")) href += detailContext;
|
|
1408
|
-
const title = type === "control"
|
|
1409
|
-
|
|
1432
|
+
const title = type === "control"
|
|
1433
|
+
? item.implementationState === "implemented-with-gaps" ? "Fix Control launch gaps" : "Finish implementation"
|
|
1434
|
+
: item.title;
|
|
1435
|
+
const statusLabel = type === "control" && item.implementationState
|
|
1436
|
+
? controlLaunchLabel(item.implementationState).split(":")[0]
|
|
1437
|
+
: properCase(item.state);
|
|
1438
|
+
return '<a class="record-workflow-action" href="' + href + '"><span class="workflow-finding-status ' + esc(item.state) + '">' + esc(statusLabel) + '</span><span><strong>' + esc(title) + '</strong><small>' + esc(stagePageItemDetail(item)) + (items.length > 1 ? " +" + (items.length - 1) + " more" : "") + '</small></span></a>';
|
|
1410
1439
|
}
|
|
1411
1440
|
|
|
1412
1441
|
function openCollectionReviewDialog(type) {
|
|
@@ -1452,9 +1481,10 @@ function openCollectionReviewDialog(type) {
|
|
|
1452
1481
|
const saveDescription = scheduleReview
|
|
1453
1482
|
? 'Approve the governing document and ' + assessment.recordCount + ' current ' + pluralize("schedule row", assessment.recordCount) + ' as one revision. A later document, row, or material scope change requires another approval.'
|
|
1454
1483
|
: 'Confirm ' + assessment.recordCount + ' current ' + pluralize("record", assessment.recordCount) + '. If the collection or material scope changes, FileGRC will ask for another review.';
|
|
1484
|
+
const changes = collectionReviewChangesHtml(assessment.changesSinceReview);
|
|
1455
1485
|
const confirmSubmit = '<button type="submit" class="button primary">Confirm and save</button>';
|
|
1456
1486
|
const submit = scheduleReview ? '<button type="submit" class="button primary">Approve schedule</button>' : confirmSubmit;
|
|
1457
|
-
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">' + dialogKicker + '</p><h2 id="collection-review-dialog-title">' + esc(dialogTitle) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + esc(configuration.description) + '</p
|
|
1487
|
+
dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">' + dialogKicker + '</p><h2 id="collection-review-dialog-title">' + esc(dialogTitle) + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + esc(configuration.description) + '</p>' + changes + '<section class="event-dialog-steps collection-review-checks"><strong>' + checklistLabel + '</strong><ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul></section>' + reviewerHelp + '<div class="form-grid"><label><span>Conclusion</span><select name="decision" required>' + decisions + '</select></label><label><span>Reviewer</span><select name="reviewerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + ((assessment.review?.reviewedByIds || []).includes(record.id) ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Reviewed on</span><input name="reviewedOn" type="date" required value="' + esc(currentDate()) + '"></label><label data-authoritative-system><span>Authoritative ' + (v4 ? "Component" : "System") + '</span><select name="authoritativeSourceId"><option value="">Select</option>' + systems.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (preservedAuthoritativeSourceId === record.id ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label class="full"><span>Review notes</span><textarea name="rationale" rows="3" required placeholder="Note what you confirmed and any scope decision that needs context.">' + esc(assessment.review?.rationale || "") + '</textarea></label></div><div class="workflow-preview"><strong>What this saves</strong><p>' + esc(saveDescription) + '</p></div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><span class="save-status review-save-status" role="status" aria-live="polite"></span><button type="button" class="button" data-event="cancel">Cancel</button>' + submit + '</div></form>';
|
|
1458
1488
|
document.body.append(dialog);
|
|
1459
1489
|
dialog.showModal();
|
|
1460
1490
|
const form = dialog.querySelector("form");
|
|
@@ -1516,6 +1546,23 @@ function openCollectionReviewDialog(type) {
|
|
|
1516
1546
|
});
|
|
1517
1547
|
}
|
|
1518
1548
|
|
|
1549
|
+
function collectionReviewChangesHtml(changes) {
|
|
1550
|
+
if (!changes) return "";
|
|
1551
|
+
if (changes.status !== "available") {
|
|
1552
|
+
return '<section class="collection-review-change-list"><strong>What changed since the last review</strong><p>' + esc(changes.summary) + '</p></section>';
|
|
1553
|
+
}
|
|
1554
|
+
const rows = changes.changes.map((change) => {
|
|
1555
|
+
const details = change.kind !== "changed" ? change.kind === "added" ? "entered review scope" : "left review scope" : [
|
|
1556
|
+
...change.fields,
|
|
1557
|
+
...(change.contentChanged ? ["Markdown"] : []),
|
|
1558
|
+
...(change.membershipChanged ? ["collection membership"] : [])
|
|
1559
|
+
].join(", ");
|
|
1560
|
+
return '<li><strong>' + esc(change.title) + '</strong> <span>(' + esc(change.type) + ')</span>: ' + esc(details) + '</li>';
|
|
1561
|
+
});
|
|
1562
|
+
if (changes.scopeFields?.length) rows.push('<li><strong>Program scope</strong>: ' + esc(changes.scopeFields.join(", ")) + '</li>');
|
|
1563
|
+
return '<section class="collection-review-change-list"><strong>What changed since the last review</strong><p>Check these changes before recording a new conclusion.</p><ul>' + rows.join("") + '</ul></section>';
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1519
1566
|
function workflowItemHref(item) {
|
|
1520
1567
|
const destinationOptions = {
|
|
1521
1568
|
retentionScheduleApproval: modelSupports("retention-schedule-approval"),
|
|
@@ -1818,7 +1865,7 @@ function stagePageCard(stage, destination, index) {
|
|
|
1818
1865
|
const taskPreview = items.length
|
|
1819
1866
|
? '<div class="stage-page-tasks">' + items.slice(0, 3).map((item) => {
|
|
1820
1867
|
const href = destination.utility === "evidence-sources" ? destination.href : item.href || workflowItemHref(item) || destination.href;
|
|
1821
|
-
return '<a href="' + href + '"><span class="workflow-finding-status ' + esc(item.state) + '">' + esc(properCase(item.state)) + '</span><span><strong>' + esc(item.title) + '</strong><small>' + esc(stagePageItemDetail(item)) + '</small></span></a>';
|
|
1868
|
+
return '<a href="' + href + '"><span class="workflow-finding-status ' + esc(item.state) + '">' + esc(item.implementationState ? controlLaunchLabel(item.implementationState).split(":")[0] : properCase(item.state)) + '</span><span><strong>' + esc(item.title) + '</strong><small>' + esc(stagePageItemDetail(item)) + '</small></span></a>';
|
|
1822
1869
|
}).join("") + (items.length > 3 ? '<small class="stage-page-tasks-more">+' + (items.length - 3) + ' more on this page</small>' : "") + '</div>'
|
|
1823
1870
|
: "";
|
|
1824
1871
|
return '<article class="stage-page-card ' + (complete ? "complete" : "") + '"><div class="stage-page-card-head"><div><small>' + esc(stepLabel) + '</small><h3>' + esc(destination.label) + '</h3></div><div class="stage-page-card-actions">' + completionState + '<a class="stage-page-open" href="' + destination.href + '" aria-label="Open ' + esc(destination.label) + '"><span>Open ›</span></a></div></div><p>' + esc(summary) + '</p>' + taskPreview + '</article>';
|
|
@@ -2256,8 +2303,9 @@ function policyEventTrigger(trigger, index, collapsed = false, scope = "events")
|
|
|
2256
2303
|
const tooltipId = "policy-event-tooltip-" + scope + "-" + index;
|
|
2257
2304
|
const proposed = trigger.programStatus === "proposed";
|
|
2258
2305
|
const unavailable = state.readOnly || proposed;
|
|
2306
|
+
const blocker = trigger.steps.find(({ programBlocker }) => programBlocker)?.programBlocker;
|
|
2259
2307
|
const availability = proposed
|
|
2260
|
-
? "
|
|
2308
|
+
? "Dormant until " + (blocker ? blocker.label.toLowerCase() + " (" + blocker.id + ")" : "its governing content is active and effective") + ". Use this workflow for the first real event after cutover."
|
|
2261
2309
|
: state.readOnly
|
|
2262
2310
|
? "Open this workspace in writable mode to trigger the workflow."
|
|
2263
2311
|
: trigger.steps.length + " " + pluralize("task", trigger.steps.length) + " will be added to the Work Queue.";
|
|
@@ -3198,7 +3246,7 @@ function renderList(main, type, params = new URLSearchParams()) {
|
|
|
3198
3246
|
const start = (pageNumber - 1) * LIST_PAGE_SIZE;
|
|
3199
3247
|
const visible = filtered.slice(start, start + LIST_PAGE_SIZE);
|
|
3200
3248
|
main.querySelector("#result-count").textContent = filtered.length + (filtered.length === 1 ? " record" : " records");
|
|
3201
|
-
main.querySelector("#record-rows").innerHTML = filtered.length ? visible.map((entry) => '<tr><td data-label="' + esc(fieldLabel(type, "title")) + '" data-primary-field><a class="record-title" href="#/resource/' + encodeURIComponent(type) + '/' + encodeURIComponent(entry.record.id) + detailContext + '">' + esc(entry.record.title) + '</a></td>' + fields.map((name) => '<td data-label="' + esc(fieldLabel(type, name)) + '">' + (name === "$workQueueStatus" ? obligationWorkQueueStatus(entry.record) : formatValue(name === "status" ? displayStatus(entry.record) : entry.record[name], name, type, true)) + '</td>').join("") + '<td data-label="Next action">' + recordWorkflowCell(type, entry) + '</td><td data-label="Git file"><code>' + esc(entry.relativePath.replace(/^data\//, "")) + '</code></td></tr>').join("") : '<tr><td colspan="' + (fields.length + 3) + '">' + empty(entries.length ? "No records match this filter." : collectionEmptyState(type, definition)) + '</td></tr>';
|
|
3249
|
+
main.querySelector("#record-rows").innerHTML = filtered.length ? visible.map((entry) => '<tr><td data-label="' + esc(fieldLabel(type, "title")) + '" data-primary-field><a class="record-title" href="#/resource/' + encodeURIComponent(type) + '/' + encodeURIComponent(entry.record.id) + detailContext + '">' + esc(entry.record.title) + '</a></td>' + fields.map((name) => '<td data-label="' + esc(fieldLabel(type, name)) + '">' + (name === "$workQueueStatus" ? obligationWorkQueueStatus(entry.record) : formatValue(name === "status" ? type === "control" ? controlLaunchState(entry.record.id) || displayStatus(entry.record) : displayStatus(entry.record) : entry.record[name], name, type, true)) + '</td>').join("") + '<td data-label="Next action">' + recordWorkflowCell(type, entry) + '</td><td data-label="Git file"><code>' + esc(entry.relativePath.replace(/^data\//, "")) + '</code></td></tr>').join("") : '<tr><td colspan="' + (fields.length + 3) + '">' + empty(entries.length ? "No records match this filter." : collectionEmptyState(type, definition)) + '</td></tr>';
|
|
3202
3250
|
pagination.hidden = totalPages === 1;
|
|
3203
3251
|
previous.disabled = pageNumber === 1;
|
|
3204
3252
|
next.disabled = pageNumber === totalPages;
|
|
@@ -6584,6 +6632,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
|
|
|
6584
6632
|
.stage-page-card-actions{display:flex;align-items:center;gap:10px}.stage-page-card-actions .stage-page-open{white-space:nowrap}.stage-page-open::after{content:"";position:absolute;inset:0;z-index:1;border-radius:10px}.stage-page-open:focus-visible::after{outline:2px solid var(--focus);outline-offset:2px}.stage-page-open>span{position:relative;z-index:2}
|
|
6585
6633
|
.workflow-findings>a:hover{border-color:var(--accent-light);box-shadow:0 3px 9px rgba(21,40,33,.05)}.workflow-findings>a:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.stage-page-card-head{align-items:flex-start}.stage-page-completion-state{flex:0 0 auto;max-width:150px;padding:5px 8px;border-radius:99px;background:#f7e9cf;color:#855717;font-size:9.6px;font-weight:750;line-height:1.25;text-align:right}.stage-page-completion-state.complete{background:#ddefe5;color:#176143}.obligation-card.workflow-target{outline:2px solid var(--focus);outline-offset:3px}.obligation-card-foot{flex-wrap:wrap}.obligation-card-foot .obligation-links{flex:1 1 120px}
|
|
6586
6634
|
.collection-review-panel.current details{margin-top:8px}.collection-review-detail-content{margin-top:10px}.collection-review-complete-summary{display:flex;align-items:baseline;gap:10px}.collection-review-complete-summary>.kicker{margin:0;white-space:nowrap}.collection-review-panel.current .collection-review-foot{justify-content:flex-end;margin-top:8px}
|
|
6635
|
+
.collection-review-change-list{margin:14px 0;padding:12px 14px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-change-list>strong{font-size:11px}.collection-review-change-list p{margin:7px 0 0;color:var(--muted);font-size:11px;line-height:1.5}.collection-review-change-list ul{max-height:180px;overflow:auto;margin:9px 0 0;padding-left:20px;font-size:11px;line-height:1.55}.collection-review-change-list li+li{margin-top:5px}.collection-review-change-list li span{color:var(--muted)}
|
|
6587
6636
|
.collection-review-current-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.collection-review-panel.current .collection-review-details{flex:0 0 auto;margin:0;padding:0;border:0;background:transparent}.collection-review-panel.current .collection-review-details>summary{color:var(--accent);white-space:nowrap}.collection-review-panel.current .collection-review-details[open]{flex-basis:100%;order:3;padding:10px 12px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.collection-review-panel.current .collection-review-details[open]>summary{margin-bottom:10px}.collection-review-panel.current .collection-review-details[open] .collection-review-detail-content{margin-top:0}.collection-review-panel.current .button{margin-left:auto}
|
|
6588
6637
|
@media(max-width:520px){.collection-review-complete-summary{flex:1 1 180px;min-width:0;flex-wrap:wrap}.collection-review-complete-summary .collection-review-result{min-width:0;flex-wrap:wrap}.collection-review-complete-summary .collection-review-result span{overflow-wrap:anywhere}.collection-review-panel.current .button{margin-left:0}}
|
|
6589
6638
|
@media(max-width:520px){.stage-page-card-head{align-items:stretch;flex-direction:column;gap:10px}.stage-page-card-actions{justify-content:space-between}}
|
package/src/workflow.js
CHANGED
|
@@ -26,6 +26,7 @@ import { assessAuditPreparation } from "./audit-preparation.js";
|
|
|
26
26
|
import { assessProgramReadiness } from "./program-readiness.js";
|
|
27
27
|
import { resolveProgram } from "./program.js";
|
|
28
28
|
import { resourceReviewRevisions, retentionReviewResourceIds, retentionRuleIsCurrent } from "./retention.js";
|
|
29
|
+
import { displayRevision } from "./revisions.js";
|
|
29
30
|
import { signatoryAppointmentIssue, soc2ReportEvidenceIssue, subsequentEventsReviewIssue } from "./soc2.js";
|
|
30
31
|
import { planReconciliation } from "./reconciliation.js";
|
|
31
32
|
import { documentIsAuditSpecific } from "./program-lifecycle.js";
|
|
@@ -544,8 +545,8 @@ function finalizationFields(record, model, resources, program, root) {
|
|
|
544
545
|
field: "applicabilityReview",
|
|
545
546
|
requiredness: "required",
|
|
546
547
|
message: record.applicabilityReview?.scopeRevision
|
|
547
|
-
? `Review applicability against the current service scope. Stored revision: ${record.applicabilityReview.scopeRevision}. Current revision: ${currentRevision}. Then record the decision, rationale, reviewer, and date.`
|
|
548
|
-
: `Review applicability against the current service scope. Current revision: ${currentRevision}. Then record the decision, rationale, reviewer, and date.`
|
|
548
|
+
? `Review applicability against the current service scope. Stored revision: ${displayRevision(record.applicabilityReview.scopeRevision)}. Current revision: ${displayRevision(currentRevision)}. Then record the decision, rationale, reviewer, and date.`
|
|
549
|
+
: `Review applicability against the current service scope. Current revision: ${displayRevision(currentRevision)}. Then record the decision, rationale, reviewer, and date.`
|
|
549
550
|
});
|
|
550
551
|
}
|
|
551
552
|
if (record.type === "policy" && model.resources.policy?.fields?.programRole && !record.programRole) {
|
|
@@ -1138,6 +1139,11 @@ function normalizeFinding(code, item, context) {
|
|
|
1138
1139
|
title: item.title,
|
|
1139
1140
|
message: item.message,
|
|
1140
1141
|
...(item.nextSteps?.length ? { nextSteps: item.nextSteps } : {}),
|
|
1142
|
+
...(item.implementationState ? {
|
|
1143
|
+
implementationState: item.implementationState,
|
|
1144
|
+
launchChecks: item.launchChecks,
|
|
1145
|
+
implementationAssertion: item.implementationAssertion
|
|
1146
|
+
} : {}),
|
|
1141
1147
|
...(item.checks ? { unmetChecks: Object.entries(item.checks)
|
|
1142
1148
|
.filter(([, passed]) => passed === false)
|
|
1143
1149
|
.map(([name]) => name) } : {}),
|