filegrc 0.7.1 → 0.8.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,5 @@
1
1
  import { createResourceId } from "./id.js";
2
- import { applyResourceBatch, contentRevision } from "./files.js";
2
+ import { applyModelMigrationBatch, applyResourceBatch, contentRevision } from "./files.js";
3
3
  import { loadWorkspace } from "./workspace.js";
4
4
  import { ACTIVE_MODEL_VERSION, loadModel } from "../model/index.js";
5
5
  import { legacyCoverage } from "./coverage.js";
@@ -882,7 +882,7 @@ async function migrateV1ToV2(input = process.cwd(), options = {}) {
882
882
  + "and resolve every missing value, conflict, and manual action."
883
883
  );
884
884
  }
885
- const result = await applyResourceBatch(input, plan.changes);
885
+ const result = await applyModelMigrationBatch(input, plan.changes);
886
886
  return { ...plan, applied: true, result };
887
887
  }
888
888
 
@@ -891,7 +891,7 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
891
891
  const sourceVersion = String(loaded.workspace?.dataModelVersion || "");
892
892
  const requestedTarget = options.targetModelVersion
893
893
  ? String(options.targetModelVersion)
894
- : sourceVersion === "1" ? V1_TARGET_MODEL_VERSION : sourceVersion === "2" ? "3" : ACTIVE_MODEL_VERSION;
894
+ : sourceVersion === "1" ? V1_TARGET_MODEL_VERSION : sourceVersion === "2" ? "3" : sourceVersion === "3" ? "4" : ACTIVE_MODEL_VERSION;
895
895
  if (sourceVersion === requestedTarget) return emptyPlan(sourceVersion, requestedTarget);
