filegrc 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,5 @@
1
+ import { createHash } from "node:crypto";
2
+ import { scaffoldResourceMutation } from "./agent.js";
1
3
  import { createResourceId } from "./id.js";
2
4
  import { createResourceAndLink, createResources, updateResource } from "./files.js";
3
5
  import { loadModel } from "../model/index.js";
@@ -10,7 +12,7 @@ import {
10
12
  parseCalendarDate,
11
13
  validCalendarRecurrence
12
14
  } from "./recurrence.js";
13
- import { isRfc3339Timestamp } from "./time.js";
15
+ import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
14
16
  import { loadWorkspace } from "./workspace.js";
15
17
  import { obligationProgramStatus } from "./program-lifecycle.js";
16
18
 
@@ -36,7 +38,14 @@ const COMPLETION_TIMESTAMP_FIELDS = [
36
38
  const MAX_PLANNED_ITEMS = 10_000;
37
39
 
38
40
  export function planObligations(resources, options = {}) {
39
- const model = options.model || loadModel("2");
41
+ const records = resources.map((item) => item?.record ?? item).filter(Boolean);
42
+ const declaredModelVersion = records.find((record) => record.type === "workspace")?.dataModelVersion;
43
+ const model = options.model || (declaredModelVersion ? loadModel(declaredModelVersion) : null);
44
+ if (!model) {
45
+ throw new Error(
46
+ "Obligation planning requires options.model or a Workspace record with dataModelVersion."
47
+ );
48
+ }
40
49
  const asOf = requireDate(options.asOf ?? new Date().toISOString().slice(0, 10), "as-of date");
41
50
  const defaultNow = options.asOf ? `${asOf}T23:59:59Z` : new Date().toISOString();
42
51
  const now = requireTimestamp(options.now ?? defaultNow, "current timestamp");
@@ -44,9 +53,11 @@ export function planObligations(resources, options = {}) {
44
53
  const requestedFrom = options.from ? requireDate(options.from, "from date") : null;
45
54
  if (through < asOf && !requestedFrom) throw new Error("The through date must not be before the as-of date.");
46
55
  if (requestedFrom && through < requestedFrom) throw new Error("The through date must not be before the from date.");
47
- const records = resources.map((item) => item?.record ?? item).filter(Boolean);
48
56
  const byId = new Map(records.map((record) => [record.id, record]));
49
- const obligations = records.filter((record) => record.type === "obligation" && record.status === "active");
57
+ const obligations = records.filter((record) => (
58
+ record.type === "obligation"
59
+ && ["active", "proposed"].includes(record.status)
60
+ ));
50
61
  if (obligations.length > MAX_PLANNED_ITEMS) {
51
62
  throw new Error(`The obligation query must be narrowed; it includes more than ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} active obligations.`);
52
63
  }
@@ -86,9 +97,11 @@ export function planObligations(resources, options = {}) {
86
97
  policyIds: obligation.policyIds || [],
87
98
  controlIds: obligation.controlIds || [],
88
99
  scopeResourceIds: obligation.scopeResourceIds || [],
100
+ eventRiskLevels: obligation.eventRiskLevels || [],
89
101
  templateResourceId: obligation.templateResourceId || null,
90
102
  completionResourceTypes: expectedCompletionTypes,
91
103
  completionType: activity.completionType,
104
+ completionProfile: activity.completionProfile || null,
92
105
  programStatus,
93
106
  window: normalizedEventWindow(obligation.window)
94
107
  });
@@ -142,6 +155,7 @@ export function planObligations(resources, options = {}) {
142
155
  scopeResourceIds: obligation.scopeResourceIds || [],
143
156
  completionResourceTypes: expectedCompletionTypes,
144
157
  completionType: activity.completionType,
158
+ completionProfile: activity.completionProfile || null,
145
159
  completionResourceIds: completions.map((record) => record.id),
146
160
  status,
147
161
  timingStatus,
@@ -187,12 +201,13 @@ export function planObligations(resources, options = {}) {
187
201
  throw new Error(`The obligation query must be narrowed; it exceeds ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} planned items.`);
188
202
  }
189
203
  const items = [...calendarItems, ...eventItems, ...standaloneItems].sort(comparePlannedItems);
190
- const counts = { overdue: 0, due: 0, upcoming: 0, proposed: 0, complete: 0 };
204
+ const counts = { overdue: 0, blocked: 0, due: 0, upcoming: 0, proposed: 0, complete: 0 };
191
205
  for (const item of items) {
192
206
  if (counts[item.status] !== undefined) counts[item.status] += 1;
193
207
  }
194
208
 
195
209
  return {
210
+ dataModelVersion: String(model.modelVersion),
196
211
  asOf,
197
212
  through,
198
213
  from: requestedFrom,
@@ -224,11 +239,22 @@ export async function createObligationEvent(input, options) {
224
239
  if (timestampDate && options?.occurredOn && occurredOn !== timestampDate) {
225
240
  throw new Error(`The event date must match the event timestamp in ${loaded.workspace.timezone}.`);
226
241
  }
242
+ const supportsRiskLevel = Boolean(loaded.model.resources["obligation-event"]?.fields?.riskLevel);
243
+ const riskLevel = supportsRiskLevel && eventType === "person-ended"
244
+ ? String(options?.riskLevel || "normal")
245
+ : null;
246
+ if (riskLevel && !["normal", "high"].includes(riskLevel)) {
247
+ throw new Error("A departure risk level must be normal or high.");
248
+ }
227
249
  const templates = records.filter((record) => (
228
250
  record.type === "obligation"
229
251
  && record.status === "active"
230
252
  && record.recurrence?.mode === "event"
231
253
  && record.recurrence.eventType === eventType
254
+ && (
255
+ !Array.isArray(record.eventRiskLevels)
256
+ || record.eventRiskLevels.includes(riskLevel)
257
+ )
232
258
  ));
233
259
  if (!eventType || templates.length === 0) throw new Error(`No active obligations use event type "${eventType}".`);
234
260
  if (templates.some((record) => obligationProgramStatus(record, byId, occurredOn) === "proposed")) {
@@ -267,6 +293,10 @@ export async function createObligationEvent(input, options) {
267
293
  eventType,
268
294
  occurredOn,
269
295
  ...(occurredAt ? { occurredAt } : {}),
296
+ ...(riskLevel ? { riskLevel } : {}),
297
+ ...(options.transitionFingerprint && loaded.model.resources["obligation-event"]?.fields?.transitionFingerprint
298
+ ? { transitionFingerprint: String(options.transitionFingerprint) }
299
+ : {}),
270
300
  ownerIds: [...new Set(templates.flatMap((record) => record.ownerIds || []))],
271
301
  obligationIds: templates.map((record) => record.id),
272
302
  ...(subjectResourceIds.length ? { subjectResourceIds } : {})
@@ -290,6 +320,62 @@ export async function completeObligationOccurrence(input, options) {
290
320
  }, { content: options.content });
291
321
  }
292
322
 
323
+ export async function scaffoldObligationCompletion(input, options = {}) {
324
+ const loaded = await loadWorkspace(input);
325
+ const action = options.actionItemId
326
+ ? loaded.resources.find((record) => record.type === "action-item" && record.id === options.actionItemId)
327
+ : null;
328
+ if (options.actionItemId && !action) {
329
+ throw new Error(`Action item "${options.actionItemId}" was not found.`);
330
+ }
331
+ const obligationId = action?.obligationId || options.obligationId;
332
+ const obligation = loaded.resources.find((record) => (
333
+ record.type === "obligation" && record.id === obligationId
334
+ ));
335
+ if (!obligation) throw new Error(`Obligation "${obligationId ?? ""}" was not found.`);
336
+
337
+ const completedOn = requireDate(
338
+ options.completedOn || currentCalendarDate(loaded.workspace.timezone),
339
+ "completion date"
340
+ );
341
+ const item = action
342
+ ? plannedActionForScaffold(loaded, action, completedOn)
343
+ : plannedOccurrenceForScaffold(loaded, obligation, options.windowStart, completedOn);
344
+ const activity = obligationActivity(loaded.model, obligation.activityType);
345
+ const type = activity.completionType;
346
+ if (!type) throw new Error(`Obligation "${obligation.id}" has no configured completion resource type.`);
347
+
348
+ const mutation = scaffoldResourceMutation(
349
+ loaded,
350
+ type,
351
+ `${item.title} · ${item.dueWindowStart || completedOn}`
352
+ );
353
+ applyCompletionScaffoldDefaults(mutation.record, {
354
+ loaded,
355
+ item,
356
+ obligation,
357
+ completedOn,
358
+ activity
359
+ });
360
+ const target = action || obligation;
361
+ const entry = loaded.entries.find(({ record }) => record.type === target.type && record.id === target.id);
362
+ return {
363
+ ...mutation,
364
+ revision: contentRevision(entry?.source || ""),
365
+ scaffold: {
366
+ target: { type: target.type, id: target.id },
367
+ obligationId: obligation.id,
368
+ activityType: obligation.activityType,
369
+ completionResourceType: type,
370
+ completionProfile: activity.completionProfile || null,
371
+ requiredFacts: loaded.model.completionProfiles?.[activity.completionProfile]?.requiredFacts || [],
372
+ dueWindowStart: item.dueWindowStart || null,
373
+ dueWindowEnd: item.dueWindowEnd || null,
374
+ instructions: "Replace every null or empty required value with the actual work performed. Keep the actual completion date and time, actors, result, scope, independent review, and supporting evidence. This revision makes the completed write safe against a stale Work Queue item."
375
+ }
376
+ };
377
+ }
378
+
293
379
  export async function completeObligationAction(input, options) {
294
380
  const loaded = await loadWorkspace(input);
295
381
  const action = loaded.resources.find((record) => (
@@ -297,6 +383,9 @@ export async function completeObligationAction(input, options) {
297
383
  ));
298
384
  if (!action) throw new Error(`Action item "${options?.actionItemId ?? ""}" was not found.`);
299
385
  if (!action.obligationId) throw new Error(`Action item "${action.id}" is not linked to an obligation.`);
386
+ if (action.status === "blocked") {
387
+ throw new Error(`Action item "${action.id}" is blocked. Resolve its blockingResourceIds before completing it.`);
388
+ }
300
389
  const obligation = loaded.resources.find((record) => (
301
390
  record.type === "obligation" && record.id === action.obligationId
302
391
  ));
@@ -366,6 +455,277 @@ function assertExpectedCompletionType(obligation, record, model) {
366
455
  }
367
456
  }
368
457
 
458
+ function plannedOccurrenceForScaffold(loaded, obligation, windowStart, completedOn) {
459
+ const start = requireDate(windowStart, "occurrence window start");
460
+ const plan = planObligations(loaded.resources, {
461
+ from: start,
462
+ asOf: completedOn,
463
+ through: start,
464
+ includeComplete: true,
465
+ model: loaded.model
466
+ });
467
+ const item = plan.calendarItems.find((candidate) => (
468
+ candidate.obligationId === obligation.id && candidate.dueWindowStart === start
469
+ ));
470
+ if (!item) {
471
+ throw new Error(`No ${obligation.id} occurrence starts on ${start}. Run filegrc obligations --json and use its dueWindowStart.`);
472
+ }
473
+ return item;
474
+ }
475
+
476
+ function plannedActionForScaffold(loaded, action, completedOn) {
477
+ const plan = planObligations(loaded.resources, {
478
+ asOf: completedOn,
479
+ through: completedOn,
480
+ includeComplete: true,
481
+ model: loaded.model
482
+ });
483
+ const item = plan.eventItems.find((candidate) => candidate.actionItemId === action.id);
484
+ if (!item) throw new Error(`Action item "${action.id}" is not an active Work Queue item.`);
485
+ return item;
486
+ }
487
+
488
+ function applyCompletionScaffoldDefaults(record, context) {
489
+ const { loaded, item, obligation, completedOn, activity } = context;
490
+ const responsiblePeople = currentPeopleForParties(loaded.resources, item.ownerIds || []);
491
+ if (!responsiblePeople.length) {
492
+ throw new Error(`Obligation "${obligation.id}" needs an active owner whose Appointment or Team resolves to a current Person.`);
493
+ }
494
+ const reviewer = loaded.resources.find((candidate) => (
495
+ candidate.type === "person"
496
+ && ["active", "external"].includes(candidate.status)
497
+ && !responsiblePeople.includes(candidate.id)
498
+ ));
499
+ const reviewerIds = reviewer ? [reviewer.id] : [];
500
+ const systemIds = loaded.resources
501
+ .filter((candidate) => (
502
+ candidate.type === "system"
503
+ && candidate.status !== "retired"
504
+ && (loaded.workspace.systemIds || []).includes(candidate.id)
505
+ ))
506
+ .map(({ id }) => id);
507
+ const vendorIds = loaded.resources
508
+ .filter((candidate) => candidate.type === "vendor" && candidate.status !== "terminated")
509
+ .map(({ id }) => id);
510
+ const timestamp = completedOn === new Date().toISOString().slice(0, 10)
511
+ ? new Date().toISOString()
512
+ : null;
513
+ const coverage = {
514
+ kind: "range",
515
+ startsOn: item.dueWindowStart || completedOn,
516
+ endsOn: item.dueWindowEnd || completedOn
517
+ };
518
+ const common = { status: "complete" };
519
+ const defaults = {
520
+ meeting: () => {
521
+ const team = completionTeam(loaded.resources, item.ownerIds || []);
522
+ if (!team) throw new Error("A Meeting completion needs an active Team with an active chair.");
523
+ return {
524
+ ...common,
525
+ teamId: team.id,
526
+ chairIds: currentPeopleForParties(loaded.resources, team.chairIds || []),
527
+ scheduledFor: completedOn,
528
+ startedAt: timestamp,
529
+ endedAt: timestamp,
530
+ attendeeIds: responsiblePeople
531
+ };
532
+ },
533
+ "policy-review": () => ({
534
+ ...common,
535
+ scopeResourceIds: obligation.scopeResourceIds || [],
536
+ reviewerIds,
537
+ completedOn,
538
+ outcome: "passed",
539
+ changesRequired: false,
540
+ evidenceIds: [],
541
+ coverage
542
+ }),
543
+ "risk-assessment": () => ({
544
+ ...common,
545
+ completedOn,
546
+ assessmentKind: "enterprise-risk",
547
+ scope: "In-scope SOC 2 systems and dependencies",
548
+ assessorIds: responsiblePeople,
549
+ reviewerIds,
550
+ methodology: loaded.workspace.riskMethodology?.method || "Documented risk methodology",
551
+ summary: "",
552
+ evidenceIds: [],
553
+ approvedOn: completedOn
554
+ }),
555
+ attestation: () => ({
556
+ status: "completed",
557
+ subjectResourceIds: [...new Set([
558
+ obligation.templateResourceId,
559
+ ...(obligation.scopeResourceIds || [])
560
+ ].filter(Boolean))],
561
+ personId: responsiblePeople[0],
562
+ attestationKind: obligation.activityType || "completion",
563
+ assignedOn: item.dueWindowStart || completedOn,
564
+ dueOn: item.dueWindowEnd || completedOn,
565
+ completedOn,
566
+ attestationMethod: "git-approval"
567
+ }),
568
+ "access-review": () => {
569
+ if (!systemIds.length) throw new Error("An Access Review completion needs an active in-scope System.");
570
+ return {
571
+ ...common,
572
+ completedOn,
573
+ reviewerIds: responsiblePeople,
574
+ systemIds,
575
+ scope: "Privileged, production, and important-system access",
576
+ outcome: "passed",
577
+ evidenceIds: [],
578
+ approvedByIds: reviewerIds,
579
+ approvedOn: completedOn,
580
+ coverage
581
+ };
582
+ },
583
+ "vulnerability-scan": () => ({
584
+ ...common,
585
+ scanKind: "vulnerability",
586
+ scope: "In-scope systems",
587
+ operatorIds: responsiblePeople,
588
+ scheduledFor: completedOn,
589
+ completedAt: timestamp,
590
+ systemIds,
591
+ resultSummary: "",
592
+ evidenceIds: [],
593
+ reviewerIds,
594
+ reviewedOn: completedOn
595
+ }),
596
+ "penetration-test": () => ({
597
+ ...common,
598
+ testKind: "independent",
599
+ scope: "In-scope systems and service boundary",
600
+ coverage: { kind: "as-of", on: completedOn },
601
+ ownerIds: responsiblePeople,
602
+ outcome: "passed",
603
+ evidenceIds: [],
604
+ systemIds,
605
+ completedOn,
606
+ reviewerIds,
607
+ reviewedOn: completedOn
608
+ }),
609
+ "control-test": () => ({
610
+ ...common,
611
+ controlId: obligation.controlIds?.[0] || null,
612
+ testKinds: [obligation.activityType || "control-operation"],
613
+ performedBy: "management",
614
+ testerIds: responsiblePeople,
615
+ reviewerIds,
616
+ completedOn,
617
+ reviewedOn: completedOn,
618
+ outcome: "passed",
619
+ evidenceIds: [],
620
+ coverage
621
+ }),
622
+ "control-activity": () => ({
623
+ ...common,
624
+ profileId: activity.completionProfile || obligation.activityType,
625
+ obligationId: obligation.id,
626
+ controlIds: item.controlIds || obligation.controlIds || [],
627
+ scopeResourceIds: (item.scopeResourceIds || obligation.scopeResourceIds || []).length
628
+ ? (item.scopeResourceIds || obligation.scopeResourceIds)
629
+ : [loaded.workspace.id],
630
+ performerIds: responsiblePeople,
631
+ completedAt: timestamp,
632
+ method: "",
633
+ result: "",
634
+ reviewerIds,
635
+ reviewedOn: completedOn,
636
+ ownerIds: item.ownerIds || obligation.ownerIds || []
637
+ }),
638
+ exercise: () => ({
639
+ ...common,
640
+ exerciseKind: item.title.toLowerCase().includes("continuity") ? "business-continuity" : "incident-response",
641
+ scheduledFor: completedOn,
642
+ facilitatorIds: responsiblePeople,
643
+ objective: item.title,
644
+ outcome: "passed",
645
+ evidenceIds: [],
646
+ systemIds,
647
+ completedAt: timestamp
648
+ }),
649
+ "backup-test": () => {
650
+ if (!systemIds.length) throw new Error("A Backup Test completion needs an active in-scope System.");
651
+ return {
652
+ ...common,
653
+ systemIds,
654
+ scheduledFor: completedOn,
655
+ operatorIds: responsiblePeople,
656
+ reviewerIds,
657
+ outcome: "passed",
658
+ evidenceIds: [],
659
+ completedAt: timestamp
660
+ };
661
+ },
662
+ "vendor-review": () => {
663
+ const subjectVendor = (item.subjectResourceIds || []).find((id) => vendorIds.includes(id));
664
+ if (!subjectVendor && !vendorIds.length) throw new Error("A Vendor Review completion needs an active Vendor.");
665
+ return {
666
+ ...common,
667
+ vendorId: subjectVendor || vendorIds[0],
668
+ reviewerIds: responsiblePeople,
669
+ completedOn,
670
+ decision: "approved",
671
+ evidenceIds: [],
672
+ coverage
673
+ };
674
+ }
675
+ };
676
+ const values = defaults[record.type]?.() || {
677
+ status: "collected",
678
+ artifactKind: "business-record",
679
+ artifactSubtype: obligation.activityType || "control-operation",
680
+ sourceKind: "authored-record",
681
+ sourceDescription: "Internal control operation",
682
+ collectedOn: completedOn,
683
+ collectorIds: responsiblePeople,
684
+ classificationId: defaultClassificationId(loaded.workspace),
685
+ coverage,
686
+ controlIds: item.controlIds || obligation.controlIds || [],
687
+ sourceResourceIds: [obligation.id]
688
+ };
689
+ Object.assign(record, values);
690
+ }
691
+
692
+ function currentPeopleForParties(resources, ids = [], seen = new Set()) {
693
+ const byId = new Map(resources.map((record) => [record.id, record]));
694
+ const people = [];
695
+ for (const id of ids) {
696
+ if (seen.has(id)) continue;
697
+ seen.add(id);
698
+ const party = byId.get(id);
699
+ if (party?.type === "person" && ["active", "external"].includes(party.status)) people.push(party.id);
700
+ if (party?.type === "team" && party.status === "active") {
701
+ people.push(...currentPeopleForParties(resources, [...(party.memberIds || []), ...(party.chairIds || [])], seen));
702
+ }
703
+ if (party?.type === "appointment" && party.status === "active") {
704
+ people.push(...currentPeopleForParties(resources, [party.holderId], seen));
705
+ }
706
+ }
707
+ return [...new Set(people)];
708
+ }
709
+
710
+ function completionTeam(resources, ownerIds) {
711
+ const owners = new Set(ownerIds);
712
+ const teams = resources.filter((record) => record.type === "team");
713
+ const owned = teams.filter((record) => owners.has(record.id));
714
+ return (owned.length ? owned : teams).find((record) => (
715
+ record.status === "active"
716
+ && currentPeopleForParties(resources, record.chairIds || []).length
717
+ )) || null;
718
+ }
719
+
720
+ function defaultClassificationId(workspace) {
721
+ const definitions = workspace.classificationDefinitions || {};
722
+ return Object.hasOwn(definitions, "internal") ? "internal" : Object.keys(definitions)[0] || "";
723
+ }
724
+
725
+ function contentRevision(source) {
726
+ return createHash("sha256").update(source).digest("hex");
727
+ }
728
+
369
729
  function calendarWindow(recurrence, configuredWindow, index) {
370
730
  const occurrence = calendarOccurrence(recurrence, index);
371
731
  const next = calendarOccurrence(recurrence, index + 1);
@@ -415,23 +775,26 @@ function eventWindow(obligation, occurredOn, occurredAt, timezone) {
415
775
  };
416
776
  }
417
777
 
418
- function planEventRun(event, actionItems, byId, asOf, now, model = loadModel("2")) {
778
+ function planEventRun(event, actionItems, byId, asOf, now, model) {
419
779
  const actions = actionItems
420
780
  .map((record) => {
421
781
  const obligation = byId.get(record.obligationId);
422
782
  const expectedCompletionTypes = obligation?.type === "obligation"
423
783
  ? obligationActivity(model, obligation.activityType).completionResourceTypes
424
784
  : [];
425
- const linkedCompletionIds = [...new Set([
426
- ...(record.completionResourceIds || []),
427
- ...(record.evidenceIds || [])
428
- ])];
785
+ const completionProfile = obligation?.type === "obligation"
786
+ ? obligationActivity(model, obligation.activityType).completionProfile || null
787
+ : null;
788
+ const completionIds = String(model.modelVersion) === "3"
789
+ ? record.completionResourceIds || []
790
+ : [...(record.completionResourceIds || []), ...(record.evidenceIds || [])];
791
+ const linkedCompletionIds = [...new Set(completionIds)];
429
792
  const matchingCompletionIds = linkedCompletionIds.filter((id) => completionTypeMatches(byId.get(id), expectedCompletionTypes));
430
793
  const completionSatisfied = expectedCompletionTypes.length === 0
431
794
  || matchingCompletionIds.length > 0;
432
795
  const complete = record.status === "done" && completionSatisfied;
433
796
  const window = plannedCompletionWindow(record.completionWindow);
434
- const status = complete
797
+ const timingStatus = complete
435
798
  ? "complete"
436
799
  : window.overdueAt && new Date(now) > new Date(window.overdueAt)
437
800
  ? "overdue"
@@ -442,6 +805,7 @@ function planEventRun(event, actionItems, byId, asOf, now, model = loadModel("2"
442
805
  : window.dueWindowStart <= asOf
443
806
  ? "due"
444
807
  : "upcoming";
808
+ const status = record.status === "blocked" ? "blocked" : timingStatus;
445
809
  return {
446
810
  key: record.id,
447
811
  kind: "event",
@@ -449,6 +813,7 @@ function planEventRun(event, actionItems, byId, asOf, now, model = loadModel("2"
449
813
  actionItemId: record.id,
450
814
  obligationId: record.obligationId,
451
815
  title: record.title,
816
+ activityType: obligation?.activityType || null,
452
817
  ownerIds: record.assigneeIds || [],
453
818
  policyIds: obligation?.policyIds || [],
454
819
  controlIds: obligation?.controlIds || [],
@@ -457,12 +822,16 @@ function planEventRun(event, actionItems, byId, asOf, now, model = loadModel("2"
457
822
  completionResourceIds: record.completionResourceIds || [],
458
823
  evidenceIds: record.evidenceIds || [],
459
824
  expectedCompletionTypes,
825
+ completionProfile,
460
826
  matchingCompletionIds,
461
827
  missingCompletion: record.status === "done" && !completionSatisfied,
462
828
  canceledAction: record.status === "canceled",
463
829
  recordedStatus: record.status,
464
830
  completedOn: record.completedOn || null,
831
+ blockingResourceIds: record.blockingResourceIds || [],
832
+ blockingReason: actionBlockingReason(record, byId),
465
833
  status,
834
+ timingStatus,
466
835
  ...window,
467
836
  ...relativeTiming(window, asOf),
468
837
  ...relativeTimestampTiming(window, now)
@@ -474,6 +843,8 @@ function planEventRun(event, actionItems, byId, asOf, now, model = loadModel("2"
474
843
  ? "complete"
475
844
  : actions.some((item) => item.status === "overdue")
476
845
  ? "overdue"
846
+ : actions.some((item) => item.status === "blocked")
847
+ ? "blocked"
477
848
  : actions.length > 0 && actions.every((item) => item.status === "upcoming")
478
849
  ? "upcoming"
479
850
  : "due";
@@ -496,7 +867,7 @@ function planStandaloneAction(record, byId, asOf, now) {
496
867
  const source = byId.get(record.sourceResourceId);
497
868
  const window = plannedCompletionWindow(record.completionWindow);
498
869
  const complete = ["done", "canceled"].includes(record.status);
499
- const status = complete
870
+ const timingStatus = complete
500
871
  ? "complete"
501
872
  : window.overdueAt && new Date(now) > new Date(window.overdueAt)
502
873
  ? "overdue"
@@ -507,6 +878,7 @@ function planStandaloneAction(record, byId, asOf, now) {
507
878
  : window.dueWindowStart && window.dueWindowStart > asOf
508
879
  ? "upcoming"
509
880
  : "due";
881
+ const status = record.status === "blocked" ? "blocked" : timingStatus;
510
882
  return {
511
883
  key: record.id,
512
884
  kind: "action",
@@ -521,13 +893,24 @@ function planStandaloneAction(record, byId, asOf, now) {
521
893
  evidenceIds: record.evidenceIds || [],
522
894
  recordedStatus: record.status,
523
895
  completedOn: record.completedOn || null,
896
+ blockingResourceIds: record.blockingResourceIds || [],
897
+ blockingReason: actionBlockingReason(record, byId),
524
898
  status,
899
+ timingStatus,
525
900
  ...window,
526
901
  ...relativeTiming(window, asOf),
527
902
  ...relativeTimestampTiming(window, now)
528
903
  };
529
904
  }
530
905
 
906
+ function actionBlockingReason(record, byId) {
907
+ if (record.status !== "blocked") return null;
908
+ const blockers = (record.blockingResourceIds || []).map((id) => byId.get(id)?.title || id);
909
+ return blockers.length
910
+ ? `Blocked by ${blockers.join(", ")}.`
911
+ : "The Action Item is marked blocked but has no blocking resource.";
912
+ }
913
+
531
914
  function storedCompletionWindow(window, timezone) {
532
915
  if (window.dueWindowEndAt) {
533
916
  return {
@@ -635,7 +1018,7 @@ function relativeTimestampTiming(window, now) {
635
1018
  }
636
1019
 
637
1020
  function comparePlannedItems(a, b) {
638
- const rank = { overdue: 0, due: 1, upcoming: 2, proposed: 3, complete: 4 };
1021
+ const rank = { overdue: 0, blocked: 1, due: 2, upcoming: 3, proposed: 4, complete: 5 };
639
1022
  return (rank[a.status] - rank[b.status])
640
1023
  || String(a.overdueAt || a.overdueOn || a.dueWindowEndAt || a.dueWindowEnd || a.dueWindowStartAt || a.dueWindowStart)
641
1024
  .localeCompare(String(b.overdueAt || b.overdueOn || b.dueWindowEndAt || b.dueWindowEnd || b.dueWindowStartAt || b.dueWindowStart))
@@ -1,6 +1,7 @@
1
1
  import { currentPartyPeople } from "./parties.js";
2
2
 
3
3
  export function obligationProgramStatus(obligation, byId, asOf) {
4
+ if (obligation.status !== "active") return "proposed";
4
5
  if (currentPartyPeople(obligation.ownerIds || [], byId).size === 0) return "proposed";
5
6
  const policyIds = obligation.policyIds || [];
6
7
  const policiesReady = policyIds.every((id) => {