filegrc 0.7.1 → 0.9.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,11 @@ 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
895
+ : sourceVersion === "2" ? "3"
896
+ : sourceVersion === "3" ? "4"
897
+ : sourceVersion === "4" ? "5"
898
+ : ACTIVE_MODEL_VERSION;
895
899
  if (sourceVersion === requestedTarget) return emptyPlan(sourceVersion, requestedTarget);
896
900
  if (sourceVersion === "1" && requestedTarget === "2") {
897
901
  return planV1ToV2Migration(input, options);
@@ -899,13 +903,19 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
899
903
  if (sourceVersion === "2" && requestedTarget === "3") {
900
904
  return planV2ToV3Migration(loaded);
901
905
  }
902
- if (sourceVersion === "3" && requestedTarget === ACTIVE_MODEL_VERSION) {
906
+ if (sourceVersion === "3" && requestedTarget === "4") {
903
907
  return planV3ToV4Migration(loaded, options);
904
908
  }
909
+ if (sourceVersion === "4" && requestedTarget === "5") {
910
+ return planV4ToV5Migration(loaded, options);
911
+ }
912
+ if (sourceVersion === "5" && requestedTarget === ACTIVE_MODEL_VERSION) {
913
+ return planV5ToV6Migration(loaded);
914
+ }
905
915
  if (sourceVersion === "1" && requestedTarget === ACTIVE_MODEL_VERSION) {
906
916
  throw new Error(
907
917
  "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."
918
+ + "Preview and apply `npx filegrc migrate --to-model 2`, then migrate one version at a time through model v6."
909
919
  );
910
920
  }
911
921
  throw new Error(`Model migration does not support v${sourceVersion} to v${requestedTarget}.`);
@@ -925,7 +935,7 @@ export async function migrateModel(input = process.cwd(), options = {}) {
925
935
  if (plan.sourceModelVersion === "1" && plan.targetModelVersion === "2") {
926
936
  return migrateV1ToV2(input, options);
927
937
  }
928
- const result = await applyResourceBatch(input, plan.changes);
938
+ const result = await applyModelMigrationBatch(input, plan.changes);
929
939
  return {
930
940
  ...plan,
931
941
  applied: true,
@@ -1498,6 +1508,352 @@ async function planV3ToV4Migration(loaded, options = {}) {
1498
1508
  };
1499
1509
  }
1500
1510
 
1511
+ async function planV4ToV5Migration(loaded, options = {}) {
1512
+ if (!loaded.workspace?.id) throw new Error("Model migration requires a valid Workspace record.");
1513
+ const targetModel = loadModel("5");
1514
+ const revisions = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
1515
+ const automatic = [];
1516
+ const reviewRequired = [];
1517
+ const unsupported = [];
1518
+ const missing = [];
1519
+ const manualActions = [];
1520
+ const updates = [];
1521
+ const documentScopeDecisions = options.documentScopes && typeof options.documentScopes === "object"
1522
+ ? options.documentScopes
1523
+ : {};
1524
+ const auditDocumentFields = [
1525
+ "engagementTermsDocumentId",
1526
+ ...(targetModel.auditReadiness?.managementDocuments || []).map(({ field }) => field)
1527
+ ];
1528
+ const auditsByDocumentId = new Map();
1529
+ const allAuditsByDocumentId = new Map();
1530
+ const addAuditReference = (map, documentId, audit) => {
1531
+ if (!documentId) return;
1532
+ if (!map.has(documentId)) map.set(documentId, new Map());
1533
+ map.get(documentId).set(audit.id, audit);
1534
+ };
1535
+ for (const audit of loaded.resources.filter(({ type }) => type === "audit")) {
1536
+ for (const field of auditDocumentFields) {
1537
+ const documentId = audit[field];
1538
+ if (!documentId) continue;
1539
+ addAuditReference(auditsByDocumentId, documentId, audit);
1540
+ addAuditReference(allAuditsByDocumentId, documentId, audit);
1541
+ }
1542
+ for (const documentId of audit.supplementalDocumentIds || []) {
1543
+ addAuditReference(allAuditsByDocumentId, documentId, audit);
1544
+ }
1545
+ }
1546
+ const programDocumentIds = new Set([
1547
+ ...loaded.resources.filter(({ type }) => type === "policy").flatMap(({ relatedDocumentIds }) => relatedDocumentIds || []),
1548
+ ...loaded.resources.filter(({ type }) => type === "obligation").flatMap((record) => [
1549
+ ...(record.scopeResourceIds || []),
1550
+ ...(record.templateResourceId ? [record.templateResourceId] : [])
1551
+ ])
1552
+ ]);
1553
+ const auditDocumentKinds = new Set([
1554
+ ...(targetModel.auditReadiness?.managementDocuments || []).map(({ kind }) => kind),
1555
+ "soc2-engagement-terms"
1556
+ ]);
1557
+ const resetDocumentIds = [];
1558
+ const legacyDocumentIds = [];
1559
+ const documentIds = new Set(loaded.resources.filter(({ type }) => type === "document").map(({ id }) => id));
1560
+
1561
+ for (const documentId of Object.keys(documentScopeDecisions)) {
1562
+ if (!documentIds.has(documentId)) {
1563
+ unsupported.push(classifiedChange(
1564
+ "unsupported",
1565
+ documentId,
1566
+ "workflowScope",
1567
+ `Document scope decision "${documentId}" does not match a Document in this workspace.`
1568
+ ));
1569
+ }
1570
+ }
1571
+
1572
+ for (const original of loaded.resources) {
1573
+ if (original.type === "workspace") {
1574
+ updates.push({ ...original, dataModelVersion: "5" });
1575
+ automatic.push(classifiedChange(
1576
+ "automatic",
1577
+ original.id,
1578
+ "dataModelVersion",
1579
+ "Select model v5 and enable the separate governed Document approval and activation lifecycle."
1580
+ ));
1581
+ continue;
1582
+ }
1583
+ if (original.type !== "document") continue;
1584
+ const governedAuditReferences = [...(auditsByDocumentId.get(original.id)?.values() || [])];
1585
+ const auditReferences = [...(allAuditsByDocumentId.get(original.id)?.values() || [])];
1586
+ const explicitScope = normalizeDocumentScopeDecision(documentScopeDecisions[original.id]);
1587
+ if (Object.hasOwn(documentScopeDecisions, original.id) && !explicitScope) {
1588
+ unsupported.push(classifiedChange(
1589
+ "unsupported",
1590
+ original.id,
1591
+ "workflowScope",
1592
+ `Document scope decision for "${original.id}" must be "program" or "engagement".`
1593
+ ));
1594
+ }
1595
+ const engagementSignal = governedAuditReferences.length > 0 || auditDocumentKinds.has(original.documentKind);
1596
+ const programSignal = programDocumentIds.has(original.id);
1597
+ if (!explicitScope && engagementSignal && programSignal) {
1598
+ unsupported.push(classifiedChange(
1599
+ "unsupported",
1600
+ original.id,
1601
+ "workflowScope",
1602
+ `Document "${original.title}" is linked to both program governance and an Audit. Choose program or engagement in documentScopes.${original.id}.`
1603
+ ));
1604
+ }
1605
+ const workflowScope = explicitScope || (engagementSignal && !programSignal ? "engagement" : "program");
1606
+ const record = { ...original, workflowScope };
1607
+ if (workflowScope === "engagement") delete record.programRole;
1608
+ automatic.push(classifiedChange(
1609
+ "automatic",
1610
+ original.id,
1611
+ "workflowScope",
1612
+ explicitScope
1613
+ ? `Use the reviewed ${workflowScope} workflow scope.`
1614
+ : `Classify this Document as ${workflowScope} from its model kind and authoritative relationships.`
1615
+ ));
1616
+ const historicalEngagement = workflowScope === "engagement" && auditReferences.some(({ status }) => (
1617
+ ["issued", "delivered", "complete"].includes(status)
1618
+ ));
1619
+ if (["superseded", "retired"].includes(record.status)) {
1620
+ if (historicalEngagement) {
1621
+ record.activationBasis = "legacy-v4";
1622
+ legacyDocumentIds.push(record.id);
1623
+ } else delete record.activationBasis;
1624
+ delete record.activatedOn;
1625
+ delete record.activatedByIds;
1626
+ delete record.activatedContentRevisions;
1627
+ } else if (record.status === "active" && historicalEngagement) {
1628
+ record.activationBasis = "legacy-v4";
1629
+ delete record.activatedOn;
1630
+ delete record.activatedByIds;
1631
+ delete record.activatedContentRevisions;
1632
+ legacyDocumentIds.push(record.id);
1633
+ reviewRequired.push(classifiedChange(
1634
+ "review-required",
1635
+ record.id,
1636
+ "activationBasis",
1637
+ "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."
1638
+ ));
1639
+ } else if (record.status === "active") {
1640
+ record.status = "approved";
1641
+ if (record.effectiveOn) record.proposedEffectiveOn = record.effectiveOn;
1642
+ delete record.effectiveOn;
1643
+ delete record.activationBasis;
1644
+ delete record.activatedOn;
1645
+ delete record.activatedByIds;
1646
+ delete record.activatedContentRevisions;
1647
+ resetDocumentIds.push(record.id);
1648
+ reviewRequired.push(classifiedChange(
1649
+ "review-required",
1650
+ record.id,
1651
+ "status",
1652
+ workflowScope === "engagement"
1653
+ ? "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."
1654
+ : "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."
1655
+ ));
1656
+ }
1657
+ updates.push(record);
1658
+ }
1659
+
1660
+ const updatedById = new Map(updates.map((record) => [record.id, record]));
1661
+ const migratedRecords = loaded.resources.map((record) => updatedById.get(record.id) || record);
1662
+ await collectTargetValidationActions(loaded, migratedRecords, targetModel, missing, manualActions);
1663
+ for (const item of [...missing, ...manualActions]) {
1664
+ unsupported.push(classifiedChange(
1665
+ "unsupported",
1666
+ item.resourceId,
1667
+ item.field,
1668
+ item.message || `Resolve ${item.field} before applying the model v5 migration.`
1669
+ ));
1670
+ }
1671
+ const ready = unsupported.length === 0;
1672
+ return {
1673
+ schemaVersion: 2,
1674
+ sourceModelVersion: "4",
1675
+ targetModelVersion: "5",
1676
+ ready,
1677
+ missing,
1678
+ conflicts: [],
1679
+ manualActions,
1680
+ classifications: { automatic, reviewRequired, unsupported },
1681
+ notes: reviewRequired,
1682
+ migrationReport: {
1683
+ resetDocumentIds,
1684
+ legacyDocumentIds
1685
+ },
1686
+ summary: {
1687
+ create: 0,
1688
+ update: updates.length,
1689
+ automatic: automatic.length,
1690
+ reviewRequired: reviewRequired.length,
1691
+ unsupported: unsupported.length
1692
+ },
1693
+ fileDiff: {
1694
+ create: [],
1695
+ update: updates.map((record) => ({
1696
+ type: record.type,
1697
+ id: record.id,
1698
+ before: loaded.resources.find(({ id }) => id === record.id),
1699
+ after: record
1700
+ }))
1701
+ },
1702
+ changes: {
1703
+ create: [],
1704
+ update: updates,
1705
+ expectedRevisions: Object.fromEntries(updates.map(({ id }) => [id, revisions.get(id)])),
1706
+ validateWholeWorkspace: true,
1707
+ targetModelVersion: "5"
1708
+ }
1709
+ };
1710
+ }
1711
+
1712
+ async function planV5ToV6Migration(loaded) {
1713
+ if (!loaded.workspace?.id) throw new Error("Model migration requires a valid Workspace record.");
1714
+ const targetModel = loadModel("6");
1715
+ const revisions = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
1716
+ const automatic = [];
1717
+ const reviewRequired = [];
1718
+ const unsupported = [];
1719
+ const missing = [];
1720
+ const manualActions = [];
1721
+ const updates = [];
1722
+ const legacyTrainingIds = [];
1723
+ const removedTrainingScheduleFields = [];
1724
+ const obligations = loaded.resources.filter(({ type }) => type === "obligation");
1725
+
1726
+ for (const original of loaded.resources) {
1727
+ if (original.type === "workspace") {
1728
+ updates.push({ ...original, dataModelVersion: "6" });
1729
+ automatic.push(classifiedChange(
1730
+ "automatic",
1731
+ original.id,
1732
+ "dataModelVersion",
1733
+ "Select model v6 and enable separate Training approval and activation."
1734
+ ));
1735
+ continue;
1736
+ }
1737
+ if (original.type !== "training") continue;
1738
+ const record = { ...original };
1739
+ if (record.approvedByIds) {
1740
+ record.approverIds = record.approvedByIds;
1741
+ delete record.approvedByIds;
1742
+ automatic.push(classifiedChange(
1743
+ "automatic",
1744
+ record.id,
1745
+ "approverIds",
1746
+ "Use the common governed-content approver field."
1747
+ ));
1748
+ }
1749
+ if (record.effectiveContentRevisions) {
1750
+ record.approvedContentRevisions = record.effectiveContentRevisions;
1751
+ delete record.effectiveContentRevisions;
1752
+ automatic.push(classifiedChange(
1753
+ "automatic",
1754
+ record.id,
1755
+ "approvedContentRevisions",
1756
+ "Preserve the exact Training revision previously bound to approval and activation as the approved revision."
1757
+ ));
1758
+ }
1759
+ const removedSchedule = {};
1760
+ for (const field of ["assignmentTrigger", "completionWindowDays"]) {
1761
+ if (record[field] === undefined) continue;
1762
+ removedSchedule[field] = record[field];
1763
+ delete record[field];
1764
+ }
1765
+ if (Object.keys(removedSchedule).length) {
1766
+ const obligationIds = obligations.filter((obligation) => (
1767
+ obligation.templateResourceId === record.id
1768
+ || (obligation.scopeResourceIds || []).includes(record.id)
1769
+ )).map(({ id }) => id);
1770
+ removedTrainingScheduleFields.push({ trainingId: record.id, values: removedSchedule, obligationIds });
1771
+ reviewRequired.push(classifiedChange(
1772
+ "review-required",
1773
+ record.id,
1774
+ "assignmentSchedule",
1775
+ `Training assignment schedules now belong only in Obligations. Confirm the removed ${Object.keys(removedSchedule).join(" and ")} values against ${obligationIds.length ? obligationIds.join(", ") : "a new Step 3 Obligation"}.`
1776
+ ));
1777
+ }
1778
+ if (record.status === "draft" && record.effectiveOn) {
1779
+ record.proposedEffectiveOn = record.effectiveOn;
1780
+ delete record.effectiveOn;
1781
+ reviewRequired.push(classifiedChange(
1782
+ "review-required",
1783
+ record.id,
1784
+ "proposedEffectiveOn",
1785
+ "Keep the draft Training date as proposed until the approved revision is activated in Step 3."
1786
+ ));
1787
+ }
1788
+ if (["active", "retired"].includes(record.status) && record.approvedContentRevisions) {
1789
+ record.activationBasis = "legacy-v5";
1790
+ legacyTrainingIds.push(record.id);
1791
+ reviewRequired.push(classifiedChange(
1792
+ "review-required",
1793
+ record.id,
1794
+ "activationBasis",
1795
+ "Preserve the combined model v5 Training approval and activation as legacy-v5. The migration does not invent a separate activation actor, date, or revision."
1796
+ ));
1797
+ }
1798
+ updates.push(record);
1799
+ }
1800
+
1801
+ const updatedById = new Map(updates.map((record) => [record.id, record]));
1802
+ const migratedRecords = loaded.resources.map((record) => updatedById.get(record.id) || record);
1803
+ await collectTargetValidationActions(loaded, migratedRecords, targetModel, missing, manualActions);
1804
+ for (const item of [...missing, ...manualActions]) {
1805
+ unsupported.push(classifiedChange(
1806
+ "unsupported",
1807
+ item.resourceId,
1808
+ item.field,
1809
+ item.message || `Resolve ${item.field} before applying the model v6 migration.`
1810
+ ));
1811
+ }
1812
+ const ready = unsupported.length === 0;
1813
+ return {
1814
+ schemaVersion: 2,
1815
+ sourceModelVersion: "5",
1816
+ targetModelVersion: "6",
1817
+ ready,
1818
+ missing,
1819
+ conflicts: [],
1820
+ manualActions,
1821
+ classifications: { automatic, reviewRequired, unsupported },
1822
+ notes: reviewRequired,
1823
+ migrationReport: { legacyTrainingIds, removedTrainingScheduleFields },
1824
+ summary: {
1825
+ create: 0,
1826
+ update: updates.length,
1827
+ automatic: automatic.length,
1828
+ reviewRequired: reviewRequired.length,
1829
+ unsupported: unsupported.length
1830
+ },
1831
+ fileDiff: {
1832
+ create: [],
1833
+ update: updates.map((record) => ({
1834
+ type: record.type,
1835
+ id: record.id,
1836
+ before: loaded.resources.find(({ id }) => id === record.id),
1837
+ after: record
1838
+ }))
1839
+ },
1840
+ changes: {
1841
+ create: [],
1842
+ update: updates,
1843
+ expectedRevisions: Object.fromEntries(updates.map(({ id }) => [id, revisions.get(id)])),
1844
+ validateWholeWorkspace: true,
1845
+ targetModelVersion: "6"
1846
+ }
1847
+ };
1848
+ }
1849
+
1850
+ function normalizeDocumentScopeDecision(value) {
1851
+ const candidate = typeof value === "object" && value
1852
+ ? value.workflowScope || value.scope
1853
+ : value;
1854
+ return ["program", "engagement"].includes(candidate) ? candidate : null;
1855
+ }
1856
+
1501
1857
  async function collectTargetValidationActions(loaded, records, model, missing, manualActions) {
1502
1858
  const { validateWorkspace } = await import("./validate.js");
1503
1859
  const existingById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
@@ -2003,7 +2359,7 @@ function migrateInverseArrays(resources, byId, editable, conflicts, mapping) {
2003
2359
  }
2004
2360
 
2005
2361
  function emptyPlan(version, targetVersion = V1_TARGET_MODEL_VERSION) {
2006
- const classifiedPlan = ["3", "4"].includes(String(targetVersion));
2362
+ const classifiedPlan = Number(targetVersion) >= 3;
2007
2363
  const includesPathMoves = String(targetVersion) === "4";
2008
2364
  return {
2009
2365
  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 { obligationGovernedContent, 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-content record 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 governedContentDates = obligationGovernedContent(obligation, byId, model)
1001
+ .map((record) => record.effectiveOn)
1002
+ .filter(Boolean);
1003
+ return [...policyDates, ...governedContentDates].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";
@@ -514,8 +515,11 @@ async function buildPolicyLibraryPlan(loaded) {
514
515
  const source = await readResourceSource(loaded, TRAINING_CONTENT_PATH);
515
516
  const rawSourceRevision = contentRevision(source);
516
517
  const sourceRevision = normalizedTrainingRevision(source, loaded.workspace?.organizationName);
517
- if (["active", "retired"].includes(trainingEntry.record.status)) {
518
- skipped.push(skippedItem(TRAINING_ID, "adopted", "Active and retired Training content is never changed by a starter-library proposal."));
518
+ const adoptedStatuses = modelSupports(loaded.model, "governed-training-activation")
519
+ ? ["approved", "active", "superseded", "retired"]
520
+ : ["active", "retired"];
521
+ if (adoptedStatuses.includes(trainingEntry.record.status)) {
522
+ skipped.push(skippedItem(TRAINING_ID, "adopted", "Approved, active, superseded, and retired Training content is never changed by a starter-library proposal."));
519
523
  } else if (sourceRevision === CURRENT_TRAINING_REVISION) {
520
524
  skipped.push(skippedItem(TRAINING_ID, "current", "The Security Awareness Training already contains the current starter language."));
521
525
  } else if (!PRIOR_TRAINING_REVISIONS.has(sourceRevision)) {
@@ -658,7 +662,7 @@ async function buildPolicyLibraryPlan(loaded) {
658
662
  });
659
663
  }
660
664
 
661
- for (const addition of String(loaded.model.modelVersion) === "4" ? OBLIGATION_ADDITIONS : []) {
665
+ for (const addition of modelSupports(loaded.model, "program-scope") ? OBLIGATION_ADDITIONS : []) {
662
666
  if (byId.has(addition.id)) {
663
667
  skipped.push(skippedItem(addition.id, "present", "An Obligation with this ID already exists, so FileGRC will not replace it."));
664
668
  continue;