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.
@@ -1,17 +1,25 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import { modelSupports } from "../model/index.js";
2
3
  import { assessRequiredAppointments } from "./appointments.js";
3
4
  import { assessCollectionReviews } from "./collection-review.js";
4
5
  import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
5
6
  import { coverageEnd, coverageStart } from "./coverage.js";
6
7
  import { planObligations } from "./obligations.js";
7
8
  import { resolveDataPath } from "./paths.js";
8
- import { obligationIsEnabled, obligationIsRunning } from "./program-lifecycle.js";
9
+ import {
10
+ contentRevisionBindingsMatch,
11
+ documentIsAuditSpecific,
12
+ governedDocumentIsOperating,
13
+ obligationIsEnabled,
14
+ obligationIsRunning
15
+ } from "./program-lifecycle.js";
9
16
  import { currentPartyPeople, partiesIndependent, partyPeople } from "./parties.js";
10
17
  import { assessPolicyLibraryUpgrades } from "./policy-library.js";
11
18
  import { programComponents, resolveProgram, selectedRequirementIds } from "./program.js";
12
19
  import { markdownEntries } from "./resource-markdown.js";
13
20
  import {
14
21
  missingSoc2References,
22
+ personWasActiveOn,
15
23
  REQUIRED_SOC2_DESCRIPTION_REFERENCES,
16
24
  REQUIRED_SOC2_SECURITY_REFERENCES
17
25
  } from "./soc2.js";
@@ -37,14 +45,15 @@ export async function assessProgramReadiness(input, options = {}) {
37
45
  return markdown.get(record.id);
38
46
  };
39
47
 
40
- const policyStage = await policiesStage(scope, records, byId, readMarkdown);
48
+ const policyStage = await policiesStage(scope, records, byId, readMarkdown, loaded.model);
41
49
  const controlStage = await controlsStage(scope, byId, readMarkdown, asOf, loaded.model);
42
50
  controlStage.items.unshift(...collectionReviews
43
51
  .filter(({ resourceType }) => resourceType === "complementary-control")
44
52
  .map(collectionReviewReadinessItem));
45
53
  const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
46
54
  controlStage.items.push(...sourceStage.items);
47
- controlStage.items.push(...await governedContentItems(scope, records, byId, readMarkdown, asOf));
55
+ const governedContent = await governedContentItems(scope, records, byId, readMarkdown, asOf, loaded.model);
56
+ controlStage.items.push(...governedContent.items);
48
57
  const policyActivations = await assessPolicyActivations(
49
58
  requiredPolicies(scope, byId),
50
59
  scope.controls,
@@ -55,7 +64,7 @@ export async function assessProgramReadiness(input, options = {}) {
55
64
  loaded.model
56
65
  );
57
66
  controlStage.items.push(...policyActivations.map(policyActivationItem));
58
- controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, enabled schedules, and complete authoritative source ${String(loaded.model.modelVersion) === "4" ? "Components" : "Systems"}. Activate approved Policies at the implementation cutover after reviewing each activation assessment.`;
67
+ controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, enabled Obligations, and complete authoritative source ${modelSupports(loaded.model, "component-sources") ? "Components" : "Systems"}. Activate unchanged approved program Documents and Training after their requirements are implemented, then activate approved Policies at the implementation cutover.`;
59
68
  const evidenceGateStages = [
60
69
  scopeStage(
61
70
  program,
@@ -110,6 +119,8 @@ export async function assessProgramReadiness(input, options = {}) {
110
119
  canStartCandidatePeriod,
111
120
  suggestedCandidatePeriodStart: canStartCandidatePeriod ? asOf : null,
112
121
  policyActivations,
122
+ documentActivations: governedContent.documentActivations,
123
+ trainingActivations: governedContent.trainingActivations,
113
124
  policyLibraryProposals: [
114
125
  ...policyLibrary.proposals,
115
126
  ...legacyPolicyLibraryProposals(records)
@@ -145,9 +156,9 @@ export async function assessEvidenceMap(input, options = {}) {
145
156
  status: counts.action ? "action" : "complete",
146
157
  counts,
147
158
  workflow: [
148
- `Choose an existing ${String(readiness.dataModelVersion) === "4" ? "Component" : "System"} or create one that is authoritative for each evidence family.`,
149
- `On every source ${String(readiness.dataModelVersion) === "4" ? "Component" : "System"}, set an evidence source role, name current evidence access owners, and write repeatable retrieval instructions in Record Markdown.`,
150
- `Map each selected Control to the authoritative source ${String(readiness.dataModelVersion) === "4" ? "Components with evidenceSourceComponentIds" : "Systems with evidenceSourceIds"} that produce its evidence.`,
159
+ `Choose an existing ${modelSupports(readiness.dataModelVersion, "component-sources") ? "Component" : "System"} or create one that is authoritative for each evidence family.`,
160
+ `On every source ${modelSupports(readiness.dataModelVersion, "component-sources") ? "Component" : "System"}, set an evidence source role, name current evidence access owners, and write repeatable retrieval instructions in Record Markdown.`,
161
+ `Map each selected Control to the authoritative source ${modelSupports(readiness.dataModelVersion, "component-sources") ? "Components with evidenceSourceComponentIds" : "Systems with evidenceSourceIds"} that produce its evidence.`,
151
162
  "Run program-readiness again and resolve every incomplete source check and control mapping before marking the Controls implemented."
152
163
  ],
153
164
  items: evidenceItems
@@ -169,7 +180,7 @@ function programScope(program, records, byId, model, loaded) {
169
180
  )),
170
181
  requirements: select(selectedRequirementIds(program, model), "requirement", (record) => (
171
182
  record.type === "requirement" && record.applicability === "applicable"
172
- )).filter((record) => String(model.modelVersion) === "4" || record.applicability === "applicable"),
183
+ )).filter((record) => modelSupports(model, "program-scope") || record.applicability === "applicable"),
173
184
  controls: select(program?.controlIds, "control", (record) => (
174
185
  record.type === "control" && !["not-applicable", "retired"].includes(record.status)
175
186
  )).filter((record) => !["not-applicable", "retired"].includes(record.status))
@@ -195,7 +206,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
195
206
  }
196
207
  ));
