filegrc 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/model/index.js +6 -5
- package/model/v6.json +10358 -0
- package/package.json +1 -1
- package/src/cli.js +75 -6
- package/src/document-activation.js +61 -25
- package/src/files.js +54 -25
- package/src/index.js +8 -1
- package/src/model-migration.js +148 -3
- package/src/obligations.js +5 -5
- package/src/policy-library.js +5 -2
- package/src/program-lifecycle.js +41 -3
- package/src/program-path.js +18 -17
- package/src/program-readiness.js +115 -12
- package/src/server.js +8 -1
- package/src/setup.js +1 -1
- package/src/validate.js +33 -5
- package/src/web.js +128 -26
- package/src/workflow.js +21 -4
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -16,7 +16,14 @@ import {
|
|
|
16
16
|
scaffoldCollectionReview
|
|
17
17
|
} from "./collection-review.js";
|
|
18
18
|
import { buildWorkspace } from "./build.js";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
activateDocuments,
|
|
21
|
+
activateGovernedContent,
|
|
22
|
+
planDocumentActivation,
|
|
23
|
+
planGovernedContentActivation,
|
|
24
|
+
scaffoldDocumentActivation,
|
|
25
|
+
scaffoldGovernedContentActivation
|
|
26
|
+
} from "./document-activation.js";
|
|
20
27
|
import { generateEvidencePacket, prepareEvidencePacket } from "./evidence-packet.js";
|
|
21
28
|
import {
|
|
22
29
|
addEvidenceAttachment,
|
|
@@ -184,7 +191,9 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
184
191
|
}
|
|
185
192
|
if (command === "migrate") {
|
|
186
193
|
const targetModel = String(flags["to-model"] || "");
|
|
187
|
-
if (!
|
|
194
|
+
if (!SUPPORTED_MODEL_VERSIONS.filter((version) => version !== "1").includes(targetModel)) {
|
|
195
|
+
throw new Error(`Pass --to-model ${SUPPORTED_MODEL_VERSIONS.filter((version) => version !== "1").join(", --to-model ")}.`);
|
|
196
|
+
}
|
|
188
197
|
const migrationDecisions = flags.decisions
|
|
189
198
|
? JSON.parse(await readFile(resolve(String(flags.decisions)), "utf8"))
|
|
190
199
|
: undefined;
|
|
@@ -644,6 +653,30 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
644
653
|
else console.log(`Activated ${result.documentIds.length} governed Documents effective ${result.effectiveOn}.`);
|
|
645
654
|
return result;
|
|
646
655
|
}
|
|
656
|
+
if (command === "activate-content") {
|
|
657
|
+
if (flags.scaffold) {
|
|
658
|
+
const result = await scaffoldGovernedContentActivation(root, { programId: flags.program });
|
|
659
|
+
console.log(JSON.stringify(result, null, 2));
|
|
660
|
+
return result;
|
|
661
|
+
}
|
|
662
|
+
const payload = await readSetupPayload(positionals[0]);
|
|
663
|
+
const options = {
|
|
664
|
+
...payload,
|
|
665
|
+
resourceIds: flags.resource ? String(flags.resource).split(",").filter(Boolean) : payload.resourceIds,
|
|
666
|
+
activatedByIds: flags["activated-by"] ? String(flags["activated-by"]).split(",").filter(Boolean) : payload.activatedByIds,
|
|
667
|
+
activatedOn: flags["activated-on"] || payload.activatedOn,
|
|
668
|
+
effectiveOn: flags["effective-on"] || payload.effectiveOn,
|
|
669
|
+
programId: flags.program || payload.programId,
|
|
670
|
+
confirmed: flags.yes === true
|
|
671
|
+
};
|
|
672
|
+
const result = flags.preview
|
|
673
|
+
? await planGovernedContentActivation(root, options)
|
|
674
|
+
: await withWorkflowDelta(root, () => activateGovernedContent(root, options));
|
|
675
|
+
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
676
|
+
else if (flags.preview) console.log(`Governed-content activation preview: ${result.resourceIds.length} records effective ${result.effectiveOn}.`);
|
|
677
|
+
else console.log(`Activated ${result.resourceIds.length} governed-content records effective ${result.effectiveOn}.`);
|
|
678
|
+
return result;
|
|
679
|
+
}
|
|
647
680
|
if (command === "policy-library") {
|
|
648
681
|
if (flags.yes && !flags.accept) {
|
|
649
682
|
throw new Error("Pass --accept <proposal-id> with --yes after reviewing the policy-library diff.");
|
|
@@ -1082,7 +1115,7 @@ Usage:
|
|
|
1082
1115
|
filegrc build [root] [--output .filegrc/site]
|
|
1083
1116
|
filegrc validate [root] [--json]
|
|
1084
1117
|
filegrc model [--json|--write-docs|--check-docs]
|
|
1085
|
-
filegrc migrate --to-model <2|3|4|5> [--preview] [--decisions path] [--job-title text] [--starts-on YYYY-MM-DD] [--yes] [--json]
|
|
1118
|
+
filegrc migrate --to-model <2|3|4|5|6> [--preview] [--decisions path] [--job-title text] [--starts-on YYYY-MM-DD] [--yes] [--json]
|
|
1086
1119
|
filegrc describe <resource-type>
|
|
1087
1120
|
filegrc types [--json]
|
|
1088
1121
|
filegrc guide [resource-type] [--id resource-id] [--program program-id] [--json]
|
|
@@ -1105,6 +1138,7 @@ Usage:
|
|
|
1105
1138
|
filegrc review-applicability [--scaffold --type requirement|control|commitment|complementary-control] [decisions.json|-] [--preview|--yes] [--json]
|
|
1106
1139
|
filegrc review-collection <resource-type> [--scaffold | review.json|-] [--preview|--yes] [--json]
|
|
1107
1140
|
filegrc activate-policies [--scaffold | activation.json|-] [--effective-on YYYY-MM-DD] [--preview|--yes] [--json]
|
|
1141
|
+
filegrc activate-content [--scaffold | activation.json|-] [--program id] [--activated-by person-id] [--activated-on YYYY-MM-DD] [--effective-on YYYY-MM-DD] [--preview|--yes] [--json]
|
|
1108
1142
|
filegrc activate-documents [--scaffold | activation.json|-] [--program id | --audit id] [--activated-by person-id] [--activated-on YYYY-MM-DD] [--effective-on YYYY-MM-DD] [--preview|--yes] [--json]
|
|
1109
1143
|
filegrc policy-library [--json | --accept proposal-id --proposal-revision revision --yes]
|
|
1110
1144
|
filegrc trigger <event-type> (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) [--risk-level normal|high] [--subject resource-id[,resource-id]] [--title text] [--json]
|
|
@@ -1179,7 +1213,8 @@ Upgrade a workspace through an explicit, reviewable model boundary. Model v1
|
|
|
1179
1213
|
workspaces migrate to v2 first. Continue one version at a time through v${ACTIVE_MODEL_VERSION}. v4 separates
|
|
1180
1214
|
the repository Workspace, management Program, bounded Systems, operational Components,
|
|
1181
1215
|
specific Assets, Vendors, normalized information, and Evidence Artifacts. Model v5 separates
|
|
1182
|
-
Document approval from activation and records program-versus-engagement scope.
|
|
1216
|
+
Document approval from activation and records program-versus-engagement scope. Model v6 gives
|
|
1217
|
+
Training the same approval and activation split and moves its schedule into Obligations. v3 migration
|
|
1183
1218
|
previews may require a decisions JSON file for ambiguous old Systems. v3 still creates planned
|
|
1184
1219
|
core Appointments, removal of obsolete manual page state, classified review work,
|
|
1185
1220
|
and dataModelVersion changed last. The command writes no Git commit.
|
|
@@ -1282,7 +1317,7 @@ Options:
|
|
|
1282
1317
|
filegrc activate-documents --scaffold [--program id | --audit id]
|
|
1283
1318
|
filegrc activate-documents <activation.json|-> [--audit id] [--activated-by person-id] [--activated-on YYYY-MM-DD] [--effective-on YYYY-MM-DD] [--preview|--yes] [--json]
|
|
1284
1319
|
|
|
1285
|
-
Activate required
|
|
1320
|
+
Activate required program Documents in Step 3 after their linked
|
|
1286
1321
|
Controls are implemented, or activate engagement Documents in Step 5 after
|
|
1287
1322
|
their audit-specific facts are complete. Approval, activation, and effective
|
|
1288
1323
|
dates remain separate, and activation binds its own exact Markdown revision.
|
|
@@ -1301,6 +1336,30 @@ Options:
|
|
|
1301
1336
|
--help Show this help`);
|
|
1302
1337
|
return;
|
|
1303
1338
|
}
|
|
1339
|
+
if (command === "activate-content") {
|
|
1340
|
+
console.log(`Usage:
|
|
1341
|
+
filegrc activate-content --scaffold [--program id]
|
|
1342
|
+
filegrc activate-content <activation.json|-> [--resource id] [--activated-by person-id] [--activated-on YYYY-MM-DD] [--effective-on YYYY-MM-DD] [--preview|--yes] [--json]
|
|
1343
|
+
|
|
1344
|
+
Activate approved program Documents and Training in Step 3 after their linked
|
|
1345
|
+
Controls are implemented and Training has an enabled assignment Obligation.
|
|
1346
|
+
Approval, activation, and effective dates remain separate, and activation binds
|
|
1347
|
+
the exact approved Markdown revision.
|
|
1348
|
+
|
|
1349
|
+
Options:
|
|
1350
|
+
--scaffold Print the ready activation payload without writing
|
|
1351
|
+
--program <id> Program to assess when more than one active Program exists
|
|
1352
|
+
--resource <id> Comma-separated Document or Training IDs
|
|
1353
|
+
--activated-by <id> Active Person who performs the activation
|
|
1354
|
+
--activated-on <date> Actual activation date, which must be today
|
|
1355
|
+
--effective-on <date> Effective date on or after activation
|
|
1356
|
+
--preview Validate and show the atomic updates without writing
|
|
1357
|
+
--yes Confirm and apply the reviewed activation
|
|
1358
|
+
--json Print the result as JSON
|
|
1359
|
+
--root <path> Workspace path
|
|
1360
|
+
--help Show this help`);
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1304
1363
|
if (command === "policy-library") {
|
|
1305
1364
|
console.log(`Usage:
|
|
1306
1365
|
filegrc policy-library [--json]
|
|
@@ -1347,7 +1406,7 @@ function agentOverview(model) {
|
|
|
1347
1406
|
build: "filegrc build [root]",
|
|
1348
1407
|
validate: "filegrc validate [root] --json",
|
|
1349
1408
|
model: "filegrc model --json",
|
|
1350
|
-
migrate: "filegrc migrate --to-model
|
|
1409
|
+
migrate: "filegrc migrate --to-model 6 --preview --json",
|
|
1351
1410
|
describe: "filegrc describe <resource-type>",
|
|
1352
1411
|
types: "filegrc types --json",
|
|
1353
1412
|
guide: "filegrc guide [resource-type] --json",
|
|
@@ -1367,6 +1426,7 @@ function agentOverview(model) {
|
|
|
1367
1426
|
externalReviewerSetup: "filegrc external-reviewer-setup [--scaffold | <reviewer.json|-> --preview] --json",
|
|
1368
1427
|
policyActivation: "filegrc activate-policies [--scaffold | <activation.json|-> --preview] --json",
|
|
1369
1428
|
documentActivation: "filegrc activate-documents [--scaffold | <activation.json|-> --preview] --json",
|
|
1429
|
+
governedContentActivation: "filegrc activate-content [--scaffold | <activation.json|-> --preview] --json",
|
|
1370
1430
|
nextAuditCycle: "filegrc next-audit-cycle <prior-audit-id> --start <date> --end <date> --preview --json",
|
|
1371
1431
|
reviewApplicability: "filegrc review-applicability <decisions.json|-> --preview --json",
|
|
1372
1432
|
reviewCollection: "filegrc review-collection <resource-type> [--scaffold | <review.json|-> --preview] --json",
|
|
@@ -1520,6 +1580,7 @@ function buildProgramPathResult(model, readiness, auditReadiness) {
|
|
|
1520
1580
|
operating: readiness.operating,
|
|
1521
1581
|
policyActivations: readiness.policyActivations,
|
|
1522
1582
|
documentActivations: readiness.documentActivations,
|
|
1583
|
+
trainingActivations: readiness.trainingActivations,
|
|
1523
1584
|
policyLibraryProposals: readiness.policyLibraryProposals,
|
|
1524
1585
|
stages
|
|
1525
1586
|
};
|
|
@@ -1730,6 +1791,14 @@ function summarizeProgramReadiness(result) {
|
|
|
1730
1791
|
label,
|
|
1731
1792
|
gapCount
|
|
1732
1793
|
})),
|
|
1794
|
+
trainingActivations: result.trainingActivations.map(({ trainingId, title, state, label, gapCount, assignmentScheduled }) => ({
|
|
1795
|
+
trainingId,
|
|
1796
|
+
title,
|
|
1797
|
+
state,
|
|
1798
|
+
label,
|
|
1799
|
+
gapCount,
|
|
1800
|
+
assignmentScheduled
|
|
1801
|
+
})),
|
|
1733
1802
|
policyLibraryProposals: result.policyLibraryProposals,
|
|
1734
1803
|
unresolvedOwnership: {
|
|
1735
1804
|
count: unresolvedOwnership.length,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { applyDocumentActivationBatch, contentRevision } from "./files.js";
|
|
1
|
+
import { applyDocumentActivationBatch, applyGovernedContentActivationBatch, contentRevision } from "./files.js";
|
|
2
2
|
import { serializeWorkspaceMutation } from "./mutation.js";
|
|
3
3
|
import { modelSupports } from "../model/index.js";
|
|
4
4
|
import { assessAuditDocumentActivations } from "./audit-preparation.js";
|
|
@@ -10,16 +10,16 @@ import { loadWorkspace } from "./workspace.js";
|
|
|
10
10
|
export async function scaffoldDocumentActivation(input = process.cwd(), options = {}) {
|
|
11
11
|
const loaded = await loadWorkspace(input);
|
|
12
12
|
requireDocumentLifecycle(loaded);
|
|
13
|
-
const candidates = await activationCandidates(loaded, options);
|
|
13
|
+
const candidates = await activationCandidates(loaded, { ...options, documentsOnly: true });
|
|
14
14
|
const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
|
|
15
15
|
const today = currentCalendarDate(loaded.workspace.timezone);
|
|
16
16
|
return {
|
|
17
|
-
documentIds: candidates.map(({
|
|
17
|
+
documentIds: candidates.filter(({ resourceType }) => resourceType === "document").map(({ resourceId }) => resourceId),
|
|
18
18
|
...(options.auditId ? { auditId: options.auditId, workflowScope: "engagement" } : { workflowScope: "program" }),
|
|
19
19
|
activatedByIds: [],
|
|
20
20
|
activatedOn: today,
|
|
21
21
|
effectiveOn: today,
|
|
22
|
-
expectedRevisions: Object.fromEntries(candidates.map(({
|
|
22
|
+
expectedRevisions: Object.fromEntries(candidates.map(({ resourceId }) => [resourceId, revisionById.get(resourceId)])),
|
|
23
23
|
confirmed: false
|
|
24
24
|
};
|
|
25
25
|
}
|
|
@@ -27,8 +27,8 @@ export async function scaffoldDocumentActivation(input = process.cwd(), options
|
|
|
27
27
|
export async function planDocumentActivation(input = process.cwd(), options = {}) {
|
|
28
28
|
const loaded = await loadWorkspace(input);
|
|
29
29
|
requireDocumentLifecycle(loaded);
|
|
30
|
-
const
|
|
31
|
-
if (!
|
|
30
|
+
const resourceIds = [...new Set((options.resourceIds || options.documentIds || []).map(String))];
|
|
31
|
+
if (!resourceIds.length) throw new Error("Governed-content activation needs at least one approved program Document or Training record.");
|
|
32
32
|
const activatedByIds = [...new Set((options.activatedByIds || []).map(String))];
|
|
33
33
|
if (!activatedByIds.length) throw new Error("Document activation needs the Person who performed the activation.");
|
|
34
34
|
const activatedOn = String(options.activatedOn || "").trim();
|
|
@@ -44,11 +44,12 @@ export async function planDocumentActivation(input = process.cwd(), options = {}
|
|
|
44
44
|
}
|
|
45
45
|
const expectedRevisions = options.expectedRevisions || {};
|
|
46
46
|
if (Array.isArray(expectedRevisions) || !expectedRevisions || typeof expectedRevisions !== "object") {
|
|
47
|
-
throw new Error("
|
|
47
|
+
throw new Error("Governed-content activation expected revisions must be keyed by resource ID.");
|
|
48
48
|
}
|
|
49
49
|
const workflowScope = options.auditId ? "engagement" : "program";
|
|
50
|
+
const governedContentOperation = workflowScope === "program" && options.resourceIds !== undefined;
|
|
50
51
|
const candidates = await activationCandidates(loaded, options);
|
|
51
|
-
const assessmentById = new Map(candidates.map((item) => [item.
|
|
52
|
+
const assessmentById = new Map(candidates.map((item) => [item.resourceId, item]));
|
|
52
53
|
const entryById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
|
|
53
54
|
const invalidActivatorIds = activatedByIds.filter((id) => {
|
|
54
55
|
const person = entryById.get(id)?.record;
|
|
@@ -57,26 +58,28 @@ export async function planDocumentActivation(input = process.cwd(), options = {}
|
|
|
57
58
|
if (invalidActivatorIds.length) {
|
|
58
59
|
throw new Error(`Document activation needs active People as activators: ${invalidActivatorIds.join(", ")}.`);
|
|
59
60
|
}
|
|
60
|
-
const update =
|
|
61
|
-
const entry = entryById.get(
|
|
62
|
-
|
|
61
|
+
const update = resourceIds.map((resourceId) => {
|
|
62
|
+
const entry = entryById.get(resourceId);
|
|
63
|
+
const allowedTypes = governedContentOperation ? ["document", "training"] : ["document"];
|
|
64
|
+
if (!entry || !allowedTypes.includes(entry.record.type)) throw new Error(`Governed content "${resourceId}" was not found in the ${workflowScope} workflow.`);
|
|
65
|
+
const resourceTitle = loaded.model.resources[entry.record.type].title;
|
|
63
66
|
if (entry.record.status !== "approved") {
|
|
64
|
-
throw new Error(
|
|
67
|
+
throw new Error(`${resourceTitle} "${resourceId}" must be independently approved before its separate ${workflowScope === "engagement" ? "Step 5" : "Step 3"} activation.`);
|
|
65
68
|
}
|
|
66
|
-
if (entry.record.workflowScope !== workflowScope) {
|
|
67
|
-
throw new Error(`Document "${
|
|
69
|
+
if (entry.record.type === "document" && entry.record.workflowScope !== workflowScope) {
|
|
70
|
+
throw new Error(`Document "${resourceId}" belongs to the ${entry.record.workflowScope} workflow, not ${workflowScope}.`);
|
|
68
71
|
}
|
|
69
|
-
const assessment = assessmentById.get(
|
|
72
|
+
const assessment = assessmentById.get(resourceId);
|
|
70
73
|
if (assessment?.state !== "ready-to-activate") {
|
|
71
74
|
const missing = assessment?.missingImplementationControlIds || [];
|
|
72
75
|
throw new Error(
|
|
73
76
|
missing.length
|
|
74
|
-
?
|
|
75
|
-
:
|
|
77
|
+
? `${resourceTitle} "${resourceId}" cannot be activated until linked Controls are implemented: ${missing.join(", ")}.`
|
|
78
|
+
: `${resourceTitle} "${resourceId}" is not ready for ${workflowScope === "engagement" ? "Step 5" : "Step 3"} activation.`
|
|
76
79
|
);
|
|
77
80
|
}
|
|
78
|
-
if (!/^[a-f0-9]{64}$/.test(expectedRevisions[
|
|
79
|
-
throw new Error(`
|
|
81
|
+
if (!/^[a-f0-9]{64}$/.test(expectedRevisions[resourceId] || "")) {
|
|
82
|
+
throw new Error(`Governed-content activation needs the current record revision for "${resourceId}". Regenerate the activation review and try again.`);
|
|
80
83
|
}
|
|
81
84
|
const record = {
|
|
82
85
|
...entry.record,
|
|
@@ -91,16 +94,18 @@ export async function planDocumentActivation(input = process.cwd(), options = {}
|
|
|
91
94
|
return record;
|
|
92
95
|
});
|
|
93
96
|
return {
|
|
94
|
-
operation: "document-activation",
|
|
97
|
+
operation: governedContentOperation ? "governed-content-activation" : "document-activation",
|
|
95
98
|
workflowScope,
|
|
96
99
|
...(options.auditId ? { auditId: options.auditId } : {}),
|
|
97
|
-
|
|
100
|
+
resourceIds,
|
|
101
|
+
documentIds: update.filter(({ type }) => type === "document").map(({ id }) => id),
|
|
102
|
+
trainingIds: update.filter(({ type }) => type === "training").map(({ id }) => id),
|
|
98
103
|
activatedByIds,
|
|
99
104
|
activatedOn,
|
|
100
105
|
effectiveOn,
|
|
101
106
|
changes: {
|
|
102
107
|
update,
|
|
103
|
-
expectedRevisions: Object.fromEntries(
|
|
108
|
+
expectedRevisions: Object.fromEntries(resourceIds.map((resourceId) => [resourceId, expectedRevisions[resourceId]])),
|
|
104
109
|
validateWholeWorkspace: true
|
|
105
110
|
}
|
|
106
111
|
};
|
|
@@ -111,21 +116,52 @@ async function activationCandidates(loaded, options) {
|
|
|
111
116
|
return (await assessAuditDocumentActivations(loaded, {
|
|
112
117
|
auditId: options.auditId,
|
|
113
118
|
asOf: currentCalendarDate(loaded.workspace.timezone)
|
|
114
|
-
})).filter(({ state }) => state === "ready-to-activate")
|
|
119
|
+
})).filter(({ state }) => state === "ready-to-activate")
|
|
120
|
+
.map((item) => ({ ...item, resourceType: "document", resourceId: item.documentId }));
|
|
115
121
|
}
|
|
116
122
|
const readiness = await assessProgramReadiness(loaded, { programId: options.programId });
|
|
117
|
-
return
|
|
123
|
+
return [
|
|
124
|
+
...readiness.documentActivations.map((item) => ({ ...item, resourceType: "document", resourceId: item.documentId })),
|
|
125
|
+
...(!options.documentsOnly ? (readiness.trainingActivations || []).map((item) => ({ ...item, resourceType: "training", resourceId: item.trainingId })) : [])
|
|
126
|
+
].filter(({ state }) => state === "ready-to-activate");
|
|
118
127
|
}
|
|
119
128
|
|
|
120
129
|
export async function activateDocuments(input = process.cwd(), options = {}) {
|
|
121
130
|
if (options.confirmed !== true) throw new Error("Review the governed Document activation and confirm the write.");
|
|
122
131
|
return serializeWorkspaceMutation(input, async (root) => {
|
|
123
132
|
const plan = await planDocumentActivation(root, options);
|
|
124
|
-
const result =
|
|
133
|
+
const result = plan.operation === "governed-content-activation"
|
|
134
|
+
? await applyGovernedContentActivationBatch(root, plan.changes)
|
|
135
|
+
: await applyDocumentActivationBatch(root, plan.changes);
|
|
125
136
|
return { ...plan, result };
|
|
126
137
|
});
|
|
127
138
|
}
|
|
128
139
|
|
|
140
|
+
export async function scaffoldGovernedContentActivation(input = process.cwd(), options = {}) {
|
|
141
|
+
const loaded = await loadWorkspace(input);
|
|
142
|
+
requireDocumentLifecycle(loaded);
|
|
143
|
+
if (!modelSupports(loaded.model, "governed-training-activation")) {
|
|
144
|
+
throw new Error("Unified governed-content activation requires a model v6 workspace.");
|
|
145
|
+
}
|
|
146
|
+
const candidates = await activationCandidates(loaded, { ...options, auditId: undefined });
|
|
147
|
+
const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
|
|
148
|
+
const today = currentCalendarDate(loaded.workspace.timezone);
|
|
149
|
+
return {
|
|
150
|
+
resourceIds: candidates.map(({ resourceId }) => resourceId),
|
|
151
|
+
documentIds: candidates.filter(({ resourceType }) => resourceType === "document").map(({ resourceId }) => resourceId),
|
|
152
|
+
trainingIds: candidates.filter(({ resourceType }) => resourceType === "training").map(({ resourceId }) => resourceId),
|
|
153
|
+
workflowScope: "program",
|
|
154
|
+
activatedByIds: [],
|
|
155
|
+
activatedOn: today,
|
|
156
|
+
effectiveOn: today,
|
|
157
|
+
expectedRevisions: Object.fromEntries(candidates.map(({ resourceId }) => [resourceId, revisionById.get(resourceId)])),
|
|
158
|
+
confirmed: false
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const planGovernedContentActivation = planDocumentActivation;
|
|
163
|
+
export const activateGovernedContent = activateDocuments;
|
|
164
|
+
|
|
129
165
|
function requireDocumentLifecycle(loaded) {
|
|
130
166
|
if (!modelSupports(loaded.model, "governed-document-activation")) {
|
|
131
167
|
throw new Error("Separate governed Document approval and activation requires a model v5 workspace.");
|
package/src/files.js
CHANGED
|
@@ -211,6 +211,12 @@ export async function applyDocumentActivationBatch(input, changes) {
|
|
|
211
211
|
));
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
export async function applyGovernedContentActivationBatch(input, changes) {
|
|
215
|
+
return serializeWorkspaceMutation(input, (root) => (
|
|
216
|
+
applyResourceBatchUnlocked(root, changes, "governed-content-activation")
|
|
217
|
+
));
|
|
218
|
+
}
|
|
219
|
+
|
|
214
220
|
export async function applyModelMigrationBatch(input, changes) {
|
|
215
221
|
return serializeWorkspaceMutation(input, (root) => (
|
|
216
222
|
applyResourceBatchUnlocked(root, changes, "model-migration")
|
|
@@ -270,10 +276,10 @@ async function applyResourceBatchUnlocked(input, changes = {}, lifecycleOperatio
|
|
|
270
276
|
throw new Error("The model-migration lifecycle operation requires a cross-model resource batch.");
|
|
271
277
|
}
|
|
272
278
|
if (
|
|
273
|
-
|
|
279
|
+
["document-activation", "governed-content-activation"].includes(lifecycleOperation)
|
|
274
280
|
&& (targetModelVersion || changes.validateWholeWorkspace !== true)
|
|
275
281
|
) {
|
|
276
|
-
throw new Error("The
|
|
282
|
+
throw new Error("The governed-content activation lifecycle operation requires same-model whole-workspace validation.");
|
|
277
283
|
}
|
|
278
284
|
const writeModel = targetModelVersion ? loadModel(targetModelVersion) : loaded.model;
|
|
279
285
|
const deferValidation = workspaceValidationDeferred();
|
|
@@ -347,14 +353,14 @@ async function applyResourceBatchUnlocked(input, changes = {}, lifecycleOperatio
|
|
|
347
353
|
const nextRecord = hasContentUpdate
|
|
348
354
|
? await prepareApprovalBinding(loaded, record, recordContentWrites, existing.record)
|
|
349
355
|
: record;
|
|
350
|
-
|
|
356
|
+
assertGovernedContentLifecycleMutation(existing.record, nextRecord, loaded.model, lifecycleOperation);
|
|
351
357
|
writes.push({ operation: path === previousPath ? "update" : "move-update", path, previousPath, record: nextRecord, previous, fileMode: mode });
|
|
352
358
|
}
|
|
353
359
|
for (const resourceId of Object.keys(contentUpdates)) {
|
|
354
360
|
if (preparedContentIds.has(resourceId)) continue;
|
|
355
361
|
const existing = existingById.get(resourceId);
|
|
356
362
|
if (!existing) throw new Error(`Resource "${resourceId}" was not found.`);
|
|
357
|
-
if (approvalBound(existing.record)) {
|
|
363
|
+
if (approvalBound(existing.record, loaded.model)) {
|
|
358
364
|
throw new Error(`Batch content for approved or active resource "${resourceId}" needs a matching resource update and validation of its approval binding.`);
|
|
359
365
|
}
|
|
360
366
|
contentWrites.push(...await prepareContentWrites(loaded, existing.record, contentUpdates[resourceId], {
|
|
@@ -607,7 +613,7 @@ async function updateResourceUnlocked(input, type, id, record, options) {
|
|
|
607
613
|
});
|
|
608
614
|
const existing = loaded.entries.find(({ record: candidate }) => candidate.id === id)?.record;
|
|
609
615
|
const nextRecord = await prepareApprovalBinding(loaded, record, contentWrites, existing);
|
|
610
|
-
|
|
616
|
+
assertGovernedContentLifecycleMutation(existing, nextRecord, loaded.model, null);
|
|
611
617
|
try {
|
|
612
618
|
for (const item of contentWrites) await writeTextAtomic(item.path, item.source);
|
|
613
619
|
await writeAtomic(path, nextRecord);
|
|
@@ -820,13 +826,16 @@ async function prepareApprovalBinding(loaded, record, contentWrites, previousRec
|
|
|
820
826
|
const bindingFields = contentBindingFields(record, loaded.model);
|
|
821
827
|
if (!bindingFields.length) return record;
|
|
822
828
|
if (
|
|
823
|
-
|
|
824
|
-
|
|
829
|
+
(
|
|
830
|
+
record.type === "document" && modelSupports(loaded.model, "governed-document-activation")
|
|
831
|
+
|| record.type === "training" && modelSupports(loaded.model, "governed-training-activation")
|
|
832
|
+
)
|
|
825
833
|
&& record.status === "active"
|
|
826
834
|
&& previousRecord?.status !== "active"
|
|
827
835
|
) {
|
|
828
|
-
const step = documentIsAuditSpecific(record, loaded.model) ? "Step 5" : "Step 3";
|
|
829
|
-
|
|
836
|
+
const step = record.type === "document" && documentIsAuditSpecific(record, loaded.model) ? "Step 5" : "Step 3";
|
|
837
|
+
const title = getResourceDefinition(loaded.model, record.type).title;
|
|
838
|
+
throw new Error(`${title} "${record.id}" must use the dedicated ${step} ${title} activation operation after approval.`);
|
|
830
839
|
}
|
|
831
840
|
const nextRecord = structuredClone(record);
|
|
832
841
|
const proposed = new Map(contentWrites.map((item) => [item.dataRelativePath, item.source]));
|
|
@@ -861,14 +870,19 @@ async function prepareApprovalBinding(loaded, record, contentWrites, previousRec
|
|
|
861
870
|
return nextRecord;
|
|
862
871
|
}
|
|
863
872
|
|
|
864
|
-
function
|
|
873
|
+
function assertGovernedContentLifecycleMutation(previousRecord, nextRecord, model, lifecycleOperation) {
|
|
865
874
|
if (lifecycleOperation === "model-migration") return;
|
|
875
|
+
const governedTraining = previousRecord?.type === "training"
|
|
876
|
+
&& nextRecord?.type === "training"
|
|
877
|
+
&& modelSupports(model, "governed-training-activation");
|
|
878
|
+
const governedDocument = previousRecord?.type === "document"
|
|
879
|
+
&& nextRecord?.type === "document"
|
|
880
|
+
&& modelSupports(model, "governed-document-activation");
|
|
866
881
|
if (
|
|
867
882
|
!previousRecord
|
|
868
|
-
||
|
|
869
|
-
|| nextRecord?.type !== "document"
|
|
870
|
-
|| !modelSupports(model, "governed-document-activation")
|
|
883
|
+
|| (!governedDocument && !governedTraining)
|
|
871
884
|
) return;
|
|
885
|
+
const title = getResourceDefinition(model, nextRecord.type).title;
|
|
872
886
|
const approvedStatuses = new Set(["approved", "active", "superseded", "retired"]);
|
|
873
887
|
const activatedStatuses = new Set(["active", "superseded", "retired"]);
|
|
874
888
|
if (approvedStatuses.has(previousRecord.status) && approvedStatuses.has(nextRecord.status)) {
|
|
@@ -876,7 +890,7 @@ function assertDocumentLifecycleMutation(previousRecord, nextRecord, model, life
|
|
|
876
890
|
"approverIds",
|
|
877
891
|
"approvedOn",
|
|
878
892
|
"approvedContentRevisions"
|
|
879
|
-
], "approval");
|
|
893
|
+
], "approval", title);
|
|
880
894
|
}
|
|
881
895
|
if (activatedStatuses.has(previousRecord.status) && activatedStatuses.has(nextRecord.status)) {
|
|
882
896
|
assertLifecycleFieldsUnchanged(previousRecord, nextRecord, [
|
|
@@ -885,26 +899,26 @@ function assertDocumentLifecycleMutation(previousRecord, nextRecord, model, life
|
|
|
885
899
|
"activatedOn",
|
|
886
900
|
"activatedContentRevisions",
|
|
887
901
|
"effectiveOn"
|
|
888
|
-
], "activation");
|
|
902
|
+
], "activation", title);
|
|
889
903
|
}
|
|
890
904
|
if (
|
|
891
905
|
nextRecord.status === "active"
|
|
892
906
|
&& previousRecord.status !== "active"
|
|
893
|
-
&&
|
|
907
|
+
&& !["document-activation", "governed-content-activation"].includes(lifecycleOperation)
|
|
894
908
|
) {
|
|
895
|
-
const step = documentIsAuditSpecific(nextRecord, model) ? "Step 5" : "Step 3";
|
|
896
|
-
throw new Error(
|
|
909
|
+
const step = governedDocument && documentIsAuditSpecific(nextRecord, model) ? "Step 5" : "Step 3";
|
|
910
|
+
throw new Error(`${title} "${nextRecord.id}" must use the dedicated ${step} ${title} activation operation after approval.`);
|
|
897
911
|
}
|
|
898
912
|
}
|
|
899
913
|
|
|
900
|
-
function assertLifecycleFieldsUnchanged(previousRecord, nextRecord, fields, eventLabel) {
|
|
914
|
+
function assertLifecycleFieldsUnchanged(previousRecord, nextRecord, fields, eventLabel, resourceTitle) {
|
|
901
915
|
const changed = fields.filter((field) => (
|
|
902
916
|
JSON.stringify(previousRecord[field] ?? null) !== JSON.stringify(nextRecord[field] ?? null)
|
|
903
917
|
));
|
|
904
918
|
if (!changed.length) return;
|
|
905
919
|
throw new Error(
|
|
906
|
-
|
|
907
|
-
+ `Move the
|
|
920
|
+
`${resourceTitle} "${nextRecord.id}" ${eventLabel} facts are immutable after the event: ${changed.join(", ")}. `
|
|
921
|
+
+ `Move the ${resourceTitle} back to ${eventLabel === "approval" ? "draft" : "approved"} and record a new lifecycle event.`
|
|
908
922
|
);
|
|
909
923
|
}
|
|
910
924
|
|
|
@@ -940,13 +954,15 @@ async function prepareAttestationBinding(loaded, record, previousRecord = null)
|
|
|
940
954
|
return nextRecord;
|
|
941
955
|
}
|
|
942
956
|
|
|
943
|
-
function approvalBound(record) {
|
|
957
|
+
function approvalBound(record, model) {
|
|
944
958
|
if (!record || !["policy", "document", "training"].includes(record.type)) return false;
|
|
945
959
|
const statuses = record.type === "policy"
|
|
946
960
|
? ["approved", "active", "superseded", "retired"]
|
|
947
961
|
: record.type === "document"
|
|
948
962
|
? ["approved", "active", "superseded", "retired"]
|
|
949
|
-
:
|
|
963
|
+
: modelSupports(model || 0, "governed-training-activation")
|
|
964
|
+
? ["approved", "active", "superseded", "retired"]
|
|
965
|
+
: ["active", "retired"];
|
|
950
966
|
return statuses.includes(record.status);
|
|
951
967
|
}
|
|
952
968
|
|
|
@@ -955,7 +971,7 @@ function contentBindingFields(record, model) {
|
|
|
955
971
|
if (["policy", "document"].includes(record?.type)) {
|
|
956
972
|
fields.push({
|
|
957
973
|
field: "approvedContentRevisions",
|
|
958
|
-
bound: approvalBound,
|
|
974
|
+
bound: (candidate) => approvalBound(candidate, model),
|
|
959
975
|
label: record.type === "policy" ? "approve or activate" : "approve"
|
|
960
976
|
});
|
|
961
977
|
}
|
|
@@ -970,7 +986,20 @@ function contentBindingFields(record, model) {
|
|
|
970
986
|
});
|
|
971
987
|
}
|
|
972
988
|
if (record?.type === "training" && model.resources.training?.fields?.effectiveContentRevisions) {
|
|
973
|
-
fields.push({ field: "effectiveContentRevisions", bound: approvalBound, label: "approve or activate" });
|
|
989
|
+
fields.push({ field: "effectiveContentRevisions", bound: (candidate) => approvalBound(candidate, model), label: "approve or activate" });
|
|
990
|
+
}
|
|
991
|
+
if (record?.type === "training" && model.resources.training?.fields?.approvedContentRevisions) {
|
|
992
|
+
fields.push({ field: "approvedContentRevisions", bound: (candidate) => approvalBound(candidate, model), label: "approve" });
|
|
993
|
+
}
|
|
994
|
+
if (record?.type === "training" && model.resources.training?.fields?.activatedContentRevisions) {
|
|
995
|
+
fields.push({
|
|
996
|
+
field: "activatedContentRevisions",
|
|
997
|
+
bound: (candidate) => (
|
|
998
|
+
["active", "superseded", "retired"].includes(candidate?.status)
|
|
999
|
+
&& candidate.activationBasis === "recorded"
|
|
1000
|
+
),
|
|
1001
|
+
label: "activate"
|
|
1002
|
+
});
|
|
974
1003
|
}
|
|
975
1004
|
return fields;
|
|
976
1005
|
}
|
package/src/index.js
CHANGED
|
@@ -54,7 +54,14 @@ export { generateModelDocumentation } from "./model-docs.js";
|
|
|
54
54
|
export { renderMarkdown } from "./markdown.js";
|
|
55
55
|
export { migrateModel, planModelMigration } from "./model-migration.js";
|
|
56
56
|
export { activatePolicies, planPolicyActivation, scaffoldPolicyActivation } from "./policy-activation.js";
|
|
57
|
-
export {
|
|
57
|
+
export {
|
|
58
|
+
activateDocuments,
|
|
59
|
+
activateGovernedContent,
|
|
60
|
+
planDocumentActivation,
|
|
61
|
+
planGovernedContentActivation,
|
|
62
|
+
scaffoldDocumentActivation,
|
|
63
|
+
scaffoldGovernedContentActivation
|
|
64
|
+
} from "./document-activation.js";
|
|
58
65
|
export {
|
|
59
66
|
applyPolicyLibraryUpgrade,
|
|
60
67
|
assessPolicyLibraryUpgrades,
|