filegrc 0.7.1 → 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/src/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
  import { createInterface } from "node:readline/promises";
4
- import { loadModel } from "../model/index.js";
4
+ import { ACTIVE_MODEL_VERSION, loadModel, SUPPORTED_MODEL_VERSIONS } from "../model/index.js";
5
5
  import { buildAgentGuide, findResourceReferences, listResourceTypes, scaffoldResourceMutation } from "./agent.js";
6
6
  import { assessAuditPreparation, prepareAuditWorkspace } from "./audit-preparation.js";
7
7
  import { createNextAuditCycle, planNextAuditCycle } from "./audit-transition.js";
@@ -16,6 +16,14 @@ import {
16
16
  scaffoldCollectionReview
17
17
  } from "./collection-review.js";
18
18
  import { buildWorkspace } from "./build.js";
19
+ import {
20
+ activateDocuments,
21
+ activateGovernedContent,
22
+ planDocumentActivation,
23
+ planGovernedContentActivation,
24
+ scaffoldDocumentActivation,
25
+ scaffoldGovernedContentActivation
26
+ } from "./document-activation.js";
19
27
  import { generateEvidencePacket, prepareEvidencePacket } from "./evidence-packet.js";
20
28
  import {
21
29
  addEvidenceAttachment,
@@ -183,15 +191,18 @@ export async function runCli(argv = process.argv.slice(2)) {
183
191
  }
184
192
  if (command === "migrate") {
185
193
  const targetModel = String(flags["to-model"] || "");
186
- if (!["2", "3", "4"].includes(targetModel)) throw new Error("Pass --to-model 2, --to-model 3, or --to-model 4.");
187
- const systemDecisions = flags.decisions
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
+ }
197
+ const migrationDecisions = flags.decisions
188
198
  ? JSON.parse(await readFile(resolve(String(flags.decisions)), "utf8"))
189
199
  : undefined;
190
200
  const options = {
191
201
  jobTitle: flags["job-title"],
192
202
  startsOn: flags["starts-on"],
193
203
  targetModelVersion: targetModel,
194
- systemDecisions: systemDecisions?.systemDecisions || systemDecisions
204
+ systemDecisions: migrationDecisions?.systemDecisions || (targetModel === "4" ? migrationDecisions : undefined),
205
+ documentScopes: migrationDecisions?.documentScopes || (targetModel === "5" ? migrationDecisions : undefined)
195
206
  };
196
207
  const plan = await planModelMigration(root, options);
197
208
  if (!flags.preview && plan.sourceModelVersion !== plan.targetModelVersion && !flags.yes) {
@@ -617,6 +628,55 @@ export async function runCli(argv = process.argv.slice(2)) {
617
628
  else console.log(`Activated ${result.policyIds.length} Policies effective ${result.effectiveOn}.`);
618
629
  return result;
619
630
  }
631
+ if (command === "activate-documents") {
632
+ if (flags.scaffold) {
633
+ const result = await scaffoldDocumentActivation(root, { programId: flags.program, auditId: flags.audit });
634
+ console.log(JSON.stringify(result, null, 2));
635
+ return result;
636
+ }
637
+ const payload = await readSetupPayload(positionals[0]);
638
+ const options = {
639
+ ...payload,
640
+ documentIds: flags.document ? String(flags.document).split(",").filter(Boolean) : payload.documentIds,
641
+ activatedByIds: flags["activated-by"] ? String(flags["activated-by"]).split(",").filter(Boolean) : payload.activatedByIds,
642
+ activatedOn: flags["activated-on"] || payload.activatedOn,
643
+ effectiveOn: flags["effective-on"] || payload.effectiveOn,
644
+ programId: flags.program || payload.programId,
645
+ auditId: flags.audit || payload.auditId,
646
+ confirmed: flags.yes === true
647
+ };
648
+ const result = flags.preview
649
+ ? await planDocumentActivation(root, options)
650
+ : await withWorkflowDelta(root, () => activateDocuments(root, options));
651
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
652
+ else if (flags.preview) console.log(`Document activation preview: ${result.documentIds.length} governed Documents effective ${result.effectiveOn}.`);
653
+ else console.log(`Activated ${result.documentIds.length} governed Documents effective ${result.effectiveOn}.`);
654
+ return result;
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
+ }
620
680
  if (command === "policy-library") {
621
681
  if (flags.yes && !flags.accept) {
622
682
  throw new Error("Pass --accept <proposal-id> with --yes after reviewing the policy-library diff.");
@@ -1055,7 +1115,7 @@ Usage:
1055
1115
  filegrc build [root] [--output .filegrc/site]
1056
1116
  filegrc validate [root] [--json]
1057
1117
  filegrc model [--json|--write-docs|--check-docs]
1058
- filegrc migrate --to-model <2|3|4> [--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]
1059
1119
  filegrc describe <resource-type>
1060
1120
  filegrc types [--json]
1061
1121
  filegrc guide [resource-type] [--id resource-id] [--program program-id] [--json]
@@ -1078,6 +1138,8 @@ Usage:
1078
1138
  filegrc review-applicability [--scaffold --type requirement|control|commitment|complementary-control] [decisions.json|-] [--preview|--yes] [--json]
1079
1139
  filegrc review-collection <resource-type> [--scaffold | review.json|-] [--preview|--yes] [--json]
1080
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]
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]
1081
1143
  filegrc policy-library [--json | --accept proposal-id --proposal-revision revision --yes]
1082
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]
1083
1145
  filegrc evidence-packet [--audit audit-id] [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--output .filegrc/path] [--preview] [--require-ready] [--json]
@@ -1145,19 +1207,21 @@ Options:
1145
1207
  }
