filegrc 0.6.5 → 0.7.1

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,13 +1,20 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { assessRequiredAppointments } from "./appointments.js";
3
3
  import { assessCollectionReviews } from "./collection-review.js";
4
+ import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
4
5
  import { coverageEnd, coverageStart } from "./coverage.js";
5
6
  import { planObligations } from "./obligations.js";
6
7
  import { resolveDataPath } from "./paths.js";
7
- import { obligationIsRunning } from "./program-lifecycle.js";
8
+ import { obligationIsEnabled, obligationIsRunning } from "./program-lifecycle.js";
8
9
  import { currentPartyPeople, partiesIndependent, partyPeople } from "./parties.js";
10
+ import { assessPolicyLibraryUpgrades } from "./policy-library.js";
9
11
  import { programComponents, resolveProgram, selectedRequirementIds } from "./program.js";
10
12
  import { markdownEntries } from "./resource-markdown.js";
13
+ import {
14
+ missingSoc2References,
15
+ REQUIRED_SOC2_DESCRIPTION_REFERENCES,
16
+ REQUIRED_SOC2_SECURITY_REFERENCES
17
+ } from "./soc2.js";
11
18
  import { assessSourceCoverageReadiness } from "./source-coverage.js";
12
19
  import { currentCalendarDate } from "./time.js";
13
20
  import { loadWorkspace } from "./workspace.js";
@@ -30,13 +37,25 @@ export async function assessProgramReadiness(input, options = {}) {
30
37
  return markdown.get(record.id);
31
38
  };
32
39
 
40
+ const policyStage = await policiesStage(scope, records, byId, readMarkdown);
33
41
  const controlStage = await controlsStage(scope, byId, readMarkdown, asOf, loaded.model);
34
42
  controlStage.items.unshift(...collectionReviews
35
43
  .filter(({ resourceType }) => resourceType === "complementary-control")
36
44
  .map(collectionReviewReadinessItem));
37
45
  const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
38
46
  controlStage.items.push(...sourceStage.items);
