filegrc 0.9.1 → 0.10.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
 
@@ -895,7 +897,8 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
895
897
  : sourceVersion === "2" ? "3"
896
898
  : sourceVersion === "3" ? "4"
897
899
  : sourceVersion === "4" ? "5"
898
- : ACTIVE_MODEL_VERSION;
900
+ : sourceVersion === "5" ? "6"
901
+ : ACTIVE_MODEL_VERSION;
899
902
  if (sourceVersion === requestedTarget) return emptyPlan(sourceVersion, requestedTarget);
900
903
  if (sourceVersion === "1" && requestedTarget === "2") {
901
904
  return planV1ToV2Migration(input, options);
@@ -909,20 +912,24 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
909
912
  if (sourceVersion === "4" && requestedTarget === "5") {
910
913
  return planV4ToV5Migration(loaded, options);
911
914
  }
912
- if (sourceVersion === "5" && requestedTarget === ACTIVE_MODEL_VERSION) {
915
+ if (sourceVersion === "5" && requestedTarget === "6") {
913
916
  return planV5ToV6Migration(loaded);
914
917
  }
918
+ if (sourceVersion === "6" && requestedTarget === ACTIVE_MODEL_VERSION) {
919
+ return planV6ToV7Migration(loaded);
920
+ }
915
921
  if (sourceVersion === "1" && requestedTarget === ACTIVE_MODEL_VERSION) {
916
922
  throw new Error(
917
923
  "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."
924
+ + `Preview and apply \`npx filegrc migrate --to-model 2\`, then migrate one version at a time through model v${ACTIVE_MODEL_VERSION}.`
919
925
  );
920
926
  }
921
927
  throw new Error(`Model migration does not support v${sourceVersion} to v${requestedTarget}.`);
922
928
  }
923
929
 
924
930
  export async function migrateModel(input = process.cwd(), options = {}) {
925
- const plan = await planModelMigration(input, options);
931
+ const loaded = await loadWorkspace(input);
932
+ const plan = await planModelMigration(loaded.root, options);
926
933
  if (plan.sourceModelVersion === plan.targetModelVersion) {
927
934
  return { ...plan, applied: false };
928
935
  }
@@ -932,18 +939,44 @@ export async function migrateModel(input = process.cwd(), options = {}) {
932
939
  + "and resolve every missing value, conflict, manual action, and unsupported change."
933
940
  );
934
941
  }
942
+ const migrationReportPath = await persistMigrationReport(loaded.root, plan);
935
943
  if (plan.sourceModelVersion === "1" && plan.targetModelVersion === "2") {
936
- return migrateV1ToV2(input, options);
944
+ return { ...await migrateV1ToV2(loaded.root, options), migrationReportPath };
937
945
  }
938
- const result = await applyModelMigrationBatch(input, plan.changes);
946
+ const result = await applyModelMigrationBatch(loaded.root, plan.changes);
939
947
  return {
940
948
  ...plan,
941
949
  applied: true,
950
+ migrationReportPath,
942
951
  result,
943
- postMigrationAssessment: await postMigrationAssessment(input)
952
+ postMigrationAssessment: await postMigrationAssessment(loaded.root)
944
953
  };
945
954
  }
946
955
 
