filegrc 0.7.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,7 +21,9 @@
21
21
  "src"
22
22
  ],
23
23
  "scripts": {
24
- "test": "node --test --test-concurrency=4"
24
+ "test": "node ../../scripts/run-filegrc-tests.mjs",
25
+ "test:fast": "node ../../scripts/run-filegrc-tests.mjs --fast",
26
+ "test:full": "npm test"
25
27
  },
26
28
  "engines": {
27
29
  "node": ">=20"
package/src/agent.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { modelSupports } from "../model/index.js";
1
2
  import { createResourceId } from "./id.js";
2
3
  import { markdownEntries } from "./resource-markdown.js";
3
4
  import { RESOURCE_INSTRUCTIONS, resourceProgramContext } from "./program-path.js";
@@ -116,7 +117,7 @@ export function buildAgentGuide(loaded, type, options = {}) {
116
117
  recommendedMarkdown.length
117
118
  ? "Keep model fields in JSON and use the recommended Markdown companion for the detailed work, decisions, results, exceptions, and follow-up that apply to this record."
118
119
  : "Keep the current facts and lifecycle state in JSON. Add optional Record Markdown only when the model fields cannot explain the record clearly.",
119
- ...(type === "program" && String(loaded.model.modelVersion) === "4"
120
+ ...(type === "program" && modelSupports(loaded.model, "program-scope")
120
121
  ? ["Review Requirement applicability with npx filegrc review-applicability --type requirement --scaffold, then preview and apply the reviewed decisions as one validated batch."]
121
122
  : []),
122
123
  "Run npx filegrc validate, review the full Git diff, and commit the JSON, Markdown, and attachments together with a message that explains why the record changed."
@@ -125,7 +126,7 @@ export function buildAgentGuide(loaded, type, options = {}) {
125
126
  "Required and status-dependent fields are complete, and the lifecycle status matches the facts.",
126
127
  "Every relationship resolves to the intended existing record.",
127
128
  "Dates describe the business event in the workspace time zone, not the file edit time.",
128
- ...(type === "program" && String(loaded.model.modelVersion) === "4"
129
+ ...(type === "program" && modelSupports(loaded.model, "program-scope")
129
130
  ? ["Every selected Requirement has an applicable or not-applicable decision reviewed against the current Program scope."]
130
131
  : []),
131
132
  ...(recommendedMarkdown.length
@@ -217,7 +218,7 @@ function applyModelScaffoldDefaults(record, loaded, options = {}) {
217
218
  }[program?.assuranceGoal];
218
219
  if (kind) record.auditKind = kind;
219
220
  for (const field of ["frameworkIds", "systemIds", "requirementIds", "controlIds"]) {
220
- if (field === "requirementIds" && String(loaded.model.modelVersion) === "4") {
221
+ if (field === "requirementIds" && modelSupports(loaded.model, "program-scope")) {
221
222
  record[field] = (program.requirementApplicability || []).filter(({ decision }) => decision === "applicable").map(({ requirementId }) => requirementId);
222
223
  } else if (program?.[field]?.length) record[field] = [...program[field]];
223
224
  }
@@ -1,5 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
+ import { modelSupports } from "../model/index.js";
4
+ import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
3
5
  import {
4
6
  coverageContains,
5
7
  coverageEnd,
@@ -15,6 +17,7 @@ import { currentPartyPeople, partiesIndependent } from "./parties.js";
15
17
  import { resolveDataPath } from "./paths.js";
16
18
  import { assessProgramReadiness } from "./program-readiness.js";
17
19
  import { markdownEntries } from "./resource-markdown.js";
20
+ import { contentRevisionBindingsMatch, governedDocumentIsOperating } from "./program-lifecycle.js";
18
21
  import {
19
22
  auditorWasEngaged,
20
23
  missingSoc2References,
@@ -25,6 +28,7 @@ import {
25
28
  signatoryAppointmentIssue,
26
29
  subsequentEventsReviewIssue
27
30
  } from "./soc2.js";
31
+ import { currentCalendarDate } from "./time.js";
28
32
  import { loadWorkspace } from "./workspace.js";
29
33
 
30
34
  const NON_EVIDENCE_RECORD_TYPES = new Set([
@@ -65,9 +69,13 @@ export async function assessAuditPreparation(input, options = {}) {
65
69
  if (options.auditId && !audit) throw new Error(`Audit "${options.auditId}" was not found.`);
66
70
 
67
71
  const programReadiness = options.programReadiness || await assessProgramReadiness(loaded, {
72
+ asOf: options.asOf,
68
73
  generatedAt: options.generatedAt,
69
74
  programId: audit?.programId
70
75
  });
76
+ const documentActivations = audit && modelSupports(loaded.model, "governed-document-activation")
77
+ ? await auditDocumentActivationAssessments(loaded, audit, byId, programReadiness.asOf)
78
+ : [];
71
79
  const stages = [
72
80
  programFoundationStage(programReadiness, loaded.workspace),
73
81
  engagementStage(audit, byId, programReadiness),
@@ -75,7 +83,7 @@ export async function assessAuditPreparation(input, options = {}) {
75
83
  ];
76
84
  const fieldworkSections = audit
77
85
  ? [
78
- await documentsStage(loaded, audit, byId),
86
+ await documentsStage(loaded, audit, byId, programReadiness.asOf),
79
87
  evidenceStage(audit, records, byId, loaded.model),
80
88
  populationsStage(audit, records, byId, loaded.model)
81
89
  ]
@@ -107,10 +115,23 @@ export async function assessAuditPreparation(input, options = {}) {
107
115
  && coverageStart(audit.coverage)
108
116
  && coverageEnd(audit.coverage)
109
117
  && initializationNeeded(audit, records, loaded.model)),
118
+ documentActivations,
110
119
  stages
111
120
  };
112
121
  }
113
122
 
123
+ export async function assessAuditDocumentActivations(input = process.cwd(), options = {}) {
124
+ const loaded = input?.resources && input?.model && input?.entries
125
+ ? input
126
+ : await loadWorkspace(input);
127
+ if (!modelSupports(loaded.model, "governed-document-activation")) return [];
128
+ const audit = loaded.resources.find((record) => record.type === "audit" && record.id === options.auditId);
129
+ if (!audit) throw new Error(`Audit "${options.auditId || ""}" was not found.`);
130
+ const byId = new Map(loaded.resources.map((record) => [record.id, record]));
131
+ const asOf = options.asOf || currentCalendarDate(loaded.workspace.timezone);
132
+ return auditDocumentActivationAssessments(loaded, audit, byId, asOf);
133
+ }
134
+
114
135
  export async function prepareAuditWorkspace(input, options = {}) {
115
136
  const loaded = await loadWorkspace(input);
116
137
  const audit = loaded.resources.find((record) => record.type === "audit" && record.id === options.auditId);
@@ -168,7 +189,7 @@ export async function prepareAuditWorkspace(input, options = {}) {
168
189
  const selectedControls = (audit.controlIds || [])
169
190
  .map((id) => loaded.resources.find((record) => record.id === id))
170
191
  .filter(Boolean);
171
- const v4 = String(loaded.model.modelVersion) === "4";
192
+ const v4 = modelSupports(loaded.model, "program-scope");
172
193
  const sourceSystems = loaded.resources.filter((record) => record.type === (v4 ? "component" : "system"));
173
194
  const populations = (audit.auditKind === "soc-2-type-2" ? model.populationTemplates || [] : [])
174
195
  .filter((template) => !existingKinds.has(template.kind))
@@ -269,7 +290,7 @@ function scopeStage(loaded, audit, records, byId, programReadiness) {
269
290
  ));
270
291
  }
271
292
 
272
- const v4 = String(programReadiness.dataModelVersion) === "4";
293
+ const v4 = modelSupports(programReadiness.dataModelVersion, "program-scope");
273
294
  const engagementStart = coverageStart(audit.coverage);
274
295
  const engagementEnd = coverageEnd(audit.coverage);
275
296
  const scopeRevision = assessScopeRevision(loaded, audit, v4);
@@ -744,9 +765,12 @@ function engagementStage(audit, byId, programReadiness) {
744
765
  const engagementTermsComplete = Boolean(
745
766
  engagementTerms?.type === "document"
746
767
  && engagementTerms.documentKind === "soc2-engagement-terms"
747
- && engagementTerms.status === "active"
768
+ && governedDocumentIsOperating(
769
+ engagementTerms,
770
+ programReadiness.asOf,
771
+ { modelVersion: programReadiness.dataModelVersion }
772
+ )
748
773
  && engagementTerms.approvedOn
749
- && engagementTerms.effectiveOn
750
774
  );
751
775
  const acknowledgementPeople = (audit.managementAcknowledgedByIds || [])
752
776
  .map((id) => byId.get(id))
@@ -805,7 +829,7 @@ function engagementStage(audit, byId, programReadiness) {
805
829
  }
806
830
  )
807
831
  ];
808
- if (["3", "4"].includes(String(programReadiness.dataModelVersion))) {
832
+ if (modelSupports(programReadiness.dataModelVersion, "guided-workflow")) {
809
833
  items.push(
810
834
  item(
811
835
  "engagement-terms",
@@ -853,51 +877,160 @@ function fieldworkStage(audit, sections) {
853
877
  );
854
878
  }
855
879
 
856
- async function documentsStage(loaded, audit, byId) {
857
- const definitions = applicableManagementDocuments(audit, loaded.model.auditReadiness || {});
858
- const items = [];
859
- for (const definition of definitions) {
860
- const document = audit?.[definition.field] ? byId.get(audit[definition.field]) : null;
861
- const source = document ? await primaryMarkdown(loaded, document) : "";
862
- const contentIssues = managementDocumentContentIssues(source, definition, audit);
863
- const engagementEnd = coverageEnd(audit?.coverage);
864
- if (document?.approvedOn && engagementEnd && document.approvedOn < engagementEnd) {
865
- contentIssues.push(`Approve the final document on or after the engagement ${audit.auditKind === "soc-2-type-1" ? "date" : "period end"}.`);
880
+ async function auditDocumentActivationAssessments(loaded, audit, byId, asOf) {
881
+ const links = new Map();
882
+ const addLink = (documentId, role, definition = null) => {
883
+ if (!documentId) return;
884
+ const current = links.get(documentId) || { documentId, roles: [], definitions: [] };
885
+ current.roles.push(role);
886
+ if (definition) current.definitions.push(definition);
887
+ links.set(documentId, current);
888
+ };
889
+ addLink(audit.engagementTermsDocumentId, "engagement-terms");
890
+ for (const definition of applicableManagementDocuments(audit, loaded.model.auditReadiness || {})) {
891
+ addLink(audit[definition.field], definition.field, definition);
892
+ }
893
+ for (const documentId of audit.supplementalDocumentIds || []) addLink(documentId, "supplemental");
894
+
895
+ const assessments = [];
896
+ for (const link of links.values()) {
897
+ const document = byId.get(link.documentId);
898
+ if (document?.type !== "document") continue;
899
+ if (link.roles.every((role) => role === "supplemental") && document.workflowScope !== "engagement") continue;
900
+ const source = await primaryMarkdown(loaded, document);
901
+ const issues = [];
902
+ if (document.workflowScope !== "engagement") issues.push("Set workflowScope to engagement.");
903
+ if (document.template === true) issues.push("Replace the starter template with the completed engagement Document.");
904
+ if (!substantiveMarkdown(source)) issues.push("Complete the Document Markdown.");
905
+ const placeholders = openPlaceholderCount(source);
906
+ if (placeholders) issues.push(`Resolve ${placeholders} open ${placeholders === 1 ? "placeholder" : "placeholders"}.`);
907
+ if (link.roles.includes("engagement-terms") && document.documentKind !== "soc2-engagement-terms") {
908
+ issues.push("Use documentKind soc2-engagement-terms for the accepted engagement terms.");
866
909
  }
867
- if (definition.kind === "soc2-period-completeness" && document?.approvedOn && audit) {
868
- const latestReconciliation = loaded.resources
869
- .filter((record) => record.type === "audit-population" && record.auditId === audit.id)
870
- .map((record) => record.reconciledOn)
871
- .filter(Boolean)
872
- .sort()
873
- .at(-1);
874
- if (latestReconciliation && document.approvedOn < latestReconciliation) {
875
- contentIssues.push("Approve the period completeness statement after the last population reconciliation.");
876
- }
910
+ for (const definition of link.definitions) {
911
+ issues.push(...await managementDocumentLifecycleIssues(loaded, audit, definition, document, source, asOf, byId));
877
912
  }
878
- if (definition.kind === "soc2-management-representation" && document) {
879
- const signedEvidence = (document.evidenceIds || [])
880
- .map((id) => byId.get(id))
881
- .find((record) => (
882
- record?.type === "evidence"
883
- && record.status === "verified"
884
- && record.artifactKind === "signed-record"
885
- && record.artifactSubtype === "signed-management-representation"
886
- && (record.filePaths || []).length
887
- ));
888
- if (!signedEvidence) contentIssues.push("Link verified signed-record Evidence with subtype signed-management-representation and a fixed-format copy of the signed letter.");
913
+ const approvalComplete = Boolean(
914
+ ["approved", "active"].includes(document.status)
915
+ && document.approvedOn
916
+ && document.approvedContentRevisions
917
+ && (document.activationBasis === "legacy-v4"
918
+ ? (document.ownerIds || []).length
919
+ : currentPartyPeople(document.ownerIds || [], byId).size)
920
+ && (document.approverIds || []).length
921
+ && partiesIndependent(document.ownerIds, document.approverIds, byId)
922
+ );
923
+ if (!approvalComplete) issues.push("Complete the independent approval and bind the approved Markdown revision first.");
924
+ if (document.status === "active" && document.activationBasis !== "legacy-v4") {
925
+ if (document.activationBasis !== "recorded") issues.push("Record the Step 5 activation basis.");
926
+ if (!document.activatedOn) issues.push("Record the separate Step 5 activation date.");
927
+ else if (document.activatedOn > asOf) issues.push(`The activation date ${document.activatedOn} is after ${asOf}.`);
928
+ if (!(document.activatedByIds || []).length) issues.push("Name the active Person who performed activation.");
889
929
  else {
890
- const dateIssue = signedRepresentationDateIssue(signedEvidence, audit, loaded.model.modelVersion);
891
- if (dateIssue) contentIssues.push(dateIssue);
930
+ const invalidActorIds = document.activatedByIds.filter((id) => !personWasActiveOn(byId.get(id), document.activatedOn));
931
+ if (invalidActorIds.length) issues.push(`Activation actors were not active on ${document.activatedOn}: ${invalidActorIds.join(", ")}.`);
892
932
  }
933
+ if (!document.activatedContentRevisions) issues.push("Bind activation to the exact Document Markdown revision.");
934
+ else if (!contentRevisionBindingsMatch(document.approvedContentRevisions, document.activatedContentRevisions)) {
935
+ issues.push("The activated revision must match the unchanged approved revision.");
936
+ }
937
+ if (!document.effectiveOn) issues.push("Record the effective date.");
938
+ else if (document.effectiveOn > asOf) issues.push(`The Document does not become effective until ${document.effectiveOn}.`);
939
+ }
940
+ const operating = governedDocumentIsOperating(document, asOf, loaded.model) && issues.length === 0;
941
+ const state = operating
942
+ ? "active-and-operating"
943
+ : document.status === "active"
944
+ ? "active-with-gaps"
945
+ : document.status === "approved" && issues.length === 0
946
+ ? "ready-to-activate"
947
+ : document.status === "approved"
948
+ ? "approved-not-ready"
949
+ : "approval-pending";
950
+ assessments.push({
951
+ auditId: audit.id,
952
+ documentId: document.id,
953
+ title: document.title,
954
+ roles: [...new Set(link.roles)],
955
+ state,
956
+ label: auditDocumentActivationLabel(state),
957
+ issues: [...new Set(issues)],
958
+ approvedOn: document.approvedOn || null,
959
+ activatedOn: document.activatedOn || null,
960
+ activatedByIds: document.activatedByIds || [],
961
+ effectiveOn: document.effectiveOn || null,
962
+ approvalRevisionBound: Boolean(document.approvedContentRevisions),
963
+ activationRevisionBound: Boolean(document.activatedContentRevisions),
964
+ gapCount: [...new Set(issues)].length
965
+ });
966
+ }
967
+ return assessments.sort((left, right) => left.title.localeCompare(right.title));
968
+ }
969
+
970
+ function auditDocumentActivationLabel(state) {
971
+ return ({
972
+ "approval-pending": "Approval pending in Step 5",
973
+ "approved-not-ready": "Approved, engagement facts incomplete",
974
+ "ready-to-activate": "Ready to activate in Step 5",
975
+ "active-with-gaps": "Active with lifecycle or engagement gaps",
976
+ "active-and-operating": "Active and ready for the engagement"
977
+ })[state] || state;
978
+ }
979
+
980
+ async function managementDocumentLifecycleIssues(loaded, audit, definition, document, source, asOf, byId) {
981
+ const issues = managementDocumentContentIssues(source, definition, audit);
982
+ if (document.documentKind !== definition.kind) {
983
+ issues.push(`Use documentKind ${definition.kind} for this Audit field.`);
984
+ }
985
+ const engagementEnd = coverageEnd(audit?.coverage);
986
+ if (document.approvedOn && engagementEnd && document.approvedOn < engagementEnd) {
987
+ issues.push(`Approve the final document on or after the engagement ${audit.auditKind === "soc-2-type-1" ? "date" : "period end"}.`);
988
+ }
989
+ if (definition.kind === "soc2-period-completeness" && document.approvedOn) {
990
+ const latestReconciliation = loaded.resources
991
+ .filter((record) => record.type === "audit-population" && record.auditId === audit.id)
992
+ .map((record) => record.reconciledOn)
993
+ .filter(Boolean)
994
+ .sort()
995
+ .at(-1);
996
+ if (latestReconciliation && document.approvedOn < latestReconciliation) {
997
+ issues.push("Approve the period completeness statement after the last population reconciliation.");
893
998
  }
999
+ }
1000
+ if (definition.kind === "soc2-management-representation") {
1001
+ const signedEvidence = (document.evidenceIds || [])
1002
+ .map((id) => byId.get(id))
1003
+ .find((record) => (
1004
+ record?.type === "evidence"
1005
+ && record.status === "verified"
1006
+ && record.artifactKind === "signed-record"
1007
+ && record.artifactSubtype === "signed-management-representation"
1008
+ && (record.filePaths || []).length
1009
+ ));
1010
+ if (!signedEvidence) issues.push("Link verified signed-record Evidence with subtype signed-management-representation and a fixed-format copy of the signed letter.");
1011
+ else {
1012
+ const dateIssue = signedRepresentationDateIssue(signedEvidence, audit, loaded.model.modelVersion);
1013
+ if (dateIssue) issues.push(dateIssue);
1014
+ }
1015
+ }
1016
+ return issues;
1017
+ }
1018
+
1019
+ async function documentsStage(loaded, audit, byId, asOf) {
1020
+ const definitions = applicableManagementDocuments(audit, loaded.model.auditReadiness || {});
1021
+ const items = [];
1022
+ for (const definition of definitions) {
1023
+ const document = audit?.[definition.field] ? byId.get(audit[definition.field]) : null;
1024
+ const source = document ? await primaryMarkdown(loaded, document) : "";
1025
+ const contentIssues = document
1026
+ ? await managementDocumentLifecycleIssues(loaded, audit, definition, document, source, asOf, byId)
1027
+ : [];
894
1028
  const complete = Boolean(
895
1029
  document
896
1030
  && document.type === "document"
897
1031
  && document.template !== true
898
- && document.status === "active"
1032
+ && governedDocumentIsOperating(document, asOf, loaded.model)
899
1033
  && document.approvedOn
900
- && document.effectiveOn
901
1034
  && (document.ownerIds || []).length
902
1035
  && (document.approverIds || []).length
903
1036
  && partiesIndependent(document.ownerIds, document.approverIds, byId)
@@ -912,7 +1045,11 @@ async function documentsStage(loaded, audit, byId) {
912
1045
  complete ? "complete" : representationLater ? "later" : "action",
913
1046
  definition.title,
914
1047
  complete
915
- ? "Linked Markdown is complete, active, approved, and effective."
1048
+ ? document.activationBasis === "legacy-v4"
1049
+ ? "Linked Markdown is complete and effective. Its active state is preserved from model v4, which did not record approval and activation as separate events."
1050
+ : modelSupports(loaded.model, "governed-document-activation")
1051
+ ? "Linked Markdown is complete, independently approved, separately activated by a named Person, revision-bound at both events, and effective."
1052
+ : "Linked Markdown is complete, active, approved, and effective."
916
1053
  : document
917
1054
  ? `${definition.timing} ${contentIssues[0] || "Complete and approve the engagement-specific document."}`
918
1055
  : `Link the starter ${definition.title.toLowerCase()} to this audit. ${definition.timing}`,
@@ -925,7 +1062,7 @@ async function documentsStage(loaded, audit, byId) {
925
1062
  export function signedRepresentationDateIssue(evidence, audit, modelVersion) {
926
1063
  const engagementEnd = coverageEnd(audit?.coverage);
927
1064
  const timingMessage = `The signed representation must be dated on or after the engagement ${audit?.auditKind === "soc-2-type-1" ? "date" : "period end"}.`;
928
- if (String(modelVersion) !== "4") {
1065
+ if (!modelSupports(modelVersion, "program-scope")) {
929
1066
  return !evidence?.collectedOn || (engagementEnd && evidence.collectedOn < engagementEnd)
930
1067
  ? timingMessage
931
1068
  : null;
@@ -999,7 +1136,7 @@ function evidenceStage(audit, records, byId, model) {
999
1136
  externalEvidence[0] || { type: "evidence" }
1000
1137
  )
1001
1138
  ];
1002
- const v4 = String(model.modelVersion) === "4";
1139
+ const v4 = modelSupports(model, "program-scope");
1003
1140
  const systems = records.filter((record) => record.type === (v4 ? "component" : "system") && record.status === "active");
1004
1141
  const sourceId = (record) => v4 ? record.sourceComponentId : record.sourceSystemId;
1005
1142
  for (const source of model.evidenceSourceFamilies || []) {
@@ -1103,7 +1240,7 @@ function auditorStage(audit, byId, modelVersion) {
1103
1240
  const signatoryStatus = !audit || ["planned", "in-progress", "fieldwork"].includes(audit.status)
1104
1241
  ? "later"
1105
1242
  : signatoryIssue ? "action" : "complete";
1106
- const managementItems = String(modelVersion) === "4" ? [
1243
+ const managementItems = modelSupports(modelVersion, "program-scope") ? [
1107
1244
  item(
1108
1245
  "subsequent-events",
1109
1246
  subsequentEventsStatus,
@@ -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
@@ -1,5 +1,6 @@
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";
@@ -20,8 +21,8 @@ const REVIEWABLE_TYPES = new Set([
20
21
  export async function scaffoldApplicabilityReview(input = process.cwd(), options = {}) {
21
22
  const context = await applicabilityReviewContext(input);
22
23
  const { loaded } = context;
23
- if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
24
- 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.");
25
26
  }
26
27
  const requestedType = options.type ? String(options.type) : null;
27
28
  if (requestedType && !REVIEWABLE_TYPES.has(requestedType)) {
@@ -34,7 +35,7 @@ export async function scaffoldApplicabilityReview(input = process.cwd(), options
34
35
  const records = loaded.resources.filter((record) => (
35
36
  REVIEWABLE_TYPES.has(record.type)
36
37
  && (!requestedType || record.type === requestedType)
37
- && (record.type === "requirement" && String(loaded.model.modelVersion) === "4"
38
+ && (record.type === "requirement" && modelSupports(loaded.model, "program-scope")
38
39
  ? !reviewedRequirementIds.has(record.id)
39
40
  : !record.applicabilityReview)
40
41
  && !["retired", "superseded"].includes(record.status)
@@ -64,8 +65,8 @@ export async function planApplicabilityReview(input = process.cwd(), options = {
64
65
 
65
66
  function planApplicabilityReviewWithContext(context, options) {
66
67
  const { basis, loaded } = context;
67
- if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
68
- 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.");
69
70
  }
70
71
  if (!Array.isArray(options.decisions) || !options.decisions.length) {
71
72
  throw new Error("Applicability review needs at least one decision.");
@@ -112,7 +113,7 @@ function planApplicabilityReviewWithContext(context, options) {
112
113
  if (!["applicable", "not-applicable"].includes(result)) {
113
114
  throw new Error(`Requirement "${record.id}" must be applicable or not-applicable.`);
114
115
  }
115
- if (String(loaded.model.modelVersion) === "4") {
116
+ if (modelSupports(loaded.model, "program-scope")) {
116
117
  v4RequirementDecisions.push({
117
118
  requirementId: record.id,
118
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,
@@ -183,15 +184,16 @@ export async function runCli(argv = process.argv.slice(2)) {
183
184
  }
184
185
  if (command === "migrate") {
185
186
  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
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
188
189
  ? JSON.parse(await readFile(resolve(String(flags.decisions)), "utf8"))
189
190
  : undefined;
190
191
  const options = {
191
192
  jobTitle: flags["job-title"],
192
193
  startsOn: flags["starts-on"],
193
194
  targetModelVersion: targetModel,
194
- systemDecisions: systemDecisions?.systemDecisions || systemDecisions
195
+ systemDecisions: migrationDecisions?.systemDecisions || (targetModel === "4" ? migrationDecisions : undefined),
196
+ documentScopes: migrationDecisions?.documentScopes || (targetModel === "5" ? migrationDecisions : undefined)
195
197
  };
196
198
  const plan = await planModelMigration(root, options);
197
199
  if (!flags.preview && plan.sourceModelVersion !== plan.targetModelVersion && !flags.yes) {
@@ -617,6 +619,31 @@ export async function runCli(argv = process.argv.slice(2)) {
617
619
  else console.log(`Activated ${result.policyIds.length} Policies effective ${result.effectiveOn}.`);
618
620
  return result;
619
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
+ }
620
647
  if (command === "policy-library") {
621
648
  if (flags.yes && !flags.accept) {
622
649
  throw new Error("Pass --accept <proposal-id> with --yes after reviewing the policy-library diff.");
@@ -1055,7 +1082,7 @@ Usage:
1055
1082
  filegrc build [root] [--output .filegrc/site]
1056
1083
  filegrc validate [root] [--json]
1057
1084
  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]
1085
+ filegrc migrate --to-model <2|3|4|5> [--preview] [--decisions path] [--job-title text] [--starts-on YYYY-MM-DD] [--yes] [--json]
1059
1086
  filegrc describe <resource-type>
1060
1087
  filegrc types [--json]
1061
1088
  filegrc guide [resource-type] [--id resource-id] [--program program-id] [--json]
@@ -1078,6 +1105,7 @@ Usage:
1078
1105
  filegrc review-applicability [--scaffold --type requirement|control|commitment|complementary-control] [decisions.json|-] [--preview|--yes] [--json]
1079
1106
  filegrc review-collection <resource-type> [--scaffold | review.json|-] [--preview|--yes] [--json]
1080
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]
1081
1109
  filegrc policy-library [--json | --accept proposal-id --proposal-revision revision --yes]
1082
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]
1083
1111
  filegrc evidence-packet [--audit audit-id] [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--output .filegrc/path] [--preview] [--require-ready] [--json]
@@ -1145,19 +1173,20 @@ Options:
1145
1173
  }
1146
1174
  if (command === "migrate") {
1147
1175
  console.log(`Usage:
1148
- filegrc migrate --to-model <2|3|4> [options]
1176
+ filegrc migrate --to-model <${SUPPORTED_MODEL_VERSIONS.join("|")}> [options]
1149
1177
 
1150
1178
  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
1179
+ workspaces migrate to v2 first. Continue one version at a time through v${ACTIVE_MODEL_VERSION}. v4 separates
1152
1180
  the repository Workspace, management Program, bounded Systems, operational Components,
1153
- 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
1154
1183
  previews may require a decisions JSON file for ambiguous old Systems. v3 still creates planned
1155
1184
  core Appointments, removal of obsolete manual page state, classified review work,
1156
1185
  and dataModelVersion changed last. The command writes no Git commit.
1157
1186
 
1158
1187
  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
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
1161
1190
  --preview Show the complete atomic record plan without writing
1162
1191
  --job-title <title> Actual job title for the former Policy Owner seed person
1163
1192
  --starts-on <date> Effective date of a new Policy Owner Appointment
@@ -1167,7 +1196,7 @@ Options:
1167
1196
  --help Show this help
1168
1197
 
1169
1198
  Start with:
1170
- npx filegrc migrate --to-model 4 --preview --json`);
1199
+ npx filegrc migrate --to-model ${ACTIVE_MODEL_VERSION} --preview --json`);
1171
1200
  return;
1172
1201
  }
1173
1202
  if (command === "program-readiness") {
@@ -1248,6 +1277,30 @@ Options:
1248
1277
  --help Show this help`);
1249
1278
  return;
1250
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
+ }
1251
1304
  if (command === "policy-library") {
1252
1305
  console.log(`Usage:
1253
1306
  filegrc policy-library [--json]
@@ -1294,7 +1347,7 @@ function agentOverview(model) {
1294
1347
  build: "filegrc build [root]",
1295
1348
  validate: "filegrc validate [root] --json",
1296
1349
  model: "filegrc model --json",
1297
- migrate: "filegrc migrate --to-model 4 --preview --json",
1350
+ migrate: "filegrc migrate --to-model 5 --preview --json",
1298
1351
  describe: "filegrc describe <resource-type>",
1299
1352
  types: "filegrc types --json",
1300
1353
  guide: "filegrc guide [resource-type] --json",
@@ -1313,6 +1366,7 @@ function agentOverview(model) {
1313
1366
  reconcile: "filegrc reconcile --preview --json",
1314
1367
  externalReviewerSetup: "filegrc external-reviewer-setup [--scaffold | <reviewer.json|-> --preview] --json",
1315
1368
  policyActivation: "filegrc activate-policies [--scaffold | <activation.json|-> --preview] --json",
1369
+ documentActivation: "filegrc activate-documents [--scaffold | <activation.json|-> --preview] --json",
1316
1370
  nextAuditCycle: "filegrc next-audit-cycle <prior-audit-id> --start <date> --end <date> --preview --json",
1317
1371
  reviewApplicability: "filegrc review-applicability <decisions.json|-> --preview --json",
1318
1372
  reviewCollection: "filegrc review-collection <resource-type> [--scaffold | <review.json|-> --preview] --json",
@@ -1465,6 +1519,7 @@ function buildProgramPathResult(model, readiness, auditReadiness) {
1465
1519
  evidenceReady: readiness.evidenceReady,
1466
1520
  operating: readiness.operating,
1467
1521
  policyActivations: readiness.policyActivations,
1522
+ documentActivations: readiness.documentActivations,
1468
1523
  policyLibraryProposals: readiness.policyLibraryProposals,
1469
1524
  stages
1470
1525
  };
@@ -1668,6 +1723,13 @@ function summarizeProgramReadiness(result) {
1668
1723
  label,
1669
1724
  gapCount
1670
1725
  })),
1726
+ documentActivations: result.documentActivations.map(({ documentId, title, state, label, gapCount }) => ({
1727
+ documentId,
1728
+ title,
1729
+ state,
1730
+ label,
1731
+ gapCount
1732
+ })),
1671
1733
  policyLibraryProposals: result.policyLibraryProposals,
1672
1734
  unresolvedOwnership: {
1673
1735
  count: unresolvedOwnership.length,