1146
1208
  if (command === "migrate") {
1147
1209
  console.log(`Usage:
1148
- filegrc migrate --to-model <2|3|4> [options]
1210
+ filegrc migrate --to-model <${SUPPORTED_MODEL_VERSIONS.join("|")}> [options]
1149
1211
 
1150
1212
  Upgrade a workspace through an explicit, reviewable model boundary. Model v1
1151
- workspaces migrate to v2 first. Model v2 workspaces migrate to v3, then v4. v4 separates
1213
+ workspaces migrate to v2 first. Continue one version at a time through v${ACTIVE_MODEL_VERSION}. v4 separates
1152
1214
  the repository Workspace, management Program, bounded Systems, operational Components,
1153
- specific Assets, Vendors, normalized information, and Evidence Artifacts. v3 migration
1215
+ specific Assets, Vendors, normalized information, and Evidence Artifacts. Model v5 separates
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
1154
1218
  previews may require a decisions JSON file for ambiguous old Systems. v3 still creates planned
1155
1219
  core Appointments, removal of obsolete manual page state, classified review work,
1156
1220
  and dataModelVersion changed last. The command writes no Git commit.
1157
1221
 
1158
1222
  Options:
1159
- --to-model <version> Required target model; migrations must run in order through 2, 3, and 4
1160
- --decisions <path> v4 JSON object keyed by old System ID with system/component decisions
1223
+ --to-model <version> Required target model; migrations must run in order through ${SUPPORTED_MODEL_VERSIONS.join(", ")}
1224
+ --decisions <path> JSON systemDecisions for v4 or documentScopes for ambiguous v5 Documents
1161
1225
  --preview Show the complete atomic record plan without writing
1162
1226
  --job-title <title> Actual job title for the former Policy Owner seed person
1163
1227
  --starts-on <date> Effective date of a new Policy Owner Appointment
@@ -1167,7 +1231,7 @@ Options:
1167
1231
  --help Show this help
1168
1232
 
1169
1233
  Start with:
1170
- npx filegrc migrate --to-model 4 --preview --json`);
1234
+ npx filegrc migrate --to-model ${ACTIVE_MODEL_VERSION} --preview --json`);
1171
1235
  return;
1172
1236
  }
1173
1237
  if (command === "program-readiness") {
@@ -1248,6 +1312,54 @@ Options:
1248
1312
  --help Show this help`);
