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.
- package/README.md +7 -7
- package/model/index.js +22 -4
- package/model/v5.json +10233 -0
- package/model/v6.json +10358 -0
- package/package.json +4 -2
- package/src/agent.js +4 -3
- package/src/audit-preparation.js +182 -45
- package/src/audit-transition.js +3 -2
- package/src/batch-review.js +7 -6
- package/src/cli.js +143 -12
- package/src/collection-review.js +4 -3
- package/src/collection-scope.js +4 -3
- package/src/document-activation.js +181 -0
- package/src/evidence-packet.js +199 -78
- package/src/external-reviewer.js +5 -4
- package/src/files.js +173 -35
- package/src/git.js +71 -7
- package/src/index.js +11 -1
- package/src/model-migration.js +363 -7
- package/src/obligations.js +14 -11
- package/src/policy-library.js +7 -3
- package/src/program-lifecycle.js +131 -3
- package/src/program-path.js +19 -13
- package/src/program-readiness.js +338 -71
- package/src/program.js +5 -3
- package/src/reconciliation.js +3 -2
- package/src/server.js +36 -7
- package/src/setup.js +8 -5
- package/src/soc2.js +3 -2
- package/src/validate.js +178 -16
- package/src/web.js +264 -17
- package/src/workflow.js +38 -7
- package/src/workspace.js +5 -0
package/src/model-migration.js
CHANGED
|
@@ -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
|
|
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
|
|
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 ===
|
|
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
|
|
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
|
|
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 =
|
|
2362
|
+
const classifiedPlan = Number(targetVersion) >= 3;
|
|
2007
2363
|
const includesPathMoves = String(targetVersion) === "4";
|
|
2008
2364
|
return {
|
|
2009
2365
|
schemaVersion: classifiedPlan ? 2 : 1,
|
package/src/obligations.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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) {
|
package/src/policy-library.js
CHANGED
|
@@ -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
|
-
|
|
518
|
-
|
|
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
|
|
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;
|