filegrc 0.4.0 → 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 +11 -3
- package/model/index.js +9 -5
- package/model/v3.json +9391 -0
- package/package.json +2 -2
- package/src/agent.js +53 -0
- package/src/appointments.js +19 -0
- package/src/audit-preparation.js +47 -6
- package/src/audit-transition.js +96 -0
- package/src/batch-review.js +109 -0
- package/src/cli.js +418 -61
- package/src/collection-review.js +185 -0
- package/src/evidence-packet.js +34 -2
- package/src/external-reviewer.js +165 -0
- package/src/files.js +46 -10
- package/src/index.js +36 -2
- package/src/model-docs.js +15 -0
- package/src/model-migration.js +516 -21
- package/src/obligations.js +396 -13
- package/src/program-lifecycle.js +1 -0
- package/src/program-path.js +49 -12
- package/src/program-readiness.js +331 -27
- package/src/reconciliation.js +277 -0
- package/src/server.js +242 -14
- package/src/setup.js +36 -4
- package/src/source-coverage.js +61 -0
- package/src/state.js +37 -1
- package/src/validate.js +100 -3
- package/src/web.js +961 -202
- package/src/workflow.js +1595 -0
package/src/obligations.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
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";
|
|
3
5
|
import { loadModel } from "../model/index.js";
|
|
@@ -10,7 +12,7 @@ import {
|
|
|
10
12
|
parseCalendarDate,
|
|
11
13
|
validCalendarRecurrence
|
|
12
14
|
} from "./recurrence.js";
|
|
13
|
-
import { isRfc3339Timestamp } from "./time.js";
|
|
15
|
+
import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
|
|
14
16
|
import { loadWorkspace } from "./workspace.js";
|
|
15
17
|
import { obligationProgramStatus } from "./program-lifecycle.js";
|
|
16
18
|
|
|
@@ -36,7 +38,14 @@ const COMPLETION_TIMESTAMP_FIELDS = [
|
|
|
36
38
|
const MAX_PLANNED_ITEMS = 10_000;
|
|
37
39
|
|
|
38
40
|
export function planObligations(resources, options = {}) {
|
|
39
|
-
const
|
|
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
|
+
}
|
|
40
49
|
const asOf = requireDate(options.asOf ?? new Date().toISOString().slice(0, 10), "as-of date");
|
|
41
50
|
const defaultNow = options.asOf ? `${asOf}T23:59:59Z` : new Date().toISOString();
|
|
42
51
|
const now = requireTimestamp(options.now ?? defaultNow, "current timestamp");
|
|
@@ -44,9 +53,11 @@ export function planObligations(resources, options = {}) {
|
|
|
44
53
|
const requestedFrom = options.from ? requireDate(options.from, "from date") : null;
|
|
45
54
|
if (through < asOf && !requestedFrom) throw new Error("The through date must not be before the as-of date.");
|
|
46
55
|
if (requestedFrom && through < requestedFrom) throw new Error("The through date must not be before the from date.");
|
|
47
|
-
const records = resources.map((item) => item?.record ?? item).filter(Boolean);
|
|
48
56
|
const byId = new Map(records.map((record) => [record.id, record]));
|
|
49
|
-
const obligations = records.filter((record) =>
|
|
57
|
+
const obligations = records.filter((record) => (
|
|
58
|
+
record.type === "obligation"
|
|
59
|
+
&& ["active", "proposed"].includes(record.status)
|
|
60
|
+
));
|
|
50
61
|
if (obligations.length > MAX_PLANNED_ITEMS) {
|
|
51
62
|
throw new Error(`The obligation query must be narrowed; it includes more than ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} active obligations.`);
|
|
52
63
|
}
|
|
@@ -86,9 +97,11 @@ export function planObligations(resources, options = {}) {
|
|
|
86
97
|
policyIds: obligation.policyIds || [],
|
|
87
98
|
controlIds: obligation.controlIds || [],
|
|
88
99
|
scopeResourceIds: obligation.scopeResourceIds || [],
|
|
100
|
+
eventRiskLevels: obligation.eventRiskLevels || [],
|
|
89
101
|
templateResourceId: obligation.templateResourceId || null,
|
|
90
102
|
completionResourceTypes: expectedCompletionTypes,
|
|
91
103
|
completionType: activity.completionType,
|
|
104
|
+
completionProfile: activity.completionProfile || null,
|
|
92
105
|
programStatus,
|
|
93
106
|
window: normalizedEventWindow(obligation.window)
|
|
94
107
|
});
|
|
@@ -142,6 +155,7 @@ export function planObligations(resources, options = {}) {
|
|
|
142
155
|
scopeResourceIds: obligation.scopeResourceIds || [],
|
|
143
156
|
completionResourceTypes: expectedCompletionTypes,
|
|
144
157
|
completionType: activity.completionType,
|
|
158
|
+
completionProfile: activity.completionProfile || null,
|
|
145
159
|
completionResourceIds: completions.map((record) => record.id),
|
|
146
160
|
status,
|
|
147
161
|
timingStatus,
|
|
@@ -187,12 +201,13 @@ export function planObligations(resources, options = {}) {
|
|
|
187
201
|
throw new Error(`The obligation query must be narrowed; it exceeds ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} planned items.`);
|
|
188
202
|
}
|
|
189
203
|
const items = [...calendarItems, ...eventItems, ...standaloneItems].sort(comparePlannedItems);
|
|
190
|
-
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 };
|
|
191
205
|
for (const item of items) {
|
|
192
206
|
if (counts[item.status] !== undefined) counts[item.status] += 1;
|
|
193
207
|
}
|
|
194
208
|
|
|
195
209
|
return {
|
|
210
|
+
dataModelVersion: String(model.modelVersion),
|
|
196
211
|
asOf,
|
|
197
212
|
through,
|
|
198
213
|
from: requestedFrom,
|
|
@@ -224,11 +239,22 @@ export async function createObligationEvent(input, options) {
|
|
|
224
239
|
if (timestampDate && options?.occurredOn && occurredOn !== timestampDate) {
|
|
225
240
|
throw new Error(`The event date must match the event timestamp in ${loaded.workspace.timezone}.`);
|
|
226
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
|
+
}
|
|
227
249
|
const templates = records.filter((record) => (
|
|
228
250
|
record.type === "obligation"
|
|
229
251
|
&& record.status === "active"
|
|
230
252
|
&& record.recurrence?.mode === "event"
|
|
231
253
|
&& record.recurrence.eventType === eventType
|
|
254
|
+
&& (
|
|
255
|
+
!Array.isArray(record.eventRiskLevels)
|
|
256
|
+
|| record.eventRiskLevels.includes(riskLevel)
|
|
257
|
+
)
|
|
232
258
|
));
|
|
233
259
|
if (!eventType || templates.length === 0) throw new Error(`No active obligations use event type "${eventType}".`);
|
|
234
260
|
if (templates.some((record) => obligationProgramStatus(record, byId, occurredOn) === "proposed")) {
|
|
@@ -267,6 +293,10 @@ export async function createObligationEvent(input, options) {
|
|
|
267
293
|
eventType,
|
|
268
294
|
occurredOn,
|
|
269
295
|
...(occurredAt ? { occurredAt } : {}),
|
|
296
|
+
...(riskLevel ? { riskLevel } : {}),
|
|
297
|
+
...(options.transitionFingerprint && loaded.model.resources["obligation-event"]?.fields?.transitionFingerprint
|
|
298
|
+
? { transitionFingerprint: String(options.transitionFingerprint) }
|
|
299
|
+
: {}),
|
|
270
300
|
ownerIds: [...new Set(templates.flatMap((record) => record.ownerIds || []))],
|
|
271
301
|
obligationIds: templates.map((record) => record.id),
|
|
272
302
|
...(subjectResourceIds.length ? { subjectResourceIds } : {})
|
|
@@ -290,6 +320,62 @@ export async function completeObligationOccurrence(input, options) {
|
|
|
290
320
|
}, { content: options.content });
|
|
291
321
|
}
|
|
292
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
|
+
|
|
293
379
|
export async function completeObligationAction(input, options) {
|
|
294
380
|
const loaded = await loadWorkspace(input);
|
|
295
381
|
const action = loaded.resources.find((record) => (
|
|
@@ -297,6 +383,9 @@ export async function completeObligationAction(input, options) {
|
|
|
297
383
|
));
|
|
298
384
|
if (!action) throw new Error(`Action item "${options?.actionItemId ?? ""}" was not found.`);
|
|
299
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
|
+
}
|
|
300
389
|
const obligation = loaded.resources.find((record) => (
|
|
301
390
|
record.type === "obligation" && record.id === action.obligationId
|
|
302
391
|
));
|
|
@@ -366,6 +455,277 @@ function assertExpectedCompletionType(obligation, record, model) {
|
|
|
366
455
|
}
|
|
367
456
|
}
|
|
368
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
|
+
|
|
369
729
|
function calendarWindow(recurrence, configuredWindow, index) {
|
|
370
730
|
const occurrence = calendarOccurrence(recurrence, index);
|
|
371
731
|
const next = calendarOccurrence(recurrence, index + 1);
|
|
@@ -415,23 +775,26 @@ function eventWindow(obligation, occurredOn, occurredAt, timezone) {
|
|
|
415
775
|
};
|
|
416
776
|
}
|
|
417
777
|
|
|
418
|
-
function planEventRun(event, actionItems, byId, asOf, now, model
|
|
778
|
+
function planEventRun(event, actionItems, byId, asOf, now, model) {
|
|
419
779
|
const actions = actionItems
|
|
420
780
|
.map((record) => {
|
|
421
781
|
const obligation = byId.get(record.obligationId);
|
|
422
782
|
const expectedCompletionTypes = obligation?.type === "obligation"
|
|
423
783
|
? obligationActivity(model, obligation.activityType).completionResourceTypes
|
|
424
784
|
: [];
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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)];
|
|
429
792
|
const matchingCompletionIds = linkedCompletionIds.filter((id) => completionTypeMatches(byId.get(id), expectedCompletionTypes));
|
|
430
793
|
const completionSatisfied = expectedCompletionTypes.length === 0
|
|
431
794
|
|| matchingCompletionIds.length > 0;
|
|
432
795
|
const complete = record.status === "done" && completionSatisfied;
|
|
433
796
|
const window = plannedCompletionWindow(record.completionWindow);
|
|
434
|
-
const
|
|
797
|
+
const timingStatus = complete
|
|
435
798
|
? "complete"
|
|
436
799
|
: window.overdueAt && new Date(now) > new Date(window.overdueAt)
|
|
437
800
|
? "overdue"
|
|
@@ -442,6 +805,7 @@ function planEventRun(event, actionItems, byId, asOf, now, model = loadModel("2"
|
|
|
442
805
|
: window.dueWindowStart <= asOf
|
|
443
806
|
? "due"
|
|
444
807
|
: "upcoming";
|
|
808
|
+
const status = record.status === "blocked" ? "blocked" : timingStatus;
|
|
445
809
|
return {
|
|
446
810
|
key: record.id,
|
|
447
811
|
kind: "event",
|
|
@@ -449,6 +813,7 @@ function planEventRun(event, actionItems, byId, asOf, now, model = loadModel("2"
|
|
|
449
813
|
actionItemId: record.id,
|
|
450
814
|
obligationId: record.obligationId,
|
|
451
815
|
title: record.title,
|
|
816
|
+
activityType: obligation?.activityType || null,
|
|
452
817
|
ownerIds: record.assigneeIds || [],
|
|
453
818
|
policyIds: obligation?.policyIds || [],
|
|
454
819
|
controlIds: obligation?.controlIds || [],
|
|
@@ -457,12 +822,16 @@ function planEventRun(event, actionItems, byId, asOf, now, model = loadModel("2"
|
|
|
457
822
|
completionResourceIds: record.completionResourceIds || [],
|
|
458
823
|
evidenceIds: record.evidenceIds || [],
|
|
459
824
|
expectedCompletionTypes,
|
|
825
|
+
completionProfile,
|
|
460
826
|
matchingCompletionIds,
|
|
461
827
|
missingCompletion: record.status === "done" && !completionSatisfied,
|
|
462
828
|
canceledAction: record.status === "canceled",
|
|
463
829
|
recordedStatus: record.status,
|
|
464
830
|
completedOn: record.completedOn || null,
|
|
831
|
+
blockingResourceIds: record.blockingResourceIds || [],
|
|
832
|
+
blockingReason: actionBlockingReason(record, byId),
|
|
465
833
|
status,
|
|
834
|
+
timingStatus,
|
|
466
835
|
...window,
|
|
467
836
|
...relativeTiming(window, asOf),
|
|
468
837
|
...relativeTimestampTiming(window, now)
|
|
@@ -474,6 +843,8 @@ function planEventRun(event, actionItems, byId, asOf, now, model = loadModel("2"
|
|
|
474
843
|
? "complete"
|
|
475
844
|
: actions.some((item) => item.status === "overdue")
|
|
476
845
|
? "overdue"
|
|
846
|
+
: actions.some((item) => item.status === "blocked")
|
|
847
|
+
? "blocked"
|
|
477
848
|
: actions.length > 0 && actions.every((item) => item.status === "upcoming")
|
|
478
849
|
? "upcoming"
|
|
479
850
|
: "due";
|
|
@@ -496,7 +867,7 @@ function planStandaloneAction(record, byId, asOf, now) {
|
|
|
496
867
|
const source = byId.get(record.sourceResourceId);
|
|
497
868
|
const window = plannedCompletionWindow(record.completionWindow);
|
|
498
869
|
const complete = ["done", "canceled"].includes(record.status);
|
|
499
|
-
const
|
|
870
|
+
const timingStatus = complete
|
|
500
871
|
? "complete"
|
|
501
872
|
: window.overdueAt && new Date(now) > new Date(window.overdueAt)
|
|
502
873
|
? "overdue"
|
|
@@ -507,6 +878,7 @@ function planStandaloneAction(record, byId, asOf, now) {
|
|
|
507
878
|
: window.dueWindowStart && window.dueWindowStart > asOf
|
|
508
879
|
? "upcoming"
|
|
509
880
|
: "due";
|
|
881
|
+
const status = record.status === "blocked" ? "blocked" : timingStatus;
|
|
510
882
|
return {
|
|
511
883
|
key: record.id,
|
|
512
884
|
kind: "action",
|
|
@@ -521,13 +893,24 @@ function planStandaloneAction(record, byId, asOf, now) {
|
|
|
521
893
|
evidenceIds: record.evidenceIds || [],
|
|
522
894
|
recordedStatus: record.status,
|
|
523
895
|
completedOn: record.completedOn || null,
|
|
896
|
+
blockingResourceIds: record.blockingResourceIds || [],
|
|
897
|
+
blockingReason: actionBlockingReason(record, byId),
|
|
524
898
|
status,
|
|
899
|
+
timingStatus,
|
|
525
900
|
...window,
|
|
526
901
|
...relativeTiming(window, asOf),
|
|
527
902
|
...relativeTimestampTiming(window, now)
|
|
528
903
|
};
|
|
529
904
|
}
|
|
530
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
|
+
|
|
531
914
|
function storedCompletionWindow(window, timezone) {
|
|
532
915
|
if (window.dueWindowEndAt) {
|
|
533
916
|
return {
|
|
@@ -635,7 +1018,7 @@ function relativeTimestampTiming(window, now) {
|
|
|
635
1018
|
}
|
|
636
1019
|
|
|
637
1020
|
function comparePlannedItems(a, b) {
|
|
638
|
-
const rank = { overdue: 0,
|
|
1021
|
+
const rank = { overdue: 0, blocked: 1, due: 2, upcoming: 3, proposed: 4, complete: 5 };
|
|
639
1022
|
return (rank[a.status] - rank[b.status])
|
|
640
1023
|
|| String(a.overdueAt || a.overdueOn || a.dueWindowEndAt || a.dueWindowEnd || a.dueWindowStartAt || a.dueWindowStart)
|
|
641
1024
|
.localeCompare(String(b.overdueAt || b.overdueOn || b.dueWindowEndAt || b.dueWindowEnd || b.dueWindowStartAt || b.dueWindowStart))
|
package/src/program-lifecycle.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { currentPartyPeople } from "./parties.js";
|
|
2
2
|
|
|
3
3
|
export function obligationProgramStatus(obligation, byId, asOf) {
|
|
4
|
+
if (obligation.status !== "active") return "proposed";
|
|
4
5
|
if (currentPartyPeople(obligation.ownerIds || [], byId).size === 0) return "proposed";
|
|
5
6
|
const policyIds = obligation.policyIds || [];
|
|
6
7
|
const policiesReady = policyIds.every((id) => {
|