39
- controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, and complete authoritative source ${String(loaded.model.modelVersion) === "4" ? "Components" : "Systems"} with the required evidence roles, access owners, and retrieval instructions.`;
47
+ controlStage.items.push(...await governedContentItems(scope, records, byId, readMarkdown, asOf));
48
+ const policyActivations = await assessPolicyActivations(
49
+ requiredPolicies(scope, byId),
50
+ scope.controls,
51
+ records,
52
+ byId,
53
+ readMarkdown,
54
+ asOf,
55
+ loaded.model
56
+ );
57
+ 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.`;
40
59
  const evidenceGateStages = [
41
60
  scopeStage(
42
61
  program,
@@ -46,7 +65,7 @@ export async function assessProgramReadiness(input, options = {}) {
46
65
  loaded.model,
47
66
  collectionReviews.filter(({ resourceType }) => resourceType !== "complementary-control")
48
67
  ),
49
- await policiesStage(scope, records, byId, readMarkdown, asOf),
68
+ policyStage,
50
69
  controlStage
51
70
  ];
52
71
  for (const current of evidenceGateStages) finalizeStage(current);
@@ -62,6 +81,7 @@ export async function assessProgramReadiness(input, options = {}) {
62
81
  && coverageStart(program.candidateCoverage) <= asOf
63
82
  );
64
83
  const obligations = planObligations(records, { asOf, through: asOf, model: loaded.model });
84
+ const policyLibrary = await assessPolicyLibraryUpgrades(loaded);
65
85
  const operating = evidenceReady && candidateStarted && stages.at(-1).counts.action === 0;
66
86
  const canStartCandidatePeriod = Boolean(
67
87
  evidenceReady
@@ -89,6 +109,11 @@ export async function assessProgramReadiness(input, options = {}) {
89
109
  operating,
90
110
  canStartCandidatePeriod,
91
111
  suggestedCandidatePeriodStart: canStartCandidatePeriod ? asOf : null,
112
+ policyActivations,
113
+ policyLibraryProposals: [
114
+ ...policyLibrary.proposals,
115
+ ...legacyPolicyLibraryProposals(records)
116
+ ],
92
117
  progress: {
93
118
  complete,
94
119
  total: managedItems.length,
@@ -202,7 +227,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
202
227
  const completeSystems = scope.systems.filter((system) => (
203
228
  system.status === "active"
204
229
  && (String(model.modelVersion) === "4" ? system.purpose && system.boundary && (system.servicesProvided || []).length : system.description)
205
- && system.classificationId
230
+ && (String(model.modelVersion) === "4" || system.classificationId)
206
231
  && (system.ownerIds || []).length
207
232
  ));
208
233
  items.push(item(
@@ -210,7 +235,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
210
235
  scope.systems.length && completeSystems.length === scope.systems.length ? "complete" : "action",
211
236
  "Define the service boundary",
212
237
  scope.systems.length
213
- ? `${completeSystems.length} of ${scope.systems.length} program systems are active, explicitly in scope, owned, classified, and described.`
238
+ ? `${completeSystems.length} of ${scope.systems.length} program systems are active, explicitly in scope, owned, and described${String(model.modelVersion) === "4" ? "" : ", with a classification"}.`
214
239
  : "Select and describe every service and supporting system in the program boundary.",
215
240
  scope.systems[0] || { type: "system" }
216
241
  ));
@@ -266,22 +291,69 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
266
291
  && (String(model.modelVersion) === "4" ? !v4Decisions.has(record.id) || v4Decisions.get(record.id) === "undetermined" : record.applicability === "undetermined")
267
292
  ));
268
293
  const missingRequirements = applicableRequirements.filter((record) => !selectedRequirementIds.has(record.id));
294
+ const selectedDescriptionRequirements = scope.requirements.filter(isDescriptionRequirement);
295
+ const selectedTrustServicesRequirements = scope.requirements.filter((requirement) => !isDescriptionRequirement(requirement));
296
+ const unresolvedDescriptionRequirements = unresolvedRequirements.filter(isDescriptionRequirement);
297
+ const unresolvedTrustServicesRequirements = unresolvedRequirements.filter((requirement) => !isDescriptionRequirement(requirement));
298
+ const uncoveredRequirements = scope.requirements.filter((requirement) => (
299
+ !isDescriptionRequirement(requirement)
300
+ && !scope.controls.some((control) => (control.requirementIds || []).includes(requirement.id))
301
+ ));
302
+ const enforceSoc2Baseline = String(model.modelVersion) === "4"
303
+ && ["readiness", "soc-2-type-1", "soc-2-type-2"].includes(goal);
304
+ const selectedFrameworkRequirements = records.filter((record) => (
305
+ record.type === "requirement"
306
+ && scope.frameworks.some((framework) => framework.id === record.frameworkId)
307
+ ));
308
+ const securityRequirements = selectedFrameworkRequirements.filter(isSecurityRequirement);
309
+ const descriptionRequirements = selectedFrameworkRequirements.filter(isDescriptionRequirement);
310
+ const missingRequiredSecurityReferences = enforceSoc2Baseline
311
+ ? missingSoc2References(securityRequirements, REQUIRED_SOC2_SECURITY_REFERENCES)
312
+ : [];
313
+ const missingRequiredDescriptionReferences = enforceSoc2Baseline
314
+ ? missingSoc2References(descriptionRequirements, REQUIRED_SOC2_DESCRIPTION_REFERENCES)
315
+ : [];
316
+ const mandatoryRequirements = enforceSoc2Baseline
317
+ ? [...securityRequirements, ...descriptionRequirements].filter(({ reference }) => (
318
+ REQUIRED_SOC2_SECURITY_REFERENCES.includes(String(reference || "").toUpperCase())
319
+ || REQUIRED_SOC2_DESCRIPTION_REFERENCES.includes(String(reference || "").toUpperCase())
320
+ ))
321
+ : [];
322
+ const invalidMandatoryDecisions = mandatoryRequirements.filter((requirement) => (
323
+ v4Decisions.get(requirement.id) !== "applicable"
324
+ ));
269
325
  const criteriaComplete = Boolean(
270
326
  scope.frameworks.length
271
327
  && scope.requirements.length
272
328
  && scope.controls.length
273
329
  && !unresolvedRequirements.length
274
330
  && !missingRequirements.length
331
+ && !uncoveredRequirements.length
332
+ && !missingRequiredSecurityReferences.length
333
+ && !missingRequiredDescriptionReferences.length
334
+ && !invalidMandatoryDecisions.length
275
335
  );
276
336
  items.push(item(
277
337
  "criteria",
278
338
  criteriaComplete ? "complete" : "action",
279
- "Confirm criteria and controls in scope",
339
+ "Confirm Trust Services criteria, Description Criteria, and Controls",
280
340
  criteriaComplete
281
- ? `${scope.requirements.length} applicable criteria and ${scope.controls.length} controls are in the management program scope.`
282
- : `Resolve the program criteria and controls. ${unresolvedRequirements.length} criteria remain undetermined and ${missingRequirements.length} applicable criteria are not selected.`,
341
+ ? `${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.`
342
+ : missingRequiredSecurityReferences.length || missingRequiredDescriptionReferences.length
343
+ ? `Use the complete SOC 2 baseline. The selected Frameworks omit ${[
344
+ ...missingRequiredSecurityReferences,
345
+ ...missingRequiredDescriptionReferences
346
+ ].join(", ")}.`
347
+ : invalidMandatoryDecisions.length
348
+ ? `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.`
349
+ : `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.`,
283
350
  workspace || { type: "workspace" },
284
351
  {
352
+ unresolvedRequirementIds: unresolvedRequirements.map(({ id }) => id),
353
+ missingRequirementIds: missingRequirements.map(({ id }) => id),
354
+ uncoveredRequirementIds: uncoveredRequirements.map(({ id }) => id),
355
+ invalidMandatoryRequirementIds: invalidMandatoryDecisions.map(({ id }) => id),
356
+ missingRequiredReferences: [...missingRequiredSecurityReferences, ...missingRequiredDescriptionReferences],
285
357
  commands: [
286
358
  "npx filegrc review-applicability --scaffold --type requirement > decisions.json",
287
359
  "npx filegrc review-applicability decisions.json --preview --json",
@@ -451,11 +523,8 @@ function ownershipResolutionReasons(ownerIds, byId) {
451
523
  });
452
524
  }
453
525
 
454
- async function policiesStage(scope, records, byId, readMarkdown, asOf) {
455
- const linkedPolicyIds = new Set(scope.controls.flatMap((control) => control.policyIds || []));
456
- const policies = [...linkedPolicyIds].map((id) => byId.get(id)).filter((record) => (
457
- record?.type === "policy" && !["superseded", "retired"].includes(record.status)
458
- ));
526
+ async function policiesStage(scope, records, byId, readMarkdown) {
527
+ const policies = requiredPolicies(scope, byId);
459
528
  const appointedReviewer = policies
460
529
  .filter((policy) => partiesIndependent(policy.ownerIds, policy.approverIds, byId))
461
530
  .flatMap((policy) => [...currentPartyPeople(policy.approverIds || [], byId)])
@@ -515,11 +584,9 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
515
584
  const source = await readMarkdown(policy);
516
585
  const placeholderCount = openPlaceholderCount(source);
517
586
  const checks = {
518
- reviewed: ["in-review", "approved", "active"].includes(policy.status),
519
587
  independentlyApproved: ["approved", "active"].includes(policy.status)
520
588
  && policy.approvedOn
521
589
  && partiesIndependent(policy.ownerIds, policy.approverIds, byId),
522
- effective: policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf,
523
590
  linkedControls: scope.controls.some((control) => (control.policyIds || []).includes(policy.id)),
524
591
  contentComplete: Boolean(source.trim()) && placeholderCount === 0
525
592
  };
@@ -529,8 +596,8 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
529
596
  missing.length ? "action" : "complete",
530
597
  policy.title,
531
598
  missing.length
532
- ? `Remaining adoption work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}.`
533
- : `Reviewed, independently approved, effective ${policy.effectiveOn}, linked to controls, with no open organization placeholders.`,
599
+ ? `Remaining approval work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}. Approval accepts the policy requirements; it does not assert Control implementation.`
600
+ : `Independently approved on ${policy.approvedOn}, bound to the approved content revision, linked to Controls, and free of open organization placeholders. Activation remains in Step 3.`,
534
601
  policy,
535
602
  {
536
603
  checks,
@@ -543,11 +610,29 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
543
610
  }
544
611
  ));
545
612
  }
613
+ return stage(
614
+ "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.",
617
+ items
618
+ );
619
+ }
620
+
621
+ function requiredPolicies(scope, byId) {
622
+ const linkedPolicyIds = new Set(scope.controls.flatMap((control) => control.policyIds || []));
623
+ return [...linkedPolicyIds].map((id) => byId.get(id)).filter((record) => (
624
+ record?.type === "policy"
625
+ && record.programRole !== "conditional"
626
+ && !["superseded", "retired"].includes(record.status)
627
+ ));
628
+ }
629
+
630
+ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
546
631
  const selectedControlIds = new Set(scope.controls.map(({ id }) => id));
547
- const activeObligations = records.filter((record) => (
548
- record.type === "obligation" && obligationIsRunning(record, byId, asOf)
632
+ const enabledObligations = records.filter((record) => (
633
+ record.type === "obligation" && obligationIsEnabled(record)
549
634
  ));
550
- const requiredGovernedIds = new Set(activeObligations.flatMap((record) => [
635
+ const requiredGovernedIds = new Set(enabledObligations.flatMap((record) => [
551
636
  ...(record.scopeResourceIds || []),
552
637
  ...(record.templateResourceId ? [record.templateResourceId] : [])
553
638
  ]));
@@ -564,9 +649,16 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
564
649
  )
565
650
  || (record.type === "training" && requiredGovernedIds.has(record.id))
566
651
  ));
652
+ const items = [];
567
653
  for (const record of governedRecords) {
568
654
  const source = await readMarkdown(record);
569
655
  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
+ ));
570
662
  const checks = record.type === "document"
571
663
  ? {
572
664
  active: record.status === "active",
@@ -576,7 +668,10 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
576
668
  && partiesIndependent(record.ownerIds, record.approverIds, byId)
577
669
  ),
578
670
  effective: Boolean(record.effectiveOn && record.effectiveOn <= asOf),
579
- contentComplete: substantiveMarkdown(source) && placeholderCount === 0
671
+ contentComplete: substantiveMarkdown(source) && placeholderCount === 0,
672
+ ...(isSecurityIncidentRecoveryPlan
673
+ ? { systemContinuityObjectives: systemsWithCompleteContinuityObjectives.length === scope.systems.length && scope.systems.length > 0 }
674
+ : {})
580
675
  }
581
676
  : {
582
677
  active: record.status === "active",
@@ -596,12 +691,20 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
596
691
  missing.length
597
692
  ? `Remaining governed-content work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}.`
598
693
  : record.type === "document"
599
- ? `Active, independently approved, effective ${record.effectiveOn}, and ready for the selected controls or running schedule.`
694
+ ? `Active, approved by a separate reviewer, effective ${record.effectiveOn}, and ready for the selected Controls or running schedule.`
600
695
  : `Active, approved, effective ${record.effectiveOn}, revision-bound, and ready for the running training schedule.`,
601
696
  record,
602
697
  {
603
698
  checks,
604
699
  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
+ : {}),
605
708
  commands: [
606
709
  `npx filegrc get ${shellArgument(record.id)} --mutation`,
607
710
  `npx filegrc update ${record.type} ${shellArgument(record.id)} MUTATION.json --json`,
@@ -610,14 +713,178 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
610
713
  }
611
714
  ));
612
715
  }
