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.
@@ -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 schedules, and complete authoritative source ${modelSupports(loaded.model, "component-sources") ? "Components" : "Systems"}. Activate the unchanged approved governed Documents 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,7 @@ export async function assessProgramReadiness(input, options = {}) {
110
119
  canStartCandidatePeriod,
111
120
  suggestedCandidatePeriodStart: canStartCandidatePeriod ? asOf : null,
112
121
  policyActivations,
122
+ documentActivations: governedContent.documentActivations,
113
123
  policyLibraryProposals: [
114
124
  ...policyLibrary.proposals,
115
125
  ...legacyPolicyLibraryProposals(records)
@@ -145,9 +155,9 @@ export async function assessEvidenceMap(input, options = {}) {
145
155
  status: counts.action ? "action" : "complete",
146
156
  counts,
147
157
  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.`,
158
+ `Choose an existing ${modelSupports(readiness.dataModelVersion, "component-sources") ? "Component" : "System"} or create one that is authoritative for each evidence family.`,
159
+ `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.`,
160
+ `Map each selected Control to the authoritative source ${modelSupports(readiness.dataModelVersion, "component-sources") ? "Components with evidenceSourceComponentIds" : "Systems with evidenceSourceIds"} that produce its evidence.`,
151
161
  "Run program-readiness again and resolve every incomplete source check and control mapping before marking the Controls implemented."
152
162
  ],
153
163
  items: evidenceItems
@@ -169,7 +179,7 @@ function programScope(program, records, byId, model, loaded) {
169
179
  )),
170
180
  requirements: select(selectedRequirementIds(program, model), "requirement", (record) => (
171
181
  record.type === "requirement" && record.applicability === "applicable"
172
- )).filter((record) => String(model.modelVersion) === "4" || record.applicability === "applicable"),
182
+ )).filter((record) => modelSupports(model, "program-scope") || record.applicability === "applicable"),
173
183
  controls: select(program?.controlIds, "control", (record) => (
174
184
  record.type === "control" && !["not-applicable", "retired"].includes(record.status)
175
185
  )).filter((record) => !["not-applicable", "retired"].includes(record.status))
@@ -195,7 +205,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
195
205
  }
196
206
  ));
197
207
 
198
- if (String(model.modelVersion) === "4") {
208
+ if (modelSupports(model, "program-scope")) {
199
209
  const completeComponents = scope.components.filter((component) => (
200
210
  component.status === "active"
201
211
  && component.description
@@ -226,8 +236,8 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
226
236
 
227
237
  const completeSystems = scope.systems.filter((system) => (
228
238
  system.status === "active"
229
- && (String(model.modelVersion) === "4" ? system.purpose && system.boundary && (system.servicesProvided || []).length : system.description)
230
- && (String(model.modelVersion) === "4" || system.classificationId)
239
+ && (modelSupports(model, "program-scope") ? system.purpose && system.boundary && (system.servicesProvided || []).length : system.description)
240
+ && (modelSupports(model, "program-scope") || system.classificationId)
231
241
  && (system.ownerIds || []).length
232
242
  ));
233
243
  items.push(item(
@@ -235,12 +245,12 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
235
245
  scope.systems.length && completeSystems.length === scope.systems.length ? "complete" : "action",
236
246
  "Define the service boundary",
237
247
  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"}.`
248
+ ? `${completeSystems.length} of ${scope.systems.length} program systems are active, explicitly in scope, owned, and described${modelSupports(model, "program-scope") ? "" : ", with a classification"}.`
239
249
  : "Select and describe every service and supporting system in the program boundary.",
240
250
  scope.systems[0] || { type: "system" }
241
251
  ));
242
252
 
243
- if (["3", "4"].includes(String(model.modelVersion))) {
253
+ if (modelSupports(model, "guided-workflow")) {
244
254
  const commitments = records.filter((record) => (
245
255
  record.type === "commitment"
246
256
  && !["superseded", "retired"].includes(record.status)
@@ -250,7 +260,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
250
260
  record.status === "active"
251
261
  && record.statement
252
262
  && record.effectiveOn
253
- && (String(model.modelVersion) === "4" || record.applicabilityReview?.decision === "applicable")
263
+ && (modelSupports(model, "program-scope") || record.applicabilityReview?.decision === "applicable")
254
264
  && currentPartyPeople(record.ownerIds, byId).size > 0
255
265
  && (record.requirementIds || []).length > 0
256
266
  && (record.controlIds || []).length > 0
@@ -283,12 +293,12 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
283
293
  const applicableRequirements = records.filter((record) => (
284
294
  record.type === "requirement"
285
295
  && scope.frameworks.some((framework) => framework.id === record.frameworkId)
286
- && (String(model.modelVersion) === "4" ? v4Decisions.get(record.id) === "applicable" : record.applicability === "applicable")
296
+ && (modelSupports(model, "program-scope") ? v4Decisions.get(record.id) === "applicable" : record.applicability === "applicable")
287
297
  ));
288
298
  const unresolvedRequirements = records.filter((record) => (
289
299
  record.type === "requirement"
290
300
  && 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")
301
+ && (modelSupports(model, "program-scope") ? !v4Decisions.has(record.id) || v4Decisions.get(record.id) === "undetermined" : record.applicability === "undetermined")
292
302
  ));
293
303
  const missingRequirements = applicableRequirements.filter((record) => !selectedRequirementIds.has(record.id));
294
304
  const selectedDescriptionRequirements = scope.requirements.filter(isDescriptionRequirement);
@@ -299,7 +309,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
299
309
  !isDescriptionRequirement(requirement)
300
310
  && !scope.controls.some((control) => (control.requirementIds || []).includes(requirement.id))
301
311
  ));
302
- const enforceSoc2Baseline = String(model.modelVersion) === "4"
312
+ const enforceSoc2Baseline = modelSupports(model, "program-scope")
303
313
  && ["readiness", "soc-2-type-1", "soc-2-type-2"].includes(goal);
304
314
  const selectedFrameworkRequirements = records.filter((record) => (
305
315
  record.type === "requirement"
@@ -523,15 +533,19 @@ function ownershipResolutionReasons(ownerIds, byId) {
523
533
  });
524
534
  }
525
535
 
526
- async function policiesStage(scope, records, byId, readMarkdown) {
536
+ async function policiesStage(scope, records, byId, readMarkdown, model) {
527
537
  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)])
538
+ const documents = modelSupports(model, "governed-document-activation")
539
+ ? requiredGovernedDocuments(scope, records, byId, model)
540
+ : [];
541
+ const governedRecords = [...policies, ...documents];
542
+ const appointedReviewer = governedRecords
543
+ .filter((record) => partiesIndependent(record.ownerIds, record.approverIds, byId))
544
+ .flatMap((record) => [...currentPartyPeople(record.approverIds || [], byId)])
531
545
  .map((id) => byId.get(id))
532
546
  .find(Boolean);
533
- const policyOwnerIds = new Set(policies.flatMap((policy) => (
534
- [...partyPeople(policy.ownerIds || [], byId)]
547
+ const policyOwnerIds = new Set(governedRecords.flatMap((record) => (
548
+ [...partyPeople(record.ownerIds || [], byId)]
535
549
  )));
536
550
  const oversight = byId.get("team-security-risk-oversight");
537
551
  const availableReviewer = oversight?.type === "team" && oversight.status === "active"
@@ -540,7 +554,7 @@ async function policiesStage(scope, records, byId, readMarkdown) {
540
554
  .map((id) => byId.get(id))
541
555
  .find(Boolean)
542
556
  : null;
543
- const reviewerNeedsAssignment = !appointedReviewer && availableReviewer && policies.length;
557
+ const reviewerNeedsAssignment = !appointedReviewer && availableReviewer && governedRecords.length;
544
558
  const items = [
545
559
  item(
546
560
  "independent-reviewer",
@@ -553,7 +567,7 @@ async function policiesStage(scope, records, byId, readMarkdown) {
553
567
  : reviewerNeedsAssignment
554
568
  ? `${availableReviewer.title} chairs Security and Risk Oversight. Assign this person as approver on each policy after review.`
555
569
  : "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" }),
570
+ appointedReviewer || (reviewerNeedsAssignment ? governedRecords[0] : { type: "person" }),
557
571
  {
558
572
  commands: [
559
573
  "npx filegrc list appointment --workflow --json",
@@ -610,10 +624,61 @@ async function policiesStage(scope, records, byId, readMarkdown) {
610
624
  }
611
625
  ));
612
626
  }
627
+ for (const document of documents) {
628
+ const source = await readMarkdown(document);
629
+ const placeholderCount = openPlaceholderCount(source);
630
+ const isSecurityIncidentRecoveryPlan = document.id === "document-security-incident-recovery-plan";
631
+ const systemsWithCompleteContinuityObjectives = scope.systems.filter(({ continuityObjectives }) => (
632
+ Number.isInteger(continuityObjectives?.recoveryTimeHours)
633
+ && Number.isInteger(continuityObjectives?.recoveryPointHours)
634
+ && Number.isInteger(continuityObjectives?.maximumTolerableDowntimeHours)
635
+ ));
636
+ const checks = {
637
+ independentlyApproved: ["approved", "active"].includes(document.status)
638
+ && Boolean(document.approvedOn)
639
+ && Boolean(document.approvedContentRevisions)
640
+ && partiesIndependent(document.ownerIds, document.approverIds, byId),
641
+ owner: currentPartyPeople(document.ownerIds, byId).size > 0,
642
+ linkedControls: (document.controlIds || []).some((id) => scope.controls.some((control) => control.id === id)),
643
+ contentComplete: substantiveMarkdown(source) && placeholderCount === 0,
644
+ ...(isSecurityIncidentRecoveryPlan
645
+ ? { systemContinuityObjectives: systemsWithCompleteContinuityObjectives.length === scope.systems.length && scope.systems.length > 0 }
646
+ : {})
647
+ };
648
+ const missing = Object.entries(checks)
649
+ .filter(([, value]) => !value)
650
+ .map(([name]) => governedApprovalCheckLabel(name));
651
+ items.push(item(
652
+ `document-approval-${document.id}`,
653
+ missing.length ? "action" : "complete",
654
+ document.title,
655
+ missing.length
656
+ ? `Remaining Step 2 approval work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}. Complete and approve the intended values before implementation.`
657
+ : `Independently approved on ${document.approvedOn} and bound to the exact intended values and Markdown revision. Activation remains in Step 3.`,
658
+ document,
659
+ {
660
+ checks,
661
+ placeholderCount,
662
+ ...(isSecurityIncidentRecoveryPlan
663
+ ? {
664
+ continuityObjectiveSystemIds: systemsWithCompleteContinuityObjectives.map(({ id }) => id),
665
+ missingContinuityObjectiveSystemIds: scope.systems
666
+ .filter(({ id }) => !systemsWithCompleteContinuityObjectives.some((system) => system.id === id))
667
+ .map(({ id }) => id)
668
+ }
669
+ : {}),
670
+ commands: [
671
+ `npx filegrc get ${shellArgument(document.id)} --mutation`,
672
+ `npx filegrc update document ${shellArgument(document.id)} MUTATION.json --json`,
673
+ "npx filegrc program-readiness --json"
674
+ ]
675
+ }
676
+ ));
677
+ }
613
678
  return stage(
614
679
  "policies",
615
- "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.",
680
+ "Approve Policies and Plans",
681
+ "Approve Policy requirements and the intended values in required governed plans and schedules. Approval binds exact revisions but does not prove implementation or activate the content.",
617
682
  items
618
683
  );
619
684
  }
@@ -627,8 +692,31 @@ function requiredPolicies(scope, byId) {
627
692
  ));
628
693
  }
629
694
 
630
- async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
695
+ function requiredGovernedDocuments(scope, records, byId, model) {
631
696
  const selectedControlIds = new Set(scope.controls.map(({ id }) => id));
697
+ const linkedDocumentIds = new Set(requiredPolicies(scope, byId).flatMap((policy) => policy.relatedDocumentIds || []));
698
+ const obligationDocumentIds = new Set(records
699
+ .filter((record) => record.type === "obligation")
700
+ .flatMap((record) => [
701
+ ...(record.scopeResourceIds || []),
702
+ ...(record.templateResourceId ? [record.templateResourceId] : [])
703
+ ]));
704
+ return records.filter((record) => (
705
+ record.type === "document"
706
+ && !documentIsAuditSpecific(record, model)
707
+ && !["superseded", "retired"].includes(record.status)
708
+ && (
709
+ linkedDocumentIds.has(record.id)
710
+ || obligationDocumentIds.has(record.id)
711
+ || (
712
+ record.programRole === "required"
713
+ && (record.controlIds || []).some((id) => selectedControlIds.has(id))
714
+ )
715
+ )
716
+ ));
717
+ }
718
+
719
+ async function governedContentItems(scope, records, byId, readMarkdown, asOf, model) {
632
720
  const enabledObligations = records.filter((record) => (
633
721
  record.type === "obligation" && obligationIsEnabled(record)
634
722
  ));
@@ -636,31 +724,39 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
636
724
  ...(record.scopeResourceIds || []),
637
725
  ...(record.templateResourceId ? [record.templateResourceId] : [])
638
726
  ]));
639
- const governedRecords = records.filter((record) => (
640
- (
641
- record.type === "document"
642
- && (
643
- requiredGovernedIds.has(record.id)
644
- || (
645
- record.programRole === "required"
646
- && (record.controlIds || []).some((id) => selectedControlIds.has(id))
647
- )
648
- )
649
- )
650
- || (record.type === "training" && requiredGovernedIds.has(record.id))
651
- ));
727
+ const documents = requiredGovernedDocuments(scope, records, byId, model);
728
+ const governedRecords = [
729
+ ...documents,
730
+ ...records.filter((record) => record.type === "training" && requiredGovernedIds.has(record.id))
731
+ ];
652
732
  const items = [];
733
+ const documentActivations = [];
653
734
  for (const record of governedRecords) {
654
735
  const source = await readMarkdown(record);
655
736
  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
- ));
737
+ const linkedControlIds = (record.controlIds || []).filter((id) => scope.controls.some((control) => control.id === id));
738
+ const missingImplementationControlIds = linkedControlIds.filter((id) => byId.get(id)?.status !== "implemented");
662
739
  const checks = record.type === "document"