896
896
  if (sourceVersion === "1" && requestedTarget === "2") {
897
897
  return planV1ToV2Migration(input, options);
@@ -899,13 +899,16 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
899
899
  if (sourceVersion === "2" && requestedTarget === "3") {
900
900
  return planV2ToV3Migration(loaded);
901
901
  }
902
- if (sourceVersion === "3" && requestedTarget === ACTIVE_MODEL_VERSION) {
902
+ if (sourceVersion === "3" && requestedTarget === "4") {
903
903
  return planV3ToV4Migration(loaded, options);
904
904
  }
905
+ if (sourceVersion === "4" && requestedTarget === ACTIVE_MODEL_VERSION) {
906
+ return planV4ToV5Migration(loaded, options);
907
+ }
905
908
  if (sourceVersion === "1" && requestedTarget === ACTIVE_MODEL_VERSION) {
906
909
  throw new Error(
907
910
  "Model v1 workspaces must migrate to model v2 first. "
908
- + "Preview and apply `npx filegrc migrate --to-model 2`, then migrate to model v3 and v4."
911
+ + "Preview and apply `npx filegrc migrate --to-model 2`, then migrate one version at a time through model v5."
909
912
  );
910
913
  }
911
914
  throw new Error(`Model migration does not support v${sourceVersion} to v${requestedTarget}.`);
@@ -925,7 +928,7 @@ export async function migrateModel(input = process.cwd(), options = {}) {
925
928
  if (plan.sourceModelVersion === "1" && plan.targetModelVersion === "2") {
926
929
  return migrateV1ToV2(input, options);
927
930
  }
928
- const result = await applyResourceBatch(input, plan.changes);
931
+ const result = await applyModelMigrationBatch(input, plan.changes);
929
932
  return {
930
933
  ...plan,
931
934
  applied: true,
@@ -1498,6 +1501,214 @@ async function planV3ToV4Migration(loaded, options = {}) {
1498
1501
  };
1499
1502
  }
1500
1503
 
1504
+ async function planV4ToV5Migration(loaded, options = {}) {
1505
+ if (!loaded.workspace?.id) throw new Error("Model migration requires a valid Workspace record.");
1506
+ const targetModel = loadModel("5");
1507
+ const revisions = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
1508
+ const automatic = [];
1509
+ const reviewRequired = [];
1510
+ const unsupported = [];
1511
+ const missing = [];
1512
+ const manualActions = [];
1513
+ const updates = [];
1514
+ const documentScopeDecisions = options.documentScopes && typeof options.documentScopes === "object"
1515
+ ? options.documentScopes
1516
+ : {};
1517
+ const auditDocumentFields = [
1518
+ "engagementTermsDocumentId",
1519
+ ...(targetModel.auditReadiness?.managementDocuments || []).map(({ field }) => field)
1520
+ ];
1521
+ const auditsByDocumentId = new Map();
1522
+ const allAuditsByDocumentId = new Map();
1523
+ const addAuditReference = (map, documentId, audit) => {
1524
+ if (!documentId) return;
1525
+ if (!map.has(documentId)) map.set(documentId, new Map());
1526
+ map.get(documentId).set(audit.id, audit);
1527
+ };
1528
+ for (const audit of loaded.resources.filter(({ type }) => type === "audit")) {
1529
+ for (const field of auditDocumentFields) {
1530
+ const documentId = audit[field];
1531
+ if (!documentId) continue;
1532
+ addAuditReference(auditsByDocumentId, documentId, audit);
1533
+ addAuditReference(allAuditsByDocumentId, documentId, audit);
1534
+ }
1535
+ for (const documentId of audit.supplementalDocumentIds || []) {
1536
+ addAuditReference(allAuditsByDocumentId, documentId, audit);
1537
+ }
1538
+ }
1539
+ const programDocumentIds = new Set([
1540
+ ...loaded.resources.filter(({ type }) => type === "policy").flatMap(({ relatedDocumentIds }) => relatedDocumentIds || []),
1541
+ ...loaded.resources.filter(({ type }) => type === "obligation").flatMap((record) => [
1542
+ ...(record.scopeResourceIds || []),
1543
+ ...(record.templateResourceId ? [record.templateResourceId] : [])
1544
+ ])
1545
+ ]);
1546
+ const auditDocumentKinds = new Set([
1547
+ ...(targetModel.auditReadiness?.managementDocuments || []).map(({ kind }) => kind),
1548
+ "soc2-engagement-terms"
1549
+ ]);
1550
+ const resetDocumentIds = [];
1551
+ const legacyDocumentIds = [];
1552
+ const documentIds = new Set(loaded.resources.filter(({ type }) => type === "document").map(({ id }) => id));
1553
+
1554
+ for (const documentId of Object.keys(documentScopeDecisions)) {
1555
+ if (!documentIds.has(documentId)) {
1556
+ unsupported.push(classifiedChange(
1557
+ "unsupported",
1558
+ documentId,
1559
+ "workflowScope",
1560
+ `Document scope decision "${documentId}" does not match a Document in this workspace.`
1561
+ ));
1562
+ }
1563
+ }
1564
+
1565
+ for (const original of loaded.resources) {
1566
+ if (original.type === "workspace") {
1567
+ updates.push({ ...original, dataModelVersion: "5" });
1568
+ automatic.push(classifiedChange(
1569
+ "automatic",
1570
+ original.id,
1571
+ "dataModelVersion",
1572
+ "Select model v5 and enable the separate governed Document approval and activation lifecycle."
1573
+ ));
1574
+ continue;
1575
+ }
1576
+ if (original.type !== "document") continue;
1577
+ const governedAuditReferences = [...(auditsByDocumentId.get(original.id)?.values() || [])];
1578
+ const auditReferences = [...(allAuditsByDocumentId.get(original.id)?.values() || [])];
1579
+ const explicitScope = normalizeDocumentScopeDecision(documentScopeDecisions[original.id]);
1580
+ if (Object.hasOwn(documentScopeDecisions, original.id) && !explicitScope) {
1581
+ unsupported.push(classifiedChange(
1582
+ "unsupported",
1583
+ original.id,
1584
+ "workflowScope",
1585
+ `Document scope decision for "${original.id}" must be "program" or "engagement".`
1586
+ ));
1587
+ }
1588
+ const engagementSignal = governedAuditReferences.length > 0 || auditDocumentKinds.has(original.documentKind);
1589
+ const programSignal = programDocumentIds.has(original.id);
1590
+ if (!explicitScope && engagementSignal && programSignal) {
1591
+ unsupported.push(classifiedChange(
1592
+ "unsupported",
1593
+ original.id,
1594
+ "workflowScope",
1595
+ `Document "${original.title}" is linked to both program governance and an Audit. Choose program or engagement in documentScopes.${original.id}.`
1596
+ ));
1597
+ }
1598
+ const workflowScope = explicitScope || (engagementSignal && !programSignal ? "engagement" : "program");
1599
+ const record = { ...original, workflowScope };
1600
+ if (workflowScope === "engagement") delete record.programRole;
1601
+ automatic.push(classifiedChange(
1602
+ "automatic",
1603
+ original.id,
1604
+ "workflowScope",
1605
+ explicitScope
1606
+ ? `Use the reviewed ${workflowScope} workflow scope.`
1607
+ : `Classify this Document as ${workflowScope} from its model kind and authoritative relationships.`
1608
+ ));
1609
+ const historicalEngagement = workflowScope === "engagement" && auditReferences.some(({ status }) => (
1610
+ ["issued", "delivered", "complete"].includes(status)
1611
+ ));
1612
+ if (["superseded", "retired"].includes(record.status)) {
1613
+ if (historicalEngagement) {
1614
+ record.activationBasis = "legacy-v4";
1615
+ legacyDocumentIds.push(record.id);
1616
+ } else delete record.activationBasis;
1617
+ delete record.activatedOn;
1618
+ delete record.activatedByIds;
1619
+ delete record.activatedContentRevisions;
1620
+ } else if (record.status === "active" && historicalEngagement) {
1621
+ record.activationBasis = "legacy-v4";
1622
+ delete record.activatedOn;
1623
+ delete record.activatedByIds;
1624
+ delete record.activatedContentRevisions;
1625
+ legacyDocumentIds.push(record.id);
1626
+ reviewRequired.push(classifiedChange(
1627
+ "review-required",
1628
+ record.id,
1629
+ "activationBasis",
1630
+ "Preserve this historical engagement Document as active with a visible legacy-v4 basis. Model v4 recorded one combined approval and activation state, so the migration does not invent an activation date, actor, or second revision."
1631
+ ));
1632
+ } else if (record.status === "active") {
1633
+ record.status = "approved";
1634
+ if (record.effectiveOn) record.proposedEffectiveOn = record.effectiveOn;
1635
+ delete record.effectiveOn;
1636
+ delete record.activationBasis;
1637
+ delete record.activatedOn;
1638
+ delete record.activatedByIds;
1639
+ delete record.activatedContentRevisions;
1640
+ resetDocumentIds.push(record.id);
1641
+ reviewRequired.push(classifiedChange(
1642
+ "review-required",
1643
+ record.id,
1644
+ "status",
1645
+ workflowScope === "engagement"
1646
+ ? "Model v4 recorded one combined approval and activation state. Preserve the approval and approved revision, then record a separate Step 5 activation after confirming the current engagement facts."
1647
+ : "Model v4 recorded one combined approval and activation state. Preserve the approval and approved revision, confirm the linked requirements, then record a separate Step 3 activation."
1648
+ ));
1649
+ }
1650
+ updates.push(record);
1651
+ }
1652
+
1653
+ const updatedById = new Map(updates.map((record) => [record.id, record]));
1654
+ const migratedRecords = loaded.resources.map((record) => updatedById.get(record.id) || record);
1655
+ await collectTargetValidationActions(loaded, migratedRecords, targetModel, missing, manualActions);
1656
+ for (const item of [...missing, ...manualActions]) {
1657
+ unsupported.push(classifiedChange(
1658
+ "unsupported",
1659
+ item.resourceId,
1660
+ item.field,
1661
+ item.message || `Resolve ${item.field} before applying the model v5 migration.`
1662
+ ));
1663
+ }
1664
+ const ready = unsupported.length === 0;
1665
+ return {
1666
+ schemaVersion: 2,
1667
+ sourceModelVersion: "4",
1668
+ targetModelVersion: "5",
1669
+ ready,
1670
+ missing,
1671
+ conflicts: [],
1672
+ manualActions,
1673
+ classifications: { automatic, reviewRequired, unsupported },
1674
+ notes: reviewRequired,
1675
+ migrationReport: {
1676
+ resetDocumentIds,
1677
+ legacyDocumentIds
1678
+ },
1679
+ summary: {
1680
+ create: 0,
1681
+ update: updates.length,
1682
+ automatic: automatic.length,
1683
+ reviewRequired: reviewRequired.length,
1684
+ unsupported: unsupported.length
1685
+ },
1686
+ fileDiff: {
1687
+ create: [],
1688
+ update: updates.map((record) => ({
1689
+ type: record.type,
1690
+ id: record.id,
1691
+ before: loaded.resources.find(({ id }) => id === record.id),
1692
+ after: record
1693
+ }))
1694
+ },
1695
+ changes: {
1696
+ create: [],
1697
+ update: updates,
1698
+ expectedRevisions: Object.fromEntries(updates.map(({ id }) => [id, revisions.get(id)])),
1699
+ validateWholeWorkspace: true,
1700
+ targetModelVersion: "5"
1701
+ }
1702
+ };
1703
+ }
1704
+
1705
+ function normalizeDocumentScopeDecision(value) {
1706
+ const candidate = typeof value === "object" && value
1707
+ ? value.workflowScope || value.scope
1708
+ : value;
1709
+ return ["program", "engagement"].includes(candidate) ? candidate : null;
1710
+ }
1711
+
1501
1712
  async function collectTargetValidationActions(loaded, records, model, missing, manualActions) {
1502
1713
  const { validateWorkspace } = await import("./validate.js");
1503
1714
  const existingById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
@@ -2003,7 +2214,7 @@ function migrateInverseArrays(resources, byId, editable, conflicts, mapping) {
2003
2214
  }
2004
2215
 
2005
2216
  function emptyPlan(version, targetVersion = V1_TARGET_MODEL_VERSION) {
2006
- const classifiedPlan = ["3", "4"].includes(String(targetVersion));
2217
+ const classifiedPlan = Number(targetVersion) >= 3;
2007
2218
  const includesPathMoves = String(targetVersion) === "4";
2008
2219
  return {
2009
2220
  schemaVersion: classifiedPlan ? 2 : 1,
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { scaffoldResourceMutation } from "./agent.js";
3
3
  import { createResourceId } from "./id.js";
4
4
  import { createResourceAndLink, createResources, updateResource } from "./files.js";
5
- import { loadModel } from "../model/index.js";
5
+ import { loadModel, modelSupports } from "../model/index.js";
6
6
  import { coverageEnd } from "./coverage.js";
7
7
  import {
8
8
  addCalendarDays,
@@ -14,7 +14,7 @@ import {
14
14
  } from "./recurrence.js";
15
15
  import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
16
16
  import { loadWorkspace } from "./workspace.js";
17
- import { obligationProgramStatus } from "./program-lifecycle.js";
17
+ import { obligationGovernedDocuments, obligationProgramStatus } from "./program-lifecycle.js";
18
18
  import { resolveProgram } from "./program.js";
19
19
 
20
20
  const COMPLETION_DATE_FIELDS = [
@@ -75,7 +75,7 @@ export function planObligations(resources, options = {}) {
75
75
  for (const obligation of obligations) {
76
76
  const activity = obligationActivity(model, obligation.activityType);
77
77
  const expectedCompletionTypes = activity.completionResourceTypes;
78
- const programStatus = obligationProgramStatus(obligation, byId, asOf);
78
+ const programStatus = obligationProgramStatus(obligation, byId, asOf, model);
79
79
  if (obligation.recurrence?.mode === "event" && obligation.recurrence.eventType) {
80
80
  const eventType = obligation.recurrence.eventType;
81
81
  const group = triggerGroups.get(eventType) ?? {
@@ -111,7 +111,7 @@ export function planObligations(resources, options = {}) {
111
111
  }
112
112
 
113
113
  const configuredAnchor = obligation.recurrence?.anchorDate || obligation.startsOn;
114
- const activationDate = obligationActivationDate(obligation, byId);
114
+ const activationDate = obligationActivationDate(obligation, byId, model);
115
115
  const recurrence = {
116
116
  ...(obligation.recurrence || {}),
117
117
  anchorDate: configuredAnchor && activationDate
@@ -258,8 +258,8 @@ export async function createObligationEvent(input, options) {
258
258
  )
259
259
  ));
260
260
  if (!eventType || templates.length === 0) throw new Error(`No active obligations use event type "${eventType}".`);
261
- if (templates.some((record) => obligationProgramStatus(record, byId, occurredOn) === "proposed")) {
262
- 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.`);
261
+ if (templates.some((record) => obligationProgramStatus(record, byId, occurredOn, loaded.model) === "proposed")) {
262
+ throw new Error(`Event type "${eventType}" still has starter proposals. Make every governing Policy and required governed Document active and effective, then implement at least one linked Control before starting this workflow.`);
263
263
  }
264
264
  if (templates.some((record) => normalizedEventWindow(record.window).precision === "timestamp") && !occurredAt) {
265
265
  throw new Error(`Event type "${eventType}" has hour-based deadlines and requires an RFC 3339 occurredAt timestamp.`);
@@ -720,7 +720,7 @@ function completionTeam(resources, ownerIds) {
720
720
  }
721
721
 
722
722
  function defaultClassificationId(loaded) {
723
- if (String(loaded.model.modelVersion) === "4") {
723
+ if (modelSupports(loaded.model, "program-scope")) {
724
724
  return loaded.resources.find(({ type, id, status }) => type === "classification" && id === "internal" && status === "active")?.id
725
725
  || loaded.resources.find(({ type, status }) => type === "classification" && status === "active")?.id
726
726
  || "";
@@ -792,7 +792,7 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
792
792
  const completionProfile = obligation?.type === "obligation"
793
793
  ? obligationActivity(model, obligation.activityType).completionProfile || null
794
794
  : null;
795
- const completionIds = ["3", "4"].includes(String(model.modelVersion))
795
+ const completionIds = modelSupports(model, "guided-workflow")
796
796
  ? record.completionResourceIds || []
797
797
  : [...(record.completionResourceIds || []), ...(record.evidenceIds || [])];
798
798
  const linkedCompletionIds = [...new Set(completionIds)];
@@ -991,13 +991,16 @@ function occurrenceStatus(window, asOf, complete) {
991
991
  return "upcoming";
992
992
  }
993
993
 
994
- function obligationActivationDate(obligation, byId) {
995
- const dates = (obligation.policyIds || [])
994
+ function obligationActivationDate(obligation, byId, model) {
995
+ const policyDates = (obligation.policyIds || [])
996
996
  .map((id) => byId.get(id))
997
997
  .filter((policy) => policy?.type === "policy")
998
998
  .map((policy) => policy.effectiveOn)
999
999
  .filter(Boolean);
1000
- return dates.sort().at(-1) || null;
1000
+ const documentDates = obligationGovernedDocuments(obligation, byId, model)
1001
+ .map((document) => document.effectiveOn)
1002
+ .filter(Boolean);
1003
+ return [...policyDates, ...documentDates].sort().at(-1) || null;
1001
1004
  }
1002
1005
 
1003
1006
  function relativeTiming(window, asOf) {
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
+ import { modelSupports } from "../model/index.js";
3
4
  import { applyResourceBatch, contentRevision } from "./files.js";
4
5
  import { serializeWorkspaceMutation } from "./mutation.js";
5
6
  import { resolveDataPath } from "./paths.js";
@@ -658,7 +659,7 @@ async function buildPolicyLibraryPlan(loaded) {
658
659
  });
659
660
  }
660
661
 
661
- for (const addition of String(loaded.model.modelVersion) === "4" ? OBLIGATION_ADDITIONS : []) {
662
+ for (const addition of modelSupports(loaded.model, "program-scope") ? OBLIGATION_ADDITIONS : []) {
662
663
  if (byId.has(addition.id)) {
663
664
  skipped.push(skippedItem(addition.id, "present", "An Obligation with this ID already exists, so FileGRC will not replace it."));
664
665
  continue;
@@ -1,6 +1,93 @@
1
1
  import { currentPartyPeople } from "./parties.js";
2
+ import { modelSupports } from "../model/index.js";
2
3
 
3
- export function obligationProgramStatus(obligation, byId, asOf) {
4
+ const requiredDocumentsByControlCache = new WeakMap();
5
+
6
+ export function auditSpecificDocumentKinds(model) {
7
+ return new Set([
8
+ ...(model?.auditReadiness?.managementDocuments || []).map(({ kind }) => kind),
9
+ "soc2-engagement-terms"
10
+ ]);
11
+ }
12
+
13
+ export function documentIsAuditSpecific(document, model) {
14
+ if (document?.type !== "document") return false;
15
+ if (modelSupports(model, "document-workflow-scope")) {
16
+ return document.workflowScope === "engagement";
17
+ }
18
+ return auditSpecificDocumentKinds(model).has(document.documentKind);
19
+ }
20
+
21
+ export function governedDocumentIsOperating(document, asOf, model) {
22
+ if (document?.type !== "document" || document.status !== "active") return false;
23
+ if (!document.effectiveOn || document.effectiveOn > asOf) return false;
24
+ if (!modelSupports(model, "governed-document-activation")) return true;
25
+ if (document.activationBasis === "legacy-v4") {
26
+ return document.workflowScope === "engagement"
27
+ && Boolean(document.approvedOn && document.approvedContentRevisions);
28
+ }
29
+ return Boolean(
30
+ document.activationBasis === "recorded"
31
+ && document.approvedOn
32
+ && document.approvedContentRevisions
33
+ && document.activatedOn
34
+ && document.activatedOn <= asOf
35
+ && (document.activatedByIds || []).length
36
+ && document.activatedContentRevisions
37
+ && contentRevisionBindingsMatch(document.approvedContentRevisions, document.activatedContentRevisions)
38
+ );
39
+ }
40
+
41
+ export function contentRevisionBindingsMatch(left, right) {
42
+ if (!left || !right || Array.isArray(left) || Array.isArray(right)) return false;
43
+ const normalize = (value) => Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
44
+ return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right));
45
+ }
46
+
47
+ export function obligationGovernedDocuments(obligation, byId, model) {
48
+ if (!modelSupports(model, "governed-document-activation")) return [];
49
+ const policyDocumentIds = (obligation.policyIds || []).flatMap((id) => {
50
+ const policy = byId.get(id);
51
+ return policy?.type === "policy" ? policy.relatedDocumentIds || [] : [];
52
+ });
53
+ const directDocumentIds = [
54
+ ...(obligation.scopeResourceIds || []),
55
+ ...(obligation.templateResourceId ? [obligation.templateResourceId] : [])
56
+ ];
57
+ const requiredDocumentsByControl = indexRequiredDocumentsByControl(byId, model);
58
+ const controlDocumentIds = (obligation.controlIds || [])
59
+ .flatMap((id) => requiredDocumentsByControl.get(id) || []);
60
+ return [...new Set([...policyDocumentIds, ...directDocumentIds, ...controlDocumentIds])]
61
+ .map((id) => byId.get(id))
62
+ .filter((record) => (
63
+ record?.type === "document"
64
+ && !["superseded", "retired"].includes(record.status)
65
+ && !documentIsAuditSpecific(record, model)
66
+ ));
67
+ }
68
+
69
+ function indexRequiredDocumentsByControl(byId, model) {
70
+ const modelVersion = String(model?.modelVersion || "");
71
+ const cached = requiredDocumentsByControlCache.get(byId);
72
+ if (cached?.modelVersion === modelVersion) return cached.index;
73
+ const index = new Map();
74
+ for (const record of byId.values()) {
75
+ if (
76
+ record.type !== "document"
77
+ || record.programRole !== "required"
78
+ || ["superseded", "retired"].includes(record.status)
79
+ || documentIsAuditSpecific(record, model)
80
+ ) continue;
81
+ for (const controlId of record.controlIds || []) {
82
+ if (!index.has(controlId)) index.set(controlId, []);
83
+ index.get(controlId).push(record.id);
84
+ }
85
+ }
86
+ requiredDocumentsByControlCache.set(byId, { modelVersion, index });
87
+ return index;
88
+ }
89
+
90
+ export function obligationProgramStatus(obligation, byId, asOf, model) {
4
91
  if (obligation.status !== "active") return "proposed";
5
92
  if (currentPartyPeople(obligation.ownerIds || [], byId).size === 0) return "proposed";
6
93
  const policyIds = obligation.policyIds || [];
@@ -12,6 +99,9 @@ export function obligationProgramStatus(obligation, byId, asOf) {
12
99
  && policy.effectiveOn <= asOf;
13
100
  });
14
101
  if (!policiesReady) return "proposed";
102
+ const governedDocumentsReady = obligationGovernedDocuments(obligation, byId, model)
103
+ .every((document) => governedDocumentIsOperating(document, asOf, model));
104
+ if (!governedDocumentsReady) return "proposed";
15
105
  const controlIds = obligation.controlIds || [];
16
106
  if (!controlIds.length) return "accepted";
17
107
  return controlIds.some((id) => byId.get(id)?.type === "control" && byId.get(id).status === "implemented")
@@ -19,10 +109,10 @@ export function obligationProgramStatus(obligation, byId, asOf) {
19
109
  : "proposed";
20
110
  }
21
111
 
22
- export function obligationIsRunning(obligation, byId, asOf) {
112
+ export function obligationIsRunning(obligation, byId, asOf, model) {
23
113
  return obligation?.type === "obligation"
24
114
  && obligation.status === "active"
25
- && obligationProgramStatus(obligation, byId, asOf) === "accepted";
115
+ && obligationProgramStatus(obligation, byId, asOf, model) === "accepted";
26
116
  }
27
117
 
28
118
  export function obligationIsEnabled(obligation) {
@@ -12,7 +12,7 @@ export const RESOURCE_INSTRUCTIONS = {
12
12
  requirement: "Keep the published criterion as catalog content. Record management applicability and rationale on the selected Program.",
13
13
  commitment: "Record supplemental customer promises and service requirements that shape the scope or control design. The Commitment’s systemIds and controlIds are authoritative for what fulfills it.",
14
14
  policy: "Tailor each Policy to match what the company is committing to. Clear placeholders, assign an owner and separate approver, then bind approval to the reviewed content. Approval does not prove implementation. Activate the Policy during the Step 3 cutover after reviewing its implementation gaps.",
15
- document: "Tailor the governed plans and other supporting documents the program needs. Assign owners and approvers, then keep the approved Markdown in Git.",
15
+ document: "Complete required governed plans and schedules in Step 2, assign an owner and separate approver, and bind approval to the intended values and exact Markdown. Implement the linked requirements and activate that approved revision in Step 3. Prepare audit-specific Documents in Step 5.",
16
16
  control: "Finish each applicable starter Control with the procedure people follow, its owner, bounded System scope, operating Components, authoritative evidence-source Components, governing Policy and Requirement mappings, and implementation date. Put calendar and event schedules in Obligations.",
17
17
  "complementary-control": "Review whether any in-scope Control depends on a customer or carved-out provider action. Record each real dependency, or confirm that the current scope has none.",
18
18
  evidence: "Create an Evidence Artifact when a real export, report, screenshot, signed file, or approved external reference exists. Select its authoritative source Component, link the Controls and operating records it supports, retain the fixed artifact or reference, and have another person verify it before audit use.",
@@ -98,16 +98,20 @@ export const PROGRAM_PATH = [
98
98
  {
99
99
  id: "policies",
100
100
  number: 2,
101
- title: "Approve Policies",
102
- description: "Tailor, review, and approve",
103
- summary: "Adapt and approve the starter policies.",
101
+ title: "Approve Policies and Plans",
102
+ description: "Tailor requirements, intended values, and approvals",
103
+ summary: "Adapt and independently approve Policies and required governed Documents.",
104
104
  sections: [
105
- { id: "library", title: "Policy Library", description: "Review and approve Policy requirements without treating approval as proof of technical implementation.", steps: ["Review Policy Markdown and replace every organization placeholder.", "Confirm the owner, separate approver, audience, review Obligation, and Controls that point to the Policy.", "Record approval against the exact reviewed content. Leave the Policy approved and inactive until the Step 3 implementation cutover."], types: ["policy"], defaultOpen: true }
105
+ { id: "library", title: "Policy Library", description: "Review and approve Policy requirements without treating approval as proof of technical implementation.", steps: ["Review Policy Markdown and replace every organization placeholder.", "Confirm the owner, separate approver, audience, review Obligation, and Controls that point to the Policy.", "Record approval against the exact reviewed content. Leave the Policy approved and inactive until the Step 3 implementation cutover."], types: ["policy"], defaultOpen: true },
106
+ { id: "governed-documents", title: "Governed Plans and Schedules", description: "Complete the intended values in each required program plan or schedule and obtain independent approval of the exact revision before implementation.", steps: ["Review each required plan or schedule and replace every organization placeholder with the intended owner, timing, threshold, scope, or response value.", "Confirm the Document owner, separate approver, linked Controls, and review schedule.", "Record approval and its date against the exact Markdown revision. Leave the Document approved until its linked requirements are implemented in Step 3."], types: [], relatedLinks: [{ type: "document", label: "Governed plans and schedules", href: "#/resources/document?stage=policies&documentScope=program" }], defaultOpen: true }
106
107
  ],
107
108
  resourceTypes: ["policy"],
109
+ supportingResourceTypes: ["document"],
108
110
  commands: [
109
111
  "filegrc guide policy --json",
112
+ "filegrc guide document --json",
110
113
  "filegrc list policy --json",
114
+ "filegrc list document --json",
111
115
  "filegrc get POLICY_ID --mutation"
112
116
  ]
113
117
  },
@@ -118,14 +122,15 @@ export const PROGRAM_PATH = [
118
122
  description: "Finish controls and their evidence sources",
119
123
  summary: "Describe each Control and connect its evidence source.",
120
124
  sections: [
121
- { id: "catalog", title: "Control Catalog", description: "Finish the starter Controls, governed plans, schedules, and authoritative evidence sources, then review the approved Policies together at implementation cutover.", steps: ["Open every planned Control and confirm its mappings and operation pattern.", "Write the real procedure in Record Markdown, add bounded System scope, and map the operating and authoritative evidence-source Components.", "Create or enable every calendar and event schedule as an Obligation. Enabled work remains dormant until its governing Policy is active.", "Confirm each source Component is active, has an evidence-source role and rationale in the Control's System scope, has current access owners, and includes repeatable retrieval instructions in Record Markdown.", "Complete required governed plans, then use Review policy activation on the Controls page to inspect each Policy’s planned or partial Controls, missing Components or sources, missing schedules, and unresolved Exceptions.", "Choose the approved Policies that should take effect, set the real effective date, and confirm the Step 3 cutover. You can activate with a documented gap or approved Exception, but Evidence Readiness still requires active and operating Policies."], types: ["control", "complementary-control", "document"], defaultOpen: true }
125
+ { id: "catalog", title: "Control Catalog", description: "Implement the approved requirements, activate required governed Documents, finish the authoritative evidence sources, then review the approved Policies together at implementation cutover.", steps: ["Open every planned Control and confirm its mappings and operation pattern.", "Write the real procedure in Record Markdown, add bounded System scope, and map the operating and authoritative evidence-source Components.", "Create or enable every calendar and event schedule as an Obligation. Enabled work remains dormant until its governing Policy is active.", "Confirm each source Component is active, has an evidence-source role and rationale in the Control's System scope, has current access owners, and includes repeatable retrieval instructions in Record Markdown.", "Implement every requirement linked from an approved governed plan or schedule, then activate the unchanged approved Document with a separate activation date and revision.", "Use Review policy activation on the Controls page to inspect each Policy’s planned or partial Controls, inactive governed Documents, missing Components or sources, missing schedules, and unresolved Exceptions.", "Choose the approved Policies that should take effect, set the real effective date, and confirm the Step 3 cutover. You can activate with a documented gap or approved Exception, but Evidence Readiness still requires active and operating Policies and Documents."], types: ["control", "complementary-control"], relatedLinks: [{ type: "document", label: "Activate governed plans and schedules", href: "#/resources/document?stage=controls&documentScope=program" }], defaultOpen: true }
122
126
  ],
123
- resourceTypes: ["control", "complementary-control", "document"],
127
+ resourceTypes: ["control", "complementary-control"],
124
128
  commands: [
125
129
  "filegrc guide control --json",
126
130
  "filegrc list control --json",
127
131
  "filegrc get CONTROL_ID --mutation",
128
132
  "filegrc review-collection complementary-control --scaffold",
133
+ "filegrc activate-documents --scaffold",
129
134
  "filegrc activate-policies --scaffold",
130
135
  "filegrc evidence-map --json",
131
136
  "filegrc program-readiness --json"
@@ -217,7 +222,7 @@ export const PROGRAM_PATH = [
217
222
  summary: "Track the CPA engagement, fieldwork, and evidence packet.",
218
223
  sections: [
219
224
  { id: "engagement", title: "Engagement", description: "Record the actual CPA engagement, formal scope and dates, requests, and management responses.", steps: ["Create the Audit after the CPA firm is engaged.", "Record the firm-agreed type, scope, systems, criteria, and dates.", "Track incoming requests and approved response material."], types: ["audit", "audit-request"], defaultOpen: true },
220
- { id: "fieldwork", title: "Fieldwork", description: "Prepare management documents, reconcile Type 2 populations, review both evidence paths, support testing, and build the indexed packet.", steps: ["Initialize engagement-specific management documents and populations.", "Review dated FileGRC operating records and verified Evidence Artifacts for the formal period.", "Reconcile complete populations, link samples, and resolve fieldwork requests and Findings.", "Build the packet from a clean Git revision; it includes FileGRC records, Markdown, Evidence Artifacts, attachments, indexes, history, and checksums."], types: ["audit-population", "control-test"], utility: "audit-packet", defaultOpen: true }
225
+ { id: "fieldwork", title: "Fieldwork", description: "Prepare audit-specific Documents, reconcile Type 2 populations, review both evidence paths, support testing, and build the indexed packet.", steps: ["Initialize and complete engagement-specific management Documents and populations.", "Approve and activate each audit-specific Document only when its engagement facts and timing are final.", "Review dated FileGRC operating records and verified Evidence Artifacts for the formal period.", "Reconcile complete populations, link samples, and resolve fieldwork requests and Findings.", "Build the packet from a clean Git revision; it includes FileGRC records, Markdown, Evidence Artifacts, attachments, indexes, history, and checksums."], types: ["audit-population", "control-test"], relatedLinks: [{ type: "document", label: "Audit-specific Documents", href: "#/resources/document?stage=audit&documentScope=audit" }], utility: "audit-packet", defaultOpen: true }
221
226
  ],
222
227
  resourceTypes: ["audit", "audit-request", "audit-population", "control-test"],
223
228
  utilities: [