filegrc 0.11.0 → 0.12.0

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