filegrc 0.6.5 → 0.7.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.
@@ -0,0 +1,84 @@
1
+ import { applyResourceBatch, contentRevision } from "./files.js";
2
+ import { serializeWorkspaceMutation } from "./mutation.js";
3
+ import { assessProgramReadiness } from "./program-readiness.js";
4
+ import { currentCalendarDate } from "./time.js";
5
+ import { loadWorkspace } from "./workspace.js";
6
+
7
+ export async function scaffoldPolicyActivation(input = process.cwd(), options = {}) {
8
+ const loaded = await loadWorkspace(input);
9
+ const readiness = await assessProgramReadiness(loaded, { programId: options.programId });
10
+ const approved = readiness.policyActivations.filter(({ state }) => (
11
+ ["approved-implementation-pending", "ready-to-activate"].includes(state)
12
+ ));
13
+ const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
14
+ return {
15
+ policyIds: approved.map(({ policyId }) => policyId),
16
+ effectiveOn: currentCalendarDate(loaded.workspace.timezone),
17
+ expectedRevisions: Object.fromEntries(approved.map(({ policyId }) => [policyId, revisionById.get(policyId)])),
18
+ confirmed: false
19
+ };
20
+ }
21
+
22
+ export async function planPolicyActivation(input = process.cwd(), options = {}) {
23
+ const loaded = await loadWorkspace(input);
24
+ const policyIds = [...new Set((options.policyIds || []).map(String))];
25
+ if (!policyIds.length) throw new Error("Policy activation needs at least one approved Policy.");
26
+ const effectiveOn = String(options.effectiveOn || "").trim();
27
+ if (!isCalendarDate(effectiveOn)) throw new Error("Policy activation needs a real effective date in YYYY-MM-DD format.");
28
+ const today = currentCalendarDate(loaded.workspace.timezone);
29
+ if (effectiveOn < today) {
30
+ throw new Error(`The effective date ${effectiveOn} has passed. Choose ${today} or a future date; do not backdate adoption.`);
31
+ }
32
+ const expectedRevisions = options.expectedRevisions || {};
33
+ if (Array.isArray(expectedRevisions) || !expectedRevisions || typeof expectedRevisions !== "object") {
34
+ throw new Error("Policy activation expected revisions must be keyed by Policy ID.");
35
+ }
36
+ const entryById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
37
+ const update = policyIds.map((policyId) => {
38
+ const entry = entryById.get(policyId);
39
+ if (!entry || entry.record.type !== "policy") throw new Error(`Policy "${policyId}" was not found.`);
40
+ if (entry.record.status !== "approved") {
41
+ throw new Error(`Policy "${policyId}" must be approved and inactive before the Step 3 cutover.`);
42
+ }
43
+ if (!/^[a-f0-9]{64}$/.test(expectedRevisions[policyId] || "")) {
44
+ throw new Error(`Policy activation needs the current revision for "${policyId}". Regenerate the cutover review and try again.`);
45
+ }
46
+ const record = { ...entry.record, status: "active", effectiveOn };
47
+ delete record.proposedEffectiveOn;
48
+ return record;
49
+ });
50
+ return {
51
+ operation: "policy-activation",
52
+ policyIds,
53
+ effectiveOn,
54
+ changes: {
55
+ update,
56
+ expectedRevisions: Object.fromEntries(policyIds.map((policyId) => [
57
+ policyId,
58
+ expectedRevisions[policyId]
59
+ ])),
60
+ validateWholeWorkspace: true
61
+ }
62
+ };
63
+ }
64
+
65
+ export async function activatePolicies(input = process.cwd(), options = {}) {
66
+ if (options.confirmed !== true) throw new Error("Review the Policy activation cutover and confirm the write.");
67
+ return serializeWorkspaceMutation(input, async (root) => {
68
+ const plan = await planPolicyActivation(root, options);
69
+ const result = await applyResourceBatch(root, plan.changes);
70
+ return { ...plan, result };
71
+ });
72
+ }
73
+
74
+ function isCalendarDate(value) {
75
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
76
+ if (!match) return false;
77
+ const [, year, month, day] = match.map(Number);
78
+ const date = new Date(0);
79
+ date.setUTCFullYear(year, month - 1, day);
80
+ date.setUTCHours(0, 0, 0, 0);
81
+ return date.getUTCFullYear() === year
82
+ && date.getUTCMonth() === month - 1
83
+ && date.getUTCDate() === day;
84
+ }
@@ -24,3 +24,7 @@ export function obligationIsRunning(obligation, byId, asOf) {
24
24
  && obligation.status === "active"
25
25
  && obligationProgramStatus(obligation, byId, asOf) === "accepted";
26
26
  }
