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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
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 { activateDocuments, planDocumentActivation, scaffoldDocumentActivation } from "./document-activation.js";
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 (!["2", "3", "4", "5"].includes(targetModel)) throw new Error("Pass --to-model 2, --to-model 3, --to-model 4, or --to-model 5.");
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. v3 migration
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 governed plans and schedules in Step 3 after their linked
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 5 --preview --json",
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(({ documentId }) => documentId),
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(({ documentId }) => [documentId, revisionById.get(documentId)])),
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 documentIds = [...new Set((options.documentIds || []).map(String))];
31
- if (!documentIds.length) throw new Error("Document activation needs at least one approved governed Document.");
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("Document activation expected revisions must be keyed by Document ID.");
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.documentId, 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 = documentIds.map((documentId) => {
61
- const entry = entryById.get(documentId);
62
- if (!entry || entry.record.type !== "document") throw new Error(`Document "${documentId}" was not found.`);
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(`Document "${documentId}" must be independently approved before its separate ${workflowScope === "engagement" ? "Step 5" : "Step 3"} activation.`);
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 "${documentId}" belongs to the ${entry.record.workflowScope} workflow, not ${workflowScope}.`);
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(documentId);
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
- ? `Document "${documentId}" cannot be activated until linked Controls are implemented: ${missing.join(", ")}.`
75
- : `Document "${documentId}" is not ready for ${workflowScope === "engagement" ? "Step 5" : "Step 3"} activation.`
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[documentId] || "")) {
79
- throw new Error(`Document activation needs the current record revision for "${documentId}". Regenerate the activation review and try again.`);
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
- documentIds,
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(documentIds.map((documentId) => [documentId, expectedRevisions[documentId]])),
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 readiness.documentActivations.filter(({ state }) => state === "ready-to-activate");
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 = await applyDocumentActivationBatch(root, plan.changes);
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
- lifecycleOperation === "document-activation"
279
+ ["document-activation", "governed-content-activation"].includes(lifecycleOperation)
274
280
  && (targetModelVersion || changes.validateWholeWorkspace !== true)
275
281
  ) {
276
- throw new Error("The document-activation lifecycle operation requires same-model whole-workspace validation.");
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
- assertDocumentLifecycleMutation(existing.record, nextRecord, loaded.model, lifecycleOperation);
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
- assertDocumentLifecycleMutation(existing, nextRecord, loaded.model, null);
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
- record.type === "document"
824
- && modelSupports(loaded.model, "governed-document-activation")
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
- throw new Error(`Document "${record.id}" must use the dedicated ${step} Document activation operation after approval.`);
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 assertDocumentLifecycleMutation(previousRecord, nextRecord, model, lifecycleOperation) {
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
- || previousRecord.type !== "document"
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
- && lifecycleOperation !== "document-activation"
907
+ && !["document-activation", "governed-content-activation"].includes(lifecycleOperation)
894
908
  ) {
895
- const step = documentIsAuditSpecific(nextRecord, model) ? "Step 5" : "Step 3";
896
- throw new Error(`Document "${nextRecord.id}" must use the dedicated ${step} Document activation operation after approval.`);
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
- `Document "${nextRecord.id}" ${eventLabel} facts are immutable after the event: ${changed.join(", ")}. `
907
- + `Move the Document back to ${eventLabel === "approval" ? "draft" : "approved"} and record a new lifecycle event.`
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
- : ["active", "retired"];
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 { activateDocuments, planDocumentActivation, scaffoldDocumentActivation } from "./document-activation.js";
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,