663
- ? {
740
+ ? modelSupports(model, "governed-document-activation") ? {
741
+ approvalBound: ["approved", "active"].includes(record.status)
742
+ && Boolean(record.approvedOn)
743
+ && Boolean(record.approvedContentRevisions)
744
+ && partiesIndependent(record.ownerIds, record.approverIds, byId),
745
+ owner: currentPartyPeople(record.ownerIds, byId).size > 0,
746
+ active: record.status === "active",
747
+ requirementsImplemented: linkedControlIds.length > 0 && missingImplementationControlIds.length === 0,
748
+ activationRecorded: record.activationBasis === "recorded",
749
+ activated: Boolean(record.activatedOn),
750
+ activator: Boolean((record.activatedByIds || []).length)
751
+ && record.activatedByIds.every((id) => personWasActiveOn(byId.get(id), record.activatedOn)),
752
+ activatedContent: Boolean(record.activatedContentRevisions),
753
+ activationMatchesApproval: contentRevisionBindingsMatch(
754
+ record.approvedContentRevisions,
755
+ record.activatedContentRevisions
756
+ ),
757
+ effective: Boolean(record.effectiveOn && record.effectiveOn <= asOf),
758
+ contentComplete: substantiveMarkdown(source) && placeholderCount === 0
759
+ } : {
664
760
  active: record.status === "active",
665
761
  owner: currentPartyPeople(record.ownerIds, byId).size > 0,
666
762
  independentlyApproved: Boolean(
@@ -668,10 +764,7 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
668
764
  && partiesIndependent(record.ownerIds, record.approverIds, byId)
669
765
  ),
670
766
  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
- : {})
767
+ contentComplete: substantiveMarkdown(source) && placeholderCount === 0
675
768
  }
