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.
@@ -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
- "periodEnd"
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) => record.type === "obligation" && record.status === "active");
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
- prompt: obligation.triggerPrompt || humanize(eventType),
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: obligation.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, obligation.completionResourceTypes)
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(event, actionsBySource.get(event.id) || [], byId, asOf, now));
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) => Number.isInteger(normalizedEventWindow(record.window).endOffsetHours)) && !occurredAt) {
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
- dueWindowStart: window.dueWindowStart,
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?.startOffsetDays) ? configuredWindow.startOffsetDays : 0;
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?.endOffsetDays)
363
- ? addCalendarDays(occurrence, configuredWindow.endOffsetDays)
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 (Number.isInteger(configuredWindow.endOffsetHours) && occurredAt) {
377
- const startOffset = Number.isInteger(configuredWindow.startOffsetHours) ? configuredWindow.startOffsetHours : 0;
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.endOffsetHours);
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.startOffsetDays) ? configuredWindow.startOffsetDays : 0;
764
+ const startOffset = Number.isInteger(configuredWindow.startsAfter) ? configuredWindow.startsAfter : 0;
390
765
  const dueWindowStart = addCalendarDays(occurredOn, startOffset);
391
- const dueWindowEnd = addCalendarDays(occurredOn, configuredWindow.endOffsetDays);
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?.completionResourceTypes || [];
408
- const linkedCompletionIds = [...new Set([
409
- ...(record.completionResourceIds || []),
410
- ...(record.evidenceIds || [])
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 configuredWindow = normalizedEventWindow(obligation?.window);
417
- const fallbackEndOffsetDays = Number.isInteger(configuredWindow.endOffsetDays)
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 dueWindowEnd = record.dueWindowEnd || record.dueOn || null;
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 status = complete
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
- const window = configuredWindow && !Array.isArray(configuredWindow) && typeof configuredWindow === "object"
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, due: 1, upcoming: 2, proposed: 3, complete: 4 };
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 completion = obligation.completionResourceTypes?.length
632
- ? ` Link completion records of type ${obligation.completionResourceTypes.join(", ")} and any evidence before marking this done.`
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;