filegrc 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,28 @@
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";
17
+ import { assessPolicyLibraryUpgrades } from "./policy-library.js";
10
18
  import { programComponents, resolveProgram, selectedRequirementIds } from "./program.js";
11
19
  import { markdownEntries } from "./resource-markdown.js";
20
+ import {
21
+ missingSoc2References,
22
+ personWasActiveOn,
23
+ REQUIRED_SOC2_DESCRIPTION_REFERENCES,
24
+ REQUIRED_SOC2_SECURITY_REFERENCES
25
+ } from "./soc2.js";
12
26
  import { assessSourceCoverageReadiness } from "./source-coverage.js";
13
27
  import { currentCalendarDate } from "./time.js";
14
28
  import { loadWorkspace } from "./workspace.js";
@@ -31,14 +45,15 @@ export async function assessProgramReadiness(input, options = {}) {
31
45
  return markdown.get(record.id);
32
46
  };
33
47
 
34
- const policyStage = await policiesStage(scope, records, byId, readMarkdown);
48
+ const policyStage = await policiesStage(scope, records, byId, readMarkdown, loaded.model);
35
49
  const controlStage = await controlsStage(scope, byId, readMarkdown, asOf, loaded.model);
36
50
  controlStage.items.unshift(...collectionReviews
37
51
  .filter(({ resourceType }) => resourceType === "complementary-control")
38
52
  .map(collectionReviewReadinessItem));
39
53
  const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
40
54
  controlStage.items.push(...sourceStage.items);
