filegrc 0.7.0 → 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) {