filegrc 0.11.0 → 0.12.0
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/model/index.js +9 -5
- package/model/v10.json +12122 -0
- package/model/v9.json +11647 -0
- package/package.json +1 -1
- package/src/agent.js +3 -0
- package/src/audit-populations.js +88 -0
- package/src/audit-preparation.js +7 -2
- package/src/cli.js +186 -13
- package/src/collection-review-integrity.js +118 -0
- package/src/collection-review.js +71 -7
- package/src/collection-scope.js +8 -0
- package/src/evidence-packet.js +328 -46
- package/src/files.js +364 -6
- package/src/git.js +1064 -149
- package/src/index.js +17 -1
- package/src/model-migration.js +221 -5
- package/src/obligations.js +834 -66
- package/src/policy-library/information-security-policy-v2.md +1 -1
- package/src/policy-library.js +85 -8
- package/src/program-path.js +11 -5
- package/src/program-readiness.js +84 -6
- package/src/reconciliation.js +332 -81
- package/src/reporting-route-integrity.js +542 -0
- package/src/reporting-route-sets.js +745 -0
- package/src/server.js +160 -47
- package/src/state.js +30 -8
- package/src/time.js +55 -0
- package/src/validate.js +978 -4
- package/src/web.js +491 -49
- package/src/workflow-history-integrity.js +872 -0
- package/src/workflow.js +36 -10
package/src/obligations.js
CHANGED
|
@@ -1,21 +1,34 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { scaffoldResourceMutation } from "./agent.js";
|
|
3
3
|
import { createResourceId } from "./id.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
applyResourceBatch,
|
|
6
|
+
createResource,
|
|
7
|
+
createResourceAndLink,
|
|
8
|
+
createResources,
|
|
9
|
+
INTERNAL_WORKFLOW_CAPABILITIES,
|
|
10
|
+
updateResource
|
|
11
|
+
} from "./files.js";
|
|
5
12
|
import { loadModel, modelSupports } from "../model/index.js";
|
|
6
|
-
import { coverageEnd } from "./coverage.js";
|
|
13
|
+
import { coverageEnd, coverageStart } from "./coverage.js";
|
|
7
14
|
import {
|
|
8
15
|
addCalendarDays,
|
|
9
16
|
calendarDayDifference,
|
|
10
17
|
calendarOccurrence,
|
|
11
18
|
calendarOccurrenceIndex,
|
|
19
|
+
nextCalendarOccurrence,
|
|
12
20
|
parseCalendarDate,
|
|
13
21
|
validCalendarRecurrence
|
|
14
22
|
} from "./recurrence.js";
|
|
15
|
-
import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
|
|
23
|
+
import { currentCalendarDate, isRfc3339Timestamp, localDateTimeValue, timestampFromLocalDateTime } from "./time.js";
|
|
16
24
|
import { loadWorkspace } from "./workspace.js";
|
|
17
25
|
import { obligationGovernedContent, obligationProgramStatus } from "./program-lifecycle.js";
|
|
18
26
|
import { resolveProgram } from "./program.js";
|
|
27
|
+
import { currentPartyPeople } from "./parties.js";
|
|
28
|
+
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
29
|
+
import { collectionReviewRevision, historicalCollectionReviewSnapshot } from "./collection-review-integrity.js";
|
|
30
|
+
import { bindAttestationReportingRouteSet, reportingRouteRevision } from "./reporting-route-integrity.js";
|
|
31
|
+
import { selectScopedCollectionRecords } from "./collection-scope.js";
|
|
19
32
|
|
|
20
33
|
const COMPLETION_DATE_FIELDS = [
|
|
21
34
|
"completedOn",
|
|
@@ -45,6 +58,20 @@ const SCAFFOLDED_COMPLETION_TYPES = new Set([
|
|
|
45
58
|
|
|
46
59
|
export function planObligations(resources, options = {}) {
|
|
47
60
|
const records = resources.map((item) => item?.record ?? item).filter(Boolean);
|
|
61
|
+
const workspace = records.find((record) => record.type === "workspace");
|
|
62
|
+
const programs = records.filter((record) => record.type === "program" && record.status !== "retired");
|
|
63
|
+
const legacyProgram = programs.length === 0 && workspace && (!options.programId || options.programId === workspace.id)
|
|
64
|
+
? workspace
|
|
65
|
+
: null;
|
|
66
|
+
const program = programs.find(({ id }) => id === options.programId) || (programs.length === 1 ? programs[0] : legacyProgram);
|
|
67
|
+
if (
|
|
68
|
+
options.programId
|
|
69
|
+
&& !programs.some(({ id }) => id === options.programId)
|
|
70
|
+
&& options.programId !== legacyProgram?.id
|
|
71
|
+
&& !(options.programId === "program-unconfigured" && programs.length === 0)
|
|
72
|
+
) {
|
|
73
|
+
throw new Error(`Program "${options.programId}" was not found or is retired.`);
|
|
74
|
+
}
|
|
48
75
|
const declaredModelVersion = records.find((record) => record.type === "workspace")?.dataModelVersion;
|
|
49
76
|
const model = options.model || (declaredModelVersion ? loadModel(declaredModelVersion) : null);
|
|
50
77
|
if (!model) {
|
|
@@ -52,22 +79,33 @@ export function planObligations(resources, options = {}) {
|
|
|
52
79
|
"Obligation planning requires options.model or a Workspace record with dataModelVersion."
|
|
53
80
|
);
|
|
54
81
|
}
|
|
55
|
-
|
|
56
|
-
|
|
82
|
+
if (!program && programs.length > 1) {
|
|
83
|
+
throw new Error("Obligation planning requires programId when more than one Program is active.");
|
|
84
|
+
}
|
|
85
|
+
const asOf = requireDate(options.asOf ?? currentCalendarDate(workspace?.timezone || "UTC"), "as-of date");
|
|
86
|
+
const defaultNow = options.asOf
|
|
87
|
+
? timestampFromLocalDateTime(`${asOf}T23:59:59`, workspace?.timezone || "UTC")
|
|
88
|
+
: new Date().toISOString();
|
|
57
89
|
const now = requireTimestamp(options.now ?? defaultNow, "current timestamp");
|
|
58
90
|
const through = requireDate(options.through ?? addCalendarDays(asOf, 90), "through date");
|
|
59
91
|
const requestedFrom = options.from ? requireDate(options.from, "from date") : null;
|
|
60
92
|
if (through < asOf && !requestedFrom) throw new Error("The through date must not be before the as-of date.");
|
|
61
93
|
if (requestedFrom && through < requestedFrom) throw new Error("The through date must not be before the from date.");
|
|
62
94
|
const byId = new Map(records.map((record) => [record.id, record]));
|
|
95
|
+
const obligationProgram = program && options.additionalControlIds?.length
|
|
96
|
+
? { ...program, controlIds: [...new Set([...(program.controlIds || []), ...options.additionalControlIds])] }
|
|
97
|
+
: program;
|
|
63
98
|
const obligations = records.filter((record) => (
|
|
64
99
|
record.type === "obligation"
|
|
65
100
|
&& ["active", "proposed"].includes(record.status)
|
|
101
|
+
&& obligationBelongsToProgram(record, obligationProgram, model)
|
|
66
102
|
));
|
|
103
|
+
const obligationIds = new Set(obligations.map(({ id }) => id));
|
|
67
104
|
if (obligations.length > MAX_PLANNED_ITEMS) {
|
|
68
105
|
throw new Error(`The obligation query must be narrowed; it includes more than ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} active obligations.`);
|
|
69
106
|
}
|
|
70
107
|
const calendarItems = [];
|
|
108
|
+
const plannedOccurrenceKeys = new Set();
|
|
71
109
|
const triggerGroups = new Map();
|
|
72
110
|
let scannedCalendarOccurrences = 0;
|
|
73
111
|
const scanCalendarWindow = (recurrence, window, index) => {
|
|
@@ -78,11 +116,16 @@ export function planObligations(resources, options = {}) {
|
|
|
78
116
|
};
|
|
79
117
|
|
|
80
118
|
for (const obligation of obligations) {
|
|
119
|
+
const rule = obligation.scheduleMode === "rule"
|
|
120
|
+
? obligationRule(obligation, byId, { now, includeProposed: true })
|
|
121
|
+
: null;
|
|
122
|
+
const schedule = rule || obligation;
|
|
81
123
|
const activity = obligationActivity(model, obligation);
|
|
82
124
|
const expectedCompletionTypes = activity.completionResourceTypes;
|
|
83
125
|
const programStatus = obligationProgramStatus(obligation, byId, asOf, model);
|
|
84
|
-
|
|
85
|
-
|
|
126
|
+
const programBlocker = programStatus === "proposed" ? obligationProgramBlocker(obligation, byId, asOf) : null;
|
|
127
|
+
if (schedule.recurrence?.mode === "event" && schedule.recurrence.eventType) {
|
|
128
|
+
const eventType = schedule.recurrence.eventType;
|
|
86
129
|
const group = triggerGroups.get(eventType) ?? {
|
|
87
130
|
eventType,
|
|
88
131
|
title: model.policyEvents?.[eventType]?.title || humanize(eventType),
|
|
@@ -97,6 +140,9 @@ export function planObligations(resources, options = {}) {
|
|
|
97
140
|
group.obligationIds.push(obligation.id);
|
|
98
141
|
group.steps.push({
|
|
99
142
|
obligationId: obligation.id,
|
|
143
|
+
ruleId: rule?.id || null,
|
|
144
|
+
ruleStatus: rule?.status || null,
|
|
145
|
+
programBlocker,
|
|
100
146
|
title: obligation.title,
|
|
101
147
|
activityType: obligation.activityType,
|
|
102
148
|
ownerIds: obligation.ownerIds || [],
|
|
@@ -109,41 +155,69 @@ export function planObligations(resources, options = {}) {
|
|
|
109
155
|
completionType: preferredCompletionType(activity, obligation, byId),
|
|
110
156
|
completionProfile: activity.completionProfile || null,
|
|
111
157
|
programStatus,
|
|
112
|
-
window: normalizedEventWindow(
|
|
158
|
+
window: normalizedEventWindow(schedule.window)
|
|
113
159
|
});
|
|
114
160
|
triggerGroups.set(eventType, group);
|
|
115
161
|
continue;
|
|
116
162
|
}
|
|
117
163
|
|
|
118
|
-
const configuredAnchor =
|
|
119
|
-
const activationDate = obligationActivationDate(obligation, byId, model);
|
|
164
|
+
const configuredAnchor = schedule.recurrence?.anchorDate || schedule.startsOn;
|
|
165
|
+
const activationDate = obligationActivationDate(obligation, byId, rule || model, workspace?.timezone || "UTC");
|
|
120
166
|
const recurrence = {
|
|
121
|
-
...(
|
|
122
|
-
anchorDate:
|
|
123
|
-
?
|
|
124
|
-
: configuredAnchor
|
|
167
|
+
...(schedule.recurrence || {}),
|
|
168
|
+
anchorDate: rule
|
|
169
|
+
? configuredAnchor || activationDate
|
|
170
|
+
: configuredAnchor && activationDate
|
|
171
|
+
? [configuredAnchor, activationDate].sort().at(-1)
|
|
172
|
+
: configuredAnchor || activationDate
|
|
125
173
|
};
|
|
126
174
|
if (!validCalendarRecurrence(recurrence)) continue;
|
|
127
|
-
const from =
|
|
175
|
+
const from = rule
|
|
176
|
+
? [requestedFrom || recurrence.anchorDate, activationDate].filter(Boolean).sort().at(-1)
|
|
177
|
+
: requestedFrom || recurrence.anchorDate;
|
|
128
178
|
let index = Math.max(0, calendarOccurrenceIndex(recurrence, from));
|
|
129
179
|
while (index > 0) {
|
|
130
|
-
const previousWindow = scanCalendarWindow(recurrence,
|
|
180
|
+
const previousWindow = scanCalendarWindow(recurrence, schedule.window, index);
|
|
131
181
|
if (!previousWindow || previousWindow.overdueOn <= from) break;
|
|
132
182
|
index -= 1;
|
|
133
183
|
}
|
|
134
184
|
for (; ; index += 1) {
|
|
135
|
-
const window = scanCalendarWindow(recurrence,
|
|
185
|
+
const window = scanCalendarWindow(recurrence, schedule.window, index);
|
|
136
186
|
if (!window || window.dueWindowStart > through) break;
|
|
137
|
-
if (
|
|
187
|
+
if (schedule.endsOn && window.dueWindowStart > schedule.endsOn) break;
|
|
188
|
+
if (rule && activationDate && window.dueWindowStart < activationDate) continue;
|
|
138
189
|
if (window.overdueOn <= from) continue;
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
190
|
+
const occurrenceKey = `${program?.id || workspace?.id || "workspace"}:${obligation.id}:${window.dueWindowStart}`;
|
|
191
|
+
const reconciliation = currentOccurrence(records, occurrenceKey, obligation.id, rule?.id, window);
|
|
192
|
+
const legacyCompletions = obligation.scheduleMode !== "rule"
|
|
193
|
+
? (obligation.completionResourceIds || [])
|
|
194
|
+
.map((id) => byId.get(id))
|
|
195
|
+
.filter((record) => (
|
|
196
|
+
record
|
|
197
|
+
&& completionFallsInWindow(record, window, workspace?.timezone || "UTC")
|
|
198
|
+
&& completionTypeMatches(record, expectedCompletionTypes)
|
|
199
|
+
))
|
|
200
|
+
: [];
|
|
201
|
+
const completionResourceIds = reconciliation
|
|
202
|
+
? [...new Set((reconciliation.members || []).flatMap((member) => member.completionResourceIds || []))]
|
|
203
|
+
: legacyCompletions.map((record) => record.id);
|
|
204
|
+
const membershipFinal = membershipIsFinal(schedule.selector, window, asOf);
|
|
205
|
+
const selectedMemberIds = schedule.selector
|
|
206
|
+
? selectScopedCollectionRecords({ resources: records, model }, schedule.selector, program).map(({ id }) => id).sort()
|
|
207
|
+
: [...new Set(obligation.scopeResourceIds?.length ? obligation.scopeResourceIds : [workspace?.id].filter(Boolean))];
|
|
208
|
+
const recordedMemberIds = (reconciliation?.members || []).map(({ resourceId }) => resourceId);
|
|
209
|
+
const expectedMemberIds = reconciliation
|
|
210
|
+
? reconciliation.status === "open" && !membershipFinal
|
|
211
|
+
? [...new Set([...recordedMemberIds, ...selectedMemberIds])].sort()
|
|
212
|
+
: recordedMemberIds
|
|
213
|
+
: selectedMemberIds;
|
|
214
|
+
const completedMemberIds = new Set((reconciliation?.members || [])
|
|
215
|
+
.filter((member) => member.result === "passed" && member.disposition === "expected")
|
|
216
|
+
.map((member) => member.resourceId));
|
|
217
|
+
const successful = reconciliation
|
|
218
|
+
? reconciliation.status === "reconciled" && ["complete", "complete-with-exceptions", "zero-population"].includes(reconciliation.conclusion)
|
|
219
|
+
: legacyCompletions.length > 0;
|
|
220
|
+
const timingStatus = occurrenceStatus(window, asOf, successful);
|
|
147
221
|
const status = timingStatus === "complete" || programStatus === "accepted" ? timingStatus : "proposed";
|
|
148
222
|
if (status === "complete" && !options.includeComplete) continue;
|
|
149
223
|
if (calendarItems.length >= MAX_PLANNED_ITEMS) {
|
|
@@ -153,6 +227,11 @@ export function planObligations(resources, options = {}) {
|
|
|
153
227
|
key: `${obligation.id}:${window.dueWindowStart}`,
|
|
154
228
|
kind: "calendar",
|
|
155
229
|
obligationId: obligation.id,
|
|
230
|
+
ruleId: rule?.id || null,
|
|
231
|
+
ruleStatus: rule?.status || null,
|
|
232
|
+
programBlocker,
|
|
233
|
+
occurrenceKey,
|
|
234
|
+
occurrenceId: reconciliation?.id || null,
|
|
156
235
|
title: obligation.title,
|
|
157
236
|
activityType: obligation.activityType,
|
|
158
237
|
ownerIds: obligation.ownerIds || [],
|
|
@@ -162,16 +241,88 @@ export function planObligations(resources, options = {}) {
|
|
|
162
241
|
completionResourceTypes: expectedCompletionTypes,
|
|
163
242
|
completionType: preferredCompletionType(activity, obligation, byId),
|
|
164
243
|
completionProfile: activity.completionProfile || null,
|
|
165
|
-
completionResourceIds
|
|
244
|
+
completionResourceIds,
|
|
245
|
+
expectedMemberIds,
|
|
246
|
+
completedMemberIds: [...completedMemberIds],
|
|
247
|
+
expectedCount: reconciliation?.status === "open" && !membershipFinal
|
|
248
|
+
? expectedMemberIds.length
|
|
249
|
+
: reconciliation?.expectedCount ?? expectedMemberIds.length,
|
|
250
|
+
completedCount: reconciliation?.completedCount ?? completedMemberIds.size,
|
|
251
|
+
membershipFinal,
|
|
252
|
+
reconciliationStatus: reconciliation?.status || "unreconciled",
|
|
253
|
+
operatingResult: reconciliation?.conclusion || null,
|
|
254
|
+
legacySchedule: obligation.scheduleMode !== "rule",
|
|
166
255
|
status,
|
|
167
256
|
timingStatus,
|
|
168
257
|
programStatus,
|
|
169
258
|
...window,
|
|
170
259
|
...relativeTiming(window, asOf)
|
|
171
260
|
});
|
|
261
|
+
plannedOccurrenceKeys.add(occurrenceKey);
|
|
172
262
|
}
|
|
173
263
|
}
|
|
174
264
|
|
|
265
|
+
for (const occurrence of records.filter((record) => (
|
|
266
|
+
record.type === "obligation-occurrence"
|
|
267
|
+
&& record.status !== "superseded"
|
|
268
|
+
&& (!program?.id || !record.programId || record.programId === program.id)
|
|
269
|
+
))) {
|
|
270
|
+
if (plannedOccurrenceKeys.has(occurrence.occurrenceKey)) continue;
|
|
271
|
+
const obligation = byId.get(occurrence.obligationId);
|
|
272
|
+
const rule = byId.get(occurrence.ruleId);
|
|
273
|
+
if (obligation?.type !== "obligation" || rule?.type !== "obligation-rule") continue;
|
|
274
|
+
const dueWindowStart = coverageStart(occurrence.coverage);
|
|
275
|
+
const dueWindowEnd = coverageEnd(occurrence.coverage);
|
|
276
|
+
if (!dueWindowStart || !dueWindowEnd || dueWindowStart > through) continue;
|
|
277
|
+
const overdueOn = addCalendarDays(dueWindowEnd, 1);
|
|
278
|
+
const window = { dueWindowStart, dueWindowEnd, overdueOn };
|
|
279
|
+
const programStatus = obligationProgramStatus(obligation, byId, asOf, model);
|
|
280
|
+
const programBlocker = programStatus === "proposed" ? obligationProgramBlocker(obligation, byId, asOf) : null;
|
|
281
|
+
const activity = obligationActivity(model, obligation);
|
|
282
|
+
const completedMemberIds = (occurrence.members || [])
|
|
283
|
+
.filter(({ disposition, result }) => disposition === "expected" && result === "passed")
|
|
284
|
+
.map(({ resourceId }) => resourceId);
|
|
285
|
+
const successful = occurrence.status === "reconciled"
|
|
286
|
+
&& ["complete", "complete-with-exceptions", "zero-population"].includes(occurrence.conclusion);
|
|
287
|
+
const timingStatus = occurrenceStatus(window, asOf, successful);
|
|
288
|
+
if (timingStatus === "complete" && !options.includeComplete) continue;
|
|
289
|
+
calendarItems.push({
|
|
290
|
+
key: `${obligation.id}:${dueWindowStart}`,
|
|
291
|
+
kind: "calendar",
|
|
292
|
+
obligationId: obligation.id,
|
|
293
|
+
ruleId: rule.id,
|
|
294
|
+
ruleStatus: rule.status,
|
|
295
|
+
programBlocker,
|
|
296
|
+
occurrenceKey: occurrence.occurrenceKey,
|
|
297
|
+
occurrenceId: occurrence.id,
|
|
298
|
+
title: obligation.title,
|
|
299
|
+
activityType: obligation.activityType,
|
|
300
|
+
ownerIds: obligation.ownerIds || [],
|
|
301
|
+
policyIds: obligation.policyIds || [],
|
|
302
|
+
controlIds: obligation.controlIds || [],
|
|
303
|
+
scopeResourceIds: obligation.scopeResourceIds || [],
|
|
304
|
+
completionResourceTypes: activity.completionResourceTypes,
|
|
305
|
+
completionType: preferredCompletionType(activity, obligation, byId),
|
|
306
|
+
completionProfile: activity.completionProfile || null,
|
|
307
|
+
completionResourceIds: [...new Set((occurrence.members || []).flatMap(({ completionResourceIds = [] }) => completionResourceIds))],
|
|
308
|
+
expectedMemberIds: (occurrence.members || []).map(({ resourceId }) => resourceId),
|
|
309
|
+
completedMemberIds,
|
|
310
|
+
expectedCount: occurrence.expectedCount,
|
|
311
|
+
completedCount: occurrence.completedCount,
|
|
312
|
+
membershipFinal: true,
|
|
313
|
+
reconciliationStatus: occurrence.status,
|
|
314
|
+
operatingResult: occurrence.conclusion || null,
|
|
315
|
+
legacySchedule: false,
|
|
316
|
+
// A frozen open occurrence remains actionable even if the current
|
|
317
|
+
// program or its replacement rule later stops accepting new windows.
|
|
318
|
+
status: timingStatus,
|
|
319
|
+
timingStatus,
|
|
320
|
+
programStatus,
|
|
321
|
+
...window,
|
|
322
|
+
...relativeTiming(window, asOf)
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
175
326
|
const events = records.filter((record) => record.type === "obligation-event");
|
|
176
327
|
if (events.length > MAX_PLANNED_ITEMS) {
|
|
177
328
|
throw new Error(`The obligation query must be narrowed; it includes more than ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} event runs.`);
|
|
@@ -180,7 +331,7 @@ export function planObligations(resources, options = {}) {
|
|
|
180
331
|
const actionsBySource = new Map();
|
|
181
332
|
let eventActionCount = 0;
|
|
182
333
|
for (const record of records) {
|
|
183
|
-
if (record.type !== "action-item" || !eventIds.has(record.sourceResourceId)) continue;
|
|
334
|
+
if (record.type !== "action-item" || !eventIds.has(record.sourceResourceId) || !obligationIds.has(record.obligationId)) continue;
|
|
184
335
|
if (++eventActionCount > MAX_PLANNED_ITEMS) {
|
|
185
336
|
throw new Error(`The obligation query must be narrowed; it includes more than ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} event actions.`);
|
|
186
337
|
}
|
|
@@ -194,7 +345,7 @@ export function planObligations(resources, options = {}) {
|
|
|
194
345
|
asOf,
|
|
195
346
|
now,
|
|
196
347
|
model
|
|
197
|
-
));
|
|
348
|
+
)).filter((run) => run.actions.length > 0);
|
|
198
349
|
const eventItems = eventRuns
|
|
199
350
|
.filter((run) => run.status !== "canceled")
|
|
200
351
|
.flatMap((run) => run.actions)
|
|
@@ -202,6 +353,7 @@ export function planObligations(resources, options = {}) {
|
|
|
202
353
|
const standaloneItems = records
|
|
203
354
|
.filter((record) => record.type === "action-item" && !eventIds.has(record.sourceResourceId))
|
|
204
355
|
.map((record) => planStandaloneAction(record, byId, asOf, now))
|
|
356
|
+
.filter((item) => workItemBelongsToProgram(item, obligationProgram, byId, model))
|
|
205
357
|
.filter((item) => item.status !== "complete" || options.includeComplete);
|
|
206
358
|
if (calendarItems.length + eventItems.length + standaloneItems.length > MAX_PLANNED_ITEMS) {
|
|
207
359
|
throw new Error(`The obligation query must be narrowed; it exceeds ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} planned items.`);
|
|
@@ -231,10 +383,35 @@ export function planObligations(resources, options = {}) {
|
|
|
231
383
|
};
|
|
232
384
|
}
|
|
233
385
|
|
|
386
|
+
function obligationProgramBlocker(obligation, byId, asOf) {
|
|
387
|
+
if (obligation.status !== "active") {
|
|
388
|
+
return { type: "obligation", id: obligation.id, label: "Activate obligation" };
|
|
389
|
+
}
|
|
390
|
+
if (currentPartyPeople(obligation.ownerIds || [], byId).size === 0) {
|
|
391
|
+
return { type: "obligation", id: obligation.id, label: "Assign current owner" };
|
|
392
|
+
}
|
|
393
|
+
for (const id of obligation.policyIds || []) {
|
|
394
|
+
const policy = byId.get(id);
|
|
395
|
+
if (policy?.type !== "policy" || policy.status !== "active" || !policy.effectiveOn || policy.effectiveOn > asOf) {
|
|
396
|
+
return { type: "policy", id, label: "Activate policy" };
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const controls = (obligation.controlIds || []).map((id) => byId.get(id)).filter(Boolean);
|
|
400
|
+
if (controls.length && !controls.some(({ status }) => status === "implemented")) {
|
|
401
|
+
return { type: "control", id: controls[0].id, label: "Implement control" };
|
|
402
|
+
}
|
|
403
|
+
return { type: "obligation", id: obligation.id, label: "Review prerequisites" };
|
|
404
|
+
}
|
|
405
|
+
|
|
234
406
|
export async function createObligationEvent(input, options) {
|
|
407
|
+
return serializeWorkspaceMutation(input, (root) => createObligationEventUnlocked(root, options));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function createObligationEventUnlocked(input, options) {
|
|
235
411
|
const loaded = await loadWorkspace(input);
|
|
236
412
|
const records = loaded.resources;
|
|
237
413
|
const byId = new Map(records.map((record) => [record.id, record]));
|
|
414
|
+
const program = options?.allPrograms === true ? null : resolveProgram(loaded, options?.programId);
|
|
238
415
|
const eventType = String(options?.eventType || "").trim();
|
|
239
416
|
const occurredAt = options?.occurredAt ? requireTimestamp(options.occurredAt, "event timestamp") : null;
|
|
240
417
|
const timestampDate = timestampCalendarDate(occurredAt, loaded.workspace.timezone);
|
|
@@ -252,33 +429,39 @@ export async function createObligationEvent(input, options) {
|
|
|
252
429
|
if (riskLevel && !["normal", "high"].includes(riskLevel)) {
|
|
253
430
|
throw new Error("A departure risk level must be normal or high.");
|
|
254
431
|
}
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
432
|
+
const eventSchedules = new Map();
|
|
433
|
+
const templates = records.filter((record) => {
|
|
434
|
+
if (
|
|
435
|
+
record.type !== "obligation"
|
|
436
|
+
|| record.status !== "active"
|
|
437
|
+
|| (program && !obligationBelongsToProgram(record, program, loaded.model))
|
|
438
|
+
) return false;
|
|
439
|
+
const schedule = obligationRule(record, byId, { now: occurredAt || `${occurredOn}T23:59:59Z` }) || record;
|
|
440
|
+
const matches = schedule.recurrence?.mode === "event"
|
|
441
|
+
&& schedule.recurrence.eventType === eventType
|
|
442
|
+
&& (!Array.isArray(record.eventRiskLevels) || record.eventRiskLevels.includes(riskLevel));
|
|
443
|
+
if (matches) eventSchedules.set(record.id, schedule);
|
|
444
|
+
return matches;
|
|
445
|
+
});
|
|
265
446
|
if (!eventType || templates.length === 0) throw new Error(`No active obligations use event type "${eventType}".`);
|
|
266
447
|
if (templates.some((record) => obligationProgramStatus(record, byId, occurredOn, loaded.model) === "proposed")) {
|
|
267
448
|
throw new Error(`Event type "${eventType}" still has starter proposals. Make every governing Policy and required governed-content record active and effective, then implement at least one linked Control before starting this workflow.`);
|
|
268
449
|
}
|
|
269
|
-
if (templates.some((record) => normalizedEventWindow(record.window).precision === "timestamp") && !occurredAt) {
|
|
450
|
+
if (templates.some((record) => normalizedEventWindow(eventSchedules.get(record.id)?.window).precision === "timestamp") && !occurredAt) {
|
|
270
451
|
throw new Error(`Event type "${eventType}" has hour-based deadlines and requires an RFC 3339 occurredAt timestamp.`);
|
|
271
452
|
}
|
|
272
453
|
const subjectResourceIds = [...new Set((options.subjectResourceIds || []).map(String).filter(Boolean))];
|
|
273
454
|
const existingIds = records.map((record) => record.id);
|
|
274
455
|
const prompt = templates.find((record) => record.triggerPrompt)?.triggerPrompt || humanize(eventType);
|
|
275
456
|
const title = String(options.title || `${prompt.replace(/\?$/, "")} · ${occurredOn}`).trim();
|
|
276
|
-
const eventId = createResourceId("obligation-event", title, existingIds);
|
|
457
|
+
const eventId = options.id || createResourceId("obligation-event", title, existingIds);
|
|
458
|
+
if (existingIds.includes(eventId)) throw new Error(`Policy Event "${eventId}" already exists.`);
|
|
277
459
|
existingIds.push(eventId);
|
|
278
460
|
const actions = templates.map((obligation) => {
|
|
279
461
|
const id = createResourceId("action-item", `${eventId} ${obligation.title}`, existingIds);
|
|
280
462
|
existingIds.push(id);
|
|
281
|
-
const
|
|
463
|
+
const schedule = eventSchedules.get(obligation.id) || obligation;
|
|
464
|
+
const window = eventWindow(schedule, occurredOn, occurredAt, loaded.workspace.timezone);
|
|
282
465
|
return {
|
|
283
466
|
id,
|
|
284
467
|
type: "action-item",
|
|
@@ -287,6 +470,7 @@ export async function createObligationEvent(input, options) {
|
|
|
287
470
|
assigneeIds: obligation.ownerIds || [],
|
|
288
471
|
sourceResourceId: eventId,
|
|
289
472
|
obligationId: obligation.id,
|
|
473
|
+
...(schedule.type === "obligation-rule" ? { obligationRuleId: schedule.id } : {}),
|
|
290
474
|
description: eventActionDescription(obligation, eventType, loaded.model),
|
|
291
475
|
completionWindow: storedCompletionWindow(window, loaded.workspace.timezone)
|
|
292
476
|
};
|
|
@@ -317,9 +501,14 @@ export async function completeObligationOccurrence(input, options) {
|
|
|
317
501
|
record.type === "obligation" && record.id === options?.obligationId
|
|
318
502
|
));
|
|
319
503
|
if (!obligation) throw new Error(`Obligation "${options?.obligationId ?? ""}" was not found.`);
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
504
|
+
const completionRecord = bindEffectiveReportingRoute(loaded, options?.record);
|
|
505
|
+
assertExpectedCompletionType(obligation, completionRecord, loaded.model);
|
|
506
|
+
assertAttestationCompletionScope(obligation, completionRecord, loaded.resources);
|
|
507
|
+
if (obligation.scheduleMode === "rule") {
|
|
508
|
+
const created = await createResource(loaded.root, completionRecord, { content: options.content });
|
|
509
|
+
return { created: created.record, linked: null };
|
|
510
|
+
}
|
|
511
|
+
return createResourceAndLink(loaded.root, completionRecord, {
|
|
323
512
|
type: "obligation",
|
|
324
513
|
id: obligation.id,
|
|
325
514
|
field: "completionResourceIds",
|
|
@@ -329,6 +518,7 @@ export async function completeObligationOccurrence(input, options) {
|
|
|
329
518
|
|
|
330
519
|
export async function scaffoldObligationCompletion(input, options = {}) {
|
|
331
520
|
const loaded = await loadWorkspace(input);
|
|
521
|
+
const program = resolveProgram(loaded, options.programId);
|
|
332
522
|
const action = options.actionItemId
|
|
333
523
|
? loaded.resources.find((record) => record.type === "action-item" && record.id === options.actionItemId)
|
|
334
524
|
: null;
|
|
@@ -346,8 +536,8 @@ export async function scaffoldObligationCompletion(input, options = {}) {
|
|
|
346
536
|
"completion date"
|
|
347
537
|
);
|
|
348
538
|
const item = action
|
|
349
|
-
? plannedActionForScaffold(loaded, action, completedOn)
|
|
350
|
-
: plannedOccurrenceForScaffold(loaded, obligation, options.windowStart, completedOn);
|
|
539
|
+
? plannedActionForScaffold(loaded, action, completedOn, program.id)
|
|
540
|
+
: plannedOccurrenceForScaffold(loaded, obligation, options.windowStart, completedOn, program.id);
|
|
351
541
|
const activity = obligationActivity(loaded.model, obligation);
|
|
352
542
|
const type = item.completionType || preferredCompletionType(activity, {
|
|
353
543
|
...obligation,
|
|
@@ -365,7 +555,8 @@ export async function scaffoldObligationCompletion(input, options = {}) {
|
|
|
365
555
|
item,
|
|
366
556
|
obligation,
|
|
367
557
|
completedOn,
|
|
368
|
-
activity
|
|
558
|
+
activity,
|
|
559
|
+
program
|
|
369
560
|
});
|
|
370
561
|
const target = action || obligation;
|
|
371
562
|
const entry = loaded.entries.find(({ record }) => record.type === target.type && record.id === target.id);
|
|
@@ -390,6 +581,469 @@ export async function scaffoldObligationCompletion(input, options = {}) {
|
|
|
390
581
|
};
|
|
391
582
|
}
|
|
392
583
|
|
|
584
|
+
export async function scaffoldObligationOccurrence(input, options = {}) {
|
|
585
|
+
const loaded = await loadWorkspace(input);
|
|
586
|
+
if (!modelSupports(loaded.model, "rolled-up-obligations")) {
|
|
587
|
+
throw new Error("Rolled-up occurrence reconciliation requires data model v9.");
|
|
588
|
+
}
|
|
589
|
+
const obligation = loaded.resources.find((record) => (
|
|
590
|
+
record.type === "obligation" && record.id === options.obligationId
|
|
591
|
+
));
|
|
592
|
+
if (!obligation) throw new Error(`Obligation "${options.obligationId || ""}" was not found.`);
|
|
593
|
+
const program = resolveProgram(loaded, options.programId);
|
|
594
|
+
const windowStart = requireDate(options.windowStart, "occurrence window start");
|
|
595
|
+
const asOf = requireDate(options.asOf || currentCalendarDate(loaded.workspace.timezone), "as-of date");
|
|
596
|
+
const plan = planObligations(loaded.resources, {
|
|
597
|
+
programId: program.id,
|
|
598
|
+
from: windowStart,
|
|
599
|
+
asOf,
|
|
600
|
+
through: windowStart,
|
|
601
|
+
includeComplete: true,
|
|
602
|
+
model: loaded.model
|
|
603
|
+
});
|
|
604
|
+
const item = plan.calendarItems.find((candidate) => (
|
|
605
|
+
candidate.obligationId === obligation.id && candidate.dueWindowStart === windowStart
|
|
606
|
+
));
|
|
607
|
+
if (!item || !item.ruleId) {
|
|
608
|
+
throw new Error(`No rule-based ${obligation.id} occurrence starts on ${windowStart}.`);
|
|
609
|
+
}
|
|
610
|
+
if (item.programStatus !== "accepted" && !item.occurrenceId) {
|
|
611
|
+
throw new Error(`Obligation "${obligation.id}" is still proposed. Activate its reviewed rule and governing program records first.`);
|
|
612
|
+
}
|
|
613
|
+
const current = item.occurrenceId
|
|
614
|
+
? loaded.entries.find(({ record }) => record.id === item.occurrenceId)
|
|
615
|
+
: null;
|
|
616
|
+
if (current && current.record.status !== "open" && options.correctFinalized !== true) {
|
|
617
|
+
throw new Error(`Occurrence "${current.record.id}" is finalized. Request a superseding reconciliation to correct it.`);
|
|
618
|
+
}
|
|
619
|
+
const existing = current?.record.status === "open" ? current : null;
|
|
620
|
+
const predecessor = current && !existing ? current : null;
|
|
621
|
+
const rule = loaded.resources.find(({ id }) => id === item.ruleId);
|
|
622
|
+
const cutoff = rule?.selector?.cutoff === "window-end" ? item.dueWindowEnd : item.dueWindowStart;
|
|
623
|
+
let historicalSnapshot = null;
|
|
624
|
+
let historicalReview = null;
|
|
625
|
+
const needsHistoricalReview = (!existing && !predecessor && asOf >= cutoff)
|
|
626
|
+
|| (existing && !existing.record.collectionReviewId && asOf > cutoff);
|
|
627
|
+
if (needsHistoricalReview) {
|
|
628
|
+
const candidates = loaded.entries.flatMap((entry) => {
|
|
629
|
+
if (!(entry.record.scopeResourceIds || []).includes(program.id)) return [];
|
|
630
|
+
const snapshot = historicalCollectionReviewSnapshot(
|
|
631
|
+
loaded.root,
|
|
632
|
+
entry.record,
|
|
633
|
+
loaded.model,
|
|
634
|
+
loaded.workspace.timezone,
|
|
635
|
+
rule?.selector?.resourceType,
|
|
636
|
+
cutoff,
|
|
637
|
+
rule?.selector,
|
|
638
|
+
entry.relativePath
|
|
639
|
+
);
|
|
640
|
+
return snapshot ? [{ entry, snapshot }] : [];
|
|
641
|
+
});
|
|
642
|
+
const supersededIds = new Set(candidates.map(({ entry }) => entry.record.supersedesId).filter(Boolean));
|
|
643
|
+
candidates.sort((left, right) => (
|
|
644
|
+
Number(right.entry.record.status === "active") - Number(left.entry.record.status === "active")
|
|
645
|
+
|| Number(supersededIds.has(left.entry.record.id)) - Number(supersededIds.has(right.entry.record.id))
|
|
646
|
+
|| right.entry.record.reviewedOn.localeCompare(left.entry.record.reviewedOn)
|
|
647
|
+
|| right.entry.record.id.localeCompare(left.entry.record.id)
|
|
648
|
+
));
|
|
649
|
+
historicalReview = candidates[0]?.entry || null;
|
|
650
|
+
historicalSnapshot = candidates[0]?.snapshot || null;
|
|
651
|
+
}
|
|
652
|
+
if (!existing && !predecessor && asOf > cutoff && !historicalReview) {
|
|
653
|
+
throw new Error(
|
|
654
|
+
`The ${cutoff} population can no longer be inferred from the current files. `
|
|
655
|
+
+ "Use a temporal Collection Review or reconstruct the population from Git history before creating this occurrence."
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
const existingMembers = new Map(((existing || predecessor)?.record.members || []).map((member) => [member.resourceId, member]));
|
|
659
|
+
const activity = obligationActivity(loaded.model, obligation);
|
|
660
|
+
const completionMemberField = activity.aggregate?.completionMemberField;
|
|
661
|
+
const populationIds = existing
|
|
662
|
+
? item.expectedMemberIds
|
|
663
|
+
: predecessor
|
|
664
|
+
? predecessor.record.members.map(({ resourceId }) => resourceId)
|
|
665
|
+
: historicalReview
|
|
666
|
+
? historicalSnapshot.selectedIds
|
|
667
|
+
: item.expectedMemberIds;
|
|
668
|
+
if (existing && historicalSnapshot) {
|
|
669
|
+
const frozenIds = [...existingMembers.keys()].sort();
|
|
670
|
+
const reviewedIds = [...historicalSnapshot.selectedIds].sort();
|
|
671
|
+
if (JSON.stringify(frozenIds) !== JSON.stringify(reviewedIds)) {
|
|
672
|
+
throw new Error(
|
|
673
|
+
`Open occurrence "${existing.record.id}" does not match the committed ${cutoff} Collection Review population. `
|
|
674
|
+
+ "Correct the open occurrence before reconciliation."
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const members = populationIds.map((resourceId) => {
|
|
679
|
+
const prior = existingMembers.get(resourceId) || {};
|
|
680
|
+
const matching = loaded.resources.filter((record) => (
|
|
681
|
+
activity.completionResourceTypes.includes(record.type)
|
|
682
|
+
&& completionFallsInWindow(record, item, loaded.workspace.timezone)
|
|
683
|
+
&& completionMatchesMember(record, completionMemberField, resourceId)
|
|
684
|
+
));
|
|
685
|
+
const accepted = matching.filter((record) => completionPassesActivity(record, activity));
|
|
686
|
+
return {
|
|
687
|
+
...prior,
|
|
688
|
+
resourceId,
|
|
689
|
+
disposition: prior.disposition || "expected",
|
|
690
|
+
result: prior.disposition && prior.disposition !== "expected"
|
|
691
|
+
? prior.result || "pending"
|
|
692
|
+
: accepted.length ? "passed" : matching.length ? "failed" : prior.result || "pending",
|
|
693
|
+
completionResourceIds: [...new Set([
|
|
694
|
+
...(prior.completionResourceIds || []),
|
|
695
|
+
...matching.map(({ id }) => id)
|
|
696
|
+
])]
|
|
697
|
+
};
|
|
698
|
+
});
|
|
699
|
+
const record = {
|
|
700
|
+
...(existing?.record || {
|
|
701
|
+
id: createResourceId(
|
|
702
|
+
"obligation-occurrence",
|
|
703
|
+
predecessor
|
|
704
|
+
? `${predecessor.record.id} correction ${asOf}`
|
|
705
|
+
: `${program.id} ${obligation.id} ${windowStart}`,
|
|
706
|
+
loaded.resources.map(({ id }) => id)
|
|
707
|
+
),
|
|
708
|
+
type: "obligation-occurrence",
|
|
709
|
+
title: `${obligation.title} · ${windowStart}`
|
|
710
|
+
}),
|
|
711
|
+
status: "open",
|
|
712
|
+
programId: program.id,
|
|
713
|
+
obligationId: obligation.id,
|
|
714
|
+
ruleId: item.ruleId,
|
|
715
|
+
occurrenceKey: item.occurrenceKey,
|
|
716
|
+
coverage: { kind: "range", startsOn: item.dueWindowStart, endsOn: item.dueWindowEnd },
|
|
717
|
+
membershipCutoffAt: cutoff,
|
|
718
|
+
...(historicalReview ? {
|
|
719
|
+
collectionReviewId: historicalReview.record.id,
|
|
720
|
+
collectionReviewCommit: historicalSnapshot.reviewCommit,
|
|
721
|
+
collectionReviewRevision: collectionReviewRevision(historicalReview.record),
|
|
722
|
+
collectionRevision: historicalReview.record.collectionRevision,
|
|
723
|
+
scopeRevision: historicalReview.record.scopeRevision
|
|
724
|
+
} : predecessor ? Object.fromEntries([
|
|
725
|
+
"collectionReviewId",
|
|
726
|
+
"collectionReviewCommit",
|
|
727
|
+
"collectionReviewRevision",
|
|
728
|
+
"collectionRevision",
|
|
729
|
+
"scopeRevision"
|
|
730
|
+
].filter((field) => predecessor.record[field] !== undefined).map((field) => [field, predecessor.record[field]])) : {}),
|
|
731
|
+
members,
|
|
732
|
+
expectedCount: members.length,
|
|
733
|
+
completedCount: members.filter(({ disposition, result }) => disposition === "expected" && result === "passed").length,
|
|
734
|
+
...(predecessor ? { supersedesId: predecessor.record.id } : {}),
|
|
735
|
+
ownerIds: obligation.ownerIds || []
|
|
736
|
+
};
|
|
737
|
+
if (predecessor) {
|
|
738
|
+
delete record.conclusion;
|
|
739
|
+
delete record.reviewedByIds;
|
|
740
|
+
delete record.reconciledAt;
|
|
741
|
+
}
|
|
742
|
+
return {
|
|
743
|
+
operation: existing ? "update" : predecessor ? "supersede" : "create",
|
|
744
|
+
record,
|
|
745
|
+
revision: existing || predecessor ? contentRevision((existing || predecessor).source) : null,
|
|
746
|
+
membershipFinal: item.membershipFinal,
|
|
747
|
+
instructions: "Review the frozen population as one occurrence. Link each member's dated completion, exception, or non-applicability decision. Set reconciled status, counts, conclusion, reviewers, and reconciledAt only after membership is final. Use a superseding record to correct a finalized occurrence."
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
export async function saveObligationOccurrence(input, options = {}) {
|
|
752
|
+
return serializeWorkspaceMutation(input, (root) => saveObligationOccurrenceUnlocked(root, options));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async function saveObligationOccurrenceUnlocked(input, options) {
|
|
756
|
+
const record = options.record;
|
|
757
|
+
if (record?.type !== "obligation-occurrence") throw new Error("An Obligation occurrence record is required.");
|
|
758
|
+
const loaded = await loadWorkspace(input);
|
|
759
|
+
const program = resolveProgram(loaded, options.programId || record.programId);
|
|
760
|
+
if (record.programId !== program.id) {
|
|
761
|
+
throw new Error(`Occurrence "${record.id}" belongs to Program "${record.programId}", not "${program.id}".`);
|
|
762
|
+
}
|
|
763
|
+
const now = options.now ? new Date(options.now) : new Date();
|
|
764
|
+
if (Number.isNaN(now.getTime())) throw new Error("A valid occurrence save time is required.");
|
|
765
|
+
if (record.reconciledAt && new Date(record.reconciledAt) > now) {
|
|
766
|
+
throw new Error("The occurrence reconciliation time cannot be in the future.");
|
|
767
|
+
}
|
|
768
|
+
const existing = loaded.entries.find(({ record: current }) => current.id === record.id);
|
|
769
|
+
if (existing?.record.programId && existing.record.programId !== program.id) {
|
|
770
|
+
throw new Error(`Existing occurrence "${record.id}" belongs to Program "${existing.record.programId}", not "${program.id}".`);
|
|
771
|
+
}
|
|
772
|
+
if (record.supersedesId) {
|
|
773
|
+
requireMutationRevision(options.expectedRevision, `Obligation occurrence "${record.supersedesId}"`);
|
|
774
|
+
if (existing) throw new Error(`Superseding occurrence "${record.id}" already exists.`);
|
|
775
|
+
const predecessor = loaded.entries.find(({ record: current }) => (
|
|
776
|
+
current.type === "obligation-occurrence" && current.id === record.supersedesId
|
|
777
|
+
));
|
|
778
|
+
if (!predecessor) throw new Error(`Superseded occurrence "${record.supersedesId}" was not found.`);
|
|
779
|
+
if (predecessor.record.programId !== program.id) {
|
|
780
|
+
throw new Error(`Superseded occurrence "${predecessor.record.id}" belongs to Program "${predecessor.record.programId}", not "${program.id}".`);
|
|
781
|
+
}
|
|
782
|
+
if (predecessor.record.status !== "reconciled") {
|
|
783
|
+
throw new Error(`Occurrence "${predecessor.record.id}" must be reconciled before it can be superseded.`);
|
|
784
|
+
}
|
|
785
|
+
const correction = {
|
|
786
|
+
...record,
|
|
787
|
+
programId: predecessor.record.programId,
|
|
788
|
+
obligationId: predecessor.record.obligationId,
|
|
789
|
+
ruleId: predecessor.record.ruleId,
|
|
790
|
+
occurrenceKey: predecessor.record.occurrenceKey,
|
|
791
|
+
coverage: predecessor.record.coverage,
|
|
792
|
+
membershipCutoffAt: predecessor.record.membershipCutoffAt
|
|
793
|
+
};
|
|
794
|
+
for (const field of [
|
|
795
|
+
"collectionReviewId",
|
|
796
|
+
"collectionReviewCommit",
|
|
797
|
+
"collectionReviewRevision",
|
|
798
|
+
"collectionRevision",
|
|
799
|
+
"scopeRevision"
|
|
800
|
+
]) {
|
|
801
|
+
if (predecessor.record[field] === undefined) delete correction[field];
|
|
802
|
+
else correction[field] = predecessor.record[field];
|
|
803
|
+
}
|
|
804
|
+
assertOccurrenceMembersMatch(correction, predecessor.record);
|
|
805
|
+
return applyResourceBatch(input, {
|
|
806
|
+
workflowCapability: INTERNAL_WORKFLOW_CAPABILITIES.obligationOccurrenceSupersession,
|
|
807
|
+
create: [correction],
|
|
808
|
+
update: [{ ...predecessor.record, status: "superseded" }],
|
|
809
|
+
contentUpdates: { [correction.id]: options.content || {} },
|
|
810
|
+
expectedRevisions: { [predecessor.record.id]: options.expectedRevision }
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
const windowStart = record.coverage?.kind === "range" ? record.coverage.startsOn : record.coverage?.on;
|
|
814
|
+
const scaffold = await scaffoldObligationOccurrence(input, {
|
|
815
|
+
obligationId: record.obligationId,
|
|
816
|
+
programId: record.programId,
|
|
817
|
+
windowStart,
|
|
818
|
+
asOf: currentCalendarDate(loaded.workspace.timezone, now)
|
|
819
|
+
});
|
|
820
|
+
assertOccurrenceDerivedFields(record, scaffold.record);
|
|
821
|
+
assertOccurrenceMembersMatch(record, scaffold.record);
|
|
822
|
+
if (record.status === "reconciled" && !scaffold.membershipFinal) {
|
|
823
|
+
throw new Error(`Occurrence "${record.id}" cannot be reconciled until its membership cutoff is final.`);
|
|
824
|
+
}
|
|
825
|
+
if (existing) requireMutationRevision(options.expectedRevision, `Obligation occurrence "${record.id}"`);
|
|
826
|
+
return existing
|
|
827
|
+
? updateResource(input, "obligation-occurrence", record.id, record, {
|
|
828
|
+
workflowCapability: INTERNAL_WORKFLOW_CAPABILITIES.obligationOccurrenceReconciliation,
|
|
829
|
+
expectedRevision: options.expectedRevision,
|
|
830
|
+
content: options.content
|
|
831
|
+
})
|
|
832
|
+
: createResource(input, record, {
|
|
833
|
+
workflowCapability: INTERNAL_WORKFLOW_CAPABILITIES.obligationOccurrenceReconciliation,
|
|
834
|
+
content: options.content
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function assertOccurrenceDerivedFields(record, scaffold) {
|
|
839
|
+
const fields = [
|
|
840
|
+
"id",
|
|
841
|
+
"type",
|
|
842
|
+
"programId",
|
|
843
|
+
"obligationId",
|
|
844
|
+
"ruleId",
|
|
845
|
+
"occurrenceKey",
|
|
846
|
+
"coverage",
|
|
847
|
+
"membershipCutoffAt",
|
|
848
|
+
"collectionReviewId",
|
|
849
|
+
"collectionReviewCommit",
|
|
850
|
+
"collectionReviewRevision",
|
|
851
|
+
"collectionRevision",
|
|
852
|
+
"scopeRevision",
|
|
853
|
+
"ownerIds"
|
|
854
|
+
];
|
|
855
|
+
if (fields.some((field) => JSON.stringify(record[field]) !== JSON.stringify(scaffold[field]))) {
|
|
856
|
+
throw new Error("The Obligation occurrence identity, coverage, cutoff, or source population changed. Scaffold it again.");
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function assertOccurrenceMembersMatch(record, scaffold) {
|
|
861
|
+
const memberIds = (record.members || []).map(({ resourceId }) => resourceId);
|
|
862
|
+
const scaffoldMemberIds = (scaffold.members || []).map(({ resourceId }) => resourceId);
|
|
863
|
+
if (JSON.stringify(memberIds) !== JSON.stringify(scaffoldMemberIds)) {
|
|
864
|
+
throw new Error("The Obligation occurrence population changed. Scaffold it again before saving.");
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function requireMutationRevision(revision, target) {
|
|
869
|
+
if (typeof revision !== "string" || revision.length === 0) {
|
|
870
|
+
throw new Error(`A revision is required when changing ${target}. Reload the resource and try again.`);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
export async function scaffoldObligationRuleActivation(input, options = {}) {
|
|
875
|
+
const loaded = await loadWorkspace(input);
|
|
876
|
+
if (!modelSupports(loaded.model, "rolled-up-obligations")) {
|
|
877
|
+
throw new Error("Obligation rule activation requires data model v9.");
|
|
878
|
+
}
|
|
879
|
+
const ruleEntry = loaded.entries.find(({ record }) => (
|
|
880
|
+
record.type === "obligation-rule" && record.id === options.ruleId
|
|
881
|
+
));
|
|
882
|
+
if (!ruleEntry) throw new Error(`Obligation rule "${options.ruleId || ""}" was not found.`);
|
|
883
|
+
if (!["proposed", "approved"].includes(ruleEntry.record.status)) {
|
|
884
|
+
throw new Error(`Obligation rule "${ruleEntry.record.id}" is already ${ruleEntry.record.status}.`);
|
|
885
|
+
}
|
|
886
|
+
const obligationEntry = loaded.entries.find(({ record }) => (
|
|
887
|
+
record.type === "obligation" && record.id === ruleEntry.record.obligationId
|
|
888
|
+
));
|
|
889
|
+
if (!obligationEntry) throw new Error(`Obligation "${ruleEntry.record.obligationId}" was not found.`);
|
|
890
|
+
const priorEntry = obligationEntry.record.activeRuleId && obligationEntry.record.activeRuleId !== ruleEntry.record.id
|
|
891
|
+
? loaded.entries.find(({ record }) => record.id === obligationEntry.record.activeRuleId)
|
|
892
|
+
: null;
|
|
893
|
+
const openOccurrenceEntries = priorEntry
|
|
894
|
+
? loaded.entries.filter(({ record }) => (
|
|
895
|
+
record.type === "obligation-occurrence"
|
|
896
|
+
&& record.obligationId === obligationEntry.record.id
|
|
897
|
+
&& record.ruleId === priorEntry.record.id
|
|
898
|
+
&& record.status === "open"
|
|
899
|
+
))
|
|
900
|
+
: [];
|
|
901
|
+
const now = options.now ? new Date(options.now) : new Date();
|
|
902
|
+
if (Number.isNaN(now.getTime())) throw new Error("A valid activation time is required.");
|
|
903
|
+
const date = options.approvedOn || currentCalendarDate(loaded.workspace.timezone, now);
|
|
904
|
+
const suggestedInstant = new Date(now.getTime() + 5 * 60 * 1000);
|
|
905
|
+
const effectiveLocal = options.effectiveLocal
|
|
906
|
+
|| (options.effectiveAt
|
|
907
|
+
? localDateTimeValue(options.effectiveAt, loaded.workspace.timezone)
|
|
908
|
+
: localDateTimeValue(suggestedInstant, loaded.workspace.timezone).replace(/:\d{2}$/, ":00"));
|
|
909
|
+
const effectiveAt = options.effectiveLocal
|
|
910
|
+
? timestampFromLocalDateTime(effectiveLocal, loaded.workspace.timezone)
|
|
911
|
+
: options.effectiveAt || timestampFromLocalDateTime(effectiveLocal, loaded.workspace.timezone);
|
|
912
|
+
const effectiveOn = currentCalendarDate(loaded.workspace.timezone, new Date(effectiveAt));
|
|
913
|
+
const review = {
|
|
914
|
+
revision: contentRevision(ruleEntry.source),
|
|
915
|
+
recurrence: ruleEntry.record.recurrence,
|
|
916
|
+
window: ruleEntry.record.window || null,
|
|
917
|
+
selector: ruleEntry.record.selector || null,
|
|
918
|
+
rationale: ruleEntry.record.rationale,
|
|
919
|
+
sourceResourceIds: ruleEntry.record.sourceResourceIds || [],
|
|
920
|
+
firstAffectedOn: ruleEntry.record.recurrence.mode === "calendar"
|
|
921
|
+
? nextCalendarOccurrence(ruleEntry.record.recurrence, effectiveOn)
|
|
922
|
+
: null,
|
|
923
|
+
...(priorEntry ? {
|
|
924
|
+
prior: {
|
|
925
|
+
id: priorEntry.record.id,
|
|
926
|
+
recurrence: priorEntry.record.recurrence,
|
|
927
|
+
window: priorEntry.record.window || null,
|
|
928
|
+
selector: priorEntry.record.selector || null
|
|
929
|
+
}
|
|
930
|
+
} : {})
|
|
931
|
+
};
|
|
932
|
+
return {
|
|
933
|
+
rule: { id: ruleEntry.record.id, title: ruleEntry.record.title },
|
|
934
|
+
obligation: { id: obligationEntry.record.id, title: obligationEntry.record.title },
|
|
935
|
+
priorRule: priorEntry ? { id: priorEntry.record.id, title: priorEntry.record.title } : null,
|
|
936
|
+
openOccurrences: openOccurrenceEntries.map(({ record }) => ({ id: record.id, title: record.title })),
|
|
937
|
+
review,
|
|
938
|
+
payload: {
|
|
939
|
+
ruleId: ruleEntry.record.id,
|
|
940
|
+
confirmedRevision: options.confirmedRevision || null,
|
|
941
|
+
approvedByIds: options.approvedByIds || [],
|
|
942
|
+
approvedOn: date,
|
|
943
|
+
effectiveAt,
|
|
944
|
+
effectiveLocal,
|
|
945
|
+
timezone: options.timezone || loaded.workspace.timezone,
|
|
946
|
+
...(priorEntry ? { cutoverDecision: options.cutoverDecision || (openOccurrenceEntries.length ? "keep-open-window" : "new-windows-only") } : {}),
|
|
947
|
+
expectedRevisions: {
|
|
948
|
+
[ruleEntry.record.id]: contentRevision(ruleEntry.source),
|
|
949
|
+
[obligationEntry.record.id]: contentRevision(obligationEntry.source),
|
|
950
|
+
...(priorEntry ? { [priorEntry.record.id]: contentRevision(priorEntry.source) } : {}),
|
|
951
|
+
...Object.fromEntries(openOccurrenceEntries.map((entry) => [entry.record.id, contentRevision(entry.source)]))
|
|
952
|
+
}
|
|
953
|
+
},
|
|
954
|
+
reviewerCandidates: loaded.resources
|
|
955
|
+
.filter((record) => record.type === "person" && record.status === "active")
|
|
956
|
+
.map(({ id, title }) => ({ id, title }))
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
export async function activateObligationRule(input, options = {}) {
|
|
961
|
+
const scaffold = await scaffoldObligationRuleActivation(input, options);
|
|
962
|
+
if (!(options.approvedByIds || []).length) throw new Error("Select at least one person who approved this rule.");
|
|
963
|
+
if (options.confirmedRevision !== scaffold.review.revision) {
|
|
964
|
+
throw new Error("Confirm the current Obligation rule revision before activation.");
|
|
965
|
+
}
|
|
966
|
+
const loaded = await loadWorkspace(input);
|
|
967
|
+
const approverIds = [...new Set(options.approvedByIds || [])];
|
|
968
|
+
if (approverIds.some((id) => !loaded.resources.some((record) => (
|
|
969
|
+
record.type === "person" && record.id === id && record.status === "active"
|
|
970
|
+
)))) {
|
|
971
|
+
throw new Error("Every Obligation rule approver must be an active Person.");
|
|
972
|
+
}
|
|
973
|
+
const rule = loaded.resources.find((record) => record.id === scaffold.rule.id);
|
|
974
|
+
const obligation = loaded.resources.find((record) => record.id === scaffold.obligation.id);
|
|
975
|
+
const prior = scaffold.priorRule ? loaded.resources.find((record) => record.id === scaffold.priorRule.id) : null;
|
|
976
|
+
const openOccurrences = scaffold.openOccurrences
|
|
977
|
+
.map(({ id }) => loaded.resources.find((record) => record.id === id))
|
|
978
|
+
.filter(Boolean);
|
|
979
|
+
const approvedOn = options.approvedOn || scaffold.payload.approvedOn;
|
|
980
|
+
const effectiveAt = options.effectiveLocal
|
|
981
|
+
? timestampFromLocalDateTime(options.effectiveLocal, scaffold.payload.timezone)
|
|
982
|
+
: options.effectiveAt || scaffold.payload.effectiveAt;
|
|
983
|
+
const effectiveOn = currentCalendarDate(options.timezone || scaffold.payload.timezone, new Date(effectiveAt));
|
|
984
|
+
const now = options.now ? new Date(options.now) : new Date();
|
|
985
|
+
if (Number.isNaN(now.getTime())) throw new Error("A valid activation time is required.");
|
|
986
|
+
const today = currentCalendarDate(options.timezone || scaffold.payload.timezone, now);
|
|
987
|
+
if (approvedOn > today) throw new Error("The rule approval date cannot be in the future.");
|
|
988
|
+
if (effectiveOn < approvedOn) throw new Error("The rule effective time cannot be before approval.");
|
|
989
|
+
if (new Date(effectiveAt) < now) throw new Error("The rule effective time cannot be in the past.");
|
|
990
|
+
const ruleModeObligation = {
|
|
991
|
+
...obligation,
|
|
992
|
+
status: "active",
|
|
993
|
+
scheduleMode: "rule",
|
|
994
|
+
ruleIds: [...new Set([...(obligation.ruleIds || []), rule.id])],
|
|
995
|
+
activeRuleId: rule.id
|
|
996
|
+
};
|
|
997
|
+
for (const field of ["recurrence", "window", "startsOn", "endsOn"]) {
|
|
998
|
+
delete ruleModeObligation[field];
|
|
999
|
+
}
|
|
1000
|
+
const updates = [
|
|
1001
|
+
{
|
|
1002
|
+
...rule,
|
|
1003
|
+
status: "active",
|
|
1004
|
+
approvedByIds: approverIds,
|
|
1005
|
+
approvedOn,
|
|
1006
|
+
effectiveAt,
|
|
1007
|
+
timezone: options.timezone || scaffold.payload.timezone,
|
|
1008
|
+
...(prior ? {
|
|
1009
|
+
supersedesId: prior.id,
|
|
1010
|
+
cutoverDecision: options.cutoverDecision || scaffold.payload.cutoverDecision
|
|
1011
|
+
} : {})
|
|
1012
|
+
},
|
|
1013
|
+
ruleModeObligation
|
|
1014
|
+
];
|
|
1015
|
+
const cutoverDecision = options.cutoverDecision || scaffold.payload.cutoverDecision;
|
|
1016
|
+
if (prior && openOccurrences.length && cutoverDecision === "new-windows-only") {
|
|
1017
|
+
throw new Error("This rule has open occurrences. Keep them open or supersede them at cutover.");
|
|
1018
|
+
}
|
|
1019
|
+
if (cutoverDecision === "supersede-open-window") {
|
|
1020
|
+
updates.push(...openOccurrences.map((record) => ({ ...record, status: "superseded" })));
|
|
1021
|
+
}
|
|
1022
|
+
if (prior) updates.push({ ...prior, status: "retired", retiredOn: effectiveOn });
|
|
1023
|
+
return applyResourceBatch(input, {
|
|
1024
|
+
workflowCapability: INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation,
|
|
1025
|
+
update: updates,
|
|
1026
|
+
expectedRevisions: options.expectedRevisions || scaffold.payload.expectedRevisions
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function completionPassesActivity(record, activity) {
|
|
1031
|
+
const aggregate = activity.aggregate;
|
|
1032
|
+
if (!aggregate) return true;
|
|
1033
|
+
if (aggregate.passingStatuses?.length && !aggregate.passingStatuses.includes(record.status)) return false;
|
|
1034
|
+
if (aggregate.passingResults?.length) {
|
|
1035
|
+
const result = record.decision ?? record.outcome ?? record.result;
|
|
1036
|
+
if (!aggregate.passingResults.includes(result)) return false;
|
|
1037
|
+
}
|
|
1038
|
+
return true;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function completionMatchesMember(record, field, resourceId) {
|
|
1042
|
+
if (!field) return false;
|
|
1043
|
+
const value = record[field];
|
|
1044
|
+
return Array.isArray(value) ? value.includes(resourceId) : value === resourceId;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
393
1047
|
export async function completeObligationAction(input, options) {
|
|
394
1048
|
const loaded = await loadWorkspace(input);
|
|
395
1049
|
const action = loaded.resources.find((record) => (
|
|
@@ -404,14 +1058,15 @@ export async function completeObligationAction(input, options) {
|
|
|
404
1058
|
record.type === "obligation" && record.id === action.obligationId
|
|
405
1059
|
));
|
|
406
1060
|
if (!obligation) throw new Error(`Obligation "${action.obligationId}" was not found.`);
|
|
407
|
-
assertExpectedCompletionType(obligation, options?.record, loaded.model);
|
|
408
1061
|
const completedOn = requireDate(options?.completedOn, "completion date");
|
|
409
1062
|
const event = loaded.resources.find((record) => record.type === "obligation-event" && record.id === action.sourceResourceId);
|
|
410
|
-
|
|
1063
|
+
const completionRecord = bindEffectiveReportingRoute(loaded, options.record);
|
|
1064
|
+
assertExpectedCompletionType(obligation, completionRecord, loaded.model);
|
|
1065
|
+
assertAttestationCompletionScope(obligation, completionRecord, loaded.resources, event);
|
|
411
1066
|
if (event?.occurredOn && completedOn < event.occurredOn) {
|
|
412
1067
|
throw new Error("The action completion date cannot be before its policy event date.");
|
|
413
1068
|
}
|
|
414
|
-
return createResourceAndLink(loaded.root,
|
|
1069
|
+
return createResourceAndLink(loaded.root, completionRecord, {
|
|
415
1070
|
type: "action-item",
|
|
416
1071
|
id: action.id,
|
|
417
1072
|
field: "completionResourceIds",
|
|
@@ -433,20 +1088,17 @@ export async function completeObligationEvent(input, options) {
|
|
|
433
1088
|
if (completedOn < event.occurredOn) {
|
|
434
1089
|
throw new Error("The event completion date cannot be before its occurrence date.");
|
|
435
1090
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
});
|
|
442
|
-
const run = plan.eventRuns.find((item) => item.id === event.id);
|
|
443
|
-
if (!run || run.actions.length === 0) {
|
|
1091
|
+
resolveProgram(loaded, options.programId);
|
|
1092
|
+
const actions = loaded.resources.filter((record) => (
|
|
1093
|
+
record.type === "action-item" && record.sourceResourceId === event.id
|
|
1094
|
+
));
|
|
1095
|
+
if (actions.length === 0) {
|
|
444
1096
|
throw new Error(`Policy Event "${event.id}" has no action checklist.`);
|
|
445
1097
|
}
|
|
446
|
-
const incomplete =
|
|
1098
|
+
const incomplete = actions.filter((action) => !["done", "canceled"].includes(action.status));
|
|
447
1099
|
if (incomplete.length) {
|
|
448
1100
|
throw new Error(
|
|
449
|
-
`Policy Event "${event.id}" still has incomplete actions: ${incomplete.map((action) => action.
|
|
1101
|
+
`Policy Event "${event.id}" still has incomplete actions across its Programs: ${incomplete.map((action) => action.id).join(", ")}.`
|
|
450
1102
|
);
|
|
451
1103
|
}
|
|
452
1104
|
return updateResource(loaded.root, "obligation-event", event.id, {
|
|
@@ -492,9 +1144,10 @@ function assertAttestationCompletionScope(obligation, record, resources, event =
|
|
|
492
1144
|
}
|
|
493
1145
|
}
|
|
494
1146
|
|
|
495
|
-
function plannedOccurrenceForScaffold(loaded, obligation, windowStart, completedOn) {
|
|
1147
|
+
function plannedOccurrenceForScaffold(loaded, obligation, windowStart, completedOn, programId) {
|
|
496
1148
|
const start = requireDate(windowStart, "occurrence window start");
|
|
497
1149
|
const plan = planObligations(loaded.resources, {
|
|
1150
|
+
programId,
|
|
498
1151
|
from: start,
|
|
499
1152
|
asOf: completedOn,
|
|
500
1153
|
through: start,
|
|
@@ -510,8 +1163,9 @@ function plannedOccurrenceForScaffold(loaded, obligation, windowStart, completed
|
|
|
510
1163
|
return item;
|
|
511
1164
|
}
|
|
512
1165
|
|
|
513
|
-
function plannedActionForScaffold(loaded, action, completedOn) {
|
|
1166
|
+
function plannedActionForScaffold(loaded, action, completedOn, programId) {
|
|
514
1167
|
const plan = planObligations(loaded.resources, {
|
|
1168
|
+
programId,
|
|
515
1169
|
asOf: completedOn,
|
|
516
1170
|
through: completedOn,
|
|
517
1171
|
includeComplete: true,
|
|
@@ -523,8 +1177,7 @@ function plannedActionForScaffold(loaded, action, completedOn) {
|
|
|
523
1177
|
}
|
|
524
1178
|
|
|
525
1179
|
function applyCompletionScaffoldDefaults(record, context) {
|
|
526
|
-
const { loaded, item, obligation, completedOn, activity } = context;
|
|
527
|
-
const program = resolveProgram(loaded);
|
|
1180
|
+
const { loaded, item, obligation, completedOn, activity, program } = context;
|
|
528
1181
|
const byId = new Map(loaded.resources.map((candidate) => [candidate.id, candidate]));
|
|
529
1182
|
const responsiblePeople = currentPeopleForParties(loaded.resources, item.ownerIds || []);
|
|
530
1183
|
if (!responsiblePeople.length) {
|
|
@@ -603,6 +1256,7 @@ function applyCompletionScaffoldDefaults(record, context) {
|
|
|
603
1256
|
if (!subjectResourceIds.length) throw new Error("An Attestation completion needs the exact Policy, Document, Training, or Action Item content acknowledged.");
|
|
604
1257
|
return {
|
|
605
1258
|
status: "completed",
|
|
1259
|
+
...(loaded.model.resources.attestation?.fields?.programId ? { programId: program.id } : {}),
|
|
606
1260
|
subjectResourceIds,
|
|
607
1261
|
personId,
|
|
608
1262
|
attestationKind: obligation.activityType || "completion",
|
|
@@ -784,6 +1438,42 @@ function defaultClassificationId(loaded) {
|
|
|
784
1438
|
return Object.hasOwn(definitions, "internal") ? "internal" : Object.keys(definitions)[0] || "";
|
|
785
1439
|
}
|
|
786
1440
|
|
|
1441
|
+
function effectiveReportingRoute(loaded, purpose, asOf) {
|
|
1442
|
+
const cutoff = timestampFromLocalDateTime(`${asOf}T23:59:59`, loaded.workspace.timezone);
|
|
1443
|
+
return loaded.entries
|
|
1444
|
+
.filter(({ record }) => (
|
|
1445
|
+
record.type === "reporting-route"
|
|
1446
|
+
&& ["active", "retired"].includes(record.status)
|
|
1447
|
+
&& record.purpose === purpose
|
|
1448
|
+
&& record.priority === "primary"
|
|
1449
|
+
&& new Date(record.effectiveAt) <= new Date(cutoff)
|
|
1450
|
+
&& (!record.endsAt || new Date(record.endsAt) > new Date(cutoff))
|
|
1451
|
+
))
|
|
1452
|
+
.sort((left, right) => right.record.effectiveAt.localeCompare(left.record.effectiveAt))[0] || null;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function bindEffectiveReportingRoute(loaded, record) {
|
|
1456
|
+
if (
|
|
1457
|
+
record?.type !== "attestation"
|
|
1458
|
+
|| (!loaded.model.resources.attestation?.fields?.reportingRouteId
|
|
1459
|
+
&& !loaded.model.resources.attestation?.fields?.reportingRouteSetId)
|
|
1460
|
+
) return record;
|
|
1461
|
+
const date = record.assignedOn || record.completedOn || currentCalendarDate(loaded.workspace.timezone);
|
|
1462
|
+
if (loaded.model.resources.attestation.fields.reportingRouteSetId) {
|
|
1463
|
+
return bindAttestationReportingRouteSet(loaded, record);
|
|
1464
|
+
}
|
|
1465
|
+
const route = effectiveReportingRoute(loaded, "security-reporting", date);
|
|
1466
|
+
const bound = { ...record };
|
|
1467
|
+
delete bound.reportingRouteId;
|
|
1468
|
+
delete bound.reportingRouteRevision;
|
|
1469
|
+
if (!route) return bound;
|
|
1470
|
+
return {
|
|
1471
|
+
...bound,
|
|
1472
|
+
reportingRouteId: route.record.id,
|
|
1473
|
+
reportingRouteRevision: reportingRouteRevision(route.record)
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
|
|
787
1477
|
function contentRevision(source) {
|
|
788
1478
|
return createHash("sha256").update(source).digest("hex");
|
|
789
1479
|
}
|
|
@@ -863,6 +1553,11 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
|
|
|
863
1553
|
const complete = record.status === "done" && completionSatisfied;
|
|
864
1554
|
const window = plannedCompletionWindow(record.completionWindow);
|
|
865
1555
|
const lateCompletion = complete && completionWasLate(record, matchingCompletionIds, byId, window);
|
|
1556
|
+
const timelinessStatus = !complete || !window.dueWindowEndAt
|
|
1557
|
+
? null
|
|
1558
|
+
: matchingCompletionIds.some((id) => completionTimestamp(byId.get(id)))
|
|
1559
|
+
? lateCompletion ? "late" : "on-time"
|
|
1560
|
+
: "unknown";
|
|
866
1561
|
const timingStatus = complete
|
|
867
1562
|
? "complete"
|
|
868
1563
|
: window.overdueAt && new Date(now) > new Date(window.overdueAt)
|
|
@@ -897,6 +1592,7 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
|
|
|
897
1592
|
matchingCompletionIds,
|
|
898
1593
|
missingCompletion: record.status === "done" && !completionSatisfied,
|
|
899
1594
|
lateCompletion,
|
|
1595
|
+
timelinessStatus,
|
|
900
1596
|
canceledAction: record.status === "canceled",
|
|
901
1597
|
recordedStatus: record.status,
|
|
902
1598
|
completedOn: record.completedOn || null,
|
|
@@ -1088,7 +1784,13 @@ function occurrenceStatus(window, asOf, complete) {
|
|
|
1088
1784
|
return "upcoming";
|
|
1089
1785
|
}
|
|
1090
1786
|
|
|
1091
|
-
function obligationActivationDate(obligation, byId,
|
|
1787
|
+
function obligationActivationDate(obligation, byId, ruleOrModel, timezone = "UTC") {
|
|
1788
|
+
if (ruleOrModel?.type === "obligation-rule") {
|
|
1789
|
+
return ruleOrModel.effectiveAt
|
|
1790
|
+
? currentCalendarDate(timezone, new Date(ruleOrModel.effectiveAt))
|
|
1791
|
+
: null;
|
|
1792
|
+
}
|
|
1793
|
+
const model = ruleOrModel;
|
|
1092
1794
|
const policyDates = (obligation.policyIds || [])
|
|
1093
1795
|
.map((id) => byId.get(id))
|
|
1094
1796
|
.filter((policy) => policy?.type === "policy")
|
|
@@ -1154,6 +1856,72 @@ function obligationActivity(model, obligation) {
|
|
|
1154
1856
|
};
|
|
1155
1857
|
}
|
|
1156
1858
|
|
|
1859
|
+
function obligationRule(obligation, byId, options = {}) {
|
|
1860
|
+
const proposedId = options.includeProposed
|
|
1861
|
+
? [...(obligation.ruleIds || [])].reverse().find((id) => ["proposed", "approved"].includes(byId.get(id)?.status))
|
|
1862
|
+
: null;
|
|
1863
|
+
if (obligation?.scheduleMode !== "rule" && !proposedId) return null;
|
|
1864
|
+
let rule = byId.get(obligation.activeRuleId || proposedId);
|
|
1865
|
+
if (rule?.status === "active" && rule.effectiveAt && options.now && new Date(rule.effectiveAt) > new Date(options.now)) {
|
|
1866
|
+
const prior = byId.get(rule.supersedesId);
|
|
1867
|
+
return prior?.type === "obligation-rule"
|
|
1868
|
+
&& prior.obligationId === obligation.id
|
|
1869
|
+
&& ["active", "retired"].includes(prior.status)
|
|
1870
|
+
? prior
|
|
1871
|
+
: null;
|
|
1872
|
+
}
|
|
1873
|
+
return rule?.type === "obligation-rule"
|
|
1874
|
+
&& rule.obligationId === obligation.id
|
|
1875
|
+
&& (rule.status === "active" || (options.includeProposed && ["proposed", "approved"].includes(rule.status)))
|
|
1876
|
+
? rule
|
|
1877
|
+
: null;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
function currentOccurrence(records, occurrenceKey, obligationId, ruleId, window) {
|
|
1881
|
+
return records.find((record) => (
|
|
1882
|
+
record.type === "obligation-occurrence"
|
|
1883
|
+
&& record.occurrenceKey === occurrenceKey
|
|
1884
|
+
&& record.obligationId === obligationId
|
|
1885
|
+
&& record.ruleId === ruleId
|
|
1886
|
+
&& record.coverage?.kind === "range"
|
|
1887
|
+
&& record.coverage.startsOn === window.dueWindowStart
|
|
1888
|
+
&& record.coverage.endsOn === window.dueWindowEnd
|
|
1889
|
+
&& record.status !== "superseded"
|
|
1890
|
+
)) || null;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
function membershipIsFinal(selector, window, asOf) {
|
|
1894
|
+
if (!selector) return true;
|
|
1895
|
+
if (selector.cutoff === "window-end") return asOf > window.dueWindowEnd;
|
|
1896
|
+
if (["as-of", "event-subject"].includes(selector.membershipMode)) return asOf > window.dueWindowStart;
|
|
1897
|
+
return asOf > window.dueWindowEnd;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
function obligationBelongsToProgram(obligation, program, model) {
|
|
1901
|
+
if (!program || !modelSupports(model, "program-scope") || program.type !== "program") return true;
|
|
1902
|
+
const controlIds = new Set(program.controlIds || []);
|
|
1903
|
+
const policyIds = new Set(program.policyIds || []);
|
|
1904
|
+
const scopedIds = new Set([program.id, ...(program.systemIds || [])]);
|
|
1905
|
+
const linkedControls = obligation.controlIds || [];
|
|
1906
|
+
const linkedPolicies = obligation.policyIds || [];
|
|
1907
|
+
const linkedScope = obligation.scopeResourceIds || [];
|
|
1908
|
+
if (linkedControls.some((id) => controlIds.has(id))) return true;
|
|
1909
|
+
if (linkedPolicies.some((id) => policyIds.has(id))) return true;
|
|
1910
|
+
if (linkedScope.some((id) => scopedIds.has(id))) return true;
|
|
1911
|
+
return linkedControls.length === 0 && linkedPolicies.length === 0 && linkedScope.length === 0;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
function workItemBelongsToProgram(item, program, byId, model) {
|
|
1915
|
+
if (!program || !modelSupports(model, "program-scope") || program.type !== "program") return true;
|
|
1916
|
+
const source = byId.get(item.sourceResourceId);
|
|
1917
|
+
if (source?.programId) return source.programId === program.id;
|
|
1918
|
+
const controlIds = item.controlIds || [];
|
|
1919
|
+
const policyIds = item.policyIds || [];
|
|
1920
|
+
if (controlIds.length) return controlIds.some((id) => (program.controlIds || []).includes(id));
|
|
1921
|
+
if (policyIds.length) return policyIds.some((id) => (program.policyIds || []).includes(id));
|
|
1922
|
+
return true;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1157
1925
|
function requireDate(value, label) {
|
|
1158
1926
|
if (!parseCalendarDate(value)) throw new Error(`A valid ${label} is required.`);
|
|
1159
1927
|
return value;
|