41
- 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);
42
57
  const policyActivations = await assessPolicyActivations(
43
58
  requiredPolicies(scope, byId),
44
59
  scope.controls,
@@ -49,7 +64,7 @@ export async function assessProgramReadiness(input, options = {}) {
49
64
  loaded.model
50
65
  );
51
66
  controlStage.items.push(...policyActivations.map(policyActivationItem));
52
- 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.`;
53
68
  const evidenceGateStages = [
54
69
  scopeStage(
55
70
  program,
@@ -75,6 +90,7 @@ export async function assessProgramReadiness(input, options = {}) {
75
90
  && coverageStart(program.candidateCoverage) <= asOf
76
91
  );
77
92
  const obligations = planObligations(records, { asOf, through: asOf, model: loaded.model });
93
+ const policyLibrary = await assessPolicyLibraryUpgrades(loaded);
78
94
  const operating = evidenceReady && candidateStarted && stages.at(-1).counts.action === 0;
79
95
  const canStartCandidatePeriod = Boolean(
80
96
  evidenceReady
@@ -103,7 +119,11 @@ export async function assessProgramReadiness(input, options = {}) {
103
119
  canStartCandidatePeriod,
104
120
  suggestedCandidatePeriodStart: canStartCandidatePeriod ? asOf : null,
105
121
  policyActivations,
106
- policyLibraryProposals: policyLibraryProposals(records),
122
+ documentActivations: governedContent.documentActivations,
123
+ policyLibraryProposals: [
124
+ ...policyLibrary.proposals,
125
+ ...legacyPolicyLibraryProposals(records)
126
+ ],
107
127
  progress: {
108
128
  complete,
109
129
  total: managedItems.length,
@@ -135,9 +155,9 @@ export async function assessEvidenceMap(input, options = {}) {
135
155
  status: counts.action ? "action" : "complete",
136
156
  counts,
137
157
  workflow: [
138
- `Choose an existing ${String(readiness.dataModelVersion) === "4" ? "Component" : "System"} or create one that is authoritative for each evidence family.`,
139
- `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.`,
140
- `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.`,
141
161
  "Run program-readiness again and resolve every incomplete source check and control mapping before marking the Controls implemented."
142
162
  ],
143
163
  items: evidenceItems
@@ -159,7 +179,7 @@ function programScope(program, records, byId, model, loaded) {
159
179
  )),
160
180
  requirements: select(selectedRequirementIds(program, model), "requirement", (record) => (
161
181
  record.type === "requirement" && record.applicability === "applicable"
162
- )).filter((record) => String(model.modelVersion) === "4" || record.applicability === "applicable"),
182
+ )).filter((record) => modelSupports(model, "program-scope") || record.applicability === "applicable"),
163
183
  controls: select(program?.controlIds, "control", (record) => (
164
184
  record.type === "control" && !["not-applicable", "retired"].includes(record.status)
165
185
  )).filter((record) => !["not-applicable", "retired"].includes(record.status))
@@ -185,7 +205,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
185
205
  }
186
206
  ));
187
207
 
188
- if (String(model.modelVersion) === "4") {
208
+ if (modelSupports(model, "program-scope")) {
189
209
  const completeComponents = scope.components.filter((component) => (
190
210
  component.status === "active"
191
211
  && component.description
@@ -216,8 +236,8 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
216
236
 
217
237
  const completeSystems = scope.systems.filter((system) => (
218
238
  system.status === "active"
219
- && (String(model.modelVersion) === "4" ? system.purpose && system.boundary && (system.servicesProvided || []).length : system.description)
220
- && system.classificationId
239
+ && (modelSupports(model, "program-scope") ? system.purpose && system.boundary && (system.servicesProvided || []).length : system.description)
240
+ && (modelSupports(model, "program-scope") || system.classificationId)
221
241
  && (system.ownerIds || []).length
222
242
  ));
223
243
  items.push(item(
@@ -225,12 +245,12 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
225
245
  scope.systems.length && completeSystems.length === scope.systems.length ? "complete" : "action",
226
246
  "Define the service boundary",
227
247
  scope.systems.length
228
- ? `${completeSystems.length} of ${scope.systems.length} program systems are active, explicitly in scope, owned, classified, and described.`
248
+ ? `${completeSystems.length} of ${scope.systems.length} program systems are active, explicitly in scope, owned, and described${modelSupports(model, "program-scope") ? "" : ", with a classification"}.`
229
249
  : "Select and describe every service and supporting system in the program boundary.",
230
250
  scope.systems[0] || { type: "system" }
231
251
  ));
232
252
 
233
- if (["3", "4"].includes(String(model.modelVersion))) {
253
+ if (modelSupports(model, "guided-workflow")) {
234
254
  const commitments = records.filter((record) => (
235
255
  record.type === "commitment"
236
256
  && !["superseded", "retired"].includes(record.status)
@@ -240,7 +260,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
240
260
  record.status === "active"
241
261
  && record.statement
242
262
  && record.effectiveOn
243
- && (String(model.modelVersion) === "4" || record.applicabilityReview?.decision === "applicable")
263
+ && (modelSupports(model, "program-scope") || record.applicabilityReview?.decision === "applicable")
244
264
  && currentPartyPeople(record.ownerIds, byId).size > 0
245
265
  && (record.requirementIds || []).length > 0
246
266
  && (record.controlIds || []).length > 0
@@ -273,30 +293,77 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
273
293
  const applicableRequirements = records.filter((record) => (
274
294
  record.type === "requirement"
275
295
  && scope.frameworks.some((framework) => framework.id === record.frameworkId)
276
- && (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")
277
297
  ));
278
298
  const unresolvedRequirements = records.filter((record) => (
279
299
  record.type === "requirement"
280
300
  && scope.frameworks.some((framework) => framework.id === record.frameworkId)
281
- && (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")
282
302
  ));
283
303
  const missingRequirements = applicableRequirements.filter((record) => !selectedRequirementIds.has(record.id));
304
+ const selectedDescriptionRequirements = scope.requirements.filter(isDescriptionRequirement);
305
+ const selectedTrustServicesRequirements = scope.requirements.filter((requirement) => !isDescriptionRequirement(requirement));
306
+ const unresolvedDescriptionRequirements = unresolvedRequirements.filter(isDescriptionRequirement);
307
+ const unresolvedTrustServicesRequirements = unresolvedRequirements.filter((requirement) => !isDescriptionRequirement(requirement));
308
+ const uncoveredRequirements = scope.requirements.filter((requirement) => (
309
+ !isDescriptionRequirement(requirement)
310
+ && !scope.controls.some((control) => (control.requirementIds || []).includes(requirement.id))
311
+ ));
312
+ const enforceSoc2Baseline = modelSupports(model, "program-scope")
313
+ && ["readiness", "soc-2-type-1", "soc-2-type-2"].includes(goal);
314
+ const selectedFrameworkRequirements = records.filter((record) => (
315
+ record.type === "requirement"
316
+ && scope.frameworks.some((framework) => framework.id === record.frameworkId)
317
+ ));
318
+ const securityRequirements = selectedFrameworkRequirements.filter(isSecurityRequirement);
319
+ const descriptionRequirements = selectedFrameworkRequirements.filter(isDescriptionRequirement);
320
+ const missingRequiredSecurityReferences = enforceSoc2Baseline
321
+ ? missingSoc2References(securityRequirements, REQUIRED_SOC2_SECURITY_REFERENCES)
322
+ : [];
323
+ const missingRequiredDescriptionReferences = enforceSoc2Baseline
324
+ ? missingSoc2References(descriptionRequirements, REQUIRED_SOC2_DESCRIPTION_REFERENCES)
325
+ : [];
326
+ const mandatoryRequirements = enforceSoc2Baseline
327
+ ? [...securityRequirements, ...descriptionRequirements].filter(({ reference }) => (
328
+ REQUIRED_SOC2_SECURITY_REFERENCES.includes(String(reference || "").toUpperCase())
329
+ || REQUIRED_SOC2_DESCRIPTION_REFERENCES.includes(String(reference || "").toUpperCase())
330
+ ))
331
+ : [];
332
+ const invalidMandatoryDecisions = mandatoryRequirements.filter((requirement) => (
333
+ v4Decisions.get(requirement.id) !== "applicable"
334
+ ));
284
335
  const criteriaComplete = Boolean(
285
336
  scope.frameworks.length
286
337
  && scope.requirements.length
287
338
  && scope.controls.length
288
339
  && !unresolvedRequirements.length
289
340
  && !missingRequirements.length
341
+ && !uncoveredRequirements.length
342
+ && !missingRequiredSecurityReferences.length
343
+ && !missingRequiredDescriptionReferences.length
344
+ && !invalidMandatoryDecisions.length
290
345
  );
291
346
  items.push(item(
292
347
  "criteria",
293
348
  criteriaComplete ? "complete" : "action",
294
- "Confirm criteria and controls in scope",
349
+ "Confirm Trust Services criteria, Description Criteria, and Controls",
295
350
  criteriaComplete
296
- ? `${scope.requirements.length} applicable criteria and ${scope.controls.length} controls are in the management program scope.`
297
- : `Resolve the program criteria and controls. ${unresolvedRequirements.length} criteria remain undetermined and ${missingRequirements.length} applicable criteria are not selected.`,
351
+ ? `${selectedTrustServicesRequirements.length} applicable Trust Services criteria, ${selectedDescriptionRequirements.length} SOC 2 Description Criteria, and ${scope.controls.length} Controls are in scope. Every applicable Trust Services criterion has at least one selected Control; Description Criteria govern the system description and do not map to Controls.`
352
+ : missingRequiredSecurityReferences.length || missingRequiredDescriptionReferences.length
353
+ ? `Use the complete SOC 2 baseline. The selected Frameworks omit ${[
354
+ ...missingRequiredSecurityReferences,
355
+ ...missingRequiredDescriptionReferences
356
+ ].join(", ")}.`
357
+ : invalidMandatoryDecisions.length
358
+ ? `Mark all 33 Security Common Criteria and all nine Description Criteria applicable for this SOC 2 Program. ${invalidMandatoryDecisions.length} required ${invalidMandatoryDecisions.length === 1 ? "decision is" : "decisions are"} missing, undetermined, or not applicable.`
359
+ : `Resolve the program criteria and Controls. ${unresolvedTrustServicesRequirements.length} Trust Services applicability decisions and ${unresolvedDescriptionRequirements.length} Description Criteria decisions remain undetermined, ${missingRequirements.length} applicable criteria are not selected, and ${uncoveredRequirements.length} selected applicable Trust Services criteria have no selected Control. Description Criteria govern the system description and do not map to Controls.`,
298
360
  workspace || { type: "workspace" },
299
361
  {
362
+ unresolvedRequirementIds: unresolvedRequirements.map(({ id }) => id),
363
+ missingRequirementIds: missingRequirements.map(({ id }) => id),
364
+ uncoveredRequirementIds: uncoveredRequirements.map(({ id }) => id),
365
+ invalidMandatoryRequirementIds: invalidMandatoryDecisions.map(({ id }) => id),
366
+ missingRequiredReferences: [...missingRequiredSecurityReferences, ...missingRequiredDescriptionReferences],
300
367
  commands: [
301
368
  "npx filegrc review-applicability --scaffold --type requirement > decisions.json",
302
369
  "npx filegrc review-applicability decisions.json --preview --json",
@@ -466,15 +533,19 @@ function ownershipResolutionReasons(ownerIds, byId) {
466
533
  });
467
534
  }
468
535
 
469
- async function policiesStage(scope, records, byId, readMarkdown) {
536
+ async function policiesStage(scope, records, byId, readMarkdown, model) {
470
537
  const policies = requiredPolicies(scope, byId);
471
- const appointedReviewer = policies
472
- .filter((policy) => partiesIndependent(policy.ownerIds, policy.approverIds, byId))
473
- .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)])
474
545
  .map((id) => byId.get(id))
475
546
  .find(Boolean);
476
- const policyOwnerIds = new Set(policies.flatMap((policy) => (
477
- [...partyPeople(policy.ownerIds || [], byId)]
547
+ const policyOwnerIds = new Set(governedRecords.flatMap((record) => (
548
+ [...partyPeople(record.ownerIds || [], byId)]
478
549
  )));
479
550
  const oversight = byId.get("team-security-risk-oversight");
480
551
  const availableReviewer = oversight?.type === "team" && oversight.status === "active"
@@ -483,7 +554,7 @@ async function policiesStage(scope, records, byId, readMarkdown) {
483
554
  .map((id) => byId.get(id))
484
555
  .find(Boolean)
485
556
  : null;
486
- const reviewerNeedsAssignment = !appointedReviewer && availableReviewer && policies.length;
557
+ const reviewerNeedsAssignment = !appointedReviewer && availableReviewer && governedRecords.length;
487
558
  const items = [
488
559
  item(
489
560
  "independent-reviewer",
@@ -496,7 +567,7 @@ async function policiesStage(scope, records, byId, readMarkdown) {
496
567
  : reviewerNeedsAssignment
497
568
  ? `${availableReviewer.title} chairs Security and Risk Oversight. Assign this person as approver on each policy after review.`
498
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.",
499
- appointedReviewer || (reviewerNeedsAssignment ? policies[0] : { type: "person" }),
570
+ appointedReviewer || (reviewerNeedsAssignment ? governedRecords[0] : { type: "person" }),
500
571
  {
501
572
  commands: [
502
573
  "npx filegrc list appointment --workflow --json",
@@ -553,10 +624,61 @@ async function policiesStage(scope, records, byId, readMarkdown) {
553
624
  }
554
625
  ));
555
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
+ }
556
678
  return stage(
557
679
  "policies",
558
- "Approve Policies",
559
- "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.",
560
682
  items
561
683
  );
562
684
  }
@@ -570,8 +692,31 @@ function requiredPolicies(scope, byId) {
570
692
  ));
571
693
  }
572
694
 
573
- async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
695
+ function requiredGovernedDocuments(scope, records, byId, model) {
574
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) {
575
720
  const enabledObligations = records.filter((record) => (
576
721
  record.type === "obligation" && obligationIsEnabled(record)
577
722
  ));
@@ -579,31 +724,39 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
579
724
  ...(record.scopeResourceIds || []),
580
725
  ...(record.templateResourceId ? [record.templateResourceId] : [])
581
726
  ]));
582
- const governedRecords = records.filter((record) => (
583
- (
584
- record.type === "document"
585
- && (
586
- requiredGovernedIds.has(record.id)
587
- || (
588
- record.programRole === "required"
589
- && (record.controlIds || []).some((id) => selectedControlIds.has(id))
590
- )
591
- )
592
- )
593
- || (record.type === "training" && requiredGovernedIds.has(record.id))
594
- ));
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
+ ];
595
732
  const items = [];
733
+ const documentActivations = [];
596
734
  for (const record of governedRecords) {
597
735
  const source = await readMarkdown(record);
598
736
  const placeholderCount = openPlaceholderCount(source);
599
- const isSecurityIncidentRecoveryPlan = record.id === "document-security-incident-recovery-plan";
600
- const systemsWithCompleteContinuityObjectives = scope.systems.filter(({ continuityObjectives }) => (
601
- Number.isInteger(continuityObjectives?.recoveryTimeHours)
602
- && Number.isInteger(continuityObjectives?.recoveryPointHours)
603
- && Number.isInteger(continuityObjectives?.maximumTolerableDowntimeHours)
604
- ));
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");
605
739
  const checks = record.type === "document"
606
- ? {
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
+ } : {
607
760
  active: record.status === "active",
608
761
  owner: currentPartyPeople(record.ownerIds, byId).size > 0,
609
762
  independentlyApproved: Boolean(
@@ -611,10 +764,7 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
611
764
  && partiesIndependent(record.ownerIds, record.approverIds, byId)
612
765
  ),
613
766
  effective: Boolean(record.effectiveOn && record.effectiveOn <= asOf),
614
- contentComplete: substantiveMarkdown(source) && placeholderCount === 0,
615
- ...(isSecurityIncidentRecoveryPlan
616
- ? { systemContinuityObjectives: systemsWithCompleteContinuityObjectives.length === scope.systems.length && scope.systems.length > 0 }
617
- : {})
767
+ contentComplete: substantiveMarkdown(source) && placeholderCount === 0
618
768
  }
619
769
  : {
620
770
  active: record.status === "active",
@@ -627,6 +777,39 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
627
777
  const missing = Object.entries(checks)
628
778
  .filter(([, value]) => !value)
629
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
+ }
630
813
  items.push(item(
631
814
  `${record.type}-${record.id}`,
632
815
  missing.length ? "action" : "complete",
@@ -634,20 +817,15 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
634
817
  missing.length
635
818
  ? `Remaining governed-content work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}.`
636
819
  : record.type === "document"
637
- ? `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.`
638
823
  : `Active, approved, effective ${record.effectiveOn}, revision-bound, and ready for the running training schedule.`,
639
824
  record,
640
825
  {
641
826
  checks,
642
827
  placeholderCount,
643
- ...(isSecurityIncidentRecoveryPlan
644
- ? {
645
- continuityObjectiveSystemIds: systemsWithCompleteContinuityObjectives.map(({ id }) => id),
646
- missingContinuityObjectiveSystemIds: scope.systems
647
- .filter(({ id }) => !systemsWithCompleteContinuityObjectives.some((system) => system.id === id))
648
- .map(({ id }) => id)
649
- }
650
- : {}),
828
+ ...(record.type === "document" ? { linkedControlIds, missingImplementationControlIds } : {}),
651
829
  commands: [
652
830
  `npx filegrc get ${shellArgument(record.id)} --mutation`,
653
831
  `npx filegrc update ${record.type} ${shellArgument(record.id)} MUTATION.json --json`,
@@ -656,12 +834,22 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
656
834
  }
657
835
  ));
658
836
  }
659
- 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;
660
848
  }
661
849
 
662
850
  async function assessPolicyActivations(policies, controls, records, byId, readMarkdown, asOf, model) {
663
- const sourceType = String(model.modelVersion) === "4" ? "component" : "system";
664
- 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";
665
853
  const assessments = [];
666
854
  for (const policy of policies.filter((record) => ["approved", "active"].includes(record.status))) {
667
855
  const linkedControls = controls.filter((control) => (control.policyIds || []).includes(policy.id));
@@ -669,7 +857,7 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
669
857
  const plannedOrPartialControlIds = linkedControls
670
858
  .filter((control) => ["planned", "partially-implemented"].includes(control.status))
671
859
  .map(({ id }) => id);
672
- const missingComponentControlIds = String(model.modelVersion) === "4"
860
+ const missingComponentControlIds = modelSupports(model, "component-sources")
673
861
  ? linkedControls.filter((control) => ![
674
862
  ...(control.componentIds || []),
675
863
  ...(control.evidenceSourceComponentIds || [])
@@ -702,6 +890,18 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
702
890
  && (record.policyIds || []).includes(policy.id)
703
891
  ))
704
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
+ });
705
905
  const relevantExceptions = records.filter((record) => (
706
906
  record.type === "exception"
707
907
  && (record.scopeResourceIds || []).some((id) => id === policy.id || linkedControlIds.includes(id))
@@ -731,6 +931,7 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
731
931
  + missingComponentControlIds.length
732
932
  + missingEvidenceSourceControlIds.length
733
933
  + missingScheduleControlIds.length
934
+ + missingGovernedDocumentIds.length
734
935
  + unresolvedExceptionIds.length
735
936
  + timingWarnings.length;
736
937
  const activeNow = policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf;
@@ -754,6 +955,8 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa
754
955
  missingComponentControlIds,
755
956
  missingEvidenceSourceControlIds,
756
957
  missingScheduleControlIds,
958
+ linkedGovernedDocumentIds,
959
+ missingGovernedDocumentIds,
757
960
  unresolvedExceptionIds,
758
961
  documentedExceptionIds,
759
962
  timingWarnings,
@@ -784,6 +987,7 @@ function policyActivationItem(assessment) {
784
987
  [assessment.missingComponentControlIds.length, "Controls missing active Components"],
785
988
  [assessment.missingEvidenceSourceControlIds.length, "Controls missing ready evidence sources"],
786
989
  [assessment.missingScheduleControlIds.length, "Controls missing enabled schedules"],
990
+ [assessment.missingGovernedDocumentIds?.length || 0, "required governed Documents not active"],
787
991
  [assessment.unresolvedExceptionIds.length, "unresolved Exceptions"]
788
992
  ].filter(([count]) => count).map(([count, label]) => `${count} ${label}`);
789
993
  const message = assessment.state === "active-and-operating"
@@ -808,7 +1012,7 @@ function policyActivationItem(assessment) {
808
1012
  );
809
1013
  }
810
1014
 
811
- function policyLibraryProposals(records) {
1015
+ function legacyPolicyLibraryProposals(records) {
812
1016
  const legacyIds = [
813
1017
  "policy-anti-bribery-corruption",
814
1018
  "policy-clear-desk-screen",
@@ -836,10 +1040,10 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
836
1040
  for (const control of scope.controls) {
837
1041
  const source = await readMarkdown(control);
838
1042
  const sourceSystems = (
839
- String(model.modelVersion) === "4"
1043
+ modelSupports(model, "component-sources")
840
1044
  ? control.evidenceSourceComponentIds || []
841
1045
  : control.evidenceSourceIds || []
842
- ).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"));
843
1047
  const queueSchedules = [...byId.values()].filter((record) => (
844
1048
  record.type === "obligation"
845
1049
  && record.status !== "retired"
@@ -894,20 +1098,20 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
894
1098
  ],
895
1099
  workQueue: queueSchedules.length ? {
896
1100
  enabled: queueSchedules.filter(obligationIsEnabled).length,
897
- running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf)).length,
1101
+ running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf, model)).length,
898
1102
  total: queueSchedules.length
899
1103
  } : null
900
1104
  }
901
1105
  ));
902
1106
  }
903
- 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);
904
1108
  }
905
1109
 
906
1110
  async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
907
1111
  const families = selectedControlFamilies(scope.controls, model);
908
1112
  const items = [];
909
1113
  for (const family of families) {
910
- const componentSources = String(model.modelVersion) === "4";
1114
+ const componentSources = modelSupports(model, "component-sources");
911
1115
  const sourceField = componentSources ? "evidenceSourceComponentIds" : "evidenceSourceIds";
912
1116
  const sourceType = componentSources ? "component" : "system";
913
1117
  const selectedSources = [...new Set(family.controls.flatMap((control) => control[sourceField] || []))]
@@ -999,7 +1203,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
999
1203
  }
1000
1204
  ));
1001
1205
  }
1002
- 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);
1003
1207
  }
1004
1208
 
1005
1209
  function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
@@ -1214,14 +1418,31 @@ function policyCheckLabel(name) {
1214
1418
  })[name] || name;
1215
1419
  }
1216
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
+
1217
1431
  function governedContentCheckLabel(name) {
1218
1432
  return ({
1433
+ approvalBound: "Step 2 independent approval and approved revision",
1219
1434
  active: "active status",
1220
1435
  owner: "current owner",
1221
1436
  independentlyApproved: "independent approval and approval date",
1222
1437
  approved: "approval and approval date",
1223
1438
  effective: "effective date",
1224
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",
1225
1446
  contentComplete: "content and organization placeholders",
1226
1447
  systemContinuityObjectives: "RTO, RPO, and maximum tolerable downtime for every in-scope System"
1227
1448
  })[name] || name;
@@ -1253,6 +1474,16 @@ function assuranceGoalLabel(goal) {
1253
1474
  return "No assurance goal selected";
1254
1475
  }
1255
1476
 
1477
+ function isDescriptionRequirement(requirement) {
1478
+ return (requirement?.tags || []).includes("description-criteria")
1479
+ || /^DC\d+/i.test(requirement?.reference || "");
1480
+ }
1481
+
1482
+ function isSecurityRequirement(requirement) {
1483
+ const tags = requirement?.tags || [];
1484
+ return tags.includes("security") || tags.includes("common-criteria") || /^CC\d+(?:\.|$)/i.test(requirement?.reference || "");
1485
+ }
1486
+
1256
1487
  function stage(id, title, description, items) {
1257
1488
  return { id, title, description, items };
1258
1489
  }