676
769
  : {
677
770
  active: record.status === "active",
@@ -684,6 +777,39 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
684
777
  const missing = Object.entries(checks)
685
778
  .filter(([, value]) => !value)
686
779
  .map(([name]) => governedContentCheckLabel(name));
780
+ if (record.type === "document" && modelSupports(model, "governed-document-activation")) {
781
+ const activationComplete = Object.values(checks).every(Boolean);
782
+ const preActivationCheckNames = ["approvalBound", "owner", "requirementsImplemented", "contentComplete"];
783
+ const preActivationGapCount = preActivationCheckNames.filter((name) => !checks[name]).length;
784
+ const readyToActivate = record.status === "approved"
785
+ && checks.approvalBound
786
+ && checks.owner
787
+ && checks.requirementsImplemented
788
+ && checks.contentComplete;
789
+ const state = record.status === "active" && activationComplete
790
+ ? "active-and-operating"
791
+ : record.status === "active"
792
+ ? "active-with-gaps"
793
+ : readyToActivate
794
+ ? "ready-to-activate"
795
+ : record.status === "approved"
796
+ ? "approved-implementation-pending"
797
+ : "approval-pending";
798
+ documentActivations.push({
799
+ documentId: record.id,
800
+ title: record.title,
801
+ state,
802
+ label: documentActivationLabel(state),
803
+ approvedOn: record.approvedOn || null,
804
+ activatedOn: record.activatedOn || null,
805
+ effectiveOn: record.effectiveOn || null,
806
+ linkedControlIds,
807
+ missingImplementationControlIds,
808
+ activationRevisionBound: Boolean(record.activatedContentRevisions),
809
+ activatedByIds: record.activatedByIds || [],
810
+ gapCount: record.status === "active" ? missing.length : preActivationGapCount
811
+ });
812
+ }
687
813
  items.push(item(
688
814
  `${record.type}-${record.id}`,
689
815
  missing.length ? "action" : "complete",
@@ -691,20 +817,15 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
691
817
  missing.length
692
818
  ? `Remaining governed-content work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}.`
693
819
  : record.type === "document"
694
- ? `Active, approved by a separate reviewer, effective ${record.effectiveOn}, and ready for the selected Controls or running schedule.`
820
+ ? modelSupports(model, "governed-document-activation")
821
+ ? `Approved on ${record.approvedOn}, activated separately on ${record.activatedOn}, effective ${record.effectiveOn}, revision-bound at both events, and ready for operation.`
822
+ : `Active, approved by a separate reviewer, effective ${record.effectiveOn}, and ready for the selected Controls or running schedule.`
695
823
  : `Active, approved, effective ${record.effectiveOn}, revision-bound, and ready for the running training schedule.`,
696
824
  record,
697
825
  {
698
826
  checks,
699
827
  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
- : {}),
828
+ ...(record.type === "document" ? { linkedControlIds, missingImplementationControlIds } : {}),
708
829
  commands: [
709
830
  `npx filegrc get ${shellArgument(record.id)} --mutation`,
710
831
  `npx filegrc update ${record.type} ${shellArgument(record.id)} MUTATION.json --json`,
@@ -713,12 +834,22 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
713
834
  }
714
835
  ));
715
836
  }