956
+ async function persistMigrationReport(root, plan) {
957
+ const relativePath = `.filegrc/migrations/model-v${plan.sourceModelVersion}-to-v${plan.targetModelVersion}.json`;
958
+ const directory = join(root, ".filegrc", "migrations");
959
+ const path = join(root, relativePath);
960
+ const temporaryPath = `${path}.${randomUUID()}.tmp`;
961
+ await mkdir(directory, { recursive: true });
962
+ try {
963
+ await writeFile(temporaryPath, `${JSON.stringify({
964
+ schemaVersion: 1,
965
+ sourceModelVersion: plan.sourceModelVersion,
966
+ targetModelVersion: plan.targetModelVersion,
967
+ summary: plan.summary,
968
+ classifications: plan.classifications,
969
+ migrationReport: plan.migrationReport || null,
970
+ fileDiff: plan.fileDiff
971
+ }, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
972
+ await rename(temporaryPath, path);
973
+ } catch (error) {
974
+ await rm(temporaryPath, { force: true }).catch(() => {});
975
+ throw error;
976
+ }
977
+ return relativePath;
978
+ }
979
+
947
980
  async function planV2ToV3Migration(loaded) {
948
981
  if (!loaded.workspace?.id) {
949
982
  throw new Error("Model migration requires a valid Workspace record.");
@@ -1312,7 +1345,7 @@ async function planV3ToV4Migration(loaded, options = {}) {
1312
1345
  ? [{
1313
1346
  systemId: targetIds[0],
1314
1347
  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.`
1348
+ rationale: `Supports the bounded System "${byId.get(targetIds[0])?.title || targetIds[0]}".`
1316
1349
  }]
1317
1350
  : [];
1318
1351
  if (!uses.length) {
@@ -1477,6 +1510,7 @@ async function planV3ToV4Migration(loaded, options = {}) {
1477
1510
  componentIds: [...systemKinds].filter(([, kind]) => kind === "component").map(([id]) => id),
1478
1511
  classificationIds: [...classifications.values()],
1479
1512
  informationTypeIds: [...informationTypes.values()],
1513
+ unmappedLegacyFields: collectV4LegacyFields(loaded.resources, systemKinds),
1480
1514
  relationshipUpdates: updates.filter((record) => record.type !== "workspace").map(({ id, type }) => ({ id, type }))
1481
1515
  },
1482
1516
  summary: {
@@ -1847,6 +1881,159 @@ async function planV5ToV6Migration(loaded) {
1847
1881
  };
1848
1882
  }
1849
1883
 
1884
+ async function planV6ToV7Migration(loaded) {
1885
+ if (!loaded.workspace?.id) throw new Error("Model migration requires a valid Workspace record.");
1886
+ const targetModel = loadModel("7");
1887
+ const revisions = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
1888
+ const automatic = [];
1889
+ const unsupported = [];
1890
+ const missing = [];
1891
+ const manualActions = [];
1892
+ const updates = [];
1893
+ const historicalActivationIds = [];
1894
+ const trainingReactivation = [];
1895
+ const preservedTrainingHistoryIds = [];
1896
+ const purifiedNarrativeIds = [];
1897
+ const removedMigrationExtensions = [];
1898
+ const byId = new Map(loaded.resources.map((record) => [record.id, record]));
1899
+
1900
+ for (const original of loaded.resources) {
1901
+ const record = structuredClone(original);
1902
+ let changed = false;
1903
+ if (original.type === "workspace") {
1904
+ record.dataModelVersion = "7";
1905
+ changed = true;
1906
+ automatic.push(classifiedChange("automatic", original.id, "dataModelVersion", "Select model v7 and keep upgrade mechanics out of compliance entities."));
1907
+ }
1908
+ if (original.type === "document" && original.activationBasis === "legacy-v4") {
1909
+ record.activationBasis = "historical";
1910
+ changed = true;
1911
+ historicalActivationIds.push(original.id);
1912
+ automatic.push(classifiedChange(
1913
+ "automatic",
1914
+ original.id,
1915
+ "activationBasis",
1916
+ "Replace the model-version label with the equivalent historical activation basis."
1917
+ ));
1918
+ }
1919
+ if (original.type === "training" && original.activationBasis === "legacy-v5") {
1920
+ delete record.activationBasis;
1921
+ delete record.activatedByIds;
1922
+ delete record.activatedOn;
1923
+ delete record.activatedContentRevisions;
1924
+ if (original.status === "active") {
1925
+ trainingReactivation.push({ resourceId: original.id, priorEffectiveOn: original.effectiveOn || null });
1926
+ record.status = "approved";
1927
+ delete record.effectiveOn;
1928
+ } else {
1929
+ preservedTrainingHistoryIds.push(original.id);
1930
+ }
1931
+ changed = true;
1932
+ automatic.push(classifiedChange(
1933
+ "automatic",
1934
+ original.id,
1935
+ original.status === "active" ? "status" : "activationBasis",
1936
+ original.status === "active"
1937
+ ? "Keep the approved Training content and require management to record its next activation explicitly."
1938
+ : "Keep the closed Training lifecycle facts without a model-version activation label."
1939
+ ));
1940
+ }
1941
+ if (
1942
+ original.type === "information-type"
1943
+ && /^Information category migrated from the v3 dataTypes value ".+"\.$/.test(original.description || "")
1944
+ ) {
1945
+ record.description = `Information handled by in-scope Systems or Components under the "${original.title}" category.`;
1946
+ changed = true;
1947
+ purifiedNarrativeIds.push(original.id);
1948
+ automatic.push(classifiedChange("automatic", original.id, "description", "Replace generated upgrade prose with the underlying information-handling fact."));
1949
+ }
1950
+ if (original.type === "component" && Array.isArray(original.systemUses)) {
1951
+ let systemUsesPurified = false;
1952
+ record.systemUses = original.systemUses.map((use) => {
1953
+ if (!/^Migrated from v3 System ".+" because it was recorded as part of or support for this bounded System\.$/.test(use.rationale || "")) return use;
1954
+ changed = true;
1955
+ systemUsesPurified = true;
1956
+ purifiedNarrativeIds.push(original.id);
1957
+ return { ...use, rationale: `Supports the bounded System "${byId.get(use.systemId)?.title || use.systemId}".` };
1958
+ });
1959
+ if (systemUsesPurified) automatic.push(classifiedChange("automatic", original.id, "systemUses", "Replace generated upgrade prose with the existing Component-to-System fact."));
1960
+ }
1961
+ if (original.type === "audit" && Array.isArray(original.subserviceTreatments)) {
1962
+ let treatmentsPurified = false;
1963
+ record.subserviceTreatments = original.subserviceTreatments.map((treatment) => {
1964
+ if (treatment.rationale !== "Migrated from the explicit v3 Audit subservice scope and method; management must confirm the treatment.") return treatment;
1965
+ changed = true;
1966
+ treatmentsPurified = true;
1967
+ purifiedNarrativeIds.push(original.id);
1968
+ return { ...treatment, rationale: `This Vendor is subject to the ${treatment.method} method for this engagement.` };
1969
+ });
1970
+ if (treatmentsPurified) automatic.push(classifiedChange("automatic", original.id, "subserviceTreatments", "Replace generated upgrade prose with the existing engagement treatment fact."));
1971
+ }
1972
+ if (record.extensions?.["filegrc.migration"] !== undefined) {
1973
+ removedMigrationExtensions.push({ resourceId: original.id, details: record.extensions["filegrc.migration"] });
1974
+ delete record.extensions["filegrc.migration"];
1975
+ if (!Object.keys(record.extensions).length) delete record.extensions;
1976
+ changed = true;
1977
+ automatic.push(classifiedChange("automatic", original.id, "extensions", "Move prior upgrade details out of the compliance record and into this migration report."));
1978
+ }
1979
+ if (changed) updates.push(record);
1980
+ }
1981
+
1982
+ const updatedById = new Map(updates.map((record) => [record.id, record]));
1983
+ const migratedRecords = loaded.resources.map((record) => updatedById.get(record.id) || record);
1984
+ await collectTargetValidationActions(loaded, migratedRecords, targetModel, missing, manualActions);
1985
+ for (const item of [...missing, ...manualActions]) {
1986
+ unsupported.push(classifiedChange(
1987
+ "unsupported",
1988
+ item.resourceId,
1989
+ item.field,
1990
+ item.message || `Resolve ${item.field} before applying the model v7 migration.`
1991
+ ));
1992
+ }
1993
+ const ready = unsupported.length === 0;
1994
+ return {
1995
+ schemaVersion: 2,
1996
+ sourceModelVersion: "6",
1997
+ targetModelVersion: "7",
1998
+ ready,
1999
+ missing,
2000
+ conflicts: [],
2001
+ manualActions,
2002
+ classifications: { automatic, reviewRequired: [], unsupported },
2003
+ notes: [],
2004
+ migrationReport: {
2005
+ historicalActivationIds,
2006
+ trainingReactivation,
2007
+ preservedTrainingHistoryIds,
2008
+ purifiedNarrativeIds: [...new Set(purifiedNarrativeIds)],
2009
+ removedMigrationExtensions
2010
+ },
2011
+ summary: {
2012
+ create: 0,
2013
+ update: updates.length,
2014
+ automatic: automatic.length,
2015
+ reviewRequired: 0,
2016
+ unsupported: unsupported.length
2017
+ },
2018
+ fileDiff: {
2019
+ create: [],
2020
+ update: updates.map((record) => ({
2021
+ type: record.type,
2022
+ id: record.id,
2023
+ before: loaded.resources.find(({ id }) => id === record.id),
2024
+ after: record
2025
+ }))
2026
+ },
2027
+ changes: {
2028
+ create: [],
2029
+ update: updates,
2030
+ expectedRevisions: Object.fromEntries(updates.map(({ id }) => [id, revisions.get(id)])),
2031
+ validateWholeWorkspace: true,
2032
+ targetModelVersion: "7"
2033
+ }
2034
+ };
2035
+ }
2036
+
1850
2037
  function normalizeDocumentScopeDecision(value) {
1851
2038
  const candidate = typeof value === "object" && value
1852
2039
  ? value.workflowScope || value.scope
@@ -2085,7 +2272,7 @@ function migrateV4InformationTypes(loaded, creates, automatic, reviewRequired, u
2085
2272
  type: "information-type",
2086
2273
  title: value.title,
2087
2274
  status: classificationIds.length === 1 ? "active" : "planned",
2088
- description: `Information category migrated from the v3 dataTypes value "${value.title}".`,
2275
+ description: `Information handled by in-scope Systems or Components under the "${value.title}" category.`,
2089
2276
  ...(classificationIds.length === 1 ? { classificationId: classificationIds[0] } : {})
2090
2277
  });
2091
2278
  result.set(key, id);
@@ -2113,13 +2300,7 @@ function migrateBoundedSystem(record, informationTypes, classifications) {
2113
2300
  continuityObjectives: record.continuityObjectives,
2114
2301
  statusTransition: record.statusTransition,
2115
2302
  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
- }),
2303
+ extensions: complianceExtensions(record.extensions),
2123
2304
  externalIds: record.externalIds
2124
2305
  });