613
- return stage(
614
- "policies",
615
- "Approve Policies",
616
- "Review and approve the policies, governed plans, and training content required by selected controls and running schedules.",
617
- items
716
+ return items;
717
+ }
718
+
719
+ 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";
722
+ const assessments = [];
723
+ for (const policy of policies.filter((record) => ["approved", "active"].includes(record.status))) {
724
+ const linkedControls = controls.filter((control) => (control.policyIds || []).includes(policy.id));
725
+ const linkedControlIds = linkedControls.map(({ id }) => id);
726
+ const plannedOrPartialControlIds = linkedControls
727
+ .filter((control) => ["planned", "partially-implemented"].includes(control.status))
728
+ .map(({ id }) => id);
729
+ const missingComponentControlIds = String(model.modelVersion) === "4"
730
+ ? linkedControls.filter((control) => ![
731
+ ...(control.componentIds || []),
732
+ ...(control.evidenceSourceComponentIds || [])
733
+ ].some((id) => (
734
+ byId.get(id)?.type === "component" && byId.get(id).status === "active"
735
+ ))).map(({ id }) => id)
736
+ : [];
737
+ const missingEvidenceSourceControlIds = [];
738
+ for (const control of linkedControls) {
739
+ const readySources = [];
740
+ for (const id of control[sourceField] || []) {
741
+ const source = byId.get(id);
742
+ if (source?.type !== sourceType || source.status !== "active") continue;
743
+ const instructions = await readMarkdown(source);
744
+ if (
745
+ (source.evidenceSourceKinds || []).length
746
+ && (source.evidenceOwnerIds || []).length
747
+ && substantiveMarkdown(instructions)
748
+ && openPlaceholderCount(instructions) === 0
749
+ ) readySources.push(source);
750
+ }
751
+ if (!readySources.length) missingEvidenceSourceControlIds.push(control.id);
752
+ }
753
+ const missingScheduleControlIds = linkedControls.filter((control) => (
754
+ ["scheduled", "event-driven", "mixed"].includes(control.operationPattern)
755
+ && !records.some((record) => (
756
+ record.type === "obligation"
757
+ && obligationIsEnabled(record)
758
+ && (record.controlIds || []).includes(control.id)
759
+ && (record.policyIds || []).includes(policy.id)
760
+ ))
761
+ )).map(({ id }) => id);
762
+ const relevantExceptions = records.filter((record) => (
763
+ record.type === "exception"
764
+ && (record.scopeResourceIds || []).some((id) => id === policy.id || linkedControlIds.includes(id))
765
+ && !["revoked", "closed"].includes(record.status)
766
+ ));
767
+ const unresolvedExceptionIds = relevantExceptions.filter((record) => (
768
+ record.status !== "approved"
769
+ || !record.approval?.expiresOn
770
+ || record.approval.expiresOn < asOf
771
+ )).map(({ id }) => id);
772
+ const documentedExceptionIds = relevantExceptions.filter((record) => (
773
+ record.status === "approved"
774
+ && record.approval?.expiresOn
775
+ && record.approval.expiresOn >= asOf
776
+ )).map(({ id }) => id);
777
+ const timingWarnings = [
778
+ policy.proposedEffectiveOn && policy.proposedEffectiveOn < asOf
779
+ ? `The proposed effective date ${policy.proposedEffectiveOn} has passed. Choose a current or future activation date; do not backdate adoption.`
780
+ : null,
781
+ policy.status === "active" && (!policy.effectiveOn || policy.effectiveOn > asOf)
782
+ ? policy.effectiveOn
783
+ ? `The Policy is marked active but does not become effective until ${policy.effectiveOn}. Governed Obligations remain dormant until then.`
784
+ : "The Policy is marked active without an effective date."
785
+ : null
786
+ ].filter(Boolean);
787
+ const gapCount = plannedOrPartialControlIds.length
788
+ + missingComponentControlIds.length
789
+ + missingEvidenceSourceControlIds.length
790
+ + missingScheduleControlIds.length
791
+ + unresolvedExceptionIds.length
792
+ + timingWarnings.length;
793
+ const activeNow = policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf;
794
+ const state = activeNow
795
+ ? gapCount ? "active-with-implementation-gaps" : "active-and-operating"
796
+ : policy.status === "approved" && gapCount === 0
797
+ ? "ready-to-activate"
798
+ : policy.status === "approved"
799
+ ? "approved-implementation-pending"
800
+ : "active-with-implementation-gaps";
801
+ assessments.push({
802
+ policyId: policy.id,
803
+ title: policy.title,
804
+ state,
805
+ label: policyActivationLabel(state),
806
+ approvedOn: policy.approvedOn || null,
807
+ effectiveOn: policy.effectiveOn || null,
808
+ proposedEffectiveOn: policy.proposedEffectiveOn || null,
809
+ linkedControlIds,
810
+ plannedOrPartialControlIds,
811
+ missingComponentControlIds,
812
+ missingEvidenceSourceControlIds,
813
+ missingScheduleControlIds,
814
+ unresolvedExceptionIds,
815
+ documentedExceptionIds,
816
+ timingWarnings,
817
+ gapCount,
818
+ canActivateWithDocumentedGaps: policy.status === "approved",
819
+ activationWarning: gapCount
820
+ ? policy.status === "approved"
821
+ ? "You can activate the Policy with a documented gap or approved Exception. Activation does not mark a Control implemented, and Evidence Readiness stays incomplete until the remaining work is done."
822
+ : "The Policy is active, but the remaining gaps keep Evidence Readiness incomplete. Resolve them or record the applicable time-bound Exception."
823
+ : null
824
+ });
825
+ }
826
+ return assessments;
827
+ }
828
+
829
+ function policyActivationLabel(state) {
830
+ return ({
831
+ "approved-implementation-pending": "Approved, implementation pending",
832
+ "ready-to-activate": "Ready to activate",
833
+ "active-with-implementation-gaps": "Active with implementation gaps",
834
+ "active-and-operating": "Active and operating"
835
+ })[state] || state;
836
+ }
837
+
838
+ function policyActivationItem(assessment) {
839
+ const counts = [
840
+ [assessment.plannedOrPartialControlIds.length, "planned or partial Controls"],
841
+ [assessment.missingComponentControlIds.length, "Controls missing active Components"],
842
+ [assessment.missingEvidenceSourceControlIds.length, "Controls missing ready evidence sources"],
843
+ [assessment.missingScheduleControlIds.length, "Controls missing enabled schedules"],
844
+ [assessment.unresolvedExceptionIds.length, "unresolved Exceptions"]
845
+ ].filter(([count]) => count).map(([count, label]) => `${count} ${label}`);
846
+ 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.`
848
+ : assessment.state === "ready-to-activate"
849
+ ? "Implementation checks are complete. Include this approved Policy in the Step 3 cutover when you are ready for it to take effect."
850
+ : `${assessment.label}: ${counts.join(", ") || assessment.timingWarnings.join(" ")}. ${assessment.activationWarning || ""}`.trim();
851
+ return item(
852
+ `policy-activation-${assessment.policyId}`,
853
+ assessment.state === "active-and-operating" ? "complete" : "action",
854
+ `${assessment.title}: ${assessment.label}`,
855
+ message,
856
+ { type: "policy", id: assessment.policyId },
857
+ {
858
+ activationAssessment: assessment,
859
+ commands: [
860
+ "npx filegrc activate-policies --scaffold > policy-activation.json",
861
+ "npx filegrc activate-policies policy-activation.json --preview --json",
862
+ "npx filegrc program-readiness --json"
863
+ ]
864
+ }
618
865
  );
619
866
  }
620
867
 
868
+ function legacyPolicyLibraryProposals(records) {
869
+ const legacyIds = [
870
+ "policy-anti-bribery-corruption",
871
+ "policy-clear-desk-screen",
872
+ "policy-data-protection-handling",
873
+ "policy-employee-handbook",
874
+ "policy-endpoint-remote-work",
875
+ "policy-mobile-computing-communications"
876
+ ];
877
+ const presentIds = legacyIds.filter((id) => records.some((record) => record.type === "policy" && record.id === id));
878
+ if (!presentIds.length) return [];
879
+ return [{
880
+ id: "consolidate-soc2-security-policy",
881
+ title: "Review the minimal SOC 2 Security Policy consolidation",
882
+ policyIds: presentIds,
883
+ status: "review",
884
+ message: "New Security-core workspaces use one Information Security Policy. Review a proposed replacement and Control remapping before superseding absorbed Policies. Keep employment, anti-bribery, privacy, or other broader records outside the SOC 2 Security scope when the organization still uses them. FileGRC never rewrites established content during an upgrade."
885
+ }];
886
+ }
887
+
621
888
  async function controlsStage(scope, byId, readMarkdown, asOf, model) {
622
889
  const items = [];
623
890
  if (!scope.controls.length) {
@@ -659,7 +926,7 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
659
926
  criteriaMapping: (control.requirementIds || []).length > 0,
660
927
  ...(["scheduled", "event-driven", "mixed"].includes(control.operationPattern) ? {
661
928
  workQueue: queueSchedules.length > 0
662
- && queueSchedules.every((obligation) => obligationIsRunning(obligation, byId, asOf))
929
+ && queueSchedules.some(obligationIsEnabled)
663
930
  } : {})
664
931
  };
665
932
  const missing = Object.entries(checks).filter(([, value]) => !value).map(([name]) => controlCheckLabel(name));
@@ -683,13 +950,14 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
683
950
  `npx filegrc get ${shellArgument(control.id)} --mutation`
684
951
  ],
685
952
  workQueue: queueSchedules.length ? {
953
+ enabled: queueSchedules.filter(obligationIsEnabled).length,
686
954
  running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf)).length,
687
955
  total: queueSchedules.length
688
956
  } : null
689
957
  }
690
958
  ));
691
959
  }
692
- 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 running.", items);
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);
693
961
  }
694
962
 
695
963
  async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
@@ -993,18 +1261,6 @@ function controlIdsForRecord(record, byId, seen = new Set()) {
993
1261
  return ids;
994
1262
  }
995
1263
 
996
- function openPlaceholderCount(source) {
997
- if (!source) return 0;
998
- const matches = source.match(
999
- /\{\{[^}\n]+\}\}|\b(?:TODO|TBD)\b|\[(?:complete|confirm|describe|insert|name|replace|select|specify|todo|tbd)[^\]\n]*\]/giu
1000
- );
1001
- return matches?.length || 0;
1002
- }
1003
-
1004
- function substantiveMarkdown(source) {
1005
- return (source.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length >= 10;
1006
- }
1007
-
1008
1264
  function policyCheckLabel(name) {
1009
1265
  return ({
1010
1266
  reviewed: "draft review",
@@ -1023,7 +1279,8 @@ function governedContentCheckLabel(name) {
1023
1279
  approved: "approval and approval date",
1024
1280
  effective: "effective date",
1025
1281
  effectiveContent: "effective content revision",
1026
- contentComplete: "content and organization placeholders"
1282
+ contentComplete: "content and organization placeholders",
1283
+ systemContinuityObjectives: "RTO, RPO, and maximum tolerable downtime for every in-scope System"
1027
1284
  })[name] || name;
1028
1285
  }
1029
1286
 
@@ -1053,6 +1310,16 @@ function assuranceGoalLabel(goal) {
1053
1310
  return "No assurance goal selected";
1054
1311
  }
1055
1312
 
1313
+ function isDescriptionRequirement(requirement) {
1314
+ return (requirement?.tags || []).includes("description-criteria")
1315
+ || /^DC\d+/i.test(requirement?.reference || "");
1316
+ }
1317
+
1318
+ function isSecurityRequirement(requirement) {
1319
+ const tags = requirement?.tags || [];
1320
+ return tags.includes("security") || tags.includes("common-criteria") || /^CC\d+(?:\.|$)/i.test(requirement?.reference || "");
1321
+ }
1322
+
1056
1323
  function stage(id, title, description, items) {
1057
1324
  return { id, title, description, items };
1058
1325
  }
package/src/server.js CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  setupExternalReviewerGovernance
44
44
  } from "./external-reviewer.js";
45
45
  import { isWithin, relativeToWorkspace, resolveWorkspacePath } from "./paths.js";
46
+ import { activatePolicies } from "./policy-activation.js";
46
47
  import { applyReconciliation, planReconciliation } from "./reconciliation.js";
47
48
  import { createAppState, createResourceDetail } from "./state.js";
48
49
  import { setupWorkspace } from "./setup.js";
@@ -322,6 +323,13 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
322
323
  };
323
324
  return json(response, 200, await completeSetup());
324
325
  }
326
+ if (request.method === "POST" && url.pathname === "/api/policy-activations") {
327
+ const payload = await readJson(request);
328
+ const result = await browserMutation(input, options, {
329
+ message: (activation) => `Activate ${activation.policyIds.length} ${activation.policyIds.length === 1 ? "Policy" : "Policies"}`
330
+ }, () => activatePolicies(input, { ...payload, confirmed: true }));
331
+ return json(response, 200, result);
332
+ }
325
333
  if (request.method === "POST" && url.pathname === "/api/resources") {
326
334
  const payload = normalizeResourceMutation(await readJson(request));
327
335
  const { record } = payload;
package/src/setup.js CHANGED
@@ -9,6 +9,7 @@ const CRITICALITIES = new Set(["low", "medium", "high", "critical"]);
9
9
  export async function setupWorkspace(input = process.cwd(), payload = {}) {
10
10
  const loaded = await loadWorkspace(input);
11
11
  const setup = normalizeSetupPayload(payload);
12
+ setup.classificationId = resolveClassificationId(loaded, setup.classificationId);
12
13
  validateSetup(loaded, setup);
13
14
  const plan = buildSetupRecords(loaded, setup);
14
15
  const updates = [
@@ -44,6 +45,7 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
44
45
  export async function planWorkspaceSetup(input = process.cwd(), payload = {}) {
45
46
  const loaded = await loadWorkspace(input);
46
47
  const setup = normalizeSetupPayload(payload);
48
+ setup.classificationId = resolveClassificationId(loaded, setup.classificationId);
47
49
  validateSetup(loaded, setup);
48
50
  const plan = buildSetupRecords(loaded, setup);
49
51
  return {
@@ -137,6 +139,21 @@ function validateSetup(loaded, setup) {
137
139
  }
138
140
  }
139
141
 
142
+ function resolveClassificationId(loaded, value) {
143
+ const normalized = String(value || "").trim().toLowerCase();
144
+ if (!normalized) return value;
145
+ const candidates = String(loaded.model.modelVersion) === "4"
146
+ ? loaded.resources
147
+ .filter(({ type, status }) => type === "classification" && status === "active")
148
+ .map(({ id, title }) => ({ id, label: title }))
149
+ : Object.entries(loaded.workspace.classificationDefinitions || {})
150
+ .map(([id, label]) => ({ id, label }));
151
+ const matches = candidates.filter(({ id, label }) => (
152
+ id.toLowerCase() === normalized || String(label || "").trim().toLowerCase() === normalized
153
+ ));
154
+ return matches.length === 1 ? matches[0].id : value;
155
+ }
156
+
140
157
  function findSetupSystem(resources, target, setup) {
141
158
  const scopedSystemIds = new Set(target.systemIds || []);
142
159
  return (setup.systemId && resources.find(({ type, id }) => type === "system" && id === setup.systemId))
@@ -227,7 +244,7 @@ function buildSetupRecords(loaded, setup) {
227
244
  title: `${setup.serviceName} service commitment`,
228
245
  status: "planned",
229
246
  commitmentKind: "service",
230
- statement: "Replace this starter with the actual customer promise or approved service requirement before activation.",
247
+ statement: "[Complete before activation: State the actual customer promise or approved service requirement.]",
231
248
  systemIds: [systemId],
232
249
  ownerIds: [setup.ownerId],
233
250
  customerFacing: true,