filegrc 0.9.2 → 0.11.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.
@@ -3,7 +3,9 @@ import { applyModelMigrationBatch, applyResourceBatch, contentRevision } from ".
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";
6
- import { readFile } from "node:fs/promises";
6
+ import { randomUUID } from "node:crypto";
7
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
8
+ import { join } from "node:path";
7
9
  import { resolveDataPath } from "./paths.js";
8
10
  import { markdownEntries } from "./resource-markdown.js";
9
11
 
@@ -894,8 +896,10 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
894
896
  : sourceVersion === "1" ? V1_TARGET_MODEL_VERSION
895
897
  : sourceVersion === "2" ? "3"
896
898
  : sourceVersion === "3" ? "4"
897
- : sourceVersion === "4" ? "5"
898
- : ACTIVE_MODEL_VERSION;
899
+ : sourceVersion === "4" ? "5"
900
+ : sourceVersion === "5" ? "6"
901
+ : sourceVersion === "6" ? "7"
902
+ : ACTIVE_MODEL_VERSION;
899
903
  if (sourceVersion === requestedTarget) return emptyPlan(sourceVersion, requestedTarget);
900
904
  if (sourceVersion === "1" && requestedTarget === "2") {
901
905
  return planV1ToV2Migration(input, options);
@@ -909,20 +913,27 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
909
913
  if (sourceVersion === "4" && requestedTarget === "5") {
910
914
  return planV4ToV5Migration(loaded, options);
911
915
  }
912
- if (sourceVersion === "5" && requestedTarget === ACTIVE_MODEL_VERSION) {
916
+ if (sourceVersion === "5" && requestedTarget === "6") {
913
917
  return planV5ToV6Migration(loaded);
914
918
  }
919
+ if (sourceVersion === "6" && requestedTarget === "7") {
920
+ return planV6ToV7Migration(loaded);
921
+ }
922
+ if (sourceVersion === "7" && requestedTarget === ACTIVE_MODEL_VERSION) {
923
+ return planV7ToV8Migration(loaded);
924
+ }
915
925
  if (sourceVersion === "1" && requestedTarget === ACTIVE_MODEL_VERSION) {
916
926
  throw new Error(
917
927
  "Model v1 workspaces must migrate to model v2 first. "
918
- + "Preview and apply `npx filegrc migrate --to-model 2`, then migrate one version at a time through model v6."
928
+ + `Preview and apply \`npx filegrc migrate --to-model 2\`, then migrate one version at a time through model v${ACTIVE_MODEL_VERSION}.`
919
929
  );
920
930
  }
921
931
  throw new Error(`Model migration does not support v${sourceVersion} to v${requestedTarget}.`);
922
932
  }
923
933
 
924
934
  export async function migrateModel(input = process.cwd(), options = {}) {
925
- const plan = await planModelMigration(input, options);
935
+ const loaded = await loadWorkspace(input);
936
+ const plan = await planModelMigration(loaded.root, options);
926
937
  if (plan.sourceModelVersion === plan.targetModelVersion) {
927
938
  return { ...plan, applied: false };
928
939
  }
@@ -932,18 +943,44 @@ export async function migrateModel(input = process.cwd(), options = {}) {
932
943
  + "and resolve every missing value, conflict, manual action, and unsupported change."
933
944
  );
934
945
  }
946
+ const migrationReportPath = await persistMigrationReport(loaded.root, plan);
935
947
  if (plan.sourceModelVersion === "1" && plan.targetModelVersion === "2") {
936
- return migrateV1ToV2(input, options);
948
+ return { ...await migrateV1ToV2(loaded.root, options), migrationReportPath };
937
949
  }
938
- const result = await applyModelMigrationBatch(input, plan.changes);
950
+ const result = await applyModelMigrationBatch(loaded.root, plan.changes);
939
951
  return {
940
952
  ...plan,
941
953
  applied: true,
954
+ migrationReportPath,
942
955
  result,
943
- postMigrationAssessment: await postMigrationAssessment(input)
956
+ postMigrationAssessment: await postMigrationAssessment(loaded.root)
944
957
  };
945
958
  }