716
- return items;
837
+ return { items, documentActivations };
838
+ }
839
+
840
+ function documentActivationLabel(state) {
841
+ return ({
842
+ "approval-pending": "Approval pending in Step 2",
843
+ "approved-implementation-pending": "Approved, implementation pending",
844
+ "ready-to-activate": "Ready to activate",
845
+ "active-with-gaps": "Active with activation or implementation gaps",
846
+ "active-and-operating": "Active and operating"
847
+ })[state] || state;
717
848
  }
718
849
 
719
850
  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";
851
+ const sourceType = modelSupports(model, "component-sources") ? "component" : "system";
852
+ const sourceField = modelSupports(model, "component-sources") ? "evidenceSourceComponentIds" : "evidenceSourceIds";
722
853
  const assessments = [];
723
854
  for (const policy of policies.filter((record) => ["approved", "active"].includes(record.status))) {
724
855
  const linkedControls = controls.filter((control) => (control.policyIds || []).includes(policy.id));
@@ -726,7 +857,7 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
726
857
  const plannedOrPartialControlIds = linkedControls
727
858
  .filter((control) => ["planned", "partially-implemented"].includes(control.status))
728
859
  .map(({ id }) => id);
729
- const missingComponentControlIds = String(model.modelVersion) === "4"
860
+ const missingComponentControlIds = modelSupports(model, "component-sources")
730
861
  ? linkedControls.filter((control) => ![
731
862
  ...(control.componentIds || []),
732
863
  ...(control.evidenceSourceComponentIds || [])
@@ -759,6 +890,18 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
759
890
  && (record.policyIds || []).includes(policy.id)
760
891
  ))
761
892
  )).map(({ id }) => id);
