filegrc 0.3.4 → 0.5.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/README.md +24 -6
- package/model/index.js +41 -3
- package/model/v1.json +81 -47
- package/model/v2.json +8022 -0
- package/model/v3.json +9391 -0
- package/package.json +2 -2
- package/src/agent.js +89 -8
- package/src/appointments.js +19 -0
- package/src/audit-preparation.js +109 -65
- package/src/audit-transition.js +96 -0
- package/src/batch-review.js +109 -0
- package/src/cli.js +563 -148
- package/src/collection-review.js +185 -0
- package/src/coverage.js +50 -0
- package/src/evidence-packet.js +149 -77
- package/src/external-reviewer.js +165 -0
- package/src/files.js +267 -29
- package/src/git.js +239 -41
- package/src/index.js +41 -7
- package/src/model-docs.js +103 -7
- package/src/model-migration.js +1958 -0
- package/src/mutation.js +42 -0
- package/src/obligations.js +502 -95
- package/src/parties.js +17 -2
- package/src/program-lifecycle.js +1 -0
- package/src/program-path.js +70 -60
- package/src/program-readiness.js +470 -130
- package/src/reconciliation.js +277 -0
- package/src/resource-status.js +17 -0
- package/src/server.js +347 -48
- package/src/setup.js +57 -26
- package/src/source-coverage.js +61 -0
- package/src/state.js +122 -25
- package/src/timing.js +41 -0
- package/src/validate.js +707 -44
- package/src/web.js +1440 -304
- package/src/workflow.js +1595 -0
- package/src/workspace.js +15 -7
- package/src/evidence-tests.js +0 -69
package/src/obligations.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { scaffoldResourceMutation } from "./agent.js";
|
|
1
3
|
import { createResourceId } from "./id.js";
|
|
2
4
|
import { createResourceAndLink, createResources, updateResource } from "./files.js";
|
|
5
|
+
import { loadModel } from "../model/index.js";
|
|
6
|
+
import { coverageEnd } from "./coverage.js";
|
|
3
7
|
import {
|
|
4
8
|
addCalendarDays,
|
|
5
9
|
calendarDayDifference,
|
|
@@ -8,7 +12,7 @@ import {
|
|
|
8
12
|
parseCalendarDate,
|
|
9
13
|
validCalendarRecurrence
|
|
10
14
|
} from "./recurrence.js";
|
|
11
|
-
import { isRfc3339Timestamp } from "./time.js";
|
|
15
|
+
import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
|
|
12
16
|
import { loadWorkspace } from "./workspace.js";
|
|
13
17
|
import { obligationProgramStatus } from "./program-lifecycle.js";
|
|
14
18
|
|
|
@@ -16,22 +20,32 @@ const COMPLETION_DATE_FIELDS = [
|
|
|
16
20
|
"completedOn",
|
|
17
21
|
"performedOn",
|
|
18
22
|
"reviewedOn",
|
|
19
|
-
"assessmentDate",
|
|
20
|
-
"meetingDate",
|
|
21
|
-
"scheduledOn",
|
|
22
23
|
"occurredOn",
|
|
23
24
|
"collectedOn",
|
|
24
25
|
"verifiedOn",
|
|
25
26
|
"approvedOn",
|
|
26
27
|
"submittedOn",
|
|
27
28
|
"closedOn",
|
|
28
|
-
"reportDate"
|
|
29
|
-
|
|
29
|
+
"reportDate"
|
|
30
|
+
];
|
|
31
|
+
const COMPLETION_TIMESTAMP_FIELDS = [
|
|
32
|
+
"completedAt",
|
|
33
|
+
"endedAt",
|
|
34
|
+
"closedAt",
|
|
35
|
+
"provisionedOn",
|
|
36
|
+
"deprovisionedOn"
|
|
30
37
|
];
|
|
31
38
|
const MAX_PLANNED_ITEMS = 10_000;
|
|
32
|
-
const DEFAULT_EVENT_DEADLINE_DAYS = 30;
|
|
33
39
|
|
|
34
40
|
export function planObligations(resources, options = {}) {
|
|
41
|
+
const records = resources.map((item) => item?.record ?? item).filter(Boolean);
|
|
42
|
+
const declaredModelVersion = records.find((record) => record.type === "workspace")?.dataModelVersion;
|
|
43
|
+
const model = options.model || (declaredModelVersion ? loadModel(declaredModelVersion) : null);
|
|
44
|
+
if (!model) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
"Obligation planning requires options.model or a Workspace record with dataModelVersion."
|
|
47
|
+
);
|
|
48
|
+
}
|
|
35
49
|
const asOf = requireDate(options.asOf ?? new Date().toISOString().slice(0, 10), "as-of date");
|
|
36
50
|
const defaultNow = options.asOf ? `${asOf}T23:59:59Z` : new Date().toISOString();
|
|
37
51
|
const now = requireTimestamp(options.now ?? defaultNow, "current timestamp");
|
|
@@ -39,9 +53,11 @@ export function planObligations(resources, options = {}) {
|
|
|
39
53
|
const requestedFrom = options.from ? requireDate(options.from, "from date") : null;
|
|
40
54
|
if (through < asOf && !requestedFrom) throw new Error("The through date must not be before the as-of date.");
|
|
41
55
|
if (requestedFrom && through < requestedFrom) throw new Error("The through date must not be before the from date.");
|
|
42
|
-
const records = resources.map((item) => item?.record ?? item).filter(Boolean);
|
|
43
56
|
const byId = new Map(records.map((record) => [record.id, record]));
|
|
44
|
-
const obligations = records.filter((record) =>
|
|
57
|
+
const obligations = records.filter((record) => (
|
|
58
|
+
record.type === "obligation"
|
|
59
|
+
&& ["active", "proposed"].includes(record.status)
|
|
60
|
+
));
|
|
45
61
|
if (obligations.length > MAX_PLANNED_ITEMS) {
|
|
46
62
|
throw new Error(`The obligation query must be narrowed; it includes more than ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} active obligations.`);
|
|
47
63
|
}
|
|
@@ -56,12 +72,15 @@ export function planObligations(resources, options = {}) {
|
|
|
56
72
|
};
|
|
57
73
|
|
|
58
74
|
for (const obligation of obligations) {
|
|
75
|
+
const activity = obligationActivity(model, obligation.activityType);
|
|
76
|
+
const expectedCompletionTypes = activity.completionResourceTypes;
|
|
59
77
|
const programStatus = obligationProgramStatus(obligation, byId, asOf);
|
|
60
78
|
if (obligation.recurrence?.mode === "event" && obligation.recurrence.eventType) {
|
|
61
79
|
const eventType = obligation.recurrence.eventType;
|
|
62
80
|
const group = triggerGroups.get(eventType) ?? {
|
|
63
81
|
eventType,
|
|
64
|
-
|
|
82
|
+
title: model.policyEvents?.[eventType]?.title || humanize(eventType),
|
|
83
|
+
prompt: obligation.triggerPrompt || model.policyEvents?.[eventType]?.title || humanize(eventType),
|
|
65
84
|
policyIds: [],
|
|
66
85
|
obligationIds: [],
|
|
67
86
|
programStatus,
|
|
@@ -78,8 +97,11 @@ export function planObligations(resources, options = {}) {
|
|
|
78
97
|
policyIds: obligation.policyIds || [],
|
|
79
98
|
controlIds: obligation.controlIds || [],
|
|
80
99
|
scopeResourceIds: obligation.scopeResourceIds || [],
|
|
100
|
+
eventRiskLevels: obligation.eventRiskLevels || [],
|
|
81
101
|
templateResourceId: obligation.templateResourceId || null,
|
|
82
|
-
completionResourceTypes:
|
|
102
|
+
completionResourceTypes: expectedCompletionTypes,
|
|
103
|
+
completionType: activity.completionType,
|
|
104
|
+
completionProfile: activity.completionProfile || null,
|
|
83
105
|
programStatus,
|
|
84
106
|
window: normalizedEventWindow(obligation.window)
|
|
85
107
|
});
|
|
@@ -113,7 +135,7 @@ export function planObligations(resources, options = {}) {
|
|
|
113
135
|
.filter((record) => (
|
|
114
136
|
record
|
|
115
137
|
&& completionFallsInWindow(record, window)
|
|
116
|
-
&& completionTypeMatches(record,
|
|
138
|
+
&& completionTypeMatches(record, expectedCompletionTypes)
|
|
117
139
|
));
|
|
118
140
|
const timingStatus = occurrenceStatus(window, asOf, completions.length > 0);
|
|
119
141
|
const status = timingStatus === "complete" || programStatus === "accepted" ? timingStatus : "proposed";
|
|
@@ -131,6 +153,9 @@ export function planObligations(resources, options = {}) {
|
|
|
131
153
|
policyIds: obligation.policyIds || [],
|
|
132
154
|
controlIds: obligation.controlIds || [],
|
|
133
155
|
scopeResourceIds: obligation.scopeResourceIds || [],
|
|
156
|
+
completionResourceTypes: expectedCompletionTypes,
|
|
157
|
+
completionType: activity.completionType,
|
|
158
|
+
completionProfile: activity.completionProfile || null,
|
|
134
159
|
completionResourceIds: completions.map((record) => record.id),
|
|
135
160
|
status,
|
|
136
161
|
timingStatus,
|
|
@@ -156,7 +181,14 @@ export function planObligations(resources, options = {}) {
|
|
|
156
181
|
if (!actionsBySource.has(record.sourceResourceId)) actionsBySource.set(record.sourceResourceId, []);
|
|
157
182
|
actionsBySource.get(record.sourceResourceId).push(record);
|
|
158
183
|
}
|
|
159
|
-
const eventRuns = events.map((event) => planEventRun(
|
|
184
|
+
const eventRuns = events.map((event) => planEventRun(
|
|
185
|
+
event,
|
|
186
|
+
actionsBySource.get(event.id) || [],
|
|
187
|
+
byId,
|
|
188
|
+
asOf,
|
|
189
|
+
now,
|
|
190
|
+
model
|
|
191
|
+
));
|
|
160
192
|
const eventItems = eventRuns
|
|
161
193
|
.filter((run) => run.status !== "canceled")
|
|
162
194
|
.flatMap((run) => run.actions)
|
|
@@ -169,12 +201,13 @@ export function planObligations(resources, options = {}) {
|
|
|
169
201
|
throw new Error(`The obligation query must be narrowed; it exceeds ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} planned items.`);
|
|
170
202
|
}
|
|
171
203
|
const items = [...calendarItems, ...eventItems, ...standaloneItems].sort(comparePlannedItems);
|
|
172
|
-
const counts = { overdue: 0, due: 0, upcoming: 0, proposed: 0, complete: 0 };
|
|
204
|
+
const counts = { overdue: 0, blocked: 0, due: 0, upcoming: 0, proposed: 0, complete: 0 };
|
|
173
205
|
for (const item of items) {
|
|
174
206
|
if (counts[item.status] !== undefined) counts[item.status] += 1;
|
|
175
207
|
}
|
|
176
208
|
|
|
177
209
|
return {
|
|
210
|
+
dataModelVersion: String(model.modelVersion),
|
|
178
211
|
asOf,
|
|
179
212
|
through,
|
|
180
213
|
from: requestedFrom,
|
|
@@ -206,17 +239,28 @@ export async function createObligationEvent(input, options) {
|
|
|
206
239
|
if (timestampDate && options?.occurredOn && occurredOn !== timestampDate) {
|
|
207
240
|
throw new Error(`The event date must match the event timestamp in ${loaded.workspace.timezone}.`);
|
|
208
241
|
}
|
|
242
|
+
const supportsRiskLevel = Boolean(loaded.model.resources["obligation-event"]?.fields?.riskLevel);
|
|
243
|
+
const riskLevel = supportsRiskLevel && eventType === "person-ended"
|
|
244
|
+
? String(options?.riskLevel || "normal")
|
|
245
|
+
: null;
|
|
246
|
+
if (riskLevel && !["normal", "high"].includes(riskLevel)) {
|
|
247
|
+
throw new Error("A departure risk level must be normal or high.");
|
|
248
|
+
}
|
|
209
249
|
const templates = records.filter((record) => (
|
|
210
250
|
record.type === "obligation"
|
|
211
251
|
&& record.status === "active"
|
|
212
252
|
&& record.recurrence?.mode === "event"
|
|
213
253
|
&& record.recurrence.eventType === eventType
|
|
254
|
+
&& (
|
|
255
|
+
!Array.isArray(record.eventRiskLevels)
|
|
256
|
+
|| record.eventRiskLevels.includes(riskLevel)
|
|
257
|
+
)
|
|
214
258
|
));
|
|
215
259
|
if (!eventType || templates.length === 0) throw new Error(`No active obligations use event type "${eventType}".`);
|
|
216
260
|
if (templates.some((record) => obligationProgramStatus(record, byId, occurredOn) === "proposed")) {
|
|
217
261
|
throw new Error(`Event type "${eventType}" still has starter proposals. Make every governing policy effective and implement at least one linked control before starting this workflow.`);
|
|
218
262
|
}
|
|
219
|
-
if (templates.some((record) =>
|
|
263
|
+
if (templates.some((record) => normalizedEventWindow(record.window).precision === "timestamp") && !occurredAt) {
|
|
220
264
|
throw new Error(`Event type "${eventType}" has hour-based deadlines and requires an RFC 3339 occurredAt timestamp.`);
|
|
221
265
|
}
|
|
222
266
|
const subjectResourceIds = [...new Set((options.subjectResourceIds || []).map(String).filter(Boolean))];
|
|
@@ -230,7 +274,6 @@ export async function createObligationEvent(input, options) {
|
|
|
230
274
|
existingIds.push(id);
|
|
231
275
|
const window = eventWindow(obligation, occurredOn, occurredAt, loaded.workspace.timezone);
|
|
232
276
|
return {
|
|
233
|
-
schemaVersion: 1,
|
|
234
277
|
id,
|
|
235
278
|
type: "action-item",
|
|
236
279
|
title: obligation.title,
|
|
@@ -238,16 +281,11 @@ export async function createObligationEvent(input, options) {
|
|
|
238
281
|
assigneeIds: obligation.ownerIds || [],
|
|
239
282
|
sourceResourceId: eventId,
|
|
240
283
|
obligationId: obligation.id,
|
|
241
|
-
description: eventActionDescription(obligation, eventType),
|
|
242
|
-
|
|
243
|
-
...(window.dueWindowEnd ? { dueWindowEnd: window.dueWindowEnd, dueOn: window.dueWindowEnd } : {}),
|
|
244
|
-
...(window.overdueOn ? { overdueOn: window.overdueOn } : {}),
|
|
245
|
-
...(window.dueWindowStartAt ? { dueWindowStartAt: window.dueWindowStartAt } : {}),
|
|
246
|
-
...(window.dueWindowEndAt ? { dueWindowEndAt: window.dueWindowEndAt, overdueAt: window.dueWindowEndAt } : {})
|
|
284
|
+
description: eventActionDescription(obligation, eventType, loaded.model),
|
|
285
|
+
completionWindow: storedCompletionWindow(window, loaded.workspace.timezone)
|
|
247
286
|
};
|
|
248
287
|
});
|
|
249
288
|
const event = {
|
|
250
|
-
schemaVersion: 1,
|
|
251
289
|
id: eventId,
|
|
252
290
|
type: "obligation-event",
|
|
253
291
|
title,
|
|
@@ -255,6 +293,10 @@ export async function createObligationEvent(input, options) {
|
|
|
255
293
|
eventType,
|
|
256
294
|
occurredOn,
|
|
257
295
|
...(occurredAt ? { occurredAt } : {}),
|
|
296
|
+
...(riskLevel ? { riskLevel } : {}),
|
|
297
|
+
...(options.transitionFingerprint && loaded.model.resources["obligation-event"]?.fields?.transitionFingerprint
|
|
298
|
+
? { transitionFingerprint: String(options.transitionFingerprint) }
|
|
299
|
+
: {}),
|
|
258
300
|
ownerIds: [...new Set(templates.flatMap((record) => record.ownerIds || []))],
|
|
259
301
|
obligationIds: templates.map((record) => record.id),
|
|
260
302
|
...(subjectResourceIds.length ? { subjectResourceIds } : {})
|
|
@@ -269,7 +311,7 @@ export async function completeObligationOccurrence(input, options) {
|
|
|
269
311
|
record.type === "obligation" && record.id === options?.obligationId
|
|
270
312
|
));
|
|
271
313
|
if (!obligation) throw new Error(`Obligation "${options?.obligationId ?? ""}" was not found.`);
|
|
272
|
-
assertExpectedCompletionType(obligation, options?.record);
|
|
314
|
+
assertExpectedCompletionType(obligation, options?.record, loaded.model);
|
|
273
315
|
return createResourceAndLink(loaded.root, options.record, {
|
|
274
316
|
type: "obligation",
|
|
275
317
|
id: obligation.id,
|
|
@@ -278,6 +320,62 @@ export async function completeObligationOccurrence(input, options) {
|
|
|
278
320
|
}, { content: options.content });
|
|
279
321
|
}
|
|
280
322
|
|
|
323
|
+
export async function scaffoldObligationCompletion(input, options = {}) {
|
|
324
|
+
const loaded = await loadWorkspace(input);
|
|
325
|
+
const action = options.actionItemId
|
|
326
|
+
? loaded.resources.find((record) => record.type === "action-item" && record.id === options.actionItemId)
|
|
327
|
+
: null;
|
|
328
|
+
if (options.actionItemId && !action) {
|
|
329
|
+
throw new Error(`Action item "${options.actionItemId}" was not found.`);
|
|
330
|
+
}
|
|
331
|
+
const obligationId = action?.obligationId || options.obligationId;
|
|
332
|
+
const obligation = loaded.resources.find((record) => (
|
|
333
|
+
record.type === "obligation" && record.id === obligationId
|
|
334
|
+
));
|
|
335
|
+
if (!obligation) throw new Error(`Obligation "${obligationId ?? ""}" was not found.`);
|
|
336
|
+
|
|
337
|
+
const completedOn = requireDate(
|
|
338
|
+
options.completedOn || currentCalendarDate(loaded.workspace.timezone),
|
|
339
|
+
"completion date"
|
|
340
|
+
);
|
|
341
|
+
const item = action
|
|
342
|
+
? plannedActionForScaffold(loaded, action, completedOn)
|
|
343
|
+
: plannedOccurrenceForScaffold(loaded, obligation, options.windowStart, completedOn);
|
|
344
|
+
const activity = obligationActivity(loaded.model, obligation.activityType);
|
|
345
|
+
const type = activity.completionType;
|
|
346
|
+
if (!type) throw new Error(`Obligation "${obligation.id}" has no configured completion resource type.`);
|
|
347
|
+
|
|
348
|
+
const mutation = scaffoldResourceMutation(
|
|
349
|
+
loaded,
|
|
350
|
+
type,
|
|
351
|
+
`${item.title} · ${item.dueWindowStart || completedOn}`
|
|
352
|
+
);
|
|
353
|
+
applyCompletionScaffoldDefaults(mutation.record, {
|
|
354
|
+
loaded,
|
|
355
|
+
item,
|
|
356
|
+
obligation,
|
|
357
|
+
completedOn,
|
|
358
|
+
activity
|
|
359
|
+
});
|
|
360
|
+
const target = action || obligation;
|
|
361
|
+
const entry = loaded.entries.find(({ record }) => record.type === target.type && record.id === target.id);
|
|
362
|
+
return {
|
|
363
|
+
...mutation,
|
|
364
|
+
revision: contentRevision(entry?.source || ""),
|
|
365
|
+
scaffold: {
|
|
366
|
+
target: { type: target.type, id: target.id },
|
|
367
|
+
obligationId: obligation.id,
|
|
368
|
+
activityType: obligation.activityType,
|
|
369
|
+
completionResourceType: type,
|
|
370
|
+
completionProfile: activity.completionProfile || null,
|
|
371
|
+
requiredFacts: loaded.model.completionProfiles?.[activity.completionProfile]?.requiredFacts || [],
|
|
372
|
+
dueWindowStart: item.dueWindowStart || null,
|
|
373
|
+
dueWindowEnd: item.dueWindowEnd || null,
|
|
374
|
+
instructions: "Replace every null or empty required value with the actual work performed. Keep the actual completion date and time, actors, result, scope, independent review, and supporting evidence. This revision makes the completed write safe against a stale Work Queue item."
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
281
379
|
export async function completeObligationAction(input, options) {
|
|
282
380
|
const loaded = await loadWorkspace(input);
|
|
283
381
|
const action = loaded.resources.find((record) => (
|
|
@@ -285,11 +383,14 @@ export async function completeObligationAction(input, options) {
|
|
|
285
383
|
));
|
|
286
384
|
if (!action) throw new Error(`Action item "${options?.actionItemId ?? ""}" was not found.`);
|
|
287
385
|
if (!action.obligationId) throw new Error(`Action item "${action.id}" is not linked to an obligation.`);
|
|
386
|
+
if (action.status === "blocked") {
|
|
387
|
+
throw new Error(`Action item "${action.id}" is blocked. Resolve its blockingResourceIds before completing it.`);
|
|
388
|
+
}
|
|
288
389
|
const obligation = loaded.resources.find((record) => (
|
|
289
390
|
record.type === "obligation" && record.id === action.obligationId
|
|
290
391
|
));
|
|
291
392
|
if (!obligation) throw new Error(`Obligation "${action.obligationId}" was not found.`);
|
|
292
|
-
assertExpectedCompletionType(obligation, options?.record);
|
|
393
|
+
assertExpectedCompletionType(obligation, options?.record, loaded.model);
|
|
293
394
|
const completedOn = requireDate(options?.completedOn, "completion date");
|
|
294
395
|
const event = loaded.resources.find((record) => record.type === "obligation-event" && record.id === action.sourceResourceId);
|
|
295
396
|
if (event?.occurredOn && completedOn < event.occurredOn) {
|
|
@@ -320,7 +421,8 @@ export async function completeObligationEvent(input, options) {
|
|
|
320
421
|
const plan = planObligations(loaded.resources, {
|
|
321
422
|
asOf: completedOn,
|
|
322
423
|
through: completedOn,
|
|
323
|
-
includeComplete: true
|
|
424
|
+
includeComplete: true,
|
|
425
|
+
model: loaded.model
|
|
324
426
|
});
|
|
325
427
|
const run = plan.eventRuns.find((item) => item.id === event.id);
|
|
326
428
|
if (!run || run.actions.length === 0) {
|
|
@@ -341,11 +443,11 @@ export async function completeObligationEvent(input, options) {
|
|
|
341
443
|
});
|
|
342
444
|
}
|
|
343
445
|
|
|
344
|
-
function assertExpectedCompletionType(obligation, record) {
|
|
446
|
+
function assertExpectedCompletionType(obligation, record, model) {
|
|
345
447
|
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
|
346
448
|
throw new Error("A completion resource record is required.");
|
|
347
449
|
}
|
|
348
|
-
const expected = obligation.completionResourceTypes
|
|
450
|
+
const expected = obligationActivity(model, obligation.activityType).completionResourceTypes;
|
|
349
451
|
if (expected.length && !expected.includes(record.type)) {
|
|
350
452
|
throw new Error(
|
|
351
453
|
`Obligation "${obligation.id}" expects a completion resource of type ${expected.join(" or ")}, not "${record.type ?? ""}".`
|
|
@@ -353,14 +455,287 @@ function assertExpectedCompletionType(obligation, record) {
|
|
|
353
455
|
}
|
|
354
456
|
}
|
|
355
457
|
|
|
458
|
+
function plannedOccurrenceForScaffold(loaded, obligation, windowStart, completedOn) {
|
|
459
|
+
const start = requireDate(windowStart, "occurrence window start");
|
|
460
|
+
const plan = planObligations(loaded.resources, {
|
|
461
|
+
from: start,
|
|
462
|
+
asOf: completedOn,
|
|
463
|
+
through: start,
|
|
464
|
+
includeComplete: true,
|
|
465
|
+
model: loaded.model
|
|
466
|
+
});
|
|
467
|
+
const item = plan.calendarItems.find((candidate) => (
|
|
468
|
+
candidate.obligationId === obligation.id && candidate.dueWindowStart === start
|
|
469
|
+
));
|
|
470
|
+
if (!item) {
|
|
471
|
+
throw new Error(`No ${obligation.id} occurrence starts on ${start}. Run filegrc obligations --json and use its dueWindowStart.`);
|
|
472
|
+
}
|
|
473
|
+
return item;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function plannedActionForScaffold(loaded, action, completedOn) {
|
|
477
|
+
const plan = planObligations(loaded.resources, {
|
|
478
|
+
asOf: completedOn,
|
|
479
|
+
through: completedOn,
|
|
480
|
+
includeComplete: true,
|
|
481
|
+
model: loaded.model
|
|
482
|
+
});
|
|
483
|
+
const item = plan.eventItems.find((candidate) => candidate.actionItemId === action.id);
|
|
484
|
+
if (!item) throw new Error(`Action item "${action.id}" is not an active Work Queue item.`);
|
|
485
|
+
return item;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function applyCompletionScaffoldDefaults(record, context) {
|
|
489
|
+
const { loaded, item, obligation, completedOn, activity } = context;
|
|
490
|
+
const responsiblePeople = currentPeopleForParties(loaded.resources, item.ownerIds || []);
|
|
491
|
+
if (!responsiblePeople.length) {
|
|
492
|
+
throw new Error(`Obligation "${obligation.id}" needs an active owner whose Appointment or Team resolves to a current Person.`);
|
|
493
|
+
}
|
|
494
|
+
const reviewer = loaded.resources.find((candidate) => (
|
|
495
|
+
candidate.type === "person"
|
|
496
|
+
&& ["active", "external"].includes(candidate.status)
|
|
497
|
+
&& !responsiblePeople.includes(candidate.id)
|
|
498
|
+
));
|
|
499
|
+
const reviewerIds = reviewer ? [reviewer.id] : [];
|
|
500
|
+
const systemIds = loaded.resources
|
|
501
|
+
.filter((candidate) => (
|
|
502
|
+
candidate.type === "system"
|
|
503
|
+
&& candidate.status !== "retired"
|
|
504
|
+
&& (loaded.workspace.systemIds || []).includes(candidate.id)
|
|
505
|
+
))
|
|
506
|
+
.map(({ id }) => id);
|
|
507
|
+
const vendorIds = loaded.resources
|
|
508
|
+
.filter((candidate) => candidate.type === "vendor" && candidate.status !== "terminated")
|
|
509
|
+
.map(({ id }) => id);
|
|
510
|
+
const timestamp = completedOn === new Date().toISOString().slice(0, 10)
|
|
511
|
+
? new Date().toISOString()
|
|
512
|
+
: null;
|
|
513
|
+
const coverage = {
|
|
514
|
+
kind: "range",
|
|
515
|
+
startsOn: item.dueWindowStart || completedOn,
|
|
516
|
+
endsOn: item.dueWindowEnd || completedOn
|
|
517
|
+
};
|
|
518
|
+
const common = { status: "complete" };
|
|
519
|
+
const defaults = {
|
|
520
|
+
meeting: () => {
|
|
521
|
+
const team = completionTeam(loaded.resources, item.ownerIds || []);
|
|
522
|
+
if (!team) throw new Error("A Meeting completion needs an active Team with an active chair.");
|
|
523
|
+
return {
|
|
524
|
+
...common,
|
|
525
|
+
teamId: team.id,
|
|
526
|
+
chairIds: currentPeopleForParties(loaded.resources, team.chairIds || []),
|
|
527
|
+
scheduledFor: completedOn,
|
|
528
|
+
startedAt: timestamp,
|
|
529
|
+
endedAt: timestamp,
|
|
530
|
+
attendeeIds: responsiblePeople
|
|
531
|
+
};
|
|
532
|
+
},
|
|
533
|
+
"policy-review": () => ({
|
|
534
|
+
...common,
|
|
535
|
+
scopeResourceIds: obligation.scopeResourceIds || [],
|
|
536
|
+
reviewerIds,
|
|
537
|
+
completedOn,
|
|
538
|
+
outcome: "passed",
|
|
539
|
+
changesRequired: false,
|
|
540
|
+
evidenceIds: [],
|
|
541
|
+
coverage
|
|
542
|
+
}),
|
|
543
|
+
"risk-assessment": () => ({
|
|
544
|
+
...common,
|
|
545
|
+
completedOn,
|
|
546
|
+
assessmentKind: "enterprise-risk",
|
|
547
|
+
scope: "In-scope SOC 2 systems and dependencies",
|
|
548
|
+
assessorIds: responsiblePeople,
|
|
549
|
+
reviewerIds,
|
|
550
|
+
methodology: loaded.workspace.riskMethodology?.method || "Documented risk methodology",
|
|
551
|
+
summary: "",
|
|
552
|
+
evidenceIds: [],
|
|
553
|
+
approvedOn: completedOn
|
|
554
|
+
}),
|
|
555
|
+
attestation: () => ({
|
|
556
|
+
status: "completed",
|
|
557
|
+
subjectResourceIds: [...new Set([
|
|
558
|
+
obligation.templateResourceId,
|
|
559
|
+
...(obligation.scopeResourceIds || [])
|
|
560
|
+
].filter(Boolean))],
|
|
561
|
+
personId: responsiblePeople[0],
|
|
562
|
+
attestationKind: obligation.activityType || "completion",
|
|
563
|
+
assignedOn: item.dueWindowStart || completedOn,
|
|
564
|
+
dueOn: item.dueWindowEnd || completedOn,
|
|
565
|
+
completedOn,
|
|
566
|
+
attestationMethod: "git-approval"
|
|
567
|
+
}),
|
|
568
|
+
"access-review": () => {
|
|
569
|
+
if (!systemIds.length) throw new Error("An Access Review completion needs an active in-scope System.");
|
|
570
|
+
return {
|
|
571
|
+
...common,
|
|
572
|
+
completedOn,
|
|
573
|
+
reviewerIds: responsiblePeople,
|
|
574
|
+
systemIds,
|
|
575
|
+
scope: "Privileged, production, and important-system access",
|
|
576
|
+
outcome: "passed",
|
|
577
|
+
evidenceIds: [],
|
|
578
|
+
approvedByIds: reviewerIds,
|
|
579
|
+
approvedOn: completedOn,
|
|
580
|
+
coverage
|
|
581
|
+
};
|
|
582
|
+
},
|
|
583
|
+
"vulnerability-scan": () => ({
|
|
584
|
+
...common,
|
|
585
|
+
scanKind: "vulnerability",
|
|
586
|
+
scope: "In-scope systems",
|
|
587
|
+
operatorIds: responsiblePeople,
|
|
588
|
+
scheduledFor: completedOn,
|
|
589
|
+
completedAt: timestamp,
|
|
590
|
+
systemIds,
|
|
591
|
+
resultSummary: "",
|
|
592
|
+
evidenceIds: [],
|
|
593
|
+
reviewerIds,
|
|
594
|
+
reviewedOn: completedOn
|
|
595
|
+
}),
|
|
596
|
+
"penetration-test": () => ({
|
|
597
|
+
...common,
|
|
598
|
+
testKind: "independent",
|
|
599
|
+
scope: "In-scope systems and service boundary",
|
|
600
|
+
coverage: { kind: "as-of", on: completedOn },
|
|
601
|
+
ownerIds: responsiblePeople,
|
|
602
|
+
outcome: "passed",
|
|
603
|
+
evidenceIds: [],
|
|
604
|
+
systemIds,
|
|
605
|
+
completedOn,
|
|
606
|
+
reviewerIds,
|
|
607
|
+
reviewedOn: completedOn
|
|
608
|
+
}),
|
|
609
|
+
"control-test": () => ({
|
|
610
|
+
...common,
|
|
611
|
+
controlId: obligation.controlIds?.[0] || null,
|
|
612
|
+
testKinds: [obligation.activityType || "control-operation"],
|
|
613
|
+
performedBy: "management",
|
|
614
|
+
testerIds: responsiblePeople,
|
|
615
|
+
reviewerIds,
|
|
616
|
+
completedOn,
|
|
617
|
+
reviewedOn: completedOn,
|
|
618
|
+
outcome: "passed",
|
|
619
|
+
evidenceIds: [],
|
|
620
|
+
coverage
|
|
621
|
+
}),
|
|
622
|
+
"control-activity": () => ({
|
|
623
|
+
...common,
|
|
624
|
+
profileId: activity.completionProfile || obligation.activityType,
|
|
625
|
+
obligationId: obligation.id,
|
|
626
|
+
controlIds: item.controlIds || obligation.controlIds || [],
|
|
627
|
+
scopeResourceIds: (item.scopeResourceIds || obligation.scopeResourceIds || []).length
|
|
628
|
+
? (item.scopeResourceIds || obligation.scopeResourceIds)
|
|
629
|
+
: [loaded.workspace.id],
|
|
630
|
+
performerIds: responsiblePeople,
|
|
631
|
+
completedAt: timestamp,
|
|
632
|
+
method: "",
|
|
633
|
+
result: "",
|
|
634
|
+
reviewerIds,
|
|
635
|
+
reviewedOn: completedOn,
|
|
636
|
+
ownerIds: item.ownerIds || obligation.ownerIds || []
|
|
637
|
+
}),
|
|
638
|
+
exercise: () => ({
|
|
639
|
+
...common,
|
|
640
|
+
exerciseKind: item.title.toLowerCase().includes("continuity") ? "business-continuity" : "incident-response",
|
|
641
|
+
scheduledFor: completedOn,
|
|
642
|
+
facilitatorIds: responsiblePeople,
|
|
643
|
+
objective: item.title,
|
|
644
|
+
outcome: "passed",
|
|
645
|
+
evidenceIds: [],
|
|
646
|
+
systemIds,
|
|
647
|
+
completedAt: timestamp
|
|
648
|
+
}),
|
|
649
|
+
"backup-test": () => {
|
|
650
|
+
if (!systemIds.length) throw new Error("A Backup Test completion needs an active in-scope System.");
|
|
651
|
+
return {
|
|
652
|
+
...common,
|
|
653
|
+
systemIds,
|
|
654
|
+
scheduledFor: completedOn,
|
|
655
|
+
operatorIds: responsiblePeople,
|
|
656
|
+
reviewerIds,
|
|
657
|
+
outcome: "passed",
|
|
658
|
+
evidenceIds: [],
|
|
659
|
+
completedAt: timestamp
|
|
660
|
+
};
|
|
661
|
+
},
|
|
662
|
+
"vendor-review": () => {
|
|
663
|
+
const subjectVendor = (item.subjectResourceIds || []).find((id) => vendorIds.includes(id));
|
|
664
|
+
if (!subjectVendor && !vendorIds.length) throw new Error("A Vendor Review completion needs an active Vendor.");
|
|
665
|
+
return {
|
|
666
|
+
...common,
|
|
667
|
+
vendorId: subjectVendor || vendorIds[0],
|
|
668
|
+
reviewerIds: responsiblePeople,
|
|
669
|
+
completedOn,
|
|
670
|
+
decision: "approved",
|
|
671
|
+
evidenceIds: [],
|
|
672
|
+
coverage
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
const values = defaults[record.type]?.() || {
|
|
677
|
+
status: "collected",
|
|
678
|
+
artifactKind: "business-record",
|
|
679
|
+
artifactSubtype: obligation.activityType || "control-operation",
|
|
680
|
+
sourceKind: "authored-record",
|
|
681
|
+
sourceDescription: "Internal control operation",
|
|
682
|
+
collectedOn: completedOn,
|
|
683
|
+
collectorIds: responsiblePeople,
|
|
684
|
+
classificationId: defaultClassificationId(loaded.workspace),
|
|
685
|
+
coverage,
|
|
686
|
+
controlIds: item.controlIds || obligation.controlIds || [],
|
|
687
|
+
sourceResourceIds: [obligation.id]
|
|
688
|
+
};
|
|
689
|
+
Object.assign(record, values);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function currentPeopleForParties(resources, ids = [], seen = new Set()) {
|
|
693
|
+
const byId = new Map(resources.map((record) => [record.id, record]));
|
|
694
|
+
const people = [];
|
|
695
|
+
for (const id of ids) {
|
|
696
|
+
if (seen.has(id)) continue;
|
|
697
|
+
seen.add(id);
|
|
698
|
+
const party = byId.get(id);
|
|
699
|
+
if (party?.type === "person" && ["active", "external"].includes(party.status)) people.push(party.id);
|
|
700
|
+
if (party?.type === "team" && party.status === "active") {
|
|
701
|
+
people.push(...currentPeopleForParties(resources, [...(party.memberIds || []), ...(party.chairIds || [])], seen));
|
|
702
|
+
}
|
|
703
|
+
if (party?.type === "appointment" && party.status === "active") {
|
|
704
|
+
people.push(...currentPeopleForParties(resources, [party.holderId], seen));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return [...new Set(people)];
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function completionTeam(resources, ownerIds) {
|
|
711
|
+
const owners = new Set(ownerIds);
|
|
712
|
+
const teams = resources.filter((record) => record.type === "team");
|
|
713
|
+
const owned = teams.filter((record) => owners.has(record.id));
|
|
714
|
+
return (owned.length ? owned : teams).find((record) => (
|
|
715
|
+
record.status === "active"
|
|
716
|
+
&& currentPeopleForParties(resources, record.chairIds || []).length
|
|
717
|
+
)) || null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function defaultClassificationId(workspace) {
|
|
721
|
+
const definitions = workspace.classificationDefinitions || {};
|
|
722
|
+
return Object.hasOwn(definitions, "internal") ? "internal" : Object.keys(definitions)[0] || "";
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function contentRevision(source) {
|
|
726
|
+
return createHash("sha256").update(source).digest("hex");
|
|
727
|
+
}
|
|
728
|
+
|
|
356
729
|
function calendarWindow(recurrence, configuredWindow, index) {
|
|
357
730
|
const occurrence = calendarOccurrence(recurrence, index);
|
|
358
731
|
const next = calendarOccurrence(recurrence, index + 1);
|
|
359
732
|
if (!occurrence || !next) return null;
|
|
360
|
-
const startOffset = Number.isInteger(configuredWindow
|
|
733
|
+
const startOffset = configuredWindow?.precision === "date" && Number.isInteger(configuredWindow.startsAfter)
|
|
734
|
+
? configuredWindow.startsAfter
|
|
735
|
+
: 0;
|
|
361
736
|
const dueWindowStart = addCalendarDays(occurrence, startOffset);
|
|
362
|
-
const dueWindowEnd = Number.isInteger(configuredWindow
|
|
363
|
-
? addCalendarDays(occurrence, configuredWindow.
|
|
737
|
+
const dueWindowEnd = configuredWindow?.precision === "date" && Number.isInteger(configuredWindow.dueAfter)
|
|
738
|
+
? addCalendarDays(occurrence, configuredWindow.dueAfter)
|
|
364
739
|
: addCalendarDays(next, -1);
|
|
365
740
|
const overdueOn = dueWindowEnd ? addCalendarDays(dueWindowEnd, 1) : null;
|
|
366
741
|
if (!dueWindowStart || !dueWindowEnd || !overdueOn) return null;
|
|
@@ -373,10 +748,10 @@ function calendarWindow(recurrence, configuredWindow, index) {
|
|
|
373
748
|
|
|
374
749
|
function eventWindow(obligation, occurredOn, occurredAt, timezone) {
|
|
375
750
|
const configuredWindow = normalizedEventWindow(obligation.window);
|
|
376
|
-
if (
|
|
377
|
-
const startOffset = Number.isInteger(configuredWindow.
|
|
751
|
+
if (configuredWindow.precision === "timestamp" && occurredAt) {
|
|
752
|
+
const startOffset = Number.isInteger(configuredWindow.startsAfter) ? configuredWindow.startsAfter : 0;
|
|
378
753
|
const dueWindowStartAt = addHours(occurredAt, startOffset);
|
|
379
|
-
const dueWindowEndAt = addHours(occurredAt, configuredWindow.
|
|
754
|
+
const dueWindowEndAt = addHours(occurredAt, configuredWindow.dueAfter);
|
|
380
755
|
return {
|
|
381
756
|
dueWindowStart: timestampCalendarDate(dueWindowStartAt, timezone),
|
|
382
757
|
dueWindowEnd: timestampCalendarDate(dueWindowEndAt, timezone),
|
|
@@ -386,9 +761,9 @@ function eventWindow(obligation, occurredOn, occurredAt, timezone) {
|
|
|
386
761
|
overdueAt: dueWindowEndAt
|
|
387
762
|
};
|
|
388
763
|
}
|
|
389
|
-
const startOffset = Number.isInteger(configuredWindow.
|
|
764
|
+
const startOffset = Number.isInteger(configuredWindow.startsAfter) ? configuredWindow.startsAfter : 0;
|
|
390
765
|
const dueWindowStart = addCalendarDays(occurredOn, startOffset);
|
|
391
|
-
const dueWindowEnd = addCalendarDays(occurredOn, configuredWindow.
|
|
766
|
+
const dueWindowEnd = addCalendarDays(occurredOn, configuredWindow.dueAfter);
|
|
392
767
|
const overdueOn = dueWindowEnd ? addCalendarDays(dueWindowEnd, 1) : null;
|
|
393
768
|
if (!dueWindowStart || !dueWindowEnd || !overdueOn) {
|
|
394
769
|
throw new Error("Event deadline dates must fall within the supported calendar range.");
|
|
@@ -400,42 +775,26 @@ function eventWindow(obligation, occurredOn, occurredAt, timezone) {
|
|
|
400
775
|
};
|
|
401
776
|
}
|
|
402
777
|
|
|
403
|
-
function planEventRun(event, actionItems, byId, asOf, now) {
|
|
778
|
+
function planEventRun(event, actionItems, byId, asOf, now, model) {
|
|
404
779
|
const actions = actionItems
|
|
405
780
|
.map((record) => {
|
|
406
781
|
const obligation = byId.get(record.obligationId);
|
|
407
|
-
const expectedCompletionTypes = obligation?.
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
782
|
+
const expectedCompletionTypes = obligation?.type === "obligation"
|
|
783
|
+
? obligationActivity(model, obligation.activityType).completionResourceTypes
|
|
784
|
+
: [];
|
|
785
|
+
const completionProfile = obligation?.type === "obligation"
|
|
786
|
+
? obligationActivity(model, obligation.activityType).completionProfile || null
|
|
787
|
+
: null;
|
|
788
|
+
const completionIds = String(model.modelVersion) === "3"
|
|
789
|
+
? record.completionResourceIds || []
|
|
790
|
+
: [...(record.completionResourceIds || []), ...(record.evidenceIds || [])];
|
|
791
|
+
const linkedCompletionIds = [...new Set(completionIds)];
|
|
412
792
|
const matchingCompletionIds = linkedCompletionIds.filter((id) => completionTypeMatches(byId.get(id), expectedCompletionTypes));
|
|
413
793
|
const completionSatisfied = expectedCompletionTypes.length === 0
|
|
414
794
|
|| matchingCompletionIds.length > 0;
|
|
415
795
|
const complete = record.status === "done" && completionSatisfied;
|
|
416
|
-
const
|
|
417
|
-
const
|
|
418
|
-
? configuredWindow.endOffsetDays
|
|
419
|
-
: Math.ceil(configuredWindow.endOffsetHours / 24);
|
|
420
|
-
const dueWindowStart = record.dueWindowStart || event.occurredOn;
|
|
421
|
-
const dueWindowEnd = record.dueWindowEnd || record.dueOn || addCalendarDays(event.occurredOn, fallbackEndOffsetDays);
|
|
422
|
-
const dueWindowStartAt = record.dueWindowStartAt
|
|
423
|
-
|| (event.occurredAt && Number.isInteger(configuredWindow.startOffsetHours)
|
|
424
|
-
? addHours(event.occurredAt, configuredWindow.startOffsetHours)
|
|
425
|
-
: null);
|
|
426
|
-
const dueWindowEndAt = record.dueWindowEndAt
|
|
427
|
-
|| (event.occurredAt && Number.isInteger(configuredWindow.endOffsetHours)
|
|
428
|
-
? addHours(event.occurredAt, configuredWindow.endOffsetHours)
|
|
429
|
-
: null);
|
|
430
|
-
const window = {
|
|
431
|
-
dueWindowStart,
|
|
432
|
-
dueWindowEnd,
|
|
433
|
-
overdueOn: record.overdueOn || (dueWindowEnd ? addCalendarDays(dueWindowEnd, 1) : null),
|
|
434
|
-
dueWindowStartAt,
|
|
435
|
-
dueWindowEndAt,
|
|
436
|
-
overdueAt: record.overdueAt || dueWindowEndAt
|
|
437
|
-
};
|
|
438
|
-
const status = complete
|
|
796
|
+
const window = plannedCompletionWindow(record.completionWindow);
|
|
797
|
+
const timingStatus = complete
|
|
439
798
|
? "complete"
|
|
440
799
|
: window.overdueAt && new Date(now) > new Date(window.overdueAt)
|
|
441
800
|
? "overdue"
|
|
@@ -446,6 +805,7 @@ function planEventRun(event, actionItems, byId, asOf, now) {
|
|
|
446
805
|
: window.dueWindowStart <= asOf
|
|
447
806
|
? "due"
|
|
448
807
|
: "upcoming";
|
|
808
|
+
const status = record.status === "blocked" ? "blocked" : timingStatus;
|
|
449
809
|
return {
|
|
450
810
|
key: record.id,
|
|
451
811
|
kind: "event",
|
|
@@ -453,6 +813,7 @@ function planEventRun(event, actionItems, byId, asOf, now) {
|
|
|
453
813
|
actionItemId: record.id,
|
|
454
814
|
obligationId: record.obligationId,
|
|
455
815
|
title: record.title,
|
|
816
|
+
activityType: obligation?.activityType || null,
|
|
456
817
|
ownerIds: record.assigneeIds || [],
|
|
457
818
|
policyIds: obligation?.policyIds || [],
|
|
458
819
|
controlIds: obligation?.controlIds || [],
|
|
@@ -461,12 +822,16 @@ function planEventRun(event, actionItems, byId, asOf, now) {
|
|
|
461
822
|
completionResourceIds: record.completionResourceIds || [],
|
|
462
823
|
evidenceIds: record.evidenceIds || [],
|
|
463
824
|
expectedCompletionTypes,
|
|
825
|
+
completionProfile,
|
|
464
826
|
matchingCompletionIds,
|
|
465
827
|
missingCompletion: record.status === "done" && !completionSatisfied,
|
|
466
828
|
canceledAction: record.status === "canceled",
|
|
467
829
|
recordedStatus: record.status,
|
|
468
830
|
completedOn: record.completedOn || null,
|
|
831
|
+
blockingResourceIds: record.blockingResourceIds || [],
|
|
832
|
+
blockingReason: actionBlockingReason(record, byId),
|
|
469
833
|
status,
|
|
834
|
+
timingStatus,
|
|
470
835
|
...window,
|
|
471
836
|
...relativeTiming(window, asOf),
|
|
472
837
|
...relativeTimestampTiming(window, now)
|
|
@@ -478,6 +843,8 @@ function planEventRun(event, actionItems, byId, asOf, now) {
|
|
|
478
843
|
? "complete"
|
|
479
844
|
: actions.some((item) => item.status === "overdue")
|
|
480
845
|
? "overdue"
|
|
846
|
+
: actions.some((item) => item.status === "blocked")
|
|
847
|
+
? "blocked"
|
|
481
848
|
: actions.length > 0 && actions.every((item) => item.status === "upcoming")
|
|
482
849
|
? "upcoming"
|
|
483
850
|
: "due";
|
|
@@ -498,20 +865,9 @@ function planEventRun(event, actionItems, byId, asOf, now) {
|
|
|
498
865
|
|
|
499
866
|
function planStandaloneAction(record, byId, asOf, now) {
|
|
500
867
|
const source = byId.get(record.sourceResourceId);
|
|
501
|
-
const
|
|
502
|
-
const dueWindowStart = record.dueWindowStart || dueWindowEnd;
|
|
503
|
-
const dueWindowEndAt = record.dueWindowEndAt || null;
|
|
504
|
-
const dueWindowStartAt = record.dueWindowStartAt || dueWindowEndAt;
|
|
505
|
-
const window = {
|
|
506
|
-
dueWindowStart,
|
|
507
|
-
dueWindowEnd,
|
|
508
|
-
overdueOn: record.overdueOn || (dueWindowEnd ? addCalendarDays(dueWindowEnd, 1) : null),
|
|
509
|
-
dueWindowStartAt,
|
|
510
|
-
dueWindowEndAt,
|
|
511
|
-
overdueAt: record.overdueAt || dueWindowEndAt
|
|
512
|
-
};
|
|
868
|
+
const window = plannedCompletionWindow(record.completionWindow);
|
|
513
869
|
const complete = ["done", "canceled"].includes(record.status);
|
|
514
|
-
const
|
|
870
|
+
const timingStatus = complete
|
|
515
871
|
? "complete"
|
|
516
872
|
: window.overdueAt && new Date(now) > new Date(window.overdueAt)
|
|
517
873
|
? "overdue"
|
|
@@ -522,6 +878,7 @@ function planStandaloneAction(record, byId, asOf, now) {
|
|
|
522
878
|
: window.dueWindowStart && window.dueWindowStart > asOf
|
|
523
879
|
? "upcoming"
|
|
524
880
|
: "due";
|
|
881
|
+
const status = record.status === "blocked" ? "blocked" : timingStatus;
|
|
525
882
|
return {
|
|
526
883
|
key: record.id,
|
|
527
884
|
kind: "action",
|
|
@@ -536,34 +893,72 @@ function planStandaloneAction(record, byId, asOf, now) {
|
|
|
536
893
|
evidenceIds: record.evidenceIds || [],
|
|
537
894
|
recordedStatus: record.status,
|
|
538
895
|
completedOn: record.completedOn || null,
|
|
896
|
+
blockingResourceIds: record.blockingResourceIds || [],
|
|
897
|
+
blockingReason: actionBlockingReason(record, byId),
|
|
539
898
|
status,
|
|
899
|
+
timingStatus,
|
|
540
900
|
...window,
|
|
541
901
|
...relativeTiming(window, asOf),
|
|
542
902
|
...relativeTimestampTiming(window, now)
|
|
543
903
|
};
|
|
544
904
|
}
|
|
545
905
|
|
|
906
|
+
function actionBlockingReason(record, byId) {
|
|
907
|
+
if (record.status !== "blocked") return null;
|
|
908
|
+
const blockers = (record.blockingResourceIds || []).map((id) => byId.get(id)?.title || id);
|
|
909
|
+
return blockers.length
|
|
910
|
+
? `Blocked by ${blockers.join(", ")}.`
|
|
911
|
+
: "The Action Item is marked blocked but has no blocking resource.";
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function storedCompletionWindow(window, timezone) {
|
|
915
|
+
if (window.dueWindowEndAt) {
|
|
916
|
+
return {
|
|
917
|
+
precision: "timestamp",
|
|
918
|
+
startsAt: window.dueWindowStartAt,
|
|
919
|
+
dueAt: window.dueWindowEndAt,
|
|
920
|
+
overdueAt: window.overdueAt || window.dueWindowEndAt,
|
|
921
|
+
timezone
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
return {
|
|
925
|
+
precision: "date",
|
|
926
|
+
startsOn: window.dueWindowStart,
|
|
927
|
+
dueOn: window.dueWindowEnd,
|
|
928
|
+
overdueOn: window.overdueOn
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function plannedCompletionWindow(window) {
|
|
933
|
+
if (window?.precision === "timestamp") {
|
|
934
|
+
return {
|
|
935
|
+
dueWindowStart: timestampCalendarDate(window.startsAt, window.timezone),
|
|
936
|
+
dueWindowEnd: timestampCalendarDate(window.dueAt, window.timezone),
|
|
937
|
+
overdueOn: timestampCalendarDate(window.overdueAt, window.timezone),
|
|
938
|
+
dueWindowStartAt: window.startsAt,
|
|
939
|
+
dueWindowEndAt: window.dueAt,
|
|
940
|
+
overdueAt: window.overdueAt
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
return {
|
|
944
|
+
dueWindowStart: window?.startsOn || null,
|
|
945
|
+
dueWindowEnd: window?.dueOn || null,
|
|
946
|
+
overdueOn: window?.overdueOn || null,
|
|
947
|
+
dueWindowStartAt: null,
|
|
948
|
+
dueWindowEndAt: null,
|
|
949
|
+
overdueAt: null
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
546
953
|
function completionFallsInWindow(record, window) {
|
|
547
954
|
const date = completionDate(record);
|
|
548
955
|
return Boolean(date && date >= window.dueWindowStart && date <= window.dueWindowEnd);
|
|
549
956
|
}
|
|
550
957
|
|
|
551
958
|
function normalizedEventWindow(configuredWindow) {
|
|
552
|
-
|
|
959
|
+
return configuredWindow && !Array.isArray(configuredWindow) && typeof configuredWindow === "object"
|
|
553
960
|
? configuredWindow
|
|
554
961
|
: {};
|
|
555
|
-
if (Number.isInteger(window.endOffsetDays) || Number.isInteger(window.endOffsetHours)) return window;
|
|
556
|
-
if (Number.isInteger(window.startOffsetHours)) {
|
|
557
|
-
return {
|
|
558
|
-
...window,
|
|
559
|
-
endOffsetHours: window.startOffsetHours + (DEFAULT_EVENT_DEADLINE_DAYS * 24)
|
|
560
|
-
};
|
|
561
|
-
}
|
|
562
|
-
return {
|
|
563
|
-
...window,
|
|
564
|
-
startOffsetDays: Number.isInteger(window.startOffsetDays) ? window.startOffsetDays : 0,
|
|
565
|
-
endOffsetDays: DEFAULT_EVENT_DEADLINE_DAYS
|
|
566
|
-
};
|
|
567
962
|
}
|
|
568
963
|
|
|
569
964
|
function completionTypeMatches(record, expectedTypes = []) {
|
|
@@ -571,6 +966,11 @@ function completionTypeMatches(record, expectedTypes = []) {
|
|
|
571
966
|
}
|
|
572
967
|
|
|
573
968
|
function completionDate(record) {
|
|
969
|
+
const coverageDate = coverageEnd(record.coverage);
|
|
970
|
+
if (parseCalendarDate(coverageDate)) return coverageDate;
|
|
971
|
+
for (const field of COMPLETION_TIMESTAMP_FIELDS) {
|
|
972
|
+
if (isRfc3339Timestamp(record[field])) return record[field].slice(0, 10);
|
|
973
|
+
}
|
|
574
974
|
for (const field of COMPLETION_DATE_FIELDS) {
|
|
575
975
|
if (parseCalendarDate(record[field])) return record[field];
|
|
576
976
|
}
|
|
@@ -618,22 +1018,29 @@ function relativeTimestampTiming(window, now) {
|
|
|
618
1018
|
}
|
|
619
1019
|
|
|
620
1020
|
function comparePlannedItems(a, b) {
|
|
621
|
-
const rank = { overdue: 0,
|
|
1021
|
+
const rank = { overdue: 0, blocked: 1, due: 2, upcoming: 3, proposed: 4, complete: 5 };
|
|
622
1022
|
return (rank[a.status] - rank[b.status])
|
|
623
1023
|
|| String(a.overdueAt || a.overdueOn || a.dueWindowEndAt || a.dueWindowEnd || a.dueWindowStartAt || a.dueWindowStart)
|
|
624
1024
|
.localeCompare(String(b.overdueAt || b.overdueOn || b.dueWindowEndAt || b.dueWindowEnd || b.dueWindowStartAt || b.dueWindowStart))
|
|
625
1025
|
|| a.title.localeCompare(b.title);
|
|
626
1026
|
}
|
|
627
1027
|
|
|
628
|
-
function eventActionDescription(obligation, eventType) {
|
|
1028
|
+
function eventActionDescription(obligation, eventType, model) {
|
|
629
1029
|
const policy = obligation.policyIds?.length ? ` Policy sources: ${obligation.policyIds.join(", ")}.` : "";
|
|
630
1030
|
const scope = obligation.scopeResourceIds?.length ? ` Review scoped resources: ${obligation.scopeResourceIds.join(", ")}.` : "";
|
|
631
|
-
const
|
|
632
|
-
|
|
1031
|
+
const expected = obligationActivity(model, obligation.activityType).completionResourceTypes;
|
|
1032
|
+
const completion = expected.length
|
|
1033
|
+
? ` Link completion records of type ${expected.join(", ")} and any evidence before marking this done.`
|
|
633
1034
|
: " Link the completion record and evidence before marking this done.";
|
|
634
1035
|
return `Triggered by ${eventType}.${policy}${scope}${completion}`;
|
|
635
1036
|
}
|
|
636
1037
|
|
|
1038
|
+
function obligationActivity(model, activityType) {
|
|
1039
|
+
const activity = model.obligationActivities?.[activityType];
|
|
1040
|
+
if (!activity) throw new Error(`Unknown obligation activity type "${activityType ?? ""}".`);
|
|
1041
|
+
return activity;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
637
1044
|
function requireDate(value, label) {
|
|
638
1045
|
if (!parseCalendarDate(value)) throw new Error(`A valid ${label} is required.`);
|
|
639
1046
|
return value;
|