filegrc 0.7.0 → 0.8.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.
@@ -1,11 +1,12 @@
1
+ import { modelSupports } from "../model/index.js";
1
2
  import { createResource } from "./files.js";
2
3
  import { createResourceId } from "./id.js";
3
4
  import { loadWorkspace } from "./workspace.js";
4
5
 
5
6
  export async function planNextAuditCycle(input = process.cwd(), options = {}) {
6
7
  const loaded = await loadWorkspace(input);
7
- if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
8
- throw new Error("Audit-cycle carry-forward requires a model v3 or v4 workspace.");
8
+ if (!modelSupports(loaded.model, "guided-workflow")) {
9
+ throw new Error("Audit-cycle carry-forward requires a model v3 or newer workspace.");
9
10
  }
10
11
  const prior = loaded.resources.find((record) => (
11
12
  record.type === "audit" && record.id === options.priorAuditId
@@ -37,6 +38,8 @@ export async function planNextAuditCycle(input = process.cwd(), options = {}) {
37
38
  subserviceVendorIds: [...(prior.subserviceVendorIds || [])],
38
39
  ...(prior.subserviceMethod ? { subserviceMethod: prior.subserviceMethod } : {})
39
40
  }),
41
+ ...(prior.subserviceConclusion ? { subserviceConclusion: prior.subserviceConclusion } : {}),
42
+ ...(prior.subserviceConclusionRationale ? { subserviceConclusionRationale: prior.subserviceConclusionRationale } : {}),
40
43
  ...(prior.complementaryControlsConclusion
41
44
  ? { complementaryControlsConclusion: prior.complementaryControlsConclusion }
42
45
  : {}),
@@ -59,6 +62,9 @@ export async function planNextAuditCycle(input = process.cwd(), options = {}) {
59
62
  "requirementIds",
60
63
  "controlIds",
61
64
  "complementaryControlIds",
65
+ "subserviceConclusion",
66
+ "subserviceConclusionRationale",
67
+ "subserviceTreatments",
62
68
  "subserviceVendorIds",
63
69
  "subserviceMethod",
64
70
  "auditorVendorId",
@@ -1,11 +1,13 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
+ import { modelSupports } from "../model/index.js";
3
4
  import { applyResourceBatch } from "./files.js";
4
5
  import { getWorkspaceRevisionSnapshot } from "./git.js";
5
6
  import { serializeWorkspaceMutation } from "./mutation.js";
6
7
  import { resolveDataPath } from "./paths.js";
7
8
  import { resolveProgram } from "./program.js";
8
9
  import { markdownEntries } from "./resource-markdown.js";
10
+ import { soc2RequirementApplicabilityConstraint } from "./soc2.js";
9
11
  import { assessWorkflow, buildWorkflowDelta } from "./workflow.js";
10
12
  import { loadWorkspace } from "./workspace.js";
11
13
 
@@ -19,8 +21,8 @@ const REVIEWABLE_TYPES = new Set([
19
21
  export async function scaffoldApplicabilityReview(input = process.cwd(), options = {}) {
20
22
  const context = await applicabilityReviewContext(input);
21
23
  const { loaded } = context;
22
- if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
23
- throw new Error("Batch applicability review requires a model v3 or v4 workspace.");
24
+ if (!modelSupports(loaded.model, "guided-workflow")) {
25
+ throw new Error("Batch applicability review requires a model v3 or newer workspace.");
24
26
  }
25
27
  const requestedType = options.type ? String(options.type) : null;
26
28
  if (requestedType && !REVIEWABLE_TYPES.has(requestedType)) {
@@ -33,7 +35,7 @@ export async function scaffoldApplicabilityReview(input = process.cwd(), options
33
35
  const records = loaded.resources.filter((record) => (
34
36
  REVIEWABLE_TYPES.has(record.type)
35
37
  && (!requestedType || record.type === requestedType)
36
- && (record.type === "requirement" && String(loaded.model.modelVersion) === "4"
38
+ && (record.type === "requirement" && modelSupports(loaded.model, "program-scope")
37
39
  ? !reviewedRequirementIds.has(record.id)
38
40
  : !record.applicabilityReview)
39
41
  && !["retired", "superseded"].includes(record.status)
@@ -44,11 +46,15 @@ export async function scaffoldApplicabilityReview(input = process.cwd(), options
44
46
  reviewedOn: null,
45
47
  decisions: records
46
48
  .sort((left, right) => `${left.type}:${left.title}:${left.id}`.localeCompare(`${right.type}:${right.title}:${right.id}`))
47
- .map((record) => ({
48
- id: record.id,
49
- decision: null,
50
- rationale: null
51
- }))
49
+ .map((record) => {
50
+ const constraint = soc2RequirementApplicabilityConstraint(record, program, loaded.model.modelVersion);
51
+ return {
52
+ id: record.id,
53
+ decision: constraint?.requiredDecision || null,
54
+ rationale: constraint?.defaultRationale || null,
55
+ ...(constraint ? { constraint } : {})
56
+ };
57
+ })
52
58
  };
53
59
  }
54
60
 
@@ -59,8 +65,8 @@ export async function planApplicabilityReview(input = process.cwd(), options = {
59
65
 
60
66
  function planApplicabilityReviewWithContext(context, options) {
61
67
  const { basis, loaded } = context;
62
- if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
63
- throw new Error("Batch applicability review requires a model v3 or v4 workspace.");
68
+ if (!modelSupports(loaded.model, "guided-workflow")) {
69
+ throw new Error("Batch applicability review requires a model v3 or newer workspace.");
64
70
  }
65
71
  if (!Array.isArray(options.decisions) || !options.decisions.length) {
66
72
  throw new Error("Applicability review needs at least one decision.");
@@ -89,6 +95,10 @@ function planApplicabilityReviewWithContext(context, options) {
89
95
  if (!reviewedByIds.length || !reviewedOn || !rationale) {
90
96
  throw new Error(`Decision for "${record.id}" needs a reviewer, review date, and rationale.`);
91
97
  }
98
+ const constraint = soc2RequirementApplicabilityConstraint(record, program, loaded.model.modelVersion);
99
+ if (constraint && !constraint.allowedDecisions.includes(result)) {
100
+ throw new Error(`${record.reference || record.title} must be applicable because it is required for the selected SOC 2 Security program.`);
101
+ }
92
102
  const next = {
93
103
  ...record,
94
104
  applicabilityReview: {
@@ -103,7 +113,7 @@ function planApplicabilityReviewWithContext(context, options) {
103
113
  if (!["applicable", "not-applicable"].includes(result)) {
104
114
  throw new Error(`Requirement "${record.id}" must be applicable or not-applicable.`);
105
115
  }
106
- if (String(loaded.model.modelVersion) === "4") {
116
+ if (modelSupports(loaded.model, "program-scope")) {
107
117
  v4RequirementDecisions.push({
108
118
  requirementId: record.id,
109
119
  decision: result,
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,7 @@ 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
20
  import { generateEvidencePacket, prepareEvidencePacket } from "./evidence-packet.js";
20
21
  import {
21
22
  addEvidenceAttachment,
@@ -42,6 +43,7 @@ import {
42
43
  } from "./external-reviewer.js";
43
44
  import { relativeToWorkspace, resolveDataPath } from "./paths.js";
44
45
  import { activatePolicies, planPolicyActivation, scaffoldPolicyActivation } from "./policy-activation.js";
46
+ import { applyPolicyLibraryUpgrade, assessPolicyLibraryUpgrades } from "./policy-library.js";
45
47
  import { buildAgentProgramPath } from "./program-path.js";
46
48
  import { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
47
49
  import { resolveProgram } from "./program.js";
@@ -182,15 +184,16 @@ export async function runCli(argv = process.argv.slice(2)) {
182
184
  }
183
185
  if (command === "migrate") {
184
186
  const targetModel = String(flags["to-model"] || "");
185
- if (!["2", "3", "4"].includes(targetModel)) throw new Error("Pass --to-model 2, --to-model 3, or --to-model 4.");
186
- const systemDecisions = flags.decisions
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.");
188
+ const migrationDecisions = flags.decisions
187
189
  ? JSON.parse(await readFile(resolve(String(flags.decisions)), "utf8"))
188
190
  : undefined;
189
191
  const options = {
190
192
  jobTitle: flags["job-title"],
191
193
  startsOn: flags["starts-on"],
192
194
  targetModelVersion: targetModel,
193
- systemDecisions: systemDecisions?.systemDecisions || systemDecisions
195
+ systemDecisions: migrationDecisions?.systemDecisions || (targetModel === "4" ? migrationDecisions : undefined),
196
+ documentScopes: migrationDecisions?.documentScopes || (targetModel === "5" ? migrationDecisions : undefined)
194
197
  };
195
198
  const plan = await planModelMigration(root, options);
196
199
  if (!flags.preview && plan.sourceModelVersion !== plan.targetModelVersion && !flags.yes) {
@@ -616,6 +619,54 @@ export async function runCli(argv = process.argv.slice(2)) {
616
619
  else console.log(`Activated ${result.policyIds.length} Policies effective ${result.effectiveOn}.`);
617
620
  return result;
618
621
  }
622
+ if (command === "activate-documents") {
623
+ if (flags.scaffold) {
624
+ const result = await scaffoldDocumentActivation(root, { programId: flags.program, auditId: flags.audit });
625
+ console.log(JSON.stringify(result, null, 2));
626
+ return result;
627
+ }
628
+ const payload = await readSetupPayload(positionals[0]);
629
+ const options = {
630
+ ...payload,
631
+ documentIds: flags.document ? String(flags.document).split(",").filter(Boolean) : payload.documentIds,
632
+ activatedByIds: flags["activated-by"] ? String(flags["activated-by"]).split(",").filter(Boolean) : payload.activatedByIds,
633
+ activatedOn: flags["activated-on"] || payload.activatedOn,
634
+ effectiveOn: flags["effective-on"] || payload.effectiveOn,
635
+ programId: flags.program || payload.programId,
636
+ auditId: flags.audit || payload.auditId,
637
+ confirmed: flags.yes === true
638
+ };
639
+ const result = flags.preview
640
+ ? await planDocumentActivation(root, options)
641
+ : await withWorkflowDelta(root, () => activateDocuments(root, options));
642
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
643
+ else if (flags.preview) console.log(`Document activation preview: ${result.documentIds.length} governed Documents effective ${result.effectiveOn}.`);
644
+ else console.log(`Activated ${result.documentIds.length} governed Documents effective ${result.effectiveOn}.`);
645
+ return result;
646
+ }
647
+ if (command === "policy-library") {
648
+ if (flags.yes && !flags.accept) {
649
+ throw new Error("Pass --accept <proposal-id> with --yes after reviewing the policy-library diff.");
650
+ }
651
+ const result = flags.accept
652
+ ? await withWorkflowDelta(root, () => applyPolicyLibraryUpgrade(root, String(flags.accept), {
653
+ confirmed: flags.yes === true,
654
+ proposalRevision: flags["proposal-revision"] ? String(flags["proposal-revision"]) : null
655
+ }))
656
+ : await assessPolicyLibraryUpgrades(root);
657
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
658
+ else if (flags.accept) console.log(`Accepted policy-library proposal ${flags.accept}.`);
659
+ else if (!result.proposals.length) console.log("No starter policy-library update applies. Existing content is unchanged.");
660
+ else {
661
+ for (const proposal of result.proposals) {
662
+ console.log(`${proposal.title} (${proposal.id})`);
663
+ console.log(proposal.message);
664
+ for (const change of proposal.changes) console.log(`\n${change.diff}`);
665
+ console.log(`\nAccept with: filegrc policy-library --accept ${proposal.id} --proposal-revision ${proposal.revision} --yes`);
666
+ }
667
+ }
668
+ return result;
669
+ }
619
670
  if (command === "trigger") {
620
671
  const result = await withWorkflowDelta(root, () => createObligationEvent(root, {
621
672
  eventType: positionals[0],
@@ -1031,7 +1082,7 @@ Usage:
1031
1082
  filegrc build [root] [--output .filegrc/site]
1032
1083
  filegrc validate [root] [--json]
1033
1084
  filegrc model [--json|--write-docs|--check-docs]
1034
- filegrc migrate --to-model <2|3|4> [--preview] [--decisions path] [--job-title text] [--starts-on YYYY-MM-DD] [--yes] [--json]
1085
+ filegrc migrate --to-model <2|3|4|5> [--preview] [--decisions path] [--job-title text] [--starts-on YYYY-MM-DD] [--yes] [--json]
1035
1086
  filegrc describe <resource-type>
1036
1087
  filegrc types [--json]
1037
1088
  filegrc guide [resource-type] [--id resource-id] [--program program-id] [--json]
@@ -1054,6 +1105,8 @@ Usage:
1054
1105
  filegrc review-applicability [--scaffold --type requirement|control|commitment|complementary-control] [decisions.json|-] [--preview|--yes] [--json]
1055
1106
  filegrc review-collection <resource-type> [--scaffold | review.json|-] [--preview|--yes] [--json]
1056
1107
  filegrc activate-policies [--scaffold | activation.json|-] [--effective-on YYYY-MM-DD] [--preview|--yes] [--json]
1108
+ 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
+ filegrc policy-library [--json | --accept proposal-id --proposal-revision revision --yes]
1057
1110
  filegrc trigger <event-type> (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) [--risk-level normal|high] [--subject resource-id[,resource-id]] [--title text] [--json]
1058
1111
  filegrc evidence-packet [--audit audit-id] [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--output .filegrc/path] [--preview] [--require-ready] [--json]
1059
1112
  filegrc get [resource-type] <id> [--mutation]
@@ -1120,19 +1173,20 @@ Options:
1120
1173
  }
1121
1174
  if (command === "migrate") {
1122
1175
  console.log(`Usage:
1123
- filegrc migrate --to-model <2|3|4> [options]
1176
+ filegrc migrate --to-model <${SUPPORTED_MODEL_VERSIONS.join("|")}> [options]
1124
1177
 
1125
1178
  Upgrade a workspace through an explicit, reviewable model boundary. Model v1
1126
- workspaces migrate to v2 first. Model v2 workspaces migrate to v3, then v4. v4 separates
1179
+ workspaces migrate to v2 first. Continue one version at a time through v${ACTIVE_MODEL_VERSION}. v4 separates
1127
1180
  the repository Workspace, management Program, bounded Systems, operational Components,
1128
- specific Assets, Vendors, normalized information, and Evidence Artifacts. v3 migration
1181
+ specific Assets, Vendors, normalized information, and Evidence Artifacts. Model v5 separates
1182
+ Document approval from activation and records program-versus-engagement scope. v3 migration
1129
1183
  previews may require a decisions JSON file for ambiguous old Systems. v3 still creates planned
1130
1184
  core Appointments, removal of obsolete manual page state, classified review work,
1131
1185
  and dataModelVersion changed last. The command writes no Git commit.
1132
1186
 
1133
1187
  Options:
1134
- --to-model <version> Required target model; migrations must run in order through 2, 3, and 4
1135
- --decisions <path> v4 JSON object keyed by old System ID with system/component decisions
1188
+ --to-model <version> Required target model; migrations must run in order through ${SUPPORTED_MODEL_VERSIONS.join(", ")}
1189
+ --decisions <path> JSON systemDecisions for v4 or documentScopes for ambiguous v5 Documents
1136
1190
  --preview Show the complete atomic record plan without writing
1137
1191
  --job-title <title> Actual job title for the former Policy Owner seed person
1138
1192
  --starts-on <date> Effective date of a new Policy Owner Appointment
@@ -1142,7 +1196,7 @@ Options:
1142
1196
  --help Show this help
1143
1197
 
1144
1198
  Start with:
1145
- npx filegrc migrate --to-model 4 --preview --json`);
1199
+ npx filegrc migrate --to-model ${ACTIVE_MODEL_VERSION} --preview --json`);
1146
1200
  return;
1147
1201
  }
1148
1202
  if (command === "program-readiness") {
@@ -1223,6 +1277,48 @@ Options:
1223
1277
  --help Show this help`);
1224
1278
  return;
1225
1279
  }
1280
+ if (command === "activate-documents") {
1281
+ console.log(`Usage:
1282
+ filegrc activate-documents --scaffold [--program id | --audit id]
1283
+ 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
+
1285
+ Activate required governed plans and schedules in Step 3 after their linked
1286
+ Controls are implemented, or activate engagement Documents in Step 5 after
1287
+ their audit-specific facts are complete. Approval, activation, and effective
1288
+ dates remain separate, and activation binds its own exact Markdown revision.
1289
+
1290
+ Options:
1291
+ --scaffold Print the ready activation payload without writing
1292
+ --program <id> Program to assess when more than one active Program exists
1293
+ --audit <id> Audit whose engagement Documents should be activated in Step 5
1294
+ --activated-by <id> Active Person who performs the activation
1295
+ --activated-on <date> Actual activation date, which must be today
1296
+ --effective-on <date> Effective date on or after activation
1297
+ --preview Validate and show the atomic updates without writing
1298
+ --yes Confirm and apply the reviewed activation
1299
+ --json Print the result as JSON
1300
+ --root <path> Workspace path
1301
+ --help Show this help`);
1302
+ return;
1303
+ }
1304
+ if (command === "policy-library") {
1305
+ console.log(`Usage:
1306
+ filegrc policy-library [--json]
1307
+ filegrc policy-library --accept <proposal-id> --proposal-revision <revision> --yes [--json]
1308
+
1309
+ Review optional starter-library updates for unchanged default Policy and Control
1310
+ content. The review prints exact diffs. FileGRC skips customized or adopted Policy
1311
+ content and writes nothing until you accept one named proposal revision with --yes.
1312
+
1313
+ Options:
1314
+ --accept <id> Accept one proposal after reviewing its diff
1315
+ --proposal-revision <hash> Confirm the exact reviewed proposal revision
1316
+ --yes Confirm the named proposal write
1317
+ --json Print the versioned proposal or acceptance result
1318
+ --root <path> Workspace path
1319
+ --help Show this help`);
1320
+ return;
1321
+ }
1226
1322
  if (command === "evidence-map") {
1227
1323
  console.log(`Usage:
1228
1324
  filegrc evidence-map [options]
@@ -1251,7 +1347,7 @@ function agentOverview(model) {
1251
1347
  build: "filegrc build [root]",
1252
1348
  validate: "filegrc validate [root] --json",
1253
1349
  model: "filegrc model --json",
1254
- migrate: "filegrc migrate --to-model 4 --preview --json",
1350
+ migrate: "filegrc migrate --to-model 5 --preview --json",
1255
1351
  describe: "filegrc describe <resource-type>",
1256
1352
  types: "filegrc types --json",
1257
1353
  guide: "filegrc guide [resource-type] --json",
@@ -1270,6 +1366,7 @@ function agentOverview(model) {
1270
1366
  reconcile: "filegrc reconcile --preview --json",
1271
1367
  externalReviewerSetup: "filegrc external-reviewer-setup [--scaffold | <reviewer.json|-> --preview] --json",
1272
1368
  policyActivation: "filegrc activate-policies [--scaffold | <activation.json|-> --preview] --json",
1369
+ documentActivation: "filegrc activate-documents [--scaffold | <activation.json|-> --preview] --json",
1273
1370
  nextAuditCycle: "filegrc next-audit-cycle <prior-audit-id> --start <date> --end <date> --preview --json",
1274
1371
  reviewApplicability: "filegrc review-applicability <decisions.json|-> --preview --json",
1275
1372
  reviewCollection: "filegrc review-collection <resource-type> [--scaffold | <review.json|-> --preview] --json",
@@ -1422,6 +1519,7 @@ function buildProgramPathResult(model, readiness, auditReadiness) {
1422
1519
  evidenceReady: readiness.evidenceReady,
1423
1520
  operating: readiness.operating,
1424
1521
  policyActivations: readiness.policyActivations,
1522
+ documentActivations: readiness.documentActivations,
1425
1523
  policyLibraryProposals: readiness.policyLibraryProposals,
1426
1524
  stages
1427
1525
  };
@@ -1625,6 +1723,13 @@ function summarizeProgramReadiness(result) {
1625
1723
  label,
1626
1724
  gapCount
1627
1725
  })),
1726
+ documentActivations: result.documentActivations.map(({ documentId, title, state, label, gapCount }) => ({
1727
+ documentId,
1728
+ title,
1729
+ state,
1730
+ label,
1731
+ gapCount
1732
+ })),
1628
1733
  policyLibraryProposals: result.policyLibraryProposals,
1629
1734
  unresolvedOwnership: {
1630
1735
  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,145 @@
1
+ import { applyDocumentActivationBatch, 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);
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.map(({ documentId }) => documentId),
18
+ ...(options.auditId ? { auditId: options.auditId, workflowScope: "engagement" } : { workflowScope: "program" }),
19
+ activatedByIds: [],
20
+ activatedOn: today,
21
+ effectiveOn: today,
22
+ expectedRevisions: Object.fromEntries(candidates.map(({ documentId }) => [documentId, revisionById.get(documentId)])),
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 documentIds = [...new Set((options.documentIds || []).map(String))];
31
+ if (!documentIds.length) throw new Error("Document activation needs at least one approved governed Document.");
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("Document activation expected revisions must be keyed by Document ID.");
48
+ }
49
+ const workflowScope = options.auditId ? "engagement" : "program";
50
+ const candidates = await activationCandidates(loaded, options);
51
+ const assessmentById = new Map(candidates.map((item) => [item.documentId, item]));
52
+ const entryById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
53
+ const invalidActivatorIds = activatedByIds.filter((id) => {
54
+ const person = entryById.get(id)?.record;
55
+ return !personWasActiveOn(person, activatedOn);
56
+ });
57
+ if (invalidActivatorIds.length) {
58
+ throw new Error(`Document activation needs active People as activators: ${invalidActivatorIds.join(", ")}.`);
59
+ }
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.`);
63
+ 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.`);
65
+ }
66
+ if (entry.record.workflowScope !== workflowScope) {
67
+ throw new Error(`Document "${documentId}" belongs to the ${entry.record.workflowScope} workflow, not ${workflowScope}.`);
68
+ }
69
+ const assessment = assessmentById.get(documentId);
70
+ if (assessment?.state !== "ready-to-activate") {
71
+ const missing = assessment?.missingImplementationControlIds || [];
72
+ throw new Error(
73
+ 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.`
76
+ );
77
+ }
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.`);
80
+ }
81
+ const record = {
82
+ ...entry.record,
83
+ status: "active",
84
+ activationBasis: "recorded",
85
+ activatedByIds,
86
+ activatedOn,
87
+ effectiveOn,
88
+ activatedContentRevisions: structuredClone(entry.record.approvedContentRevisions)
89
+ };
90
+ delete record.proposedEffectiveOn;
91
+ return record;
92
+ });
93
+ return {
94
+ operation: "document-activation",
95
+ workflowScope,
96
+ ...(options.auditId ? { auditId: options.auditId } : {}),
97
+ documentIds,
98
+ activatedByIds,
99
+ activatedOn,
100
+ effectiveOn,
101
+ changes: {
102
+ update,
103
+ expectedRevisions: Object.fromEntries(documentIds.map((documentId) => [documentId, expectedRevisions[documentId]])),
104
+ validateWholeWorkspace: true
105
+ }
106
+ };
107
+ }
108
+
109
+ async function activationCandidates(loaded, options) {
110
+ if (options.auditId) {
111
+ return (await assessAuditDocumentActivations(loaded, {
112
+ auditId: options.auditId,
113
+ asOf: currentCalendarDate(loaded.workspace.timezone)
114
+ })).filter(({ state }) => state === "ready-to-activate");
115
+ }
116
+ const readiness = await assessProgramReadiness(loaded, { programId: options.programId });
117
+ return readiness.documentActivations.filter(({ state }) => state === "ready-to-activate");
118
+ }
119
+
120
+ export async function activateDocuments(input = process.cwd(), options = {}) {
121
+ if (options.confirmed !== true) throw new Error("Review the governed Document activation and confirm the write.");
122
+ return serializeWorkspaceMutation(input, async (root) => {
123
+ const plan = await planDocumentActivation(root, options);
124
+ const result = await applyDocumentActivationBatch(root, plan.changes);
125
+ return { ...plan, result };
126
+ });
127
+ }
128
+
129
+ function requireDocumentLifecycle(loaded) {
130
+ if (!modelSupports(loaded.model, "governed-document-activation")) {
131
+ throw new Error("Separate governed Document approval and activation requires a model v5 workspace.");
132
+ }
133
+ }
134
+
135
+ function isCalendarDate(value) {
136
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
137
+ if (!match) return false;
138
+ const [, year, month, day] = match.map(Number);
139
+ const date = new Date(0);
140
+ date.setUTCFullYear(year, month - 1, day);
141
+ date.setUTCHours(0, 0, 0, 0);
142
+ return date.getUTCFullYear() === year
143
+ && date.getUTCMonth() === month - 1
144
+ && date.getUTCDate() === day;
145
+ }