946
959
 
960
+ async function persistMigrationReport(root, plan) {
961
+ const relativePath = `.filegrc/migrations/model-v${plan.sourceModelVersion}-to-v${plan.targetModelVersion}.json`;
962
+ const directory = join(root, ".filegrc", "migrations");
963
+ const path = join(root, relativePath);
964
+ const temporaryPath = `${path}.${randomUUID()}.tmp`;
965
+ await mkdir(directory, { recursive: true });
966
+ try {
967
+ await writeFile(temporaryPath, `${JSON.stringify({
968
+ schemaVersion: 1,
969
+ sourceModelVersion: plan.sourceModelVersion,
970
+ targetModelVersion: plan.targetModelVersion,
971
+ summary: plan.summary,
972
+ classifications: plan.classifications,
973
+ migrationReport: plan.migrationReport || null,
974
+ fileDiff: plan.fileDiff
975
+ }, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
976
+ await rename(temporaryPath, path);
977
+ } catch (error) {
978
+ await rm(temporaryPath, { force: true }).catch(() => {});
979
+ throw error;
980
+ }
981
+ return relativePath;
982
+ }
983
+
947
984
  async function planV2ToV3Migration(loaded) {
948
985
  if (!loaded.workspace?.id) {
949
986
  throw new Error("Model migration requires a valid Workspace record.");
@@ -1312,7 +1349,7 @@ async function planV3ToV4Migration(loaded, options = {}) {
1312
1349
  ? [{
1313
1350
  systemId: targetIds[0],
1314
1351
  roles: derivedComponentRoles(record, loaded.resources),
1315
- rationale: `Migrated from v3 System "${record.title}" because it was recorded as part of or support for this bounded System.`
1352
+ rationale: `Supports the bounded System "${byId.get(targetIds[0])?.title || targetIds[0]}".`
1316
1353
  }]
1317
1354
  : [];
1318
1355
  if (!uses.length) {
@@ -1477,6 +1514,7 @@ async function planV3ToV4Migration(loaded, options = {}) {
1477
1514
  componentIds: [...systemKinds].filter(([, kind]) => kind === "component").map(([id]) => id),
1478
1515
  classificationIds: [...classifications.values()],
1479
1516
  informationTypeIds: [...informationTypes.values()],
1517
+ unmappedLegacyFields: collectV4LegacyFields(loaded.resources, systemKinds),
1480
1518
  relationshipUpdates: updates.filter((record) => record.type !== "workspace").map(({ id, type }) => ({ id, type }))
1481
1519
  },
1482
1520
  summary: {
@@ -1847,6 +1885,311 @@ async function planV5ToV6Migration(loaded) {
1847
1885
  };
1848
1886
  }
1849
1887
 
1888
+ async function planV6ToV7Migration(loaded) {
1889
+ if (!loaded.workspace?.id) throw new Error("Model migration requires a valid Workspace record.");
1890
+ const targetModel = loadModel("7");
1891
+ const revisions = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
1892
+ const automatic = [];
1893
+ const unsupported = [];
1894
+ const missing = [];
1895
+ const manualActions = [];
1896
+ const updates = [];
1897
+ const historicalActivationIds = [];
1898
+ const trainingReactivation = [];
1899
+ const preservedTrainingHistoryIds = [];
1900
+ const purifiedNarrativeIds = [];
1901
+ const removedMigrationExtensions = [];
1902
+ const byId = new Map(loaded.resources.map((record) => [record.id, record]));
1903
+
1904
+ for (const original of loaded.resources) {
1905
+ const record = structuredClone(original);
1906
+ let changed = false;
1907
+ if (original.type === "workspace") {
1908
+ record.dataModelVersion = "7";
1909
+ changed = true;
1910
+ automatic.push(classifiedChange("automatic", original.id, "dataModelVersion", "Select model v7 and keep upgrade mechanics out of compliance entities."));
1911
+ }
1912
+ if (original.type === "document" && original.activationBasis === "legacy-v4") {
1913
+ record.activationBasis = "historical";
1914
+ changed = true;
1915
+ historicalActivationIds.push(original.id);
1916
+ automatic.push(classifiedChange(
1917
+ "automatic",
1918
+ original.id,
1919
+ "activationBasis",
1920
+ "Replace the model-version label with the equivalent historical activation basis."
1921
+ ));
1922
+ }
1923
+ if (original.type === "training" && original.activationBasis === "legacy-v5") {
1924
+ delete record.activationBasis;
1925
+ delete record.activatedByIds;
1926
+ delete record.activatedOn;
1927
+ delete record.activatedContentRevisions;
1928
+ if (original.status === "active") {
1929
+ trainingReactivation.push({ resourceId: original.id, priorEffectiveOn: original.effectiveOn || null });
1930
+ record.status = "approved";
1931
+ delete record.effectiveOn;
1932
+ } else {
1933
+ preservedTrainingHistoryIds.push(original.id);
1934
+ }
1935
+ changed = true;
1936
+ automatic.push(classifiedChange(
1937
+ "automatic",
1938
+ original.id,
1939
+ original.status === "active" ? "status" : "activationBasis",
1940
+ original.status === "active"
1941
+ ? "Keep the approved Training content and require management to record its next activation explicitly."
1942
+ : "Keep the closed Training lifecycle facts without a model-version activation label."
1943
+ ));
1944
+ }
1945
+ if (
1946
+ original.type === "information-type"
1947
+ && /^Information category migrated from the v3 dataTypes value ".+"\.$/.test(original.description || "")
1948
+ ) {
1949
+ record.description = `Information handled by in-scope Systems or Components under the "${original.title}" category.`;
1950
+ changed = true;
1951
+ purifiedNarrativeIds.push(original.id);
1952
+ automatic.push(classifiedChange("automatic", original.id, "description", "Replace generated upgrade prose with the underlying information-handling fact."));
1953
+ }
1954
+ if (original.type === "component" && Array.isArray(original.systemUses)) {
1955
+ let systemUsesPurified = false;
1956
+ record.systemUses = original.systemUses.map((use) => {
1957
+ if (!/^Migrated from v3 System ".+" because it was recorded as part of or support for this bounded System\.$/.test(use.rationale || "")) return use;
1958
+ changed = true;
1959
+ systemUsesPurified = true;
1960
+ purifiedNarrativeIds.push(original.id);
1961
+ return { ...use, rationale: `Supports the bounded System "${byId.get(use.systemId)?.title || use.systemId}".` };
1962
+ });
1963
+ if (systemUsesPurified) automatic.push(classifiedChange("automatic", original.id, "systemUses", "Replace generated upgrade prose with the existing Component-to-System fact."));
1964
+ }
1965
+ if (original.type === "audit" && Array.isArray(original.subserviceTreatments)) {
1966
+ let treatmentsPurified = false;
1967
+ record.subserviceTreatments = original.subserviceTreatments.map((treatment) => {
1968
+ if (treatment.rationale !== "Migrated from the explicit v3 Audit subservice scope and method; management must confirm the treatment.") return treatment;
1969
+ changed = true;
1970
+ treatmentsPurified = true;
1971
+ purifiedNarrativeIds.push(original.id);
1972
+ return { ...treatment, rationale: `This Vendor is subject to the ${treatment.method} method for this engagement.` };
1973
+ });
1974
+ if (treatmentsPurified) automatic.push(classifiedChange("automatic", original.id, "subserviceTreatments", "Replace generated upgrade prose with the existing engagement treatment fact."));
1975
+ }
1976
+ if (record.extensions?.["filegrc.migration"] !== undefined) {
1977
+ removedMigrationExtensions.push({ resourceId: original.id, details: record.extensions["filegrc.migration"] });
1978
+ delete record.extensions["filegrc.migration"];
1979
+ if (!Object.keys(record.extensions).length) delete record.extensions;
1980
+ changed = true;
1981
+ automatic.push(classifiedChange("automatic", original.id, "extensions", "Move prior upgrade details out of the compliance record and into this migration report."));
1982
+ }
1983
+ if (changed) updates.push(record);
1984
+ }
1985
+
1986
+ const updatedById = new Map(updates.map((record) => [record.id, record]));
1987
+ const migratedRecords = loaded.resources.map((record) => updatedById.get(record.id) || record);
1988
+ await collectTargetValidationActions(loaded, migratedRecords, targetModel, missing, manualActions);
1989
+ for (const item of [...missing, ...manualActions]) {
1990
+ unsupported.push(classifiedChange(
1991
+ "unsupported",
1992
+ item.resourceId,
1993
+ item.field,
1994
+ item.message || `Resolve ${item.field} before applying the model v7 migration.`
1995
+ ));
1996
+ }
1997
+ const ready = unsupported.length === 0;
1998
+ return {
1999
+ schemaVersion: 2,
2000
+ sourceModelVersion: "6",
2001
+ targetModelVersion: "7",
2002
+ ready,
2003
+ missing,
2004
+ conflicts: [],
2005
+ manualActions,
2006
+ classifications: { automatic, reviewRequired: [], unsupported },
2007
+ notes: [],
2008
+ migrationReport: {
2009
+ historicalActivationIds,
2010
+ trainingReactivation,
2011
+ preservedTrainingHistoryIds,
2012
+ purifiedNarrativeIds: [...new Set(purifiedNarrativeIds)],
2013
+ removedMigrationExtensions
2014
+ },
2015
+ summary: {
2016
+ create: 0,
2017
+ update: updates.length,
2018
+ automatic: automatic.length,
2019
+ reviewRequired: 0,
2020
+ unsupported: unsupported.length
2021
+ },
2022
+ fileDiff: {
2023
+ create: [],
2024
+ update: updates.map((record) => ({
2025
+ type: record.type,
2026
+ id: record.id,
2027
+ before: loaded.resources.find(({ id }) => id === record.id),
2028
+ after: record
2029
+ }))
2030
+ },
2031
+ changes: {
2032
+ create: [],
2033
+ update: updates,
2034
+ expectedRevisions: Object.fromEntries(updates.map(({ id }) => [id, revisions.get(id)])),
2035
+ validateWholeWorkspace: true,
2036
+ targetModelVersion: "7"
2037
+ }
2038
+ };
2039
+ }
2040
+
2041
+ async function planV7ToV8Migration(loaded) {
2042
+ if (!loaded.workspace?.id) throw new Error("Model migration requires a valid Workspace record.");
2043
+ const targetModel = loadModel("8");
2044
+ const revisions = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
2045
+ const automatic = [];
2046
+ const reviewRequired = [];
2047
+ const unsupported = [];
2048
+ const missing = [];
2049
+ const manualActions = [];
2050
+ const updates = [];
2051
+
2052
+ for (const original of loaded.resources) {
2053
+ const record = structuredClone(original);
2054
+ let changed = false;
2055
+ if (original.type === "workspace") {
2056
+ record.dataModelVersion = "8";
2057
+ changed = true;
2058
+ automatic.push(classifiedChange("automatic", original.id, "dataModelVersion", "Select model v8."));
2059
+ }
2060
+ if (original.type === "component" && Array.isArray(original.informationUses)) {
2061
+ record.informationUses = original.informationUses.map((use) => {
2062
+ if (!Array.isArray(use.activities)) return use;
2063
+ const migrated = { ...use, processingOperations: use.activities };
2064
+ delete migrated.activities;
2065
+ return migrated;
2066
+ });
2067
+ changed = true;
2068
+ automatic.push(classifiedChange(
2069
+ "automatic",
2070
+ original.id,
2071
+ "informationUses",
2072
+ "Rename information-use activities to the standard processingOperations term without changing the recorded values."
2073
+ ));
2074
+ }
2075
+ if (original.type === "commitment" && Array.isArray(original.sourceDocumentIds)) {
2076
+ record.sourceResourceIds = [...new Set([
2077
+ ...(record.sourceResourceIds || []),
2078
+ ...original.sourceDocumentIds
2079
+ ])];
2080
+ delete record.sourceDocumentIds;
2081
+ changed = true;
2082
+ automatic.push(classifiedChange(
2083
+ "automatic",
2084
+ original.id,
2085
+ "sourceResourceIds",
2086
+ "Preserve source Document links in the broader source-resource relationship."
2087
+ ));
2088
+ }
2089
+ if (original.type === "source-coverage" && typeof original.retention === "string") {
2090
+ record.retentionNotes = original.retention;
2091
+ delete record.retention;
2092
+ changed = true;
2093
+ automatic.push(classifiedChange(
2094
+ "automatic",
2095
+ original.id,
2096
+ "retentionNotes",
2097
+ "Preserve the prior narrative retention statement as notes without treating it as an approved schedule rule."
2098
+ ));
2099
+ }
2100
+ if (original.type === "audit" && typeof original.retentionDecision === "string") {
2101
+ record.retentionNotes = original.retentionDecision;
2102
+ delete record.retentionDecision;
2103
+ changed = true;
2104
+ automatic.push(classifiedChange(
2105
+ "automatic",
2106
+ original.id,
2107
+ "retentionNotes",
2108
+ "Preserve the prior audit retention decision as notes without inferring a structured retention rule."
2109
+ ));
2110
+ }
2111
+ if (changed) updates.push(record);
2112
+ }
2113
+
2114
+ for (const record of loaded.resources) {
2115
+ if (record.type === "source-coverage" && record.status === "active") {
2116
+ reviewRequired.push(classifiedChange(
2117
+ "review-required",
2118
+ record.id,
2119
+ "retentionScheduleItemIds",
2120
+ "Management must select or create an approved retention schedule item for this source coverage."
2121
+ ));
2122
+ }
2123
+ if (record.type === "component" && record.informationUses?.length) {
2124
+ reviewRequired.push(classifiedChange(
2125
+ "review-required",
2126
+ record.id,
2127
+ "informationUses",
2128
+ "Review every Information Type use against the structured retention schedule."
2129
+ ));
2130
+ }
2131
+ if (["system", "vendor"].includes(record.type) && record.informationTypeIds?.length) {
2132
+ reviewRequired.push(classifiedChange(
2133
+ "review-required",
2134
+ record.id,
2135
+ "informationTypeIds",
2136
+ `Review every Information Type used by this ${record.type === "system" ? "System" : "Vendor"} against the structured retention schedule.`
2137
+ ));
2138
+ }
2139
+ }
2140
+
2141
+ const updatedById = new Map(updates.map((record) => [record.id, record]));
2142
+ const migratedRecords = loaded.resources.map((record) => updatedById.get(record.id) || record);
2143
+ await collectTargetValidationActions(loaded, migratedRecords, targetModel, missing, manualActions);
2144
+ for (const item of [...missing, ...manualActions]) {
2145
+ unsupported.push(classifiedChange(
2146
+ "unsupported",
2147
+ item.resourceId,
2148
+ item.field,
2149
+ item.message || `Resolve ${item.field} before applying the model v8 migration.`
2150
+ ));
2151
+ }
2152
+ const ready = unsupported.length === 0;
2153
+ return {
2154
+ schemaVersion: 2,
2155
+ sourceModelVersion: "7",
2156
+ targetModelVersion: "8",
2157
+ ready,
2158
+ missing,
2159
+ conflicts: [],
2160
+ manualActions,
2161
+ classifications: { automatic, reviewRequired, unsupported },
2162
+ notes: [
2163
+ "The migration never chooses a retention period, cutoff, or disposition action.",
2164
+ "Review-required items remain visible after migration through program readiness."
2165
+ ],
2166
+ migrationReport: {},
2167
+ summary: {
2168
+ create: 0,
2169
+ update: updates.length,
2170
+ automatic: automatic.length,
2171
+ reviewRequired: reviewRequired.length,
2172
+ unsupported: unsupported.length
2173
+ },
2174
+ fileDiff: {
2175
+ create: [],
2176
+ update: updates.map((record) => ({
2177
+ type: record.type,
2178
+ id: record.id,
2179
+ before: loaded.resources.find(({ id }) => id === record.id),
2180
+ after: record
2181
+ }))
2182
+ },
2183
+ changes: {
2184
+ create: [],
2185
+ update: updates,
2186
+ expectedRevisions: Object.fromEntries(updates.map(({ id }) => [id, revisions.get(id)])),
2187
+ validateWholeWorkspace: true,
2188
+ targetModelVersion: "8"
2189
+ }
2190
+ };
2191
+ }
2192
+
1850
2193
  function normalizeDocumentScopeDecision(value) {
1851
2194
  const candidate = typeof value === "object" && value
1852
2195
  ? value.workflowScope || value.scope
@@ -2085,7 +2428,7 @@ function migrateV4InformationTypes(loaded, creates, automatic, reviewRequired, u
2085
2428
  type: "information-type",
2086
2429
  title: value.title,
2087
2430
  status: classificationIds.length === 1 ? "active" : "planned",
2088
- description: `Information category migrated from the v3 dataTypes value "${value.title}".`,
2431
+ description: `Information handled by in-scope Systems or Components under the "${value.title}" category.`,
2089
2432
  ...(classificationIds.length === 1 ? { classificationId: classificationIds[0] } : {})
2090
2433
  });
2091
2434
  result.set(key, id);
@@ -2113,13 +2456,7 @@ function migrateBoundedSystem(record, informationTypes, classifications) {
2113
2456
  continuityObjectives: record.continuityObjectives,
2114
2457
  statusTransition: record.statusTransition,
2115
2458
  tags: record.tags,
2116
- extensions: mergeMigrationExtension(record.extensions, {
2117
- ...(record.environment ? { environment: record.environment } : {}),
2118
- ...(record.vendorId ? { vendorId: record.vendorId } : {}),
2119
- ...(record.subserviceVendorIds ? { subserviceVendorIds: record.subserviceVendorIds } : {}),
2120
- ...(record.evidenceSourceKinds ? { evidenceSourceKinds: record.evidenceSourceKinds } : {}),
2121
- ...(record.evidenceOwnerIds ? { evidenceOwnerIds: record.evidenceOwnerIds } : {})
2122
- }),
2459
+ extensions: complianceExtensions(record.extensions),
2123
2460
  externalIds: record.externalIds
2124
2461
  });
2125
2462
  }
@@ -2149,9 +2486,7 @@ function migrateComponent(record, systemUses, informationTypes, classifications)
2149
2486
  continuityObjectives: record.continuityObjectives,
2150
2487
  statusTransition: record.statusTransition,
2151
2488
  tags: record.tags,
2152
- extensions: mergeMigrationExtension(record.extensions, {
2153
- ...(record.subserviceVendorIds ? { subserviceVendorIds: record.subserviceVendorIds } : {})
2154
- }),
2489
+ extensions: complianceExtensions(record.extensions),
2155
2490
  externalIds: record.externalIds
2156
2491
  });
2157
2492
  }
@@ -2165,10 +2500,8 @@ function componentKind(value, vendorId) {
2165
2500
 
2166
2501
  function migrateV4Vendor(record, informationTypes, classifications, reviewRequired) {
2167
2502
  const migrated = { ...record };
2168
- const legacy = {};
2169
2503
  for (const field of ["service", "subprocessor", "backupVendorId"]) {
2170
2504
  if (!Object.hasOwn(migrated, field)) continue;
2171
- legacy[field] = migrated[field];
2172
2505
  delete migrated[field];
2173
2506
  reviewRequired.push(classifiedChange(
2174
2507
  "review-required",
@@ -2184,7 +2517,7 @@ function migrateV4Vendor(record, informationTypes, classifications, reviewRequir
2184
2517
  migrated.informationTypeIds = normalizedInformationTypeIds(record.dataTypes, informationTypes);
2185
2518
  delete migrated.dataTypes;
2186
2519
  if (record.classificationId) migrated.classificationId = classifications.get(record.classificationId);
2187
- migrated.extensions = mergeMigrationExtension(record.extensions, legacy);
2520
+ migrated.extensions = complianceExtensions(record.extensions);
2188
2521
  if (!Object.keys(migrated.extensions || {}).length) delete migrated.extensions;
2189
2522
  return migrated;
2190
2523
  }
@@ -2291,7 +2624,7 @@ function rewriteV4SystemRelationships(record, context) {
2291
2624
  vendorId,
2292
2625
  componentIds,
2293
2626
  method,
2294
- rationale: "Migrated from the explicit v3 Audit subservice scope and method; management must confirm the treatment."
2627
+ rationale: `This Vendor is subject to the ${method} method for this engagement.`
2295
2628
  });
2296
2629
  context.reviewRequired.push(classifiedChange("review-required", record.id, "subserviceTreatments", "Confirm each migrated audit-time subservice treatment and rationale."));
2297
2630
  } else requireComponentDecision(record, "subserviceVendorIds", context, `Choose the Components and audit-time treatment for Vendor "${vendorId}".`);
@@ -2312,16 +2645,29 @@ function normalizedInformationTypeIds(values, informationTypes) {
2312
2645
  return [...new Set((values || []).map((value) => informationTypes.get(String(value).trim().toLowerCase())).filter(Boolean))];
2313
2646
  }
2314
2647
 
2315
- function mergeMigrationExtension(existing, legacy) {
2316
- const useful = Object.fromEntries(Object.entries(legacy || {}).filter(([, value]) => value !== undefined));
2317
- if (!Object.keys(useful).length) return existing;
2318
- return {
2319
- ...(existing || {}),
2320
- "filegrc.migration": {
2321
- ...(existing?.["filegrc.migration"] || {}),
2322
- v3: useful
2323
- }
2324
- };
2648
+ function complianceExtensions(existing) {
2649
+ if (!existing) return undefined;
2650
+ const result = { ...existing };
2651
+ delete result["filegrc.migration"];
2652
+ return Object.keys(result).length ? result : undefined;
2653
+ }
2654
+
2655
+ function collectV4LegacyFields(resources, systemKinds) {
2656
+ return resources.flatMap((record) => {
2657
+ const fieldNames = record.type === "system"
2658
+ ? systemKinds.get(record.id) === "system"
2659
+ ? ["environment", "vendorId", "subserviceVendorIds", "evidenceSourceKinds", "evidenceOwnerIds"]
2660
+ : ["subserviceVendorIds"]
2661
+ : record.type === "vendor"
2662
+ ? ["service", "subprocessor", "backupVendorId"]
2663
+ : [];
2664
+ const fields = Object.fromEntries(fieldNames
2665
+ .filter((field) => Object.hasOwn(record, field))
2666
+ .map((field) => [field, record[field]]));
2667
+ const priorMigrationDetails = record.extensions?.["filegrc.migration"];
2668
+ if (priorMigrationDetails !== undefined) fields.priorMigrationDetails = priorMigrationDetails;
2669
+ return Object.keys(fields).length ? [{ resourceId: record.id, resourceType: record.type, fields }] : [];
2670
+ });
2325
2671
  }
2326
2672
 
2327
2673
  function cleanUndefined(value) {