27
+
28
+ export function obligationIsEnabled(obligation) {
29
+ return obligation?.type === "obligation" && obligation.status === "active";
30
+ }
@@ -11,7 +11,7 @@ export const RESOURCE_INSTRUCTIONS = {
11
11
  framework: "Confirm the criteria framework and version used for the program.",
12
12
  requirement: "Keep the published criterion as catalog content. Record management applicability and rationale on the selected Program.",
13
13
  commitment: "Record supplemental customer promises and service requirements that shape the scope or control design. The Commitment’s systemIds and controlIds are authoritative for what fulfills it.",
14
- policy: "Tailor each policy to match how the organization works. Clear placeholders, assign an owner and separate approver, then record its approval and effective dates. Controls link to their governing Policies.",
14
+ policy: "Tailor each Policy to match what the company is committing to. Clear placeholders, assign an owner and separate approver, then bind approval to the reviewed content. Approval does not prove implementation. Activate the Policy during the Step 3 cutover after reviewing its implementation gaps.",
15
15
  document: "Tailor the governed plans and other supporting documents the program needs. Assign owners and approvers, then keep the approved Markdown in Git.",
16
16
  control: "Finish each applicable starter Control with the procedure people follow, its owner, bounded System scope, operating Components, authoritative evidence-source Components, governing Policy and Requirement mappings, and implementation date. Put calendar and event schedules in Obligations.",
17
17
  "complementary-control": "Review whether any in-scope Control depends on a customer or carved-out provider action. Record each real dependency, or confirm that the current scope has none.",
@@ -58,7 +58,7 @@ export const RESOURCE_PAGE_SUMMARIES = {
58
58
  component: "Connect each material Component to a System.",
59
59
  classification: "Define handling levels.",
60
60
  "information-type": "Define information categories.",
61
- policy: "Adapt and approve starter policies.",
61
+ policy: "Tailor the starter Policy and have someone other than its owner approve it.",
62
62
  document: "Adapt and approve plans.",
63
63
  control: "Describe each Control and its evidence source.",
64
64
  "complementary-control": "Record customer or provider responsibilities, or confirm there are none.",
@@ -77,7 +77,7 @@ export const PROGRAM_PATH = [
77
77
  summary: "Name the owners, criteria, service, Systems, and providers in scope.",
78
78
  sections: [
79
79
  { id: "ownership", title: "Program Ownership", description: "Confirm the people, appointments, and teams that own, approve, review, and operate the program.", steps: ["Confirm the initial program lead’s actual job title and the separate Policy Owner Appointment.", "Add the organization’s real appointments, reviewers, and operators.", "Review the starter Security and Risk Oversight team, its members, and its chair.", "Add other teams only when the organization assigns shared responsibility to them."], types: ["person", "appointment", "team"], defaultOpen: true },
80
- { id: "criteria", title: "Program and Criteria", description: "Define the Program, confirm its Frameworks, record Program-scoped Requirement applicability, and connect customer commitments that shape the System or Control design.", steps: ["Confirm the Program goal, owners, risk method, and candidate period.", "Review the included Security criteria references and record each applicability decision on the Program.", "Record customer commitments and keep optional criteria out until management deliberately adds them."], types: ["program", "framework", "requirement", "commitment"], defaultOpen: true },
80
+ { id: "criteria", title: "Program and Criteria", description: "Define the Program, confirm its Frameworks, record Program-scoped Requirement applicability, and connect customer commitments that shape the System or Control design.", steps: ["Confirm the Program goal, owners, risk method, and candidate period.", "Review the included Security criteria references and record each applicability decision on the Program.", "Record customer commitments and keep optional criteria out until the company chooses to add them."], types: ["program", "framework", "requirement", "commitment"], defaultOpen: true },
81
81
  { id: "boundary", title: "System Boundary", description: "Start with the bounded System. Add Components that materially deliver the service, support Controls, produce authoritative Evidence, or support relevant operations. Keep Vendor relationships and specific Assets separate.", steps: ["Create the complete bounded System and select it on the Program.", "Add only relevant Components, with a role and rationale for each System use.", "Create Vendors for material external provider relationships and link supplied Components when factual.", "Normalize Information Types and Classifications used by the System, Components, Vendors, Risks, and Evidence Artifacts."], types: ["system", "component", "vendor", "classification", "information-type"], defaultOpen: false }
82
82
  ],
83
83
  resourceTypes: ["person", "appointment", "team", "program", "framework", "requirement", "commitment", "system", "component", "vendor", "classification", "information-type"],
@@ -99,12 +99,12 @@ export const PROGRAM_PATH = [
99
99
  id: "policies",
100
100
  number: 2,
101
101
  title: "Approve Policies",
102
- description: "Tailor, review, approve, and adopt",
102
+ description: "Tailor, review, and approve",
103
103
  summary: "Adapt and approve the starter policies.",
104
104
  sections: [
105
- { id: "library", title: "Policy Library", description: "Review, approve, and activate policies and governed plans without treating starter text as adopted practice.", steps: ["Review policy Markdown and replace every organization placeholder.", "Confirm the owner, separate approver, audience, review Obligation, and Controls that point to the Policy.", "Record approval and effective dates before changing the status to active."], types: ["policy", "document"], defaultOpen: true }
105
+ { id: "library", title: "Policy Library", description: "Review and approve Policy requirements without treating approval as proof of technical implementation.", steps: ["Review Policy Markdown and replace every organization placeholder.", "Confirm the owner, separate approver, audience, review Obligation, and Controls that point to the Policy.", "Record approval against the exact reviewed content. Leave the Policy approved and inactive until the Step 3 implementation cutover."], types: ["policy"], defaultOpen: true }
106
106
  ],
107
- resourceTypes: ["policy", "document"],
107
+ resourceTypes: ["policy"],
108
108
  commands: [
109
109
  "filegrc guide policy --json",
110
110
  "filegrc list policy --json",
@@ -118,14 +118,15 @@ export const PROGRAM_PATH = [
118
118
  description: "Finish controls and their evidence sources",
119
119
  summary: "Describe each Control and connect its evidence source.",
120
120
  sections: [
121
- { id: "catalog", title: "Control Catalog", description: "Finish the starter Controls and their authoritative evidence sources, record applicable complementary controls, and see whether FileGRC tracks operation through Work Queue or operating records.", steps: ["Open every planned Control and confirm its mappings and operation pattern.", "Write the real procedure in Record Markdown, add bounded System scope, and map the operating and authoritative evidence-source Components.", "Create or confirm every calendar and event schedule as an Obligation.", "Confirm each source Component is active, has an evidence-source role and rationale in the Control's System scope, has current access owners, and includes repeatable retrieval instructions in Record Markdown.", "Resolve every incomplete evidence-family check before marking the Controls implemented.", "Record any required customer or carved-out provider controls as Complementary Controls."], types: ["control", "complementary-control"], defaultOpen: true }
121
+ { id: "catalog", title: "Control Catalog", description: "Finish the starter Controls, governed plans, schedules, and authoritative evidence sources, then review the approved Policies together at implementation cutover.", steps: ["Open every planned Control and confirm its mappings and operation pattern.", "Write the real procedure in Record Markdown, add bounded System scope, and map the operating and authoritative evidence-source Components.", "Create or enable every calendar and event schedule as an Obligation. Enabled work remains dormant until its governing Policy is active.", "Confirm each source Component is active, has an evidence-source role and rationale in the Control's System scope, has current access owners, and includes repeatable retrieval instructions in Record Markdown.", "Complete required governed plans, then use Review policy activation on the Controls page to inspect each Policy’s planned or partial Controls, missing Components or sources, missing schedules, and unresolved Exceptions.", "Choose the approved Policies that should take effect, set the real effective date, and confirm the Step 3 cutover. You can activate with a documented gap or approved Exception, but Evidence Readiness still requires active and operating Policies."], types: ["control", "complementary-control", "document"], defaultOpen: true }
122
122
  ],
123
- resourceTypes: ["control", "complementary-control"],
123
+ resourceTypes: ["control", "complementary-control", "document"],
124
124
  commands: [
125
125
  "filegrc guide control --json",
126
126
  "filegrc list control --json",
127
127
  "filegrc get CONTROL_ID --mutation",
128
128
  "filegrc review-collection complementary-control --scaffold",
129
+ "filegrc activate-policies --scaffold",
129
130
  "filegrc evidence-map --json",
130
131
  "filegrc program-readiness --json"
131
132
  ]
@@ -180,7 +181,7 @@ export const PROGRAM_PATH = [
180
181
  summary: "Start a guided checklist when a policy-triggering change occurs.",
181
182
  instructions: "Trigger the matching workflow when an event occurs. filegrc adds every required action to the Work Queue with its owner and deadline.",
182
183
  use: "Preview the full workflow before triggering it, then create the event and every linked task in one validated write.",
183
- policyBasis: "Active event obligations translate policy-triggering changes into owned, deadline-bound Action Items. Proposed workflows remain unavailable until their governing policies and linked controls are ready.",
184
+ policyBasis: "Active event Obligations translate policy-triggering changes into owned, deadline-bound Action Items. They remain dormant until their governing Policies are active and effective.",
184
185
  commands: ["filegrc obligations --json", "filegrc trigger EVENT_TYPE (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) --subject RESOURCE_ID --json"]
185
186
  },
186
187
  {
@@ -189,7 +190,7 @@ export const PROGRAM_PATH = [
189
190
  summary: "Complete scheduled, event-driven, and assigned work by its due date.",
190
191
  instructions: "Complete recurring work, Policy Event tasks, and assigned Action Items within their allowed windows, link the requested dated proof, and resolve overdue items.",
191
192
  use: "See proposed, upcoming, blocked, due, and overdue policy work together with every open Action Item. Continuous and per-transaction Controls still operate through their Components and need dated operating records or Evidence.",
192
- policyBasis: "Effective policies and implemented linked controls activate reusable obligations. Policy Events and source records create owned Action Items. Each occurrence or task retains its own deadline, completion record, and evidence.",
193
+ policyBasis: "Active and effective Policies start enabled reusable Obligations. Policy Events and source records create owned Action Items. Each occurrence or task retains its own deadline, completion record, and evidence.",
193
194
  commands: [
194
195
  "filegrc obligations --json",
195
196
  "filegrc complete OBLIGATION_ID --scaffold --window-start YYYY-MM-DD --completed-on YYYY-MM-DD",
@@ -1,10 +1,11 @@
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";
9
10
  import { programComponents, resolveProgram, selectedRequirementIds } from "./program.js";
10
11
  import { markdownEntries } from "./resource-markdown.js";
@@ -30,13 +31,25 @@ export async function assessProgramReadiness(input, options = {}) {
30
31
  return markdown.get(record.id);
31
32
  };
32
33
 
34
+ const policyStage = await policiesStage(scope, records, byId, readMarkdown);
33
35
  const controlStage = await controlsStage(scope, byId, readMarkdown, asOf, loaded.model);
34
36
  controlStage.items.unshift(...collectionReviews
35
37
  .filter(({ resourceType }) => resourceType === "complementary-control")
36
38
  .map(collectionReviewReadinessItem));
37
39
  const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
38
40
  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.`;
41
+ controlStage.items.push(...await governedContentItems(scope, records, byId, readMarkdown, asOf));
42
+ const policyActivations = await assessPolicyActivations(
43
+ requiredPolicies(scope, byId),
44
+ scope.controls,
45
+ records,
46
+ byId,
47
+ readMarkdown,
48
+ asOf,
49
+ loaded.model
50
+ );
51
+ 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.`;
40
53
  const evidenceGateStages = [
41
54
  scopeStage(
42
55
  program,
@@ -46,7 +59,7 @@ export async function assessProgramReadiness(input, options = {}) {
46
59
  loaded.model,
47
60
  collectionReviews.filter(({ resourceType }) => resourceType !== "complementary-control")
48
61
  ),
49
- await policiesStage(scope, records, byId, readMarkdown, asOf),
62
+ policyStage,
50
63
  controlStage
51
64
  ];
52
65
  for (const current of evidenceGateStages) finalizeStage(current);
@@ -89,6 +102,8 @@ export async function assessProgramReadiness(input, options = {}) {
89
102
  operating,
90
103
  canStartCandidatePeriod,
91
104
  suggestedCandidatePeriodStart: canStartCandidatePeriod ? asOf : null,
105
+ policyActivations,
106
+ policyLibraryProposals: policyLibraryProposals(records),
92
107
  progress: {
93
108
  complete,
94
109
  total: managedItems.length,
@@ -451,11 +466,8 @@ function ownershipResolutionReasons(ownerIds, byId) {
451
466
  });
452
467
  }
453
468
 
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
- ));
469
+ async function policiesStage(scope, records, byId, readMarkdown) {
470
+ const policies = requiredPolicies(scope, byId);
459
471
  const appointedReviewer = policies
460
472
  .filter((policy) => partiesIndependent(policy.ownerIds, policy.approverIds, byId))
461
473
  .flatMap((policy) => [...currentPartyPeople(policy.approverIds || [], byId)])
@@ -515,11 +527,9 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
515
527
  const source = await readMarkdown(policy);
516
528
  const placeholderCount = openPlaceholderCount(source);
517
529
  const checks = {
518
- reviewed: ["in-review", "approved", "active"].includes(policy.status),
519
530
  independentlyApproved: ["approved", "active"].includes(policy.status)
520
531
  && policy.approvedOn
521
532
  && partiesIndependent(policy.ownerIds, policy.approverIds, byId),
522
- effective: policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf,
523
533
  linkedControls: scope.controls.some((control) => (control.policyIds || []).includes(policy.id)),
524
534
  contentComplete: Boolean(source.trim()) && placeholderCount === 0
525
535
  };
@@ -529,8 +539,8 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
529
539
  missing.length ? "action" : "complete",
530
540
  policy.title,
531
541
  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.`,
542
+ ? `Remaining approval work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}. Approval accepts the policy requirements; it does not assert Control implementation.`
543
+ : `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
544
  policy,
535
545
  {
536
546
  checks,
@@ -543,11 +553,29 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
543
553
  }
544
554
  ));
545
555
  }
556
+ return stage(
557
+ "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.",
560
+ items
561
+ );
562
+ }
563
+
564
+ function requiredPolicies(scope, byId) {
565
+ const linkedPolicyIds = new Set(scope.controls.flatMap((control) => control.policyIds || []));
566
+ return [...linkedPolicyIds].map((id) => byId.get(id)).filter((record) => (
567
+ record?.type === "policy"
568
+ && record.programRole !== "conditional"
569
+ && !["superseded", "retired"].includes(record.status)
570
+ ));
571
+ }
572
+
573
+ async function governedContentItems(scope, records, byId, readMarkdown, asOf) {
546
574
  const selectedControlIds = new Set(scope.controls.map(({ id }) => id));
547
- const activeObligations = records.filter((record) => (
548
- record.type === "obligation" && obligationIsRunning(record, byId, asOf)
575
+ const enabledObligations = records.filter((record) => (
576
+ record.type === "obligation" && obligationIsEnabled(record)
549
577
  ));
550
- const requiredGovernedIds = new Set(activeObligations.flatMap((record) => [
578
+ const requiredGovernedIds = new Set(enabledObligations.flatMap((record) => [
551
579
  ...(record.scopeResourceIds || []),
552
580
  ...(record.templateResourceId ? [record.templateResourceId] : [])
553
581
  ]));
@@ -564,9 +592,16 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
564
592
  )
565
593
  || (record.type === "training" && requiredGovernedIds.has(record.id))
566
594
  ));
595
+ const items = [];
567
596
  for (const record of governedRecords) {
568
597
  const source = await readMarkdown(record);
569
598
  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
+ ));
570
605
  const checks = record.type === "document"
571
606
  ? {
572
607
  active: record.status === "active",
@@ -576,7 +611,10 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
576
611
  && partiesIndependent(record.ownerIds, record.approverIds, byId)
577
612
  ),
578
613
  effective: Boolean(record.effectiveOn && record.effectiveOn <= asOf),
579
- contentComplete: substantiveMarkdown(source) && placeholderCount === 0
614
+ contentComplete: substantiveMarkdown(source) && placeholderCount === 0,
615
+ ...(isSecurityIncidentRecoveryPlan
616
+ ? { systemContinuityObjectives: systemsWithCompleteContinuityObjectives.length === scope.systems.length && scope.systems.length > 0 }
617
+ : {})
580
618
  }
581
619
  : {
582
620
  active: record.status === "active",
@@ -596,12 +634,20 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
596
634
  missing.length
597
635
  ? `Remaining governed-content work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}.`
598
636
  : record.type === "document"
599
- ? `Active, independently approved, effective ${record.effectiveOn}, and ready for the selected controls or running schedule.`
637
+ ? `Active, approved by a separate reviewer, effective ${record.effectiveOn}, and ready for the selected Controls or running schedule.`
600
638
  : `Active, approved, effective ${record.effectiveOn}, revision-bound, and ready for the running training schedule.`,
601
639
  record,
602
640
  {
603
641
  checks,
604
642
  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
+ : {}),
605
651
  commands: [
606
652
  `npx filegrc get ${shellArgument(record.id)} --mutation`,
607
653
  `npx filegrc update ${record.type} ${shellArgument(record.id)} MUTATION.json --json`,
@@ -610,14 +656,178 @@ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
610
656
  }
611
657
  ));
612
658
  }
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
659
+ return items;
660
+ }
661
+
662
+ 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";
665
+ const assessments = [];
666
+ for (const policy of policies.filter((record) => ["approved", "active"].includes(record.status))) {
667
+ const linkedControls = controls.filter((control) => (control.policyIds || []).includes(policy.id));
668
+ const linkedControlIds = linkedControls.map(({ id }) => id);
669
+ const plannedOrPartialControlIds = linkedControls
670
+ .filter((control) => ["planned", "partially-implemented"].includes(control.status))
671
+ .map(({ id }) => id);
672
+ const missingComponentControlIds = String(model.modelVersion) === "4"
673
+ ? linkedControls.filter((control) => ![
674
+ ...(control.componentIds || []),
675
+ ...(control.evidenceSourceComponentIds || [])
676
+ ].some((id) => (
677
+ byId.get(id)?.type === "component" && byId.get(id).status === "active"
678
+ ))).map(({ id }) => id)
679
+ : [];
680
+ const missingEvidenceSourceControlIds = [];
681
+ for (const control of linkedControls) {
682
+ const readySources = [];
683
+ for (const id of control[sourceField] || []) {
684
+ const source = byId.get(id);
685
+ if (source?.type !== sourceType || source.status !== "active") continue;
686
+ const instructions = await readMarkdown(source);
687
+ if (
688
+ (source.evidenceSourceKinds || []).length
689
+ && (source.evidenceOwnerIds || []).length
690
+ && substantiveMarkdown(instructions)
691
+ && openPlaceholderCount(instructions) === 0
692
+ ) readySources.push(source);
693
+ }
694
+ if (!readySources.length) missingEvidenceSourceControlIds.push(control.id);
695
+ }
696
+ const missingScheduleControlIds = linkedControls.filter((control) => (
697
+ ["scheduled", "event-driven", "mixed"].includes(control.operationPattern)
698
+ && !records.some((record) => (
699
+ record.type === "obligation"
700
+ && obligationIsEnabled(record)
701
+ && (record.controlIds || []).includes(control.id)
702
+ && (record.policyIds || []).includes(policy.id)
703
+ ))
704
+ )).map(({ id }) => id);
705
+ const relevantExceptions = records.filter((record) => (
706
+ record.type === "exception"
707
+ && (record.scopeResourceIds || []).some((id) => id === policy.id || linkedControlIds.includes(id))
708
+ && !["revoked", "closed"].includes(record.status)
709
+ ));
710
+ const unresolvedExceptionIds = relevantExceptions.filter((record) => (
711
+ record.status !== "approved"
712
+ || !record.approval?.expiresOn
713
+ || record.approval.expiresOn < asOf
714
+ )).map(({ id }) => id);
715
+ const documentedExceptionIds = relevantExceptions.filter((record) => (
716
+ record.status === "approved"
717
+ && record.approval?.expiresOn
718
+ && record.approval.expiresOn >= asOf
719
+ )).map(({ id }) => id);
720
+ const timingWarnings = [
721
+ policy.proposedEffectiveOn && policy.proposedEffectiveOn < asOf
722
+ ? `The proposed effective date ${policy.proposedEffectiveOn} has passed. Choose a current or future activation date; do not backdate adoption.`
723
+ : null,
724
+ policy.status === "active" && (!policy.effectiveOn || policy.effectiveOn > asOf)
725
+ ? policy.effectiveOn
726
+ ? `The Policy is marked active but does not become effective until ${policy.effectiveOn}. Governed Obligations remain dormant until then.`
727
+ : "The Policy is marked active without an effective date."
728
+ : null
729
+ ].filter(Boolean);
730
+ const gapCount = plannedOrPartialControlIds.length
731
+ + missingComponentControlIds.length
732
+ + missingEvidenceSourceControlIds.length
733
+ + missingScheduleControlIds.length
734
+ + unresolvedExceptionIds.length
735
+ + timingWarnings.length;
736
+ const activeNow = policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf;
737
+ const state = activeNow
738
+ ? gapCount ? "active-with-implementation-gaps" : "active-and-operating"
739
+ : policy.status === "approved" && gapCount === 0
740
+ ? "ready-to-activate"
741
+ : policy.status === "approved"
742
+ ? "approved-implementation-pending"
743
+ : "active-with-implementation-gaps";
744
+ assessments.push({
745
+ policyId: policy.id,
746
+ title: policy.title,
747
+ state,
748
+ label: policyActivationLabel(state),
749
+ approvedOn: policy.approvedOn || null,
750
+ effectiveOn: policy.effectiveOn || null,
751
+ proposedEffectiveOn: policy.proposedEffectiveOn || null,
752
+ linkedControlIds,
753
+ plannedOrPartialControlIds,
754
+ missingComponentControlIds,
755
+ missingEvidenceSourceControlIds,
756
+ missingScheduleControlIds,
757
+ unresolvedExceptionIds,
758
+ documentedExceptionIds,
759
+ timingWarnings,
760
+ gapCount,
761
+ canActivateWithDocumentedGaps: policy.status === "approved",
762
+ activationWarning: gapCount
763
+ ? policy.status === "approved"
764
+ ? "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."
765
+ : "The Policy is active, but the remaining gaps keep Evidence Readiness incomplete. Resolve them or record the applicable time-bound Exception."
766
+ : null
767
+ });
768
+ }
769
+ return assessments;
770
+ }
771
+
772
+ function policyActivationLabel(state) {
773
+ return ({
774
+ "approved-implementation-pending": "Approved, implementation pending",
775
+ "ready-to-activate": "Ready to activate",
776
+ "active-with-implementation-gaps": "Active with implementation gaps",
777
+ "active-and-operating": "Active and operating"
778
+ })[state] || state;
779
+ }
780
+
781
+ function policyActivationItem(assessment) {
782
+ const counts = [
783
+ [assessment.plannedOrPartialControlIds.length, "planned or partial Controls"],
784
+ [assessment.missingComponentControlIds.length, "Controls missing active Components"],
785
+ [assessment.missingEvidenceSourceControlIds.length, "Controls missing ready evidence sources"],
786
+ [assessment.missingScheduleControlIds.length, "Controls missing enabled schedules"],
787
+ [assessment.unresolvedExceptionIds.length, "unresolved Exceptions"]
788
+ ].filter(([count]) => count).map(([count, label]) => `${count} ${label}`);
789
+ const message = assessment.state === "active-and-operating"
790
+ ? `Active and effective ${assessment.effectiveOn}; all ${assessment.linkedControlIds.length} linked Controls are implemented with Components, evidence sources, and enabled schedules.`
791
+ : assessment.state === "ready-to-activate"
792
+ ? "Implementation checks are complete. Include this approved Policy in the Step 3 cutover when you are ready for it to take effect."
793
+ : `${assessment.label}: ${counts.join(", ") || assessment.timingWarnings.join(" ")}. ${assessment.activationWarning || ""}`.trim();
794
+ return item(
795
+ `policy-activation-${assessment.policyId}`,
796
+ assessment.state === "active-and-operating" ? "complete" : "action",
797
+ `${assessment.title}: ${assessment.label}`,
798
+ message,
799
+ { type: "policy", id: assessment.policyId },
800
+ {
801
+ activationAssessment: assessment,
802
+ commands: [
803
+ "npx filegrc activate-policies --scaffold > policy-activation.json",
804
+ "npx filegrc activate-policies policy-activation.json --preview --json",
805
+ "npx filegrc program-readiness --json"
806
+ ]
807
+ }
618
808
  );
619
809
  }
620
810
 
811
+ function policyLibraryProposals(records) {
812
+ const legacyIds = [
813
+ "policy-anti-bribery-corruption",
814
+ "policy-clear-desk-screen",
815
+ "policy-data-protection-handling",
816
+ "policy-employee-handbook",
817
+ "policy-endpoint-remote-work",
818
+ "policy-mobile-computing-communications"
819
+ ];
820
+ const presentIds = legacyIds.filter((id) => records.some((record) => record.type === "policy" && record.id === id));
821
+ if (!presentIds.length) return [];
822
+ return [{
823
+ id: "consolidate-soc2-security-policy",
824
+ title: "Review the minimal SOC 2 Security Policy consolidation",
825
+ policyIds: presentIds,
826
+ status: "review",
827
+ 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."
828
+ }];
829
+ }
830
+
621
831
  async function controlsStage(scope, byId, readMarkdown, asOf, model) {
622
832
  const items = [];
623
833
  if (!scope.controls.length) {
@@ -659,7 +869,7 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
659
869
  criteriaMapping: (control.requirementIds || []).length > 0,
660
870
  ...(["scheduled", "event-driven", "mixed"].includes(control.operationPattern) ? {
661
871
  workQueue: queueSchedules.length > 0
662
- && queueSchedules.every((obligation) => obligationIsRunning(obligation, byId, asOf))
872
+ && queueSchedules.some(obligationIsEnabled)
663
873
  } : {})
664
874
  };
665
875
  const missing = Object.entries(checks).filter(([, value]) => !value).map(([name]) => controlCheckLabel(name));
@@ -683,13 +893,14 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
683
893
  `npx filegrc get ${shellArgument(control.id)} --mutation`
684
894
  ],
685
895
  workQueue: queueSchedules.length ? {
896
+ enabled: queueSchedules.filter(obligationIsEnabled).length,
686
897
  running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf)).length,
687
898
  total: queueSchedules.length
688
899
  } : null
689
900
  }
690
901
  ));
691
902
  }
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);
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);
693
904
  }
694
905
 
695
906
  async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
@@ -993,18 +1204,6 @@ function controlIdsForRecord(record, byId, seen = new Set()) {
993
1204
  return ids;
994
1205
  }
995
1206
 
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
1207
  function policyCheckLabel(name) {
1009
1208
  return ({
1010
1209
  reviewed: "draft review",
@@ -1023,7 +1222,8 @@ function governedContentCheckLabel(name) {
1023
1222
  approved: "approval and approval date",
1024
1223
  effective: "effective date",
1025
1224
  effectiveContent: "effective content revision",
1026
- contentComplete: "content and organization placeholders"
1225
+ contentComplete: "content and organization placeholders",
1226
+ systemContinuityObjectives: "RTO, RPO, and maximum tolerable downtime for every in-scope System"
1027
1227
  })[name] || name;
1028
1228
  }
1029
1229
 
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;