197
208
 
198
- if (String(model.modelVersion) === "4") {
209
+ if (modelSupports(model, "program-scope")) {
199
210
  const completeComponents = scope.components.filter((component) => (
200
211
  component.status === "active"
201
212
  && component.description
@@ -226,8 +237,8 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
226
237
 
227
238
  const completeSystems = scope.systems.filter((system) => (
228
239
  system.status === "active"
229
- && (String(model.modelVersion) === "4" ? system.purpose && system.boundary && (system.servicesProvided || []).length : system.description)
230
- && (String(model.modelVersion) === "4" || system.classificationId)
240
+ && (modelSupports(model, "program-scope") ? system.purpose && system.boundary && (system.servicesProvided || []).length : system.description)
241
+ && (modelSupports(model, "program-scope") || system.classificationId)
231
242
  && (system.ownerIds || []).length
232
243
  ));
233
244
  items.push(item(
@@ -235,12 +246,12 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
235
246
  scope.systems.length && completeSystems.length === scope.systems.length ? "complete" : "action",
236
247
  "Define the service boundary",
237
248
  scope.systems.length
238
- ? `${completeSystems.length} of ${scope.systems.length} program systems are active, explicitly in scope, owned, and described${String(model.modelVersion) === "4" ? "" : ", with a classification"}.`
249
+ ? `${completeSystems.length} of ${scope.systems.length} program systems are active, explicitly in scope, owned, and described${modelSupports(model, "program-scope") ? "" : ", with a classification"}.`
239
250
  : "Select and describe every service and supporting system in the program boundary.",
240
251
  scope.systems[0] || { type: "system" }
241
252
  ));
242
253
 
243
- if (["3", "4"].includes(String(model.modelVersion))) {
254
+ if (modelSupports(model, "guided-workflow")) {
244
255
  const commitments = records.filter((record) => (
245
256
  record.type === "commitment"
246
257
  && !["superseded", "retired"].includes(record.status)
@@ -250,7 +261,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
250
261
  record.status === "active"
251
262
  && record.statement
252
263
  && record.effectiveOn
253
- && (String(model.modelVersion) === "4" || record.applicabilityReview?.decision === "applicable")
264
+ && (modelSupports(model, "program-scope") || record.applicabilityReview?.decision === "applicable")
254
265
  && currentPartyPeople(record.ownerIds, byId).size > 0
255
266
  && (record.requirementIds || []).length > 0
256
267
  && (record.controlIds || []).length > 0
@@ -283,12 +294,12 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
283
294
  const applicableRequirements = records.filter((record) => (
284
295
  record.type === "requirement"
285
296
  && scope.frameworks.some((framework) => framework.id === record.frameworkId)
286
- && (String(model.modelVersion) === "4" ? v4Decisions.get(record.id) === "applicable" : record.applicability === "applicable")
297
+ && (modelSupports(model, "program-scope") ? v4Decisions.get(record.id) === "applicable" : record.applicability === "applicable")
287
298
  ));
288
299
  const unresolvedRequirements = records.filter((record) => (
289
300
  record.type === "requirement"
290
301
  && scope.frameworks.some((framework) => framework.id === record.frameworkId)
291
- && (String(model.modelVersion) === "4" ? !v4Decisions.has(record.id) || v4Decisions.get(record.id) === "undetermined" : record.applicability === "undetermined")
302
+ && (modelSupports(model, "program-scope") ? !v4Decisions.has(record.id) || v4Decisions.get(record.id) === "undetermined" : record.applicability === "undetermined")
292
303
  ));
293
304
  const missingRequirements = applicableRequirements.filter((record) => !selectedRequirementIds.has(record.id));
294
305
  const selectedDescriptionRequirements = scope.requirements.filter(isDescriptionRequirement);
@@ -299,7 +310,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
299
310
  !isDescriptionRequirement(requirement)
300
311
  && !scope.controls.some((control) => (control.requirementIds || []).includes(requirement.id))
301
312
  ));
302
- const enforceSoc2Baseline = String(model.modelVersion) === "4"
313
+ const enforceSoc2Baseline = modelSupports(model, "program-scope")
303
314
  && ["readiness", "soc-2-type-1", "soc-2-type-2"].includes(goal);
304
315
  const selectedFrameworkRequirements = records.filter((record) => (
305
316
  record.type === "requirement"
@@ -523,15 +534,22 @@ function ownershipResolutionReasons(ownerIds, byId) {
523
534
  });
524
535
  }
525
536
 
526
- async function policiesStage(scope, records, byId, readMarkdown) {
537
+ async function policiesStage(scope, records, byId, readMarkdown, model) {
527
538
  const policies = requiredPolicies(scope, byId);
528
- const appointedReviewer = policies
529
- .filter((policy) => partiesIndependent(policy.ownerIds, policy.approverIds, byId))
530
- .flatMap((policy) => [...currentPartyPeople(policy.approverIds || [], byId)])
539
+ const documents = modelSupports(model, "governed-document-activation")
540
+ ? requiredGovernedDocuments(scope, records, byId, model)
541
+ : [];
542
+ const trainings = modelSupports(model, "governed-training-activation")
543
+ ? records.filter(({ type, status }) => type === "training" && !["superseded", "retired"].includes(status))
544
+ : [];
545
+ const governedRecords = [...policies, ...documents, ...trainings];
546
+ const appointedReviewer = governedRecords
547
+ .filter((record) => partiesIndependent(record.ownerIds, record.approverIds, byId))
548
+ .flatMap((record) => [...currentPartyPeople(record.approverIds || [], byId)])
531
549
  .map((id) => byId.get(id))
532
550
  .find(Boolean);
533
- const policyOwnerIds = new Set(policies.flatMap((policy) => (
534
- [...partyPeople(policy.ownerIds || [], byId)]
551
+ const policyOwnerIds = new Set(governedRecords.flatMap((record) => (
552
+ [...partyPeople(record.ownerIds || [], byId)]
535
553
  )));
536
554
  const oversight = byId.get("team-security-risk-oversight");
537
555
  const availableReviewer = oversight?.type === "team" && oversight.status === "active"
@@ -540,7 +558,7 @@ async function policiesStage(scope, records, byId, readMarkdown) {
540
558
  .map((id) => byId.get(id))
541
559
  .find(Boolean)
542
560
  : null;
543
- const reviewerNeedsAssignment = !appointedReviewer && availableReviewer && policies.length;
561
+ const reviewerNeedsAssignment = !appointedReviewer && availableReviewer && governedRecords.length;
544
562
  const items = [
545
563
  item(
546
564
  "independent-reviewer",
@@ -553,7 +571,7 @@ async function policiesStage(scope, records, byId, readMarkdown) {
553
571
  : reviewerNeedsAssignment
554
572
  ? `${availableReviewer.title} chairs Security and Risk Oversight. Assign this person as approver on each policy after review.`
555
573
  : "Appoint a reviewer who is separate from the policy owner. The reviewer may be another person in the organization or an external person, and is separate from the CPA firm that may later perform the audit.",
556
- appointedReviewer || (reviewerNeedsAssignment ? policies[0] : { type: "person" }),
574
+ appointedReviewer || (reviewerNeedsAssignment ? governedRecords[0] : { type: "person" }),
557
575
  {
558
576
  commands: [
559
577
  "npx filegrc list appointment --workflow --json",
@@ -610,10 +628,97 @@ async function policiesStage(scope, records, byId, readMarkdown) {
610
628
  }
611
629
  ));
612
630
  }
631
+ for (const document of documents) {
632
+ const source = await readMarkdown(document);
633
+ const placeholderCount = openPlaceholderCount(source);
634
+ const isSecurityIncidentRecoveryPlan = document.id === "document-security-incident-recovery-plan";
635
+ const systemsWithCompleteContinuityObjectives = scope.systems.filter(({ continuityObjectives }) => (
636
+ Number.isInteger(continuityObjectives?.recoveryTimeHours)
637
+ && Number.isInteger(continuityObjectives?.recoveryPointHours)
638
+ && Number.isInteger(continuityObjectives?.maximumTolerableDowntimeHours)
639
+ ));
640
+ const checks = {
641
+ independentlyApproved: ["approved", "active"].includes(document.status)
642
+ && Boolean(document.approvedOn)
643
+ && Boolean(document.approvedContentRevisions)
644
+ && partiesIndependent(document.ownerIds, document.approverIds, byId),
645
+ owner: currentPartyPeople(document.ownerIds, byId).size > 0,
646
+ linkedControls: (document.controlIds || []).some((id) => scope.controls.some((control) => control.id === id)),
647
+ contentComplete: substantiveMarkdown(source) && placeholderCount === 0,
648
+ ...(isSecurityIncidentRecoveryPlan
649
+ ? { systemContinuityObjectives: systemsWithCompleteContinuityObjectives.length === scope.systems.length && scope.systems.length > 0 }
650
+ : {})
651
+ };
652
+ const missing = Object.entries(checks)
653
+ .filter(([, value]) => !value)
654
+ .map(([name]) => governedApprovalCheckLabel(name));
655
+ items.push(item(
656
+ `document-approval-${document.id}`,
657
+ missing.length ? "action" : "complete",
658
+ document.title,
659
+ missing.length
660
+ ? `Remaining Step 2 approval work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}. Complete and approve the intended values before implementation.`
661
+ : `Independently approved on ${document.approvedOn} and bound to the exact intended values and Markdown revision. Activation remains in Step 3.`,
662
+ document,
663
+ {
664
+ checks,
665
+ placeholderCount,
666
+ ...(isSecurityIncidentRecoveryPlan
667
+ ? {
668
+ continuityObjectiveSystemIds: systemsWithCompleteContinuityObjectives.map(({ id }) => id),
669
+ missingContinuityObjectiveSystemIds: scope.systems
670
+ .filter(({ id }) => !systemsWithCompleteContinuityObjectives.some((system) => system.id === id))
671
+ .map(({ id }) => id)
672
+ }
673
+ : {}),
674
+ commands: [
675
+ `npx filegrc get ${shellArgument(document.id)} --mutation`,
676
+ `npx filegrc update document ${shellArgument(document.id)} MUTATION.json --json`,
677
+ "npx filegrc program-readiness --json"
678
+ ]
679
+ }
680
+ ));
681
+ }
682
+ if (modelSupports(model, "governed-training-activation")) {
683
+ for (const training of trainings) {
684
+ const source = await readMarkdown(training);
685
+ const placeholderCount = openPlaceholderCount(source);
686
+ const checks = {
687
+ independentlyApproved: ["approved", "active"].includes(training.status)
688
+ && Boolean(training.approvedOn)
689
+ && Boolean(training.approvedContentRevisions)
690
+ && partiesIndependent(training.ownerIds, training.approverIds, byId),
691
+ owner: currentPartyPeople(training.ownerIds, byId).size > 0,
692
+ linkedControls: (training.controlIds || []).some((id) => scope.controls.some((control) => control.id === id)),
693
+ contentComplete: substantiveMarkdown(source) && placeholderCount === 0
694
+ };
695
+ const missing = Object.entries(checks)
696
+ .filter(([, value]) => !value)
697
+ .map(([name]) => governedApprovalCheckLabel(name));
698
+ items.push(item(
699
+ `training-approval-${training.id}`,
700
+ missing.length ? "action" : "complete",
701
+ training.title,
702
+ missing.length
703
+ ? `Remaining Step 2 approval work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}. Review and approve the exact Training content before implementation.`
704
+ : `Independently approved on ${training.approvedOn} and bound to the exact Training revision. Activation remains in Step 3.`,
705
+ training,
706
+ {
707
+ checks,
708
+ placeholderCount,
709
+ commands: [
710
+ `npx filegrc get ${shellArgument(training.id)} --mutation`,
711
+ `npx filegrc update training ${shellArgument(training.id)} MUTATION.json --json`,
712
+ "npx filegrc program-readiness --json"
713
+ ]
714
+ }
715
+ ));
716
+ }
717
+ }
613
718
  return stage(
614
719
  "policies",
615
720
  "Approve Policies",
616
- "A Policy says what the company commits to do by the date it takes effect. Approval means the company accepts those commitments. It does not prove the work is done. Controls and operating records describe how the company meets them and provide the proof.",
721
+ "Approve the exact Policy, program Document, and Training content that defines what the organization intends to require. Approval does not prove implementation or activate the content.",
617
722
  items
618
723
  );
619
724
  }
@@ -627,8 +732,31 @@ function requiredPolicies(scope, byId) {
627
732
  ));
628
733
  }
629
734
 
630
- async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
735
+ function requiredGovernedDocuments(scope, records, byId, model) {
631
736
  const selectedControlIds = new Set(scope.controls.map(({ id }) => id));
737
+ const linkedDocumentIds = new Set(requiredPolicies(scope, byId).flatMap((policy) => policy.relatedDocumentIds || []));
738
+ const obligationDocumentIds = new Set(records
739
+ .filter((record) => record.type === "obligation")
740
+ .flatMap((record) => [
741
+ ...(record.scopeResourceIds || []),
742
+ ...(record.templateResourceId ? [record.templateResourceId] : [])
743
+ ]));
744
+ return records.filter((record) => (
745
+ record.type === "document"
746
+ && !documentIsAuditSpecific(record, model)
747
+ && !["superseded", "retired"].includes(record.status)
748
+ && (
749
+ linkedDocumentIds.has(record.id)
750
+ || obligationDocumentIds.has(record.id)
751
+ || (
752
+ record.programRole === "required"
753
+ && (record.controlIds || []).some((id) => selectedControlIds.has(id))
754
+ )
755
+ )
756
+ ));
757
+ }
758
+
759
+ async function governedContentItems(scope, records, byId, readMarkdown, asOf, model) {
632
760
  const enabledObligations = records.filter((record) => (
633
761
  record.type === "obligation" && obligationIsEnabled(record)
634
762
  ));
@@ -636,31 +764,47 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
636
764
  ...(record.scopeResourceIds || []),
637
765
  ...(record.templateResourceId ? [record.templateResourceId] : [])
638
766
  ]));
639
- const governedRecords = records.filter((record) => (
640
- (
641
- record.type === "document"
767
+ const documents = requiredGovernedDocuments(scope, records, byId, model);
768
+ const governedRecords = [
769
+ ...documents,
770
+ ...records.filter((record) => (
771
+ record.type === "training"
772
+ && !["superseded", "retired"].includes(record.status)
642
773
  && (
643
774
  requiredGovernedIds.has(record.id)
644
- || (
645
- record.programRole === "required"
646
- && (record.controlIds || []).some((id) => selectedControlIds.has(id))
647
- )
775
+ || (record.controlIds || []).some((id) => scope.controls.some((control) => control.id === id))
648
776
  )
649
- )
650
- || (record.type === "training" && requiredGovernedIds.has(record.id))
651
- ));
777
+ ))
778
+ ];
652
779
  const items = [];
780
+ const documentActivations = [];
781
+ const trainingActivations = [];
653
782
  for (const record of governedRecords) {
654
783
  const source = await readMarkdown(record);
655
784
  const placeholderCount = openPlaceholderCount(source);
656
- const isSecurityIncidentRecoveryPlan = record.id === "document-security-incident-recovery-plan";
657
- const systemsWithCompleteContinuityObjectives = scope.systems.filter(({ continuityObjectives }) => (
658
- Number.isInteger(continuityObjectives?.recoveryTimeHours)
659
- && Number.isInteger(continuityObjectives?.recoveryPointHours)
660
- && Number.isInteger(continuityObjectives?.maximumTolerableDowntimeHours)
661
- ));
785
+ const linkedControlIds = (record.controlIds || []).filter((id) => scope.controls.some((control) => control.id === id));
786
+ const missingImplementationControlIds = linkedControlIds.filter((id) => byId.get(id)?.status !== "implemented");
662
787
  const checks = record.type === "document"
663
- ? {
788
+ ? modelSupports(model, "governed-document-activation") ? {
789
+ approvalBound: ["approved", "active"].includes(record.status)
790
+ && Boolean(record.approvedOn)
791
+ && Boolean(record.approvedContentRevisions)
792
+ && partiesIndependent(record.ownerIds, record.approverIds, byId),
793
+ owner: currentPartyPeople(record.ownerIds, byId).size > 0,
794
+ active: record.status === "active",
795
+ requirementsImplemented: linkedControlIds.length > 0 && missingImplementationControlIds.length === 0,
796
+ activationRecorded: record.activationBasis === "recorded",
797
+ activated: Boolean(record.activatedOn),
798
+ activator: Boolean((record.activatedByIds || []).length)
799
+ && record.activatedByIds.every((id) => personWasActiveOn(byId.get(id), record.activatedOn)),
800
+ activatedContent: Boolean(record.activatedContentRevisions),
801
+ activationMatchesApproval: contentRevisionBindingsMatch(
802
+ record.approvedContentRevisions,
803
+ record.activatedContentRevisions
804
+ ),
805
+ effective: Boolean(record.effectiveOn && record.effectiveOn <= asOf),
806
+ contentComplete: substantiveMarkdown(source) && placeholderCount === 0
807
+ } : {
664
808
  active: record.status === "active",
665
809
  owner: currentPartyPeople(record.ownerIds, byId).size > 0,
666
810
  independentlyApproved: Boolean(
@@ -668,12 +812,32 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
668
812
  && partiesIndependent(record.ownerIds, record.approverIds, byId)
669
813
  ),
670
814
  effective: Boolean(record.effectiveOn && record.effectiveOn <= asOf),
671
- contentComplete: substantiveMarkdown(source) && placeholderCount === 0,
672
- ...(isSecurityIncidentRecoveryPlan
673
- ? { systemContinuityObjectives: systemsWithCompleteContinuityObjectives.length === scope.systems.length && scope.systems.length > 0 }
674
- : {})
815
+ contentComplete: substantiveMarkdown(source) && placeholderCount === 0
675
816
  }
676
- : {
817
+ : modelSupports(model, "governed-training-activation") ? {
818
+ approvalBound: ["approved", "active"].includes(record.status)
819
+ && Boolean(record.approvedOn)
820
+ && Boolean(record.approvedContentRevisions)
821
+ && partiesIndependent(record.ownerIds, record.approverIds, byId),
822
+ owner: currentPartyPeople(record.ownerIds, byId).size > 0,
823
+ active: record.status === "active",
824
+ requirementsImplemented: linkedControlIds.length > 0 && missingImplementationControlIds.length === 0,
825
+ assignmentScheduled: enabledObligations.some((obligation) => (
826
+ obligation.templateResourceId === record.id
827
+ || (obligation.scopeResourceIds || []).includes(record.id)
828
+ )),
829
+ activationRecorded: ["recorded", "legacy-v5"].includes(record.activationBasis),
830
+ activated: record.activationBasis === "legacy-v5" || Boolean(record.activatedOn),
831
+ activator: record.activationBasis === "legacy-v5" || Boolean((record.activatedByIds || []).length)
832
+ && record.activatedByIds.every((id) => personWasActiveOn(byId.get(id), record.activatedOn)),
833
+ activatedContent: record.activationBasis === "legacy-v5" || Boolean(record.activatedContentRevisions),
834
+ activationMatchesApproval: record.activationBasis === "legacy-v5" || contentRevisionBindingsMatch(
835
+ record.approvedContentRevisions,
836
+ record.activatedContentRevisions
837
+ ),
838
+ effective: Boolean(record.effectiveOn && record.effectiveOn <= asOf),
839
+ contentComplete: substantiveMarkdown(source) && placeholderCount === 0
840
+ } : {
677
841
  active: record.status === "active",
678
842
  owner: currentPartyPeople(record.ownerIds, byId).size > 0,
679
843
  approved: Boolean(record.approvedOn && (record.approvedByIds || []).length),
@@ -684,6 +848,70 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
684
848
  const missing = Object.entries(checks)
685
849
  .filter(([, value]) => !value)
686
850
  .map(([name]) => governedContentCheckLabel(name));
851
+ if (record.type === "document" && modelSupports(model, "governed-document-activation")) {
852
+ const activationComplete = Object.values(checks).every(Boolean);
853
+ const preActivationCheckNames = ["approvalBound", "owner", "requirementsImplemented", "contentComplete"];
854
+ const preActivationGapCount = preActivationCheckNames.filter((name) => !checks[name]).length;
855
+ const readyToActivate = record.status === "approved"
856
+ && checks.approvalBound
857
+ && checks.owner
858
+ && checks.requirementsImplemented
859
+ && checks.contentComplete;
860
+ const state = record.status === "active" && activationComplete
861
+ ? "active-and-operating"
862
+ : record.status === "active"
863
+ ? "active-with-gaps"
864
+ : readyToActivate
865
+ ? "ready-to-activate"
866
+ : record.status === "approved"
867
+ ? "approved-implementation-pending"
868
+ : "approval-pending";
869
+ documentActivations.push({
870
+ documentId: record.id,
871
+ title: record.title,
872
+ state,
873
+ label: documentActivationLabel(state),
874
+ approvedOn: record.approvedOn || null,
875
+ activatedOn: record.activatedOn || null,
876
+ effectiveOn: record.effectiveOn || null,
877
+ linkedControlIds,
878
+ missingImplementationControlIds,
879
+ activationRevisionBound: Boolean(record.activatedContentRevisions),
880
+ activatedByIds: record.activatedByIds || [],
881
+ gapCount: record.status === "active" ? missing.length : preActivationGapCount
882
+ });
883
+ }
884
+ if (record.type === "training" && modelSupports(model, "governed-training-activation")) {
885
+ const activationComplete = Object.values(checks).every(Boolean);
886
+ const preActivationCheckNames = ["approvalBound", "owner", "requirementsImplemented", "assignmentScheduled", "contentComplete"];
887
+ const preActivationGapCount = preActivationCheckNames.filter((name) => !checks[name]).length;
888
+ const readyToActivate = record.status === "approved"
889
+ && preActivationCheckNames.every((name) => checks[name]);
890
+ const state = record.status === "active" && activationComplete
891
+ ? "active-and-operating"
892
+ : record.status === "active"
893
+ ? "active-with-gaps"
894
+ : readyToActivate
895
+ ? "ready-to-activate"
896
+ : record.status === "approved"
897
+ ? "approved-implementation-pending"
898
+ : "approval-pending";
899
+ trainingActivations.push({
900
+ trainingId: record.id,
901
+ title: record.title,
902
+ state,
903
+ label: documentActivationLabel(state),
904
+ approvedOn: record.approvedOn || null,
905
+ activatedOn: record.activatedOn || null,
906
+ effectiveOn: record.effectiveOn || null,
907
+ linkedControlIds,
908
+ missingImplementationControlIds,
909
+ assignmentScheduled: checks.assignmentScheduled,
910
+ activationRevisionBound: Boolean(record.activatedContentRevisions),
911
+ activatedByIds: record.activatedByIds || [],
912
+ gapCount: record.status === "active" ? missing.length : preActivationGapCount
913
+ });
914
+ }
687
915
  items.push(item(
688
916
  `${record.type}-${record.id}`,
689
917
  missing.length ? "action" : "complete",
@@ -691,20 +919,15 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
691
919
  missing.length
692
920
  ? `Remaining governed-content work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}.`
693
921
  : record.type === "document"
694
- ? `Active, approved by a separate reviewer, effective ${record.effectiveOn}, and ready for the selected Controls or running schedule.`
922
+ ? modelSupports(model, "governed-document-activation")
923
+ ? `Approved on ${record.approvedOn}, activated separately on ${record.activatedOn}, effective ${record.effectiveOn}, revision-bound at both events, and ready for operation.`
924
+ : `Active, approved by a separate reviewer, effective ${record.effectiveOn}, and ready for the selected Controls or running schedule.`
695
925
  : `Active, approved, effective ${record.effectiveOn}, revision-bound, and ready for the running training schedule.`,
696
926
  record,
697
927
  {
698
928
  checks,
699
929
  placeholderCount,
700
- ...(isSecurityIncidentRecoveryPlan
701
- ? {
702
- continuityObjectiveSystemIds: systemsWithCompleteContinuityObjectives.map(({ id }) => id),
703
- missingContinuityObjectiveSystemIds: scope.systems
704
- .filter(({ id }) => !systemsWithCompleteContinuityObjectives.some((system) => system.id === id))
705
- .map(({ id }) => id)
706
- }
707
- : {}),
930
+ ...(["document", "training"].includes(record.type) ? { linkedControlIds, missingImplementationControlIds } : {}),
708
931
  commands: [
709
932
  `npx filegrc get ${shellArgument(record.id)} --mutation`,
710
933
  `npx filegrc update ${record.type} ${shellArgument(record.id)} MUTATION.json --json`,
@@ -713,12 +936,22 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
713
936
  }
714
937
  ));
715
938
  }
716
- return items;
939
+ return { items, documentActivations, trainingActivations };
940
+ }
941
+
942
+ function documentActivationLabel(state) {
943
+ return ({
944
+ "approval-pending": "Approval pending in Step 2",
945
+ "approved-implementation-pending": "Approved, implementation pending",
946
+ "ready-to-activate": "Ready to activate",
947
+ "active-with-gaps": "Active with activation or implementation gaps",
948
+ "active-and-operating": "Active and operating"
949
+ })[state] || state;
717
950
  }
718
951
 
719
952
  async function assessPolicyActivations(policies, controls, records, byId, readMarkdown, asOf, model) {
720
- const sourceType = String(model.modelVersion) === "4" ? "component" : "system";
721
- const sourceField = String(model.modelVersion) === "4" ? "evidenceSourceComponentIds" : "evidenceSourceIds";
953
+ const sourceType = modelSupports(model, "component-sources") ? "component" : "system";
954
+ const sourceField = modelSupports(model, "component-sources") ? "evidenceSourceComponentIds" : "evidenceSourceIds";
722
955
  const assessments = [];
723
956
  for (const policy of policies.filter((record) => ["approved", "active"].includes(record.status))) {
724
957
  const linkedControls = controls.filter((control) => (control.policyIds || []).includes(policy.id));
@@ -726,7 +959,7 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
726
959
  const plannedOrPartialControlIds = linkedControls
727
960
  .filter((control) => ["planned", "partially-implemented"].includes(control.status))
728
961
  .map(({ id }) => id);
729
- const missingComponentControlIds = String(model.modelVersion) === "4"
962
+ const missingComponentControlIds = modelSupports(model, "component-sources")
730
963
  ? linkedControls.filter((control) => ![
731
964
  ...(control.componentIds || []),
732
965
  ...(control.evidenceSourceComponentIds || [])
@@ -759,6 +992,18 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
759
992
  && (record.policyIds || []).includes(policy.id)
760
993
  ))
761
994
  )).map(({ id }) => id);
995
+ const linkedGovernedDocumentIds = modelSupports(model, "governed-document-activation")
996
+ ? (policy.relatedDocumentIds || []).filter((id) => {
997
+ const document = byId.get(id);
998
+ return document?.type === "document"
999
+ && !["superseded", "retired"].includes(document.status)
1000
+ && !documentIsAuditSpecific(document, model);
1001
+ })
1002
+ : [];
1003
+ const missingGovernedDocumentIds = linkedGovernedDocumentIds.filter((id) => {
1004
+ const document = byId.get(id);
1005
+ return !governedDocumentIsOperating(document, asOf, model);
1006
+ });
762
1007
  const relevantExceptions = records.filter((record) => (
763
1008
  record.type === "exception"
764
1009
  && (record.scopeResourceIds || []).some((id) => id === policy.id || linkedControlIds.includes(id))
@@ -788,6 +1033,7 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
788
1033
  + missingComponentControlIds.length
789
1034
  + missingEvidenceSourceControlIds.length
790
1035
  + missingScheduleControlIds.length
1036
+ + missingGovernedDocumentIds.length
791
1037
  + unresolvedExceptionIds.length
792
1038
  + timingWarnings.length;
793
1039
  const activeNow = policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf;
@@ -811,6 +1057,8 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
811
1057
  missingComponentControlIds,
812
1058
  missingEvidenceSourceControlIds,
813
1059
  missingScheduleControlIds,
1060
+ linkedGovernedDocumentIds,
1061
+ missingGovernedDocumentIds,
814
1062
  unresolvedExceptionIds,
815
1063
  documentedExceptionIds,
816
1064
  timingWarnings,
@@ -840,11 +1088,12 @@ function policyActivationItem(assessment) {
840
1088
  [assessment.plannedOrPartialControlIds.length, "planned or partial Controls"],
841
1089
  [assessment.missingComponentControlIds.length, "Controls missing active Components"],
842
1090
  [assessment.missingEvidenceSourceControlIds.length, "Controls missing ready evidence sources"],
843
- [assessment.missingScheduleControlIds.length, "Controls missing enabled schedules"],
1091
+ [assessment.missingScheduleControlIds.length, "Controls missing enabled Obligations"],
1092
+ [assessment.missingGovernedDocumentIds?.length || 0, "required governed Documents not active"],
844
1093
  [assessment.unresolvedExceptionIds.length, "unresolved Exceptions"]
845
1094
  ].filter(([count]) => count).map(([count, label]) => `${count} ${label}`);
846
1095
  const message = assessment.state === "active-and-operating"
847
- ? `Active and effective ${assessment.effectiveOn}; all ${assessment.linkedControlIds.length} linked Controls are implemented with Components, evidence sources, and enabled schedules.`
1096
+ ? `Active and effective ${assessment.effectiveOn}; all ${assessment.linkedControlIds.length} linked Controls are implemented with Components, evidence sources, and enabled Obligations.`
848
1097
  : assessment.state === "ready-to-activate"
849
1098
  ? "Implementation checks are complete. Include this approved Policy in the Step 3 cutover when you are ready for it to take effect."
850
1099
  : `${assessment.label}: ${counts.join(", ") || assessment.timingWarnings.join(" ")}. ${assessment.activationWarning || ""}`.trim();
@@ -893,10 +1142,10 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
893
1142
  for (const control of scope.controls) {
894
1143
  const source = await readMarkdown(control);
895
1144
  const sourceSystems = (
896
- String(model.modelVersion) === "4"
1145
+ modelSupports(model, "component-sources")
897
1146
  ? control.evidenceSourceComponentIds || []
898
1147
  : control.evidenceSourceIds || []
899
- ).map((id) => byId.get(id)).filter((record) => record?.type === (String(model.modelVersion) === "4" ? "component" : "system"));
1148
+ ).map((id) => byId.get(id)).filter((record) => record?.type === (modelSupports(model, "component-sources") ? "component" : "system"));
900
1149
  const queueSchedules = [...byId.values()].filter((record) => (
901
1150
  record.type === "obligation"
902
1151
  && record.status !== "retired"
@@ -951,20 +1200,20 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
951
1200
  ],
952
1201
  workQueue: queueSchedules.length ? {
953
1202
  enabled: queueSchedules.filter(obligationIsEnabled).length,
954
- running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf)).length,
1203
+ running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf, model)).length,
955
1204
  total: queueSchedules.length
956
1205
  } : null
957
1206
  }
958
1207
  ));
959
1208
  }
960
- return stage("controls", "Implement Controls", "Each implemented Control needs an owner, actual procedure, scope, operation pattern, evidence source, mappings, an implementation date, and any required Work Queue schedules enabled. An enabled schedule stays dormant until its governing Policy is active and effective.", items);
1209
+ return stage("controls", "Implement Controls", "Each implemented Control needs an owner, actual procedure, scope, operation pattern, evidence source, mappings, an implementation date, and any required Obligations enabled. Scheduled work stays dormant until its governing Policy, program Documents, and Training are active and effective.", items);
961
1210
  }
962
1211
 
963
1212
  async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
964
1213
  const families = selectedControlFamilies(scope.controls, model);
965
1214
  const items = [];
966
1215
  for (const family of families) {
967
- const componentSources = String(model.modelVersion) === "4";
1216
+ const componentSources = modelSupports(model, "component-sources");
968
1217
  const sourceField = componentSources ? "evidenceSourceComponentIds" : "evidenceSourceIds";
969
1218
  const sourceType = componentSources ? "component" : "system";
970
1219
  const selectedSources = [...new Set(family.controls.flatMap((control) => control[sourceField] || []))]
@@ -1056,7 +1305,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
1056
1305
  }
1057
1306
  ));
1058
1307
  }
1059
- return stage("sources", "Control Evidence Sources", `Complete the authoritative ${String(model.modelVersion) === "4" ? "Components" : "Systems"} for every selected control family before marking the Controls implemented.`, items);
1308
+ return stage("sources", "Control Evidence Sources", `Complete the authoritative ${modelSupports(model, "component-sources") ? "Components" : "Systems"} for every selected control family before marking the Controls implemented.`, items);
1060
1309
  }
1061
1310
 
1062
1311
  function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
@@ -1271,14 +1520,32 @@ function policyCheckLabel(name) {
1271
1520
  })[name] || name;
1272
1521
  }