1249
1313
  return;
1250
1314
  }
1315
+ if (command === "activate-documents") {
1316
+ console.log(`Usage:
1317
+ filegrc activate-documents --scaffold [--program id | --audit id]
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]
1319
+
1320
+ Activate required program Documents in Step 3 after their linked
1321
+ Controls are implemented, or activate engagement Documents in Step 5 after
1322
+ their audit-specific facts are complete. Approval, activation, and effective
1323
+ dates remain separate, and activation binds its own exact Markdown revision.
1324
+
1325
+ Options:
1326
+ --scaffold Print the ready activation payload without writing
1327
+ --program <id> Program to assess when more than one active Program exists
1328
+ --audit <id> Audit whose engagement Documents should be activated in Step 5
1329
+ --activated-by <id> Active Person who performs the activation
1330
+ --activated-on <date> Actual activation date, which must be today
1331
+ --effective-on <date> Effective date on or after activation
1332
+ --preview Validate and show the atomic updates without writing
1333
+ --yes Confirm and apply the reviewed activation
1334
+ --json Print the result as JSON
1335
+ --root <path> Workspace path
1336
+ --help Show this help`);
1337
+ return;
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
+ }
1251
1363
  if (command === "policy-library") {
1252
1364
  console.log(`Usage:
1253
1365
  filegrc policy-library [--json]
@@ -1294,7 +1406,7 @@ function agentOverview(model) {
1294
1406
  build: "filegrc build [root]",
1295
1407
  validate: "filegrc validate [root] --json",
1296
1408
  model: "filegrc model --json",
1297
- migrate: "filegrc migrate --to-model 4 --preview --json",
1409
+ migrate: "filegrc migrate --to-model 6 --preview --json",
1298
1410
  describe: "filegrc describe <resource-type>",
1299
1411
  types: "filegrc types --json",
1300
1412
  guide: "filegrc guide [resource-type] --json",
@@ -1313,6 +1425,8 @@ function agentOverview(model) {
1313
1425
  reconcile: "filegrc reconcile --preview --json",
1314
1426
  externalReviewerSetup: "filegrc external-reviewer-setup [--scaffold | <reviewer.json|-> --preview] --json",
1315
1427
  policyActivation: "filegrc activate-policies [--scaffold | <activation.json|-> --preview] --json",
1428
+ documentActivation: "filegrc activate-documents [--scaffold | <activation.json|-> --preview] --json",
1429
+ governedContentActivation: "filegrc activate-content [--scaffold | <activation.json|-> --preview] --json",
1316
1430
  nextAuditCycle: "filegrc next-audit-cycle <prior-audit-id> --start <date> --end <date> --preview --json",
1317
1431
  reviewApplicability: "filegrc review-applicability <decisions.json|-> --preview --json",
1318
1432
  reviewCollection: "filegrc review-collection <resource-type> [--scaffold | <review.json|-> --preview] --json",
@@ -1465,6 +1579,8 @@ function buildProgramPathResult(model, readiness, auditReadiness) {
1465
1579
  evidenceReady: readiness.evidenceReady,
1466
1580
  operating: readiness.operating,
1467
1581
  policyActivations: readiness.policyActivations,
1582
+ documentActivations: readiness.documentActivations,
1583
+ trainingActivations: readiness.trainingActivations,
1468
1584
  policyLibraryProposals: readiness.policyLibraryProposals,
1469
1585
  stages
1470
1586
  };
@@ -1668,6 +1784,21 @@ function summarizeProgramReadiness(result) {
1668
1784
  label,
1669
1785
  gapCount
1670
1786
  })),
1787
+ documentActivations: result.documentActivations.map(({ documentId, title, state, label, gapCount }) => ({
1788
+ documentId,
1789
+ title,
1790
+ state,
1791
+ label,
1792
+ gapCount
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
+ })),
1671
1802
  policyLibraryProposals: result.policyLibraryProposals,
1672
1803
  unresolvedOwnership: {
1673
1804
  count: unresolvedOwnership.length,
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { modelSupports } from "../model/index.js";
2
3
  import { collectionRevision } from "./collection-revision.js";
3
4
  import { scopedCollectionRecords } from "./collection-scope.js";
4
5
  import { applyResourceBatch } from "./files.js";
@@ -26,7 +27,7 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
26
27
  record.type === "collection-review"
27
28
  && record.resourceType === resourceType
28
29
  && record.status !== "retired"
29
- && (String(loaded.model.modelVersion) !== "4" || (record.scopeResourceIds || []).includes(program.id))
30
+ && (!modelSupports(loaded.model, "program-scope") || (record.scopeResourceIds || []).includes(program.id))
30
31
  ));
31
32
  const review = reviewEntry?.record || null;
32
33
  const authoritativeSourceId = review?.decision === "externally-managed"
@@ -86,7 +87,7 @@ export async function scaffoldCollectionReview(input = process.cwd(), options =
86
87
  rationale: null,
87
88
  reviewedByIds: [],
88
89
  reviewedOn: null,
89
- ...(String(loaded.model.modelVersion) === "4"
90
+ ...(modelSupports(loaded.model, "program-scope")
90
91
  ? { authoritativeComponentId: null }
91
92
  : { authoritativeSystemId: null })
92
93
  };
@@ -103,7 +104,7 @@ export async function planCollectionReview(input = process.cwd(), options = {})
103
104
  const reviewedByIds = [...new Set((options.reviewedByIds || []).map(String).filter(Boolean))];
104
105
  const reviewedOn = String(options.reviewedOn || "").trim();
105
106
  const scopeRevision = String(options.scopeRevision || getGitSummary(loaded.root).commit || "uncommitted").trim();
106
- const v4 = String(loaded.model.modelVersion) === "4";
107
+ const v4 = modelSupports(loaded.model, "program-scope");
107
108
  const authoritativeSourceId = String(v4 ? options.authoritativeComponentId : options.authoritativeSystemId || "").trim();
108
109
  if (!(configuration.decisions || ["complete"]).includes(decision)) {
109
110
  throw new Error(
@@ -1,7 +1,8 @@
1
+ import { modelSupports } from "../model/index.js";
1
2
  import { programComponents, selectedRequirementIds } from "./program.js";
2
3
 
3
4
  export function scopedCollectionRecords(loaded, resourceType, program) {
4
- if (String(loaded.model.modelVersion) !== "4") {
5
+ if (!modelSupports(loaded.model, "program-scope")) {
5
6
  return loaded.resources.filter((record) => record.type === resourceType);
6
7
  }
7
8
  if (resourceType === "vendor") {
@@ -37,7 +38,7 @@ export function scopedCollectionRecords(loaded, resourceType, program) {
37
38
 
38
39
  export function collectionRevisionInputs(loaded, resourceType, program) {
39
40
  const reviewed = scopedCollectionRecords(loaded, resourceType, program);
40
- if (String(loaded.model.modelVersion) !== "4") {
41
+ if (!modelSupports(loaded.model, "program-scope")) {
41
42
  return reviewed.map((record) => ({ record, value: record }));
42
43
  }
43
44
  const byId = new Map(loaded.resources.map((record) => [record.id, record]));
@@ -94,7 +95,7 @@ export function collectionRevisionInputs(loaded, resourceType, program) {
94
95
  }
95
96
 
96
97
  export function collectionScopeRevisionFacts(loaded, resourceType, program) {
97
- if (String(loaded.model.modelVersion) !== "4") {
98
+ if (!modelSupports(loaded.model, "program-scope")) {
98
99
  const common = { programId: program?.id ?? null };
99
100
  if (resourceType === "framework") {
100
101
  return {
@@ -0,0 +1,181 @@
1
+ import { applyDocumentActivationBatch, applyGovernedContentActivationBatch, contentRevision } from "./files.js";
2
+ import { serializeWorkspaceMutation } from "./mutation.js";
3
+ import { modelSupports } from "../model/index.js";
4
+ import { assessAuditDocumentActivations } from "./audit-preparation.js";
5
+ import { assessProgramReadiness } from "./program-readiness.js";
6
+ import { personWasActiveOn } from "./soc2.js";
7
+ import { currentCalendarDate } from "./time.js";
8
+ import { loadWorkspace } from "./workspace.js";
9
+
10
+ export async function scaffoldDocumentActivation(input = process.cwd(), options = {}) {
11
+ const loaded = await loadWorkspace(input);
12
+ requireDocumentLifecycle(loaded);
13
+ const candidates = await activationCandidates(loaded, { ...options, documentsOnly: true });
14
+ const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
15
+ const today = currentCalendarDate(loaded.workspace.timezone);
16
+ return {
17
+ documentIds: candidates.filter(({ resourceType }) => resourceType === "document").map(({ resourceId }) => resourceId),
18
+ ...(options.auditId ? { auditId: options.auditId, workflowScope: "engagement" } : { workflowScope: "program" }),
19
+ activatedByIds: [],
20
+ activatedOn: today,
21
+ effectiveOn: today,
22
+ expectedRevisions: Object.fromEntries(candidates.map(({ resourceId }) => [resourceId, revisionById.get(resourceId)])),
23
+ confirmed: false
24
+ };
25
+ }
26
+
27
+ export async function planDocumentActivation(input = process.cwd(), options = {}) {
28
+ const loaded = await loadWorkspace(input);
29
+ requireDocumentLifecycle(loaded);
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
+ const activatedByIds = [...new Set((options.activatedByIds || []).map(String))];
33
+ if (!activatedByIds.length) throw new Error("Document activation needs the Person who performed the activation.");
34
+ const activatedOn = String(options.activatedOn || "").trim();
35
+ const effectiveOn = String(options.effectiveOn || "").trim();
36
+ if (!isCalendarDate(activatedOn)) throw new Error("Document activation needs the actual activation date in YYYY-MM-DD format.");
37
+ if (!isCalendarDate(effectiveOn)) throw new Error("Document activation needs a real effective date in YYYY-MM-DD format.");
38
+ const today = currentCalendarDate(loaded.workspace.timezone);
39
+ if (activatedOn !== today) {
40
+ throw new Error(`The activation date records today's lifecycle event and must be ${today}. Keep a future date in proposedEffectiveOn until activation occurs.`);
41
+ }
42
+ if (effectiveOn < activatedOn) {
43
+ throw new Error("The effective date cannot be before the separate activation date; do not backdate adoption.");
44
+ }
45
+ const expectedRevisions = options.expectedRevisions || {};
46
+ if (Array.isArray(expectedRevisions) || !expectedRevisions || typeof expectedRevisions !== "object") {
47
+ throw new Error("Governed-content activation expected revisions must be keyed by resource ID.");
48
+ }
49
+ const workflowScope = options.auditId ? "engagement" : "program";
50
+ const governedContentOperation = workflowScope === "program" && options.resourceIds !== undefined;
51
+ const candidates = await activationCandidates(loaded, options);
52
+ const assessmentById = new Map(candidates.map((item) => [item.resourceId, item]));
53
+ const entryById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
54
+ const invalidActivatorIds = activatedByIds.filter((id) => {
55
+ const person = entryById.get(id)?.record;
56
+ return !personWasActiveOn(person, activatedOn);
57
+ });
58
+ if (invalidActivatorIds.length) {
59
+ throw new Error(`Document activation needs active People as activators: ${invalidActivatorIds.join(", ")}.`);
60
+ }
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;
66
+ if (entry.record.status !== "approved") {
67
+ throw new Error(`${resourceTitle} "${resourceId}" must be independently approved before its separate ${workflowScope === "engagement" ? "Step 5" : "Step 3"} activation.`);
68
+ }
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}.`);
71
+ }
72
+ const assessment = assessmentById.get(resourceId);
73
+ if (assessment?.state !== "ready-to-activate") {
74
+ const missing = assessment?.missingImplementationControlIds || [];
75
+ throw new Error(
76
+ missing.length
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.`
79
+ );
80
+ }
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.`);
83
+ }
84
+ const record = {
85
+ ...entry.record,
86
+ status: "active",
87
+ activationBasis: "recorded",
88
+ activatedByIds,
89
+ activatedOn,
90
+ effectiveOn,
91
+ activatedContentRevisions: structuredClone(entry.record.approvedContentRevisions)
92
+ };
93
+ delete record.proposedEffectiveOn;
94
+ return record;
95
+ });
96
+ return {
97
+ operation: governedContentOperation ? "governed-content-activation" : "document-activation",
98
+ workflowScope,
99
+ ...(options.auditId ? { auditId: options.auditId } : {}),
100
+ resourceIds,
101
+ documentIds: update.filter(({ type }) => type === "document").map(({ id }) => id),
102
+ trainingIds: update.filter(({ type }) => type === "training").map(({ id }) => id),
103
+ activatedByIds,
104
+ activatedOn,
105
+ effectiveOn,
106
+ changes: {
107
+ update,
108
+ expectedRevisions: Object.fromEntries(resourceIds.map((resourceId) => [resourceId, expectedRevisions[resourceId]])),
109
+ validateWholeWorkspace: true
110
+ }
111
+ };
112
+ }
113
+
114
+ async function activationCandidates(loaded, options) {
115
+ if (options.auditId) {
116
+ return (await assessAuditDocumentActivations(loaded, {
117
+ auditId: options.auditId,
118
+ asOf: currentCalendarDate(loaded.workspace.timezone)
119
+ })).filter(({ state }) => state === "ready-to-activate")
120
+ .map((item) => ({ ...item, resourceType: "document", resourceId: item.documentId }));
121
+ }
122
+ const readiness = await assessProgramReadiness(loaded, { programId: options.programId });
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");
127
+ }
128
+
129
+ export async function activateDocuments(input = process.cwd(), options = {}) {
130
+ if (options.confirmed !== true) throw new Error("Review the governed Document activation and confirm the write.");
131
+ return serializeWorkspaceMutation(input, async (root) => {
132
+ const plan = await planDocumentActivation(root, options);
133
+ const result = plan.operation === "governed-content-activation"
134
+ ? await applyGovernedContentActivationBatch(root, plan.changes)
135
+ : await applyDocumentActivationBatch(root, plan.changes);
136
+ return { ...plan, result };
137
+ });
138
+ }
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
+
165
+ function requireDocumentLifecycle(loaded) {
166
+ if (!modelSupports(loaded.model, "governed-document-activation")) {
167
+ throw new Error("Separate governed Document approval and activation requires a model v5 workspace.");
168
+ }
169
+ }
170
+
171
+ function isCalendarDate(value) {
172
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
173
+ if (!match) return false;
174
+ const [, year, month, day] = match.map(Number);
175
+ const date = new Date(0);
176
+ date.setUTCFullYear(year, month - 1, day);
177
+ date.setUTCHours(0, 0, 0, 0);
178
+ return date.getUTCFullYear() === year
179
+ && date.getUTCMonth() === month - 1
180
+ && date.getUTCDate() === day;
181
+ }