893
+ const linkedGovernedDocumentIds = modelSupports(model, "governed-document-activation")
894
+ ? (policy.relatedDocumentIds || []).filter((id) => {
895
+ const document = byId.get(id);
896
+ return document?.type === "document"
897
+ && !["superseded", "retired"].includes(document.status)
898
+ && !documentIsAuditSpecific(document, model);
899
+ })
900
+ : [];
901
+ const missingGovernedDocumentIds = linkedGovernedDocumentIds.filter((id) => {
902
+ const document = byId.get(id);
903
+ return !governedDocumentIsOperating(document, asOf, model);
904
+ });
762
905
  const relevantExceptions = records.filter((record) => (
763
906
  record.type === "exception"
764
907
  && (record.scopeResourceIds || []).some((id) => id === policy.id || linkedControlIds.includes(id))
@@ -788,6 +931,7 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
788
931
  + missingComponentControlIds.length
789
932
  + missingEvidenceSourceControlIds.length
790
933
  + missingScheduleControlIds.length
934
+ + missingGovernedDocumentIds.length
791
935
  + unresolvedExceptionIds.length
792
936
  + timingWarnings.length;
793
937
  const activeNow = policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf;
@@ -811,6 +955,8 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
811
955
  missingComponentControlIds,
812
956
  missingEvidenceSourceControlIds,
813
957
  missingScheduleControlIds,
958
+ linkedGovernedDocumentIds,
959
+ missingGovernedDocumentIds,
814
960
  unresolvedExceptionIds,
815
961
  documentedExceptionIds,
816
962
  timingWarnings,
@@ -841,6 +987,7 @@ function policyActivationItem(assessment) {
841
987
  [assessment.missingComponentControlIds.length, "Controls missing active Components"],
842
988
  [assessment.missingEvidenceSourceControlIds.length, "Controls missing ready evidence sources"],
843
989
  [assessment.missingScheduleControlIds.length, "Controls missing enabled schedules"],
990
+ [assessment.missingGovernedDocumentIds?.length || 0, "required governed Documents not active"],
844
991
  [assessment.unresolvedExceptionIds.length, "unresolved Exceptions"]
845
992
  ].filter(([count]) => count).map(([count, label]) => `${count} ${label}`);
846
993
  const message = assessment.state === "active-and-operating"
@@ -893,10 +1040,10 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
893
1040
  for (const control of scope.controls) {
894
1041
  const source = await readMarkdown(control);
895
1042
  const sourceSystems = (
896
- String(model.modelVersion) === "4"
1043
+ modelSupports(model, "component-sources")
897
1044
  ? control.evidenceSourceComponentIds || []
898
1045
  : control.evidenceSourceIds || []
899
- ).map((id) => byId.get(id)).filter((record) => record?.type === (String(model.modelVersion) === "4" ? "component" : "system"));
1046
+ ).map((id) => byId.get(id)).filter((record) => record?.type === (modelSupports(model, "component-sources") ? "component" : "system"));
900
1047
  const queueSchedules = [...byId.values()].filter((record) => (
901
1048
  record.type === "obligation"
902
1049
  && record.status !== "retired"
@@ -951,20 +1098,20 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
951
1098
  ],
952
1099
  workQueue: queueSchedules.length ? {
953
1100
  enabled: queueSchedules.filter(obligationIsEnabled).length,
954
- running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf)).length,
1101
+ running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf, model)).length,
955
1102
  total: queueSchedules.length