1273
1522
 
1523
+ function governedApprovalCheckLabel(name) {
1524
+ return ({
1525
+ independentlyApproved: "independent approval and approval date",
1526
+ owner: "current owner",
1527
+ linkedControls: "linked Controls",
1528
+ contentComplete: "intended values, content, and organization placeholders",
1529
+ systemContinuityObjectives: "RTO, RPO, and maximum tolerable downtime for every in-scope System"
1530
+ })[name] || name;
1531
+ }
1532
+
1274
1533
  function governedContentCheckLabel(name) {
1275
1534
  return ({
1535
+ approvalBound: "Step 2 independent approval and approved revision",
1276
1536
  active: "active status",
1277
1537
  owner: "current owner",
1278
1538
  independentlyApproved: "independent approval and approval date",
1279
1539
  approved: "approval and approval date",
1280
1540
  effective: "effective date",
1281
1541
  effectiveContent: "effective content revision",
1542
+ requirementsImplemented: "implemented linked requirements",
1543
+ assignmentScheduled: "enabled Training assignment schedule",
1544
+ activationRecorded: "recorded activation basis",
1545
+ activated: "separate activation date",
1546
+ activator: "named activation Person",
1547
+ activatedContent: "separate activated content revision",
1548
+ activationMatchesApproval: "unchanged approved revision at activation",
1282
1549
  contentComplete: "content and organization placeholders",
1283
1550
  systemContinuityObjectives: "RTO, RPO, and maximum tolerable downtime for every in-scope System"
1284
1551
  })[name] || name;
@@ -1299,7 +1566,7 @@ function controlCheckLabel(name) {
1299
1566
  implementationReview: "independent implementation review",
1300
1567
  policyMapping: "policy mapping",
1301
1568
  criteriaMapping: "criteria mapping",
1302
- workQueue: "running Work Queue schedules"
1569
+ workQueue: "running Obligation schedules"
1303
1570
  })[name] || name;
1304
1571
  }
1305
1572