2125
2306
  }
@@ -2149,9 +2330,7 @@ function migrateComponent(record, systemUses, informationTypes, classifications)
2149
2330
  continuityObjectives: record.continuityObjectives,
2150
2331
  statusTransition: record.statusTransition,
2151
2332
  tags: record.tags,
2152
- extensions: mergeMigrationExtension(record.extensions, {
2153
- ...(record.subserviceVendorIds ? { subserviceVendorIds: record.subserviceVendorIds } : {})
2154
- }),
2333
+ extensions: complianceExtensions(record.extensions),
2155
2334
  externalIds: record.externalIds
2156
2335
  });
2157
2336
  }
@@ -2165,10 +2344,8 @@ function componentKind(value, vendorId) {
2165
2344
 
2166
2345
  function migrateV4Vendor(record, informationTypes, classifications, reviewRequired) {
2167
2346
  const migrated = { ...record };
2168
- const legacy = {};
2169
2347
  for (const field of ["service", "subprocessor", "backupVendorId"]) {
2170
2348
  if (!Object.hasOwn(migrated, field)) continue;
2171
- legacy[field] = migrated[field];
2172
2349
  delete migrated[field];
2173
2350
  reviewRequired.push(classifiedChange(
2174
2351
  "review-required",
@@ -2184,7 +2361,7 @@ function migrateV4Vendor(record, informationTypes, classifications, reviewRequir
2184
2361
  migrated.informationTypeIds = normalizedInformationTypeIds(record.dataTypes, informationTypes);
2185
2362
  delete migrated.dataTypes;
2186
2363
  if (record.classificationId) migrated.classificationId = classifications.get(record.classificationId);
2187
- migrated.extensions = mergeMigrationExtension(record.extensions, legacy);
2364
+ migrated.extensions = complianceExtensions(record.extensions);
2188
2365
  if (!Object.keys(migrated.extensions || {}).length) delete migrated.extensions;
2189
2366
  return migrated;
2190
2367
  }
@@ -2291,7 +2468,7 @@ function rewriteV4SystemRelationships(record, context) {
2291
2468
  vendorId,
2292
2469
  componentIds,
2293
2470
  method,
2294
- rationale: "Migrated from the explicit v3 Audit subservice scope and method; management must confirm the treatment."
2471
+ rationale: `This Vendor is subject to the ${method} method for this engagement.`
2295
2472
  });
2296
2473
  context.reviewRequired.push(classifiedChange("review-required", record.id, "subserviceTreatments", "Confirm each migrated audit-time subservice treatment and rationale."));
2297
2474
  } else requireComponentDecision(record, "subserviceVendorIds", context, `Choose the Components and audit-time treatment for Vendor "${vendorId}".`);
@@ -2312,16 +2489,29 @@ function normalizedInformationTypeIds(values, informationTypes) {
2312
2489
  return [...new Set((values || []).map((value) => informationTypes.get(String(value).trim().toLowerCase())).filter(Boolean))];
2313
2490
  }
2314
2491
 
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
- };
2492
+ function complianceExtensions(existing) {
2493
+ if (!existing) return undefined;
2494
+ const result = { ...existing };
2495
+ delete result["filegrc.migration"];
2496
+ return Object.keys(result).length ? result : undefined;
2497
+ }
2498
+
2499
+ function collectV4LegacyFields(resources, systemKinds) {
2500
+ return resources.flatMap((record) => {
2501
+ const fieldNames = record.type === "system"
2502
+ ? systemKinds.get(record.id) === "system"
2503
+ ? ["environment", "vendorId", "subserviceVendorIds", "evidenceSourceKinds", "evidenceOwnerIds"]
2504
+ : ["subserviceVendorIds"]
2505
+ : record.type === "vendor"
2506
+ ? ["service", "subprocessor", "backupVendorId"]
2507
+ : [];
2508
+ const fields = Object.fromEntries(fieldNames
2509
+ .filter((field) => Object.hasOwn(record, field))
2510
+ .map((field) => [field, record[field]]));
2511
+ const priorMigrationDetails = record.extensions?.["filegrc.migration"];
2512
+ if (priorMigrationDetails !== undefined) fields.priorMigrationDetails = priorMigrationDetails;
2513
+ return Object.keys(fields).length ? [{ resourceId: record.id, resourceType: record.type, fields }] : [];
2514
+ });
2325
2515
  }
2326
2516
 
2327
2517
  function cleanUndefined(value) {
@@ -37,6 +37,11 @@ const COMPLETION_TIMESTAMP_FIELDS = [
37
37
  "deprovisionedOn"
38
38
  ];
39
39
  const MAX_PLANNED_ITEMS = 10_000;
40
+ const SCAFFOLDED_COMPLETION_TYPES = new Set([
41
+ "access-review", "attestation", "backup-test", "control-activity", "control-test",
42
+ "evidence", "exercise", "meeting", "penetration-test", "policy-review",
43
+ "risk-assessment", "vendor-review", "vulnerability-scan"
44
+ ]);
40
45
 
41
46
  export function planObligations(resources, options = {}) {
42
47
  const records = resources.map((item) => item?.record ?? item).filter(Boolean);
@@ -101,7 +106,7 @@ export function planObligations(resources, options = {}) {
101
106
  eventRiskLevels: obligation.eventRiskLevels || [],
102
107
  templateResourceId: obligation.templateResourceId || null,
103
108
  completionResourceTypes: expectedCompletionTypes,
104
- completionType: activity.completionType,
109
+ completionType: preferredCompletionType(activity, obligation, byId),
105
110
  completionProfile: activity.completionProfile || null,
106
111
  programStatus,
107
112
  window: normalizedEventWindow(obligation.window)
@@ -155,7 +160,7 @@ export function planObligations(resources, options = {}) {
155
160
  controlIds: obligation.controlIds || [],
156
161
  scopeResourceIds: obligation.scopeResourceIds || [],
157
162
  completionResourceTypes: expectedCompletionTypes,
158
- completionType: activity.completionType,
163
+ completionType: preferredCompletionType(activity, obligation, byId),
159
164
  completionProfile: activity.completionProfile || null,
160
165
  completionResourceIds: completions.map((record) => record.id),
161
166
  status,
@@ -313,6 +318,7 @@ export async function completeObligationOccurrence(input, options) {
313
318
  ));
314
319
  if (!obligation) throw new Error(`Obligation "${options?.obligationId ?? ""}" was not found.`);
315
320
  assertExpectedCompletionType(obligation, options?.record, loaded.model);
321
+ assertAttestationCompletionScope(obligation, options.record, loaded.resources);
316
322
  return createResourceAndLink(loaded.root, options.record, {
317
323
  type: "obligation",
318
324
  id: obligation.id,
@@ -343,7 +349,10 @@ export async function scaffoldObligationCompletion(input, options = {}) {
343
349
  ? plannedActionForScaffold(loaded, action, completedOn)
344
350
  : plannedOccurrenceForScaffold(loaded, obligation, options.windowStart, completedOn);
345
351
  const activity = obligationActivity(loaded.model, obligation.activityType);
346
- const type = activity.completionType;
352
+ const type = item.completionType || preferredCompletionType(activity, {
353
+ ...obligation,
354
+ subjectResourceIds: item.subjectResourceIds || []
355
+ }, new Map(loaded.resources.map((record) => [record.id, record])));
347
356
  if (!type) throw new Error(`Obligation "${obligation.id}" has no configured completion resource type.`);
348
357
 
349
358
  const mutation = scaffoldResourceMutation(
@@ -369,10 +378,14 @@ export async function scaffoldObligationCompletion(input, options = {}) {
369
378
  activityType: obligation.activityType,
370
379
  completionResourceType: type,
371
380
  completionProfile: activity.completionProfile || null,
381
+ workItemStatus: item.status,
382
+ programStatus: item.programStatus || null,
372
383
  requiredFacts: loaded.model.completionProfiles?.[activity.completionProfile]?.requiredFacts || [],
373
384
  dueWindowStart: item.dueWindowStart || null,
374
385
  dueWindowEnd: item.dueWindowEnd || null,
375
- instructions: "Replace every null or empty required value with the actual work performed. Keep the actual completion date and time, actors, result, scope, independent review, and supporting evidence. This revision makes the completed write safe against a stale Work Queue item."
386
+ instructions: item.status === "proposed" || item.programStatus === "proposed"
387
+ ? "This work is still a proposal. Resolve its governing Policy, Control, owner, and completion profile before recording completion."
388
+ : "Replace every null or empty required value with the actual work performed. Keep the actual completion date and time, actors, result, scope, independent review, and supporting evidence. This revision makes the completed write safe against a stale Work Queue item."
376
389
  }
377
390
  };
378
391
  }
@@ -394,6 +407,7 @@ export async function completeObligationAction(input, options) {
394
407
  assertExpectedCompletionType(obligation, options?.record, loaded.model);
395
408
  const completedOn = requireDate(options?.completedOn, "completion date");
396
409
  const event = loaded.resources.find((record) => record.type === "obligation-event" && record.id === action.sourceResourceId);
410
+ assertAttestationCompletionScope(obligation, options.record, loaded.resources, event);
397
411
  if (event?.occurredOn && completedOn < event.occurredOn) {
398
412
  throw new Error("The action completion date cannot be before its policy event date.");
399
413
  }
@@ -456,6 +470,28 @@ function assertExpectedCompletionType(obligation, record, model) {
456
470
  }
457
471
  }
458
472
 
473
+ function assertAttestationCompletionScope(obligation, record, resources, event = null) {
474
+ if (record?.type !== "attestation") return;
475
+ const byId = new Map(resources.map((candidate) => [candidate.id, candidate]));
476
+ const eventPeople = (event?.subjectResourceIds || []).filter((id) => byId.get(id)?.type === "person");
477
+ const expectedPeople = eventPeople.length
478
+ ? eventPeople
479
+ : (obligation.scopeResourceIds || []).filter((id) => byId.get(id)?.type === "person");
480
+ if (expectedPeople.length && !expectedPeople.includes(record.personId)) {
481
+ throw new Error(`Attestation personId must name the Person in scope for Obligation "${obligation.id}".`);
482
+ }
483
+ const primarySubjects = [obligation.templateResourceId, ...(obligation.scopeResourceIds || [])]
484
+ .filter((id) => ["policy", "document", "training", "action-item"].includes(byId.get(id)?.type));
485
+ const allowedSubjects = new Set(primarySubjects.length ? primarySubjects : obligation.policyIds || []);
486
+ const actualSubjects = new Set(record.subjectResourceIds || []);
487
+ if (
488
+ actualSubjects.size !== allowedSubjects.size
489
+ || [...actualSubjects].some((id) => !allowedSubjects.has(id))
490
+ ) {
491
+ throw new Error(`Attestation subjects must name the exact authored content in scope for Obligation "${obligation.id}".`);
492
+ }
493
+ }
494
+
459
495
  function plannedOccurrenceForScaffold(loaded, obligation, windowStart, completedOn) {
460
496
  const start = requireDate(windowStart, "occurrence window start");
461
497
  const plan = planObligations(loaded.resources, {
@@ -489,6 +525,7 @@ function plannedActionForScaffold(loaded, action, completedOn) {
489
525
  function applyCompletionScaffoldDefaults(record, context) {
490
526
  const { loaded, item, obligation, completedOn, activity } = context;
491
527
  const program = resolveProgram(loaded);
528
+ const byId = new Map(loaded.resources.map((candidate) => [candidate.id, candidate]));
492
529
  const responsiblePeople = currentPeopleForParties(loaded.resources, item.ownerIds || []);
493
530
  if (!responsiblePeople.length) {
494
531
  throw new Error(`Obligation "${obligation.id}" needs an active owner whose Appointment or Team resolves to a current Person.`);
@@ -554,19 +591,27 @@ function applyCompletionScaffoldDefaults(record, context) {
554
591
  evidenceIds: [],
555
592
  approvedOn: completedOn
556
593
  }),
557
- attestation: () => ({
558
- status: "completed",
559
- subjectResourceIds: [...new Set([
594
+ attestation: () => {
595
+ const personId = (item.subjectResourceIds || []).find((id) => byId.get(id)?.type === "person")
596
+ || (obligation.scopeResourceIds || []).find((id) => byId.get(id)?.type === "person");
597
+ const primarySubjectIds = [...new Set([
560
598
  obligation.templateResourceId,
561
599
  ...(obligation.scopeResourceIds || [])
562
- ].filter(Boolean))],
563
- personId: responsiblePeople[0],
564
- attestationKind: obligation.activityType || "completion",
565
- assignedOn: item.dueWindowStart || completedOn,
566
- dueOn: item.dueWindowEnd || completedOn,
567
- completedOn,
568
- attestationMethod: "git-approval"
569
- }),
600
+ ].filter((id) => ["policy", "document", "training", "action-item"].includes(byId.get(id)?.type)))];
601
+ const subjectResourceIds = primarySubjectIds.length ? primarySubjectIds : [...(obligation.policyIds || [])];
602
+ if (!personId) throw new Error("An Attestation completion needs the Person who made the acknowledgement or completed the training.");
603
+ if (!subjectResourceIds.length) throw new Error("An Attestation completion needs the exact Policy, Document, Training, or Action Item content acknowledged.");
604
+ return {
605
+ status: "completed",
606
+ subjectResourceIds,
607
+ personId,
608
+ attestationKind: obligation.activityType || "completion",
609
+ assignedOn: item.dueWindowStart || completedOn,
610
+ dueOn: item.dueWindowEnd || completedOn,
611
+ completedOn,
612
+ attestationMethod: "git-approval"
613
+ };
614
+ },
570
615
  "access-review": () => {
571
616
  if (!systemIds.length) throw new Error("An Access Review completion needs an active in-scope System.");
572
617
  return {
@@ -621,22 +666,32 @@ function applyCompletionScaffoldDefaults(record, context) {
621
666
  evidenceIds: [],
622
667
  coverage
623
668
  }),
624
- "control-activity": () => ({
625
- ...common,
626
- profileId: activity.completionProfile || obligation.activityType,
627
- obligationId: obligation.id,
628
- controlIds: item.controlIds || obligation.controlIds || [],
629
- scopeResourceIds: (item.scopeResourceIds || obligation.scopeResourceIds || []).length
630
- ? (item.scopeResourceIds || obligation.scopeResourceIds)
631
- : [program.id],
632
- performerIds: responsiblePeople,
633
- completedAt: timestamp,
634
- method: "",
635
- result: "",
636
- reviewerIds,
637
- reviewedOn: completedOn,
638
- ownerIds: item.ownerIds || obligation.ownerIds || []
639
- }),
669
+ "control-activity": () => {
670
+ const allowedScopeTypes = new Set(
671
+ loaded.model.resources["control-activity"].fields.scopeResourceIds.relation || []
672
+ );
673
+ const requestedScopeIds = item.subjectResourceIds || item.scopeResourceIds || obligation.scopeResourceIds || [];
674
+ const validScopeIds = requestedScopeIds.filter((id) => allowedScopeTypes.has(byId.get(id)?.type));
675
+ const fallbackScopeIds = systemIds.length
676
+ ? systemIds
677
+ : (item.controlIds || obligation.controlIds || []).filter((id) => allowedScopeTypes.has(byId.get(id)?.type));
678
+ return {
679
+ ...common,
680
+ profileId: activity.completionProfile || obligation.activityType,
681
+ obligationId: obligation.id,
682
+ controlIds: item.controlIds || obligation.controlIds || [],
683
+ scopeResourceIds: validScopeIds.length
684
+ ? validScopeIds
685
+ : fallbackScopeIds.length ? fallbackScopeIds : [loaded.workspace.id],
686
+ performerIds: responsiblePeople,
687
+ completedAt: timestamp,
688
+ method: "",
689
+ result: "",
690
+ reviewerIds,
691
+ reviewedOn: completedOn,
692
+ ownerIds: item.ownerIds || obligation.ownerIds || []
693
+ };
694
+ },
640
695
  exercise: () => ({
641
696
  ...common,
642
697
  exerciseKind: item.title.toLowerCase().includes("continuity") ? "business-continuity" : "incident-response",
@@ -792,6 +847,12 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
792
847
  const completionProfile = obligation?.type === "obligation"
793
848
  ? obligationActivity(model, obligation.activityType).completionProfile || null
794
849
  : null;
850
+ const completionType = obligation?.type === "obligation"
851
+ ? preferredCompletionType(obligationActivity(model, obligation.activityType), {
852
+ ...obligation,
853
+ subjectResourceIds: event.subjectResourceIds || []
854
+ }, byId)
855
+ : null;
795
856
  const completionIds = modelSupports(model, "guided-workflow")
796
857
  ? record.completionResourceIds || []
797
858
  : [...(record.completionResourceIds || []), ...(record.evidenceIds || [])];
@@ -801,6 +862,7 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
801
862
  || matchingCompletionIds.length > 0;
802
863
  const complete = record.status === "done" && completionSatisfied;
803
864
  const window = plannedCompletionWindow(record.completionWindow);
865
+ const lateCompletion = complete && completionWasLate(record, matchingCompletionIds, byId, window);
804
866
  const timingStatus = complete
805
867
  ? "complete"
806
868
  : window.overdueAt && new Date(now) > new Date(window.overdueAt)
@@ -824,14 +886,17 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
824
886
  ownerIds: record.assigneeIds || [],
825
887
  policyIds: obligation?.policyIds || [],
826
888
  controlIds: obligation?.controlIds || [],
889
+ subjectResourceIds: event.subjectResourceIds || [],
827
890
  scopeResourceIds: obligation?.scopeResourceIds || [],
828
891
  templateResourceId: obligation?.templateResourceId || null,
829
892
  completionResourceIds: record.completionResourceIds || [],
830
893
  evidenceIds: record.evidenceIds || [],
831
894
  expectedCompletionTypes,
832
895
  completionProfile,
896
+ completionType,
833
897
  matchingCompletionIds,
834
898
  missingCompletion: record.status === "done" && !completionSatisfied,
899
+ lateCompletion,
835
900
  canceledAction: record.status === "canceled",
836
901
  recordedStatus: record.status,
837
902
  completedOn: record.completedOn || null,
@@ -870,6 +935,38 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
870
935
  };
871
936
  }
872
937
 
938
+ function completionWasLate(action, completionIds, byId, window) {
939
+ if (window.dueWindowEndAt) {
940
+ const completedAt = completionIds
941
+ .map((id) => completionTimestamp(byId.get(id)))
942
+ .filter(Boolean)
943
+ .sort()[0];
944
+ if (completedAt) return new Date(completedAt) > new Date(window.dueWindowEndAt);
945
+ return !action.completedOn || action.completedOn >= window.dueWindowEndAt.slice(0, 10);
946
+ }
947
+ return Boolean(window.dueWindowEnd && action.completedOn && action.completedOn > window.dueWindowEnd);
948
+ }
949
+
950
+ function completionTimestamp(record) {
951
+ if (!record) return null;
952
+ return record.completedAt || record.occurredAt || record.collectedAt || record.verifiedAt || null;
953
+ }
954
+
955
+ function preferredCompletionType(activity, item, byId) {
956
+ const primary = activity.completionType;
957
+ const hasPersonSubject = [...(item.subjectResourceIds || []), ...(item.scopeResourceIds || [])]
958
+ .some((id) => byId.get(id)?.type === "person");
959
+ const hasAuthoredSubject = [item.templateResourceId, ...(item.scopeResourceIds || [])]
960
+ .some((id) => ["policy", "document", "training", "action-item"].includes(byId.get(id)?.type))
961
+ || (item.policyIds || []).some((id) => byId.get(id)?.type === "policy");
962
+ if (primary === "attestation" && (!hasPersonSubject || !hasAuthoredSubject) && activity.completionResourceTypes.includes("evidence")) {
963
+ return "evidence";
964
+ }
965
+ if (SCAFFOLDED_COMPLETION_TYPES.has(primary)) return primary;
966
+ if (activity.completionResourceTypes.includes("evidence")) return "evidence";
967
+ return primary;
968
+ }
969
+
873
970
  function planStandaloneAction(record, byId, asOf, now) {
874
971
  const source = byId.get(record.sourceResourceId);
875
972
  const window = plannedCompletionWindow(record.completionWindow);
@@ -12,6 +12,11 @@ export async function scaffoldPolicyActivation(input = process.cwd(), options =
12
12
  ));
13
13
  const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
14
14
  return {
15
+ available: approved.length > 0,
16
+ message: approved.length
17
+ ? `${approved.length} approved ${approved.length === 1 ? "Policy is" : "Policies are"} available for the Step 3 cutover.`
18
+ : "No Policy is ready for activation. Finish Step 2 approval, then resolve its Step 3 implementation gaps.",
19
+ nextCommand: approved.length ? null : "npx filegrc program-path --next --json",
15
20
  policyIds: approved.map(({ policyId }) => policyId),
16
21
  effectiveOn: currentCalendarDate(loaded.workspace.timezone),
17
22
  expectedRevisions: Object.fromEntries(approved.map(({ policyId }) => [policyId, revisionById.get(policyId)])),