956
1103
  } : null
957
1104
  }
958
1105
  ));
959
1106
  }
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);
1107
+ 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 and required governed Documents are active and effective.", items);
961
1108
  }
962
1109
 
963
1110
  async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
964
1111
  const families = selectedControlFamilies(scope.controls, model);
965
1112
  const items = [];
966
1113
  for (const family of families) {
967
- const componentSources = String(model.modelVersion) === "4";
1114
+ const componentSources = modelSupports(model, "component-sources");
968
1115
  const sourceField = componentSources ? "evidenceSourceComponentIds" : "evidenceSourceIds";
969
1116
  const sourceType = componentSources ? "component" : "system";
970
1117
  const selectedSources = [...new Set(family.controls.flatMap((control) => control[sourceField] || []))]
@@ -1056,7 +1203,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
1056
1203
  }
1057
1204
  ));
1058
1205
  }
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);
1206
+ 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
1207
  }
1061
1208
 
1062
1209
  function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
@@ -1271,14 +1418,31 @@ function policyCheckLabel(name) {
1271
1418
  })[name] || name;
1272
1419
  }
1273
1420
 
1421
+ function governedApprovalCheckLabel(name) {
1422
+ return ({
1423
+ independentlyApproved: "independent approval and approval date",
1424
+ owner: "current owner",
1425
+ linkedControls: "linked Controls",
1426
+ contentComplete: "intended values, content, and organization placeholders",
1427
+ systemContinuityObjectives: "RTO, RPO, and maximum tolerable downtime for every in-scope System"
1428
+ })[name] || name;
1429
+ }
1430
+
1274
1431
  function governedContentCheckLabel(name) {
1275
1432
  return ({
1433
+ approvalBound: "Step 2 independent approval and approved revision",
1276
1434
  active: "active status",
1277
1435
  owner: "current owner",
1278
1436
  independentlyApproved: "independent approval and approval date",
1279
1437
  approved: "approval and approval date",
1280
1438
  effective: "effective date",
1281
1439
  effectiveContent: "effective content revision",
1440
+ requirementsImplemented: "implemented linked requirements",
1441
+ activationRecorded: "recorded activation basis",
1442
+ activated: "separate activation date",
1443
+ activator: "named activation Person",
1444
+ activatedContent: "separate activated content revision",
1445
+ activationMatchesApproval: "unchanged approved revision at activation",
1282
1446
  contentComplete: "content and organization placeholders",
1283
1447
  systemContinuityObjectives: "RTO, RPO, and maximum tolerable downtime for every in-scope System"
1284
1448
  })[name] || name;
package/src/program.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { modelSupports } from "../model/index.js";
2
+
1
3
  export function resolveProgram(loaded, requestedId) {
2
- if (String(loaded.model.modelVersion) !== "4") return loaded.workspace;
4
+ if (!modelSupports(loaded.model, "program-scope")) return loaded.workspace;
3
5
  const programs = loaded.resources.filter((record) => record.type === "program" && record.status !== "retired");
4
6
  if (requestedId) {
5
7
  const program = programs.find(({ id }) => id === requestedId);
@@ -33,7 +35,7 @@ export function resolveProgram(loaded, requestedId) {
33
35
  }
34
36
 
35
37
  export function selectedRequirementIds(program, model) {
36
- if (String(model.modelVersion) === "4") {
38
+ if (modelSupports(model, "program-scope")) {
37
39
  return (program.requirementApplicability || [])
38
40
  .filter(({ decision }) => decision === "applicable")
39
41
  .map(({ requirementId }) => requirementId);
@@ -42,7 +44,7 @@ export function selectedRequirementIds(program, model) {
42
44
  }
43
45
 
44
46
  export function programComponents(loaded, program) {
45
- if (String(loaded.model.modelVersion) !== "4") return [];
47
+ if (!modelSupports(loaded.model, "program-scope")) return [];
46
48
  const systemIds = new Set(program.systemIds || []);
47
49
  return loaded.resources.filter((record) => (
48
50
  record.type === "component"
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
+ import { modelSupports } from "../model/index.js";
5
6
  import { createObligationEvent } from "./obligations.js";
6
7
  import { markdownEntries } from "./resource-markdown.js";
7
8
  import { loadWorkspace } from "./workspace.js";
@@ -94,13 +95,13 @@ export async function planReconciliation(input = process.cwd()) {
94
95
  const loaded = input?.resources && input?.model && input?.entries
95
96
  ? input
96
97
  : await loadWorkspace(input);
97
- if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
98
+ if (!modelSupports(loaded.model, "guided-workflow")) {
98
99
  return {
99
100
  contractVersion: 1,
100
101
  gitRevision: gitRevision(loaded.root),
101
102
  changedPaths: [],
102
103
  candidates: [],
103
- message: "Direct-file transition reconciliation is available in model v3 and v4 workspaces."
104
+ message: "Direct-file transition reconciliation is available in model v3 and newer workspaces."
104
105
  };
105
106
  }
106
107
  const changedPaths = gitChangedPaths(loaded.root);