filegrc 0.10.0 → 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.
- package/model/index.js +8 -5
- package/model/v8.json +10947 -0
- package/package.json +1 -1
- package/src/cli.js +66 -1
- package/src/collection-scope.js +12 -0
- package/src/git.js +4 -4
- package/src/index.js +3 -0
- package/src/model-migration.js +160 -4
- package/src/obligations.js +15 -9
- package/src/policy-library/data-retention-schedule-v2.md +25 -0
- package/src/policy-library.js +52 -24
- package/src/program-amendment.js +222 -0
- package/src/program-path.js +12 -4
- package/src/program-readiness.js +25 -12
- package/src/requirement-mapping.js +61 -0
- package/src/retention.js +261 -0
- package/src/server.js +74 -1
- package/src/source-coverage.js +21 -7
- package/src/state.js +169 -1
- package/src/validate.js +81 -4
- package/src/web.js +320 -27
- package/src/workflow.js +26 -7
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -53,7 +53,9 @@ import { activatePolicies, planPolicyActivation, scaffoldPolicyActivation } from
|
|
|
53
53
|
import { applyPolicyLibraryUpgrade, assessPolicyLibraryUpgrades } from "./policy-library.js";
|
|
54
54
|
import { buildAgentProgramPath } from "./program-path.js";
|
|
55
55
|
import { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
|
|
56
|
+
import { planProgramAmendment } from "./program-amendment.js";
|
|
56
57
|
import { resolveProgram } from "./program.js";
|
|
58
|
+
import { resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
|
|
57
59
|
import { applyReconciliation, planReconciliation } from "./reconciliation.js";
|
|
58
60
|
import { markdownEntries } from "./resource-markdown.js";
|
|
59
61
|
import { effectiveResourceStatus } from "./resource-status.js";
|
|
@@ -462,6 +464,46 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
462
464
|
if (flags["require-ready"] && !result.evidenceReady) process.exitCode = 2;
|
|
463
465
|
return output;
|
|
464
466
|
}
|
|
467
|
+
if (command === "program-amendment") {
|
|
468
|
+
const sourceResourceId = flags.source || positionals[0];
|
|
469
|
+
if (!sourceResourceId) throw new Error("Pass a source resource ID or --source resource-id.");
|
|
470
|
+
const result = await planProgramAmendment(root, { sourceResourceId });
|
|
471
|
+
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
472
|
+
else {
|
|
473
|
+
console.log(`Program amendment review for ${result.source.title}`);
|
|
474
|
+
for (const [type, ids] of Object.entries(result.byResourceType)) console.log(`${type}\t${ids.join(",")}`);
|
|
475
|
+
for (const work of result.reviewWork) {
|
|
476
|
+
console.log(`REVIEW\t${work.resourceType}\t${work.resourceIds?.length ? work.resourceIds.join(",") + "\t" : ""}${work.message}`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return result;
|
|
480
|
+
}
|
|
481
|
+
if (command === "review-bindings") {
|
|
482
|
+
const resourceId = positionals[0];
|
|
483
|
+
if (!resourceId) throw new Error("Pass a Retention Schedule Item or Requirement Mapping ID.");
|
|
484
|
+
const loaded = await loadWorkspace(root);
|
|
485
|
+
const record = loaded.resources.find(({ id }) => id === resourceId);
|
|
486
|
+
if (!record) throw new Error(`Resource "${resourceId}" was not found.`);
|
|
487
|
+
const dependencyIds = record.type === "retention-schedule-item"
|
|
488
|
+
? retentionReviewResourceIds(record, loaded)
|
|
489
|
+
: record.type === "requirement-mapping"
|
|
490
|
+
? [...new Set([...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])])]
|
|
491
|
+
: null;
|
|
492
|
+
if (!dependencyIds) throw new Error("Review bindings are available for Retention Schedule Items and Requirement Mappings.");
|
|
493
|
+
const revisions = await resourceReviewRevisions(loaded, dependencyIds);
|
|
494
|
+
const result = {
|
|
495
|
+
resource: { type: record.type, id: record.id, title: record.title },
|
|
496
|
+
dependencyIds,
|
|
497
|
+
reviewedSourceRevisions: Object.fromEntries(revisions),
|
|
498
|
+
missingResourceIds: dependencyIds.filter((id) => !revisions.has(id))
|
|
499
|
+
};
|
|
500
|
+
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
501
|
+
else {
|
|
502
|
+
console.log(`Review bindings for ${record.title}`);
|
|
503
|
+
for (const id of dependencyIds) console.log(`${revisions.has(id) ? "READY" : "MISSING"}\t${id}\t${revisions.get(id) || ""}`);
|
|
504
|
+
}
|
|
505
|
+
return result;
|
|
506
|
+
}
|
|
465
507
|
if (command === "evidence-map") {
|
|
466
508
|
const loaded = await loadWorkspace(root);
|
|
467
509
|
const result = await assessEvidenceMap(loaded, { asOf: flags["as-of"], programId: flags.program });
|
|
@@ -1135,6 +1177,8 @@ Usage:
|
|
|
1135
1177
|
filegrc search <query> [--type resource-type] [--json]
|
|
1136
1178
|
filegrc obligations [--as-of YYYY-MM-DD] [--from YYYY-MM-DD] [--through YYYY-MM-DD] [--now RFC3339] [--complete] [--json]
|
|
1137
1179
|
filegrc program-readiness [--as-of YYYY-MM-DD] [--require-ready] [--summary] [--json]
|
|
1180
|
+
filegrc program-amendment <source-resource-id> [--json]
|
|
1181
|
+
filegrc review-bindings <retention-or-mapping-id> [--json]
|
|
1138
1182
|
filegrc evidence-map [--as-of YYYY-MM-DD] [--json]
|
|
1139
1183
|
filegrc audit-readiness [audit-id] [--as-of YYYY-MM-DD] [--require-ready] [--json]
|
|
1140
1184
|
filegrc prepare-audit <audit-id> [--json]
|
|
@@ -1222,7 +1266,8 @@ the repository Workspace, management Program, bounded Systems, operational Compo
|
|
|
1222
1266
|
specific Assets, Vendors, normalized information, and Evidence Artifacts. Model v5 separates
|
|
1223
1267
|
Document approval from activation and records program-versus-engagement scope. Model v6 gives
|
|
1224
1268
|
Training the same approval and activation split and moves its schedule into Obligations. Model v7
|
|
1225
|
-
keeps issued historical Documents neutral and requires a current activation for legacy Training.
|
|
1269
|
+
keeps issued historical Documents neutral and requires a current activation for legacy Training. Model v8
|
|
1270
|
+
adds structured retention schedule items, reviewed requirement mappings, source-linked Commitments, and custom obligations. v3 migration
|
|
1226
1271
|
previews may require a decisions JSON file for ambiguous old Systems. v3 still creates planned
|
|
1227
1272
|
core Appointments, removal of obsolete manual page state, classified review work,
|
|
1228
1273
|
and dataModelVersion changed last. The command writes no Git commit.
|
|
@@ -1261,6 +1306,24 @@ Options:
|
|
|
1261
1306
|
--help Show this help`);
|
|
1262
1307
|
return;
|
|
1263
1308
|
}
|
|
1309
|
+
if (command === "program-amendment") {
|
|
1310
|
+
console.log(`Usage:
|
|
1311
|
+
filegrc program-amendment <source-resource-id> [--json]
|
|
1312
|
+
|
|
1313
|
+
Trace a Policy, Document, Framework, Requirement, or Commitment through its
|
|
1314
|
+
Commitments, mappings, Controls, Obligations, and retention rules. The command
|
|
1315
|
+
only reports review work. It does not infer or write management decisions.`);
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
if (command === "review-bindings") {
|
|
1319
|
+
console.log(`Usage:
|
|
1320
|
+
filegrc review-bindings <retention-or-mapping-id> [--json]
|
|
1321
|
+
|
|
1322
|
+
Calculate the exact current source revisions required by an active Retention
|
|
1323
|
+
Schedule Item or Requirement Mapping. Copy reviewedSourceRevisions into the
|
|
1324
|
+
record only after management completes the review.`);
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1264
1327
|
if (command === "workflow") {
|
|
1265
1328
|
console.log(`Usage:
|
|
1266
1329
|
filegrc workflow [audit-id] [options]
|
|
@@ -1432,6 +1495,8 @@ function agentOverview(model) {
|
|
|
1432
1495
|
search: "filegrc search <query> --json",
|
|
1433
1496
|
obligations: "filegrc obligations --json",
|
|
1434
1497
|
programReadiness: "filegrc program-readiness --json",
|
|
1498
|
+
programAmendment: "filegrc program-amendment <source-resource-id> --json",
|
|
1499
|
+
reviewBindings: "filegrc review-bindings <retention-or-mapping-id> --json",
|
|
1435
1500
|
evidenceMap: "filegrc evidence-map --json",
|
|
1436
1501
|
auditReadiness: "filegrc audit-readiness <audit-id> --json",
|
|
1437
1502
|
prepareAudit: "filegrc prepare-audit <audit-id>",
|
package/src/collection-scope.js
CHANGED
|
@@ -131,6 +131,13 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
|
|
|
131
131
|
addIds((record.informationUses || []).map(({ informationTypeId }) => informationTypeId));
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
|
+
if (resourceType === "information-type" && !legacy) {
|
|
135
|
+
addIds(program?.systemIds);
|
|
136
|
+
addIds(programComponents(loaded, program || {}).map(({ id }) => id));
|
|
137
|
+
addIds(loaded.resources
|
|
138
|
+
.filter((record) => record.type === "vendor" && record.status !== "retired")
|
|
139
|
+
.map(({ id }) => id));
|
|
140
|
+
}
|
|
134
141
|
if (resourceType === "complementary-control") {
|
|
135
142
|
addIds(program?.systemIds);
|
|
136
143
|
addIds(program?.controlIds);
|
|
@@ -229,6 +236,11 @@ const dependencyFields = {
|
|
|
229
236
|
classification: ["id", "type", "status", "rank", "description", "handlingRequirements"],
|
|
230
237
|
"information-type": ["id", "type", "status", "classificationId", "description"]
|
|
231
238
|
},
|
|
239
|
+
"information-type": {
|
|
240
|
+
system: ["id", "type", "status", "informationTypeIds"],
|
|
241
|
+
component: ["id", "type", "status", "systemUses", "informationUses"],
|
|
242
|
+
vendor: ["id", "type", "status", "informationTypeIds"]
|
|
243
|
+
},
|
|
232
244
|
"complementary-control": {
|
|
233
245
|
system: ["id", "type", "status", "purpose", "servicesProvided", "boundary", "exclusions", "criticality", "informationTypeIds", "classificationId", "internetExposed", "continuityObjectives"],
|
|
234
246
|
control: ["id", "type", "status", "statement", "activity", "systemIds", "componentIds", "evidenceSourceComponentIds"],
|
package/src/git.js
CHANGED
|
@@ -390,8 +390,8 @@ function unavailableSnapshot(error, extra = {}) {
|
|
|
390
390
|
}
|
|
391
391
|
|
|
392
392
|
export async function getBrowserRepositoryState(input = process.cwd(), options = {}) {
|
|
393
|
-
const root = resolveWorkspaceRoot(input);
|
|
394
|
-
const config = await getRepositoryConfig(
|
|
393
|
+
const root = input?.entries && input?.root ? input.root : resolveWorkspaceRoot(input);
|
|
394
|
+
const config = await getRepositoryConfig(input);
|
|
395
395
|
const gitSummary = options.repositorySnapshot ?? await getRepositorySnapshot(root);
|
|
396
396
|
if (config.mode !== "trunk") {
|
|
397
397
|
return {
|
|
@@ -852,8 +852,8 @@ function syncReadySummary(root, action) {
|
|
|
852
852
|
return summary;
|
|
853
853
|
}
|
|
854
854
|
|
|
855
|
-
async function getRepositoryConfig(
|
|
856
|
-
const loaded = await loadWorkspace(
|
|
855
|
+
async function getRepositoryConfig(input) {
|
|
856
|
+
const loaded = input?.entries && input?.root ? input : await loadWorkspace(input);
|
|
857
857
|
const renderer = loaded.resources.find(({ type, id }) => type === "renderer-settings" && id === "renderer-settings");
|
|
858
858
|
const mode = renderer?.repositoryMode;
|
|
859
859
|
const authoritativeBranch = cleanGitName(renderer?.authoritativeBranch);
|
package/src/index.js
CHANGED
|
@@ -82,6 +82,7 @@ export {
|
|
|
82
82
|
setupExternalReviewerGovernance
|
|
83
83
|
} from "./external-reviewer.js";
|
|
84
84
|
export { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
|
|
85
|
+
export { planProgramAmendment } from "./program-amendment.js";
|
|
85
86
|
export {
|
|
86
87
|
buildAgentProgramPath,
|
|
87
88
|
PROGRAM_PATH,
|
|
@@ -99,6 +100,8 @@ export {
|
|
|
99
100
|
export { searchResources, searchableValues } from "./search.js";
|
|
100
101
|
export { effectiveResourceStatus } from "./resource-status.js";
|
|
101
102
|
export { applyReconciliation, planReconciliation } from "./reconciliation.js";
|
|
103
|
+
export { assessRequirementMappingReadiness } from "./requirement-mapping.js";
|
|
104
|
+
export { assessRetentionReadiness, nearDuplicateInformationTypes, resourceReviewRevision, resourceReviewRevisions, retentionReviewResourceIds, retentionRuleIsCurrent, retentionUses } from "./retention.js";
|
|
102
105
|
export { createFilegrcServer, serveWorkspace } from "./server.js";
|
|
103
106
|
export { normalizeSetupPayload, planWorkspaceSetup, setupWorkspace, summarizeSetupResult } from "./setup.js";
|
|
104
107
|
export { createAppState, createResourceDetail } from "./state.js";
|
package/src/model-migration.js
CHANGED
|
@@ -896,9 +896,10 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
|
|
|
896
896
|
: sourceVersion === "1" ? V1_TARGET_MODEL_VERSION
|
|
897
897
|
: sourceVersion === "2" ? "3"
|
|
898
898
|
: sourceVersion === "3" ? "4"
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
899
|
+
: sourceVersion === "4" ? "5"
|
|
900
|
+
: sourceVersion === "5" ? "6"
|
|
901
|
+
: sourceVersion === "6" ? "7"
|
|
902
|
+
: ACTIVE_MODEL_VERSION;
|
|
902
903
|
if (sourceVersion === requestedTarget) return emptyPlan(sourceVersion, requestedTarget);
|
|
903
904
|
if (sourceVersion === "1" && requestedTarget === "2") {
|
|
904
905
|
return planV1ToV2Migration(input, options);
|
|
@@ -915,9 +916,12 @@ export async function planModelMigration(input = process.cwd(), options = {}) {
|
|
|
915
916
|
if (sourceVersion === "5" && requestedTarget === "6") {
|
|
916
917
|
return planV5ToV6Migration(loaded);
|
|
917
918
|
}
|
|
918
|
-
if (sourceVersion === "6" && requestedTarget ===
|
|
919
|
+
if (sourceVersion === "6" && requestedTarget === "7") {
|
|
919
920
|
return planV6ToV7Migration(loaded);
|
|
920
921
|
}
|
|
922
|
+
if (sourceVersion === "7" && requestedTarget === ACTIVE_MODEL_VERSION) {
|
|
923
|
+
return planV7ToV8Migration(loaded);
|
|
924
|
+
}
|
|
921
925
|
if (sourceVersion === "1" && requestedTarget === ACTIVE_MODEL_VERSION) {
|
|
922
926
|
throw new Error(
|
|
923
927
|
"Model v1 workspaces must migrate to model v2 first. "
|
|
@@ -2034,6 +2038,158 @@ async function planV6ToV7Migration(loaded) {
|
|
|
2034
2038
|
};
|
|
2035
2039
|
}
|
|
2036
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
|
+
|
|
2037
2193
|
function normalizeDocumentScopeDecision(value) {
|
|
2038
2194
|
const candidate = typeof value === "object" && value
|
|
2039
2195
|
? value.workflowScope || value.scope
|
package/src/obligations.js
CHANGED
|
@@ -78,7 +78,7 @@ export function planObligations(resources, options = {}) {
|
|
|
78
78
|
};
|
|
79
79
|
|
|
80
80
|
for (const obligation of obligations) {
|
|
81
|
-
const activity = obligationActivity(model, obligation
|
|
81
|
+
const activity = obligationActivity(model, obligation);
|
|
82
82
|
const expectedCompletionTypes = activity.completionResourceTypes;
|
|
83
83
|
const programStatus = obligationProgramStatus(obligation, byId, asOf, model);
|
|
84
84
|
if (obligation.recurrence?.mode === "event" && obligation.recurrence.eventType) {
|
|
@@ -348,7 +348,7 @@ export async function scaffoldObligationCompletion(input, options = {}) {
|
|
|
348
348
|
const item = action
|
|
349
349
|
? plannedActionForScaffold(loaded, action, completedOn)
|
|
350
350
|
: plannedOccurrenceForScaffold(loaded, obligation, options.windowStart, completedOn);
|
|
351
|
-
const activity = obligationActivity(loaded.model, obligation
|
|
351
|
+
const activity = obligationActivity(loaded.model, obligation);
|
|
352
352
|
const type = item.completionType || preferredCompletionType(activity, {
|
|
353
353
|
...obligation,
|
|
354
354
|
subjectResourceIds: item.subjectResourceIds || []
|
|
@@ -462,7 +462,7 @@ function assertExpectedCompletionType(obligation, record, model) {
|
|
|
462
462
|
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
|
463
463
|
throw new Error("A completion resource record is required.");
|
|
464
464
|
}
|
|
465
|
-
const expected = obligationActivity(model, obligation
|
|
465
|
+
const expected = obligationActivity(model, obligation).completionResourceTypes;
|
|
466
466
|
if (expected.length && !expected.includes(record.type)) {
|
|
467
467
|
throw new Error(
|
|
468
468
|
`Obligation "${obligation.id}" expects a completion resource of type ${expected.join(" or ")}, not "${record.type ?? ""}".`
|
|
@@ -842,13 +842,13 @@ function planEventRun(event, actionItems, byId, asOf, now, model) {
|
|
|
842
842
|
.map((record) => {
|
|
843
843
|
const obligation = byId.get(record.obligationId);
|
|
844
844
|
const expectedCompletionTypes = obligation?.type === "obligation"
|
|
845
|
-
? obligationActivity(model, obligation
|
|
845
|
+
? obligationActivity(model, obligation).completionResourceTypes
|
|
846
846
|
: [];
|
|
847
847
|
const completionProfile = obligation?.type === "obligation"
|
|
848
|
-
? obligationActivity(model, obligation
|
|
848
|
+
? obligationActivity(model, obligation).completionProfile || null
|
|
849
849
|
: null;
|
|
850
850
|
const completionType = obligation?.type === "obligation"
|
|
851
|
-
? preferredCompletionType(obligationActivity(model, obligation
|
|
851
|
+
? preferredCompletionType(obligationActivity(model, obligation), {
|
|
852
852
|
...obligation,
|
|
853
853
|
subjectResourceIds: event.subjectResourceIds || []
|
|
854
854
|
}, byId)
|
|
@@ -1135,17 +1135,23 @@ function comparePlannedItems(a, b) {
|
|
|
1135
1135
|
function eventActionDescription(obligation, eventType, model) {
|
|
1136
1136
|
const policy = obligation.policyIds?.length ? ` Policy sources: ${obligation.policyIds.join(", ")}.` : "";
|
|
1137
1137
|
const scope = obligation.scopeResourceIds?.length ? ` Review scoped resources: ${obligation.scopeResourceIds.join(", ")}.` : "";
|
|
1138
|
-
const expected = obligationActivity(model, obligation
|
|
1138
|
+
const expected = obligationActivity(model, obligation).completionResourceTypes;
|
|
1139
1139
|
const completion = expected.length
|
|
1140
1140
|
? ` Link completion records of type ${expected.join(", ")} and any evidence before marking this done.`
|
|
1141
1141
|
: " Link the completion record and evidence before marking this done.";
|
|
1142
1142
|
return `Triggered by ${eventType}.${policy}${scope}${completion}`;
|
|
1143
1143
|
}
|
|
1144
1144
|
|
|
1145
|
-
function obligationActivity(model,
|
|
1145
|
+
function obligationActivity(model, obligation) {
|
|
1146
|
+
const activityType = typeof obligation === "string" ? obligation : obligation?.activityType;
|
|
1146
1147
|
const activity = model.obligationActivities?.[activityType];
|
|
1147
1148
|
if (!activity) throw new Error(`Unknown obligation activity type "${activityType ?? ""}".`);
|
|
1148
|
-
return activity;
|
|
1149
|
+
if (activityType !== "custom") return activity;
|
|
1150
|
+
return {
|
|
1151
|
+
...activity,
|
|
1152
|
+
...(obligation?.customActivity || {}),
|
|
1153
|
+
completionType: obligation?.customActivity?.completionResourceTypes?.[0] || activity.completionType
|
|
1154
|
+
};
|
|
1149
1155
|
}
|
|
1150
1156
|
|
|
1151
1157
|
function requireDate(value, label) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Data Retention Schedule
|
|
2
|
+
|
|
3
|
+
## Use
|
|
4
|
+
|
|
5
|
+
This schedule records how long {{company_name}} keeps important record classes and what happens when each period ends. The policy owner and data owners must complete the organization-specific rows before approval.
|
|
6
|
+
|
|
7
|
+
Retention periods may come from law, contract, tax, audit, security, or a documented business need. Use the longest applicable period, but do not keep data indefinitely without a reason.
|
|
8
|
+
|
|
9
|
+
## Schedule
|
|
10
|
+
|
|
11
|
+
The structured Retention Schedule Items linked to this document are its schedule rows. Each approved item must name the covered Information Types and operational scope, owner, cutoff, period, disposition action, instructions, and authority. Planned items are review prompts and are not approved retention behavior.
|
|
12
|
+
|
|
13
|
+
Management must cover important information used by Systems, Components, and Vendors, including security logs, backups or alternate recovery copies, governance records, audit evidence, customer and service records, and incident records when those classes exist. No starter period or disposition action is an approved organization value.
|
|
14
|
+
|
|
15
|
+
## Holds and exceptions
|
|
16
|
+
|
|
17
|
+
An approved legal hold, investigation, or preservation duty suspends normal deletion for the affected records. Record the authority, scope, owner, start date, and release decision in controlled legal-hold records.
|
|
18
|
+
|
|
19
|
+
Any retention exception needs a reason, owner, approval, compensating safeguards, and expiration or next review date.
|
|
20
|
+
|
|
21
|
+
## Review and disposal evidence
|
|
22
|
+
|
|
23
|
+
Review this schedule at least annually and within 30 days after a material change to systems, data use, vendors, contracts, or applicable duties. The approver must be separate from the owner.
|
|
24
|
+
|
|
25
|
+
For material disposal work, retain a record of the record class, source, date range, method, completion date, responsible person, exceptions, and verification.
|
package/src/policy-library.js
CHANGED
|
@@ -42,14 +42,18 @@ const DOCUMENT_CONTENT_UPDATES = [
|
|
|
42
42
|
id: "document-data-retention-schedule",
|
|
43
43
|
path: "documents/document-data-retention-schedule.md",
|
|
44
44
|
priorRevision: "45a408e8139bd57f42dda5ca5ae5c8cd4480b4e7bf08834f60058148a3a63475",
|
|
45
|
-
additionalPriorRevisions: new Set([
|
|
46
|
-
|
|
45
|
+
additionalPriorRevisions: new Set([
|
|
46
|
+
"d80b99ce53d1012cc169bbbc2afab8d0597bfbe9f30ac0812a8d5bbeb2ed9f90",
|
|
47
|
+
"dd11857ae7d881f176bd93947ef3031c33c75ee41e3c0435198fd60c67a94cf7"
|
|
48
|
+
]),
|
|
49
|
+
currentRevision: "4a48c15a4e20e4f29028cf2ff8597315eb51878814120125b5268356b923c9db",
|
|
50
|
+
currentSourcePath: "./policy-library/data-retention-schedule-v2.md",
|
|
47
51
|
replacements: [
|
|
48
52
|
["FileGRC detects the bracketed prompts as approval blockers. Remove each prompt only after replacing it with a reviewed fact.", "Remove each bracketed prompt only after replacing it with a reviewed fact."],
|
|
49
53
|
["Record the authority, scope, owner, start date, and release decision outside this public template.", "Record the authority, scope, owner, start date, and release decision in controlled legal-hold records."],
|
|
50
54
|
["| Production backups or alternate recovery copies | [Complete before approval: Systems or Components] | [Complete before approval: owner] | Backup or recovery-copy creation | [Confirm or replace proposed default before approval: 30 days, adjusted to approved System recovery objectives] | [Complete before approval: expiration or disposal action] | [Complete before approval: continuity objective or risk decision] |", "| Production backups or alternate recovery copies | [Complete before approval: Systems or Components] | [Complete before approval: owner] | Backup or recovery-copy creation | [Confirm or replace proposed default before approval: 30 days, adjusted to approved System recovery needs] | [Complete before approval: expiration or disposal action] | [Complete before approval: recovery need, commitment, or risk decision] |"]
|
|
51
55
|
],
|
|
52
|
-
summary: "
|
|
56
|
+
summary: "Move schedule rows into structured records and keep organization-specific periods and disposition choices under management review."
|
|
53
57
|
},
|
|
54
58
|
{
|
|
55
59
|
id: "document-security-incident-recovery-plan",
|
|
@@ -592,31 +596,51 @@ async function buildPolicyLibraryPlan(loaded) {
|
|
|
592
596
|
}
|
|
593
597
|
if (sourceRevision === documentUpdate.currentRevision) {
|
|
594
598
|
skipped.push(skippedItem(documentUpdate.id, "current", "The governed Document already contains the current standalone starter language."));
|
|
595
|
-
|
|
596
|
-
}
|
|
597
|
-
if (sourceRevision !== documentUpdate.priorRevision
|
|
599
|
+
} else if (sourceRevision !== documentUpdate.priorRevision
|
|
598
600
|
&& !documentUpdate.additionalPriorRevisions?.has(sourceRevision)) {
|
|
599
601
|
skipped.push(skippedItem(documentUpdate.id, "customized", "The governed Document differs from the recognized prior starter, so FileGRC will not rewrite it."));
|
|
600
|
-
|
|
602
|
+
} else {
|
|
603
|
+
const nextSource = documentUpdate.currentSourcePath
|
|
604
|
+
? materializeDocument(
|
|
605
|
+
await readFile(new URL(documentUpdate.currentSourcePath, import.meta.url), "utf8"),
|
|
606
|
+
loaded.workspace?.organizationName
|
|
607
|
+
)
|
|
608
|
+
: documentUpdate.replacements.reduce((current, [prior, next]) => current.replace(prior, next), source);
|
|
609
|
+
if (normalizedDocumentRevision(nextSource, loaded.workspace?.organizationName) !== documentUpdate.currentRevision) {
|
|
610
|
+
throw new Error(`The ${documentUpdate.id} starter update does not produce the current governed Document.`);
|
|
611
|
+
}
|
|
612
|
+
proposalChanges.push({
|
|
613
|
+
resourceType: "document",
|
|
614
|
+
resourceId: documentUpdate.id,
|
|
615
|
+
path: displayPath,
|
|
616
|
+
summary: documentUpdate.summary,
|
|
617
|
+
diff: fullReplacementDiff(displayPath, source, nextSource)
|
|
618
|
+
});
|
|
619
|
+
contentUpdates[documentUpdate.id] = { content: nextSource };
|
|
620
|
+
expectedContentRevisions[documentUpdate.id] = {
|
|
621
|
+
[documentUpdate.path]: rawSourceRevision
|
|
622
|
+
};
|
|
601
623
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
624
|
+
if (documentUpdate.id === "document-data-retention-schedule") {
|
|
625
|
+
const expectedControlIds = ["control-logging-monitoring", "control-backup-restoration"];
|
|
626
|
+
const requiredControlIds = expectedControlIds.filter((id) => byId.get(id)?.record.type === "control");
|
|
627
|
+
for (const id of expectedControlIds.filter((id) => !requiredControlIds.includes(id))) {
|
|
628
|
+
skipped.push(skippedItem(id, "missing", `The starter ${id} Control is not present, so FileGRC did not add a dangling schedule relationship.`));
|
|
629
|
+
}
|
|
630
|
+
const nextControlIds = [...new Set([...(entry.record.controlIds || []), ...requiredControlIds])];
|
|
631
|
+
if (!sameValue(entry.record.controlIds || [], nextControlIds)) {
|
|
632
|
+
updates.push({ ...entry.record, controlIds: nextControlIds });
|
|
633
|
+
expectedRevisions[entry.record.id] = entry.revision;
|
|
634
|
+
const jsonPath = "data/documents/document-data-retention-schedule.json";
|
|
635
|
+
proposalChanges.push({
|
|
636
|
+
resourceType: "document",
|
|
637
|
+
resourceId: entry.record.id,
|
|
638
|
+
path: jsonPath,
|
|
639
|
+
summary: "Link the schedule to logging and backup Controls while preserving existing Control relationships.",
|
|
640
|
+
diff: replacementDiff(jsonPath, [["controlIds", entry.record.controlIds || [], nextControlIds]])
|
|
641
|
+
});
|
|
642
|
+
}
|
|
608
643
|
}
|
|
609
|
-
proposalChanges.push({
|
|
610
|
-
resourceType: "document",
|
|
611
|
-
resourceId: documentUpdate.id,
|
|
612
|
-
path: displayPath,
|
|
613
|
-
summary: documentUpdate.summary,
|
|
614
|
-
diff: fullReplacementDiff(displayPath, source, nextSource)
|
|
615
|
-
});
|
|
616
|
-
contentUpdates[documentUpdate.id] = { content: nextSource };
|
|
617
|
-
expectedContentRevisions[documentUpdate.id] = {
|
|
618
|
-
[documentUpdate.path]: rawSourceRevision
|
|
619
|
-
};
|
|
620
644
|
}
|
|
621
645
|
|
|
622
646
|
for (const controlUpdate of CONTROL_UPDATES) {
|
|
@@ -816,6 +840,10 @@ function materializePolicy(source, organizationName, securityContact) {
|
|
|
816
840
|
.replaceAll("{{security_contact_email}}", securityContact || "security@example.com");
|
|
817
841
|
}
|
|
818
842
|
|
|
843
|
+
function materializeDocument(source, organizationName) {
|
|
844
|
+
return source.replaceAll("{{company_name}}", organizationName || "Organization");
|
|
845
|
+
}
|
|
846
|
+
|
|
819
847
|
function securityContactFromPolicy(source) {
|
|
820
848
|
return source.match(/through the primary route at ([^\r\n]+?) or the usable alternate route documented/)?.[1];
|
|
821
849
|
}
|