filegrc 0.8.0 → 0.9.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.
@@ -64,7 +64,7 @@ export async function assessProgramReadiness(input, options = {}) {
64
64
  loaded.model
65
65
  );
66
66
  controlStage.items.push(...policyActivations.map(policyActivationItem));
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.`;
67
+ controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, enabled Obligations, and complete authoritative source ${modelSupports(loaded.model, "component-sources") ? "Components" : "Systems"}. Activate unchanged approved program Documents and Training after their requirements are implemented, then activate approved Policies at the implementation cutover.`;
68
68
  const evidenceGateStages = [
69
69
  scopeStage(
70
70
  program,
@@ -120,6 +120,7 @@ export async function assessProgramReadiness(input, options = {}) {
120
120
  suggestedCandidatePeriodStart: canStartCandidatePeriod ? asOf : null,
121
121
  policyActivations,
122
122
  documentActivations: governedContent.documentActivations,
123
+ trainingActivations: governedContent.trainingActivations,
123
124
  policyLibraryProposals: [
124
125
  ...policyLibrary.proposals,
125
126
  ...legacyPolicyLibraryProposals(records)
@@ -538,7 +539,10 @@ async function policiesStage(scope, records, byId, readMarkdown, model) {
538
539
  const documents = modelSupports(model, "governed-document-activation")
539
540
  ? requiredGovernedDocuments(scope, records, byId, model)
540
541
  : [];
541
- const governedRecords = [...policies, ...documents];
542
+ const trainings = modelSupports(model, "governed-training-activation")
543
+ ? records.filter(({ type, status }) => type === "training" && !["superseded", "retired"].includes(status))
544
+ : [];
545
+ const governedRecords = [...policies, ...documents, ...trainings];
542
546
  const appointedReviewer = governedRecords
543
547
  .filter((record) => partiesIndependent(record.ownerIds, record.approverIds, byId))
544
548
  .flatMap((record) => [...currentPartyPeople(record.approverIds || [], byId)])
@@ -628,6 +632,8 @@ async function policiesStage(scope, records, byId, readMarkdown, model) {
628
632
  const source = await readMarkdown(document);
629
633
  const placeholderCount = openPlaceholderCount(source);
630
634
  const isSecurityIncidentRecoveryPlan = document.id === "document-security-incident-recovery-plan";
635
+ const systemContinuityObjectivesRequired = isSecurityIncidentRecoveryPlan
636
+ && scope.requirements.some(isAvailabilityRequirement);
631
637
  const systemsWithCompleteContinuityObjectives = scope.systems.filter(({ continuityObjectives }) => (
632
638
  Number.isInteger(continuityObjectives?.recoveryTimeHours)
633
639
  && Number.isInteger(continuityObjectives?.recoveryPointHours)
@@ -642,7 +648,10 @@ async function policiesStage(scope, records, byId, readMarkdown, model) {
642
648
  linkedControls: (document.controlIds || []).some((id) => scope.controls.some((control) => control.id === id)),
643
649
  contentComplete: substantiveMarkdown(source) && placeholderCount === 0,
644
650
  ...(isSecurityIncidentRecoveryPlan
645
- ? { systemContinuityObjectives: systemsWithCompleteContinuityObjectives.length === scope.systems.length && scope.systems.length > 0 }
651
+ ? {
652
+ systemContinuityObjectives: !systemContinuityObjectivesRequired
653
+ || systemsWithCompleteContinuityObjectives.length === scope.systems.length && scope.systems.length > 0
654
+ }
646
655
  : {})
647
656
  };
648
657
  const missing = Object.entries(checks)
@@ -661,10 +670,13 @@ async function policiesStage(scope, records, byId, readMarkdown, model) {
661
670
  placeholderCount,
662
671
  ...(isSecurityIncidentRecoveryPlan
663
672
  ? {
673
+ systemContinuityObjectivesRequired,
664
674
  continuityObjectiveSystemIds: systemsWithCompleteContinuityObjectives.map(({ id }) => id),
665
- missingContinuityObjectiveSystemIds: scope.systems
666
- .filter(({ id }) => !systemsWithCompleteContinuityObjectives.some((system) => system.id === id))
667
- .map(({ id }) => id)
675
+ missingContinuityObjectiveSystemIds: systemContinuityObjectivesRequired
676
+ ? scope.systems
677
+ .filter(({ id }) => !systemsWithCompleteContinuityObjectives.some((system) => system.id === id))
678
+ .map(({ id }) => id)
679
+ : []
668
680
  }
669
681
  : {}),
670
682
  commands: [
@@ -675,10 +687,46 @@ async function policiesStage(scope, records, byId, readMarkdown, model) {
675
687
  }
676
688
  ));
677
689
  }
690
+ if (modelSupports(model, "governed-training-activation")) {
691
+ for (const training of trainings) {
692
+ const source = await readMarkdown(training);
693
+ const placeholderCount = openPlaceholderCount(source);
694
+ const checks = {
695
+ independentlyApproved: ["approved", "active"].includes(training.status)
696
+ && Boolean(training.approvedOn)
697
+ && Boolean(training.approvedContentRevisions)
698
+ && partiesIndependent(training.ownerIds, training.approverIds, byId),
699
+ owner: currentPartyPeople(training.ownerIds, byId).size > 0,
700
+ linkedControls: (training.controlIds || []).some((id) => scope.controls.some((control) => control.id === id)),
701
+ contentComplete: substantiveMarkdown(source) && placeholderCount === 0
702
+ };
703
+ const missing = Object.entries(checks)
704
+ .filter(([, value]) => !value)
705
+ .map(([name]) => governedApprovalCheckLabel(name));
706
+ items.push(item(
707
+ `training-approval-${training.id}`,
708
+ missing.length ? "action" : "complete",
709
+ training.title,
710
+ missing.length
711
+ ? `Remaining Step 2 approval work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}. Review and approve the exact Training content before implementation.`
712
+ : `Independently approved on ${training.approvedOn} and bound to the exact Training revision. Activation remains in Step 3.`,
713
+ training,
714
+ {
715
+ checks,
716
+ placeholderCount,
717
+ commands: [
718
+ `npx filegrc get ${shellArgument(training.id)} --mutation`,
719
+ `npx filegrc update training ${shellArgument(training.id)} MUTATION.json --json`,
720
+ "npx filegrc program-readiness --json"
721
+ ]
722
+ }
723
+ ));
724
+ }
725
+ }
678
726
  return stage(
679
727
  "policies",
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.",
728
+ "Approve Policies",
729
+ "Approve the exact Policy, program Document, and Training content that defines what the organization intends to require. Approval does not prove implementation or activate the content.",
682
730
  items
683
731
  );
684
732
  }
@@ -727,10 +775,18 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf, mo
727
775
  const documents = requiredGovernedDocuments(scope, records, byId, model);
728
776
  const governedRecords = [
729
777
  ...documents,
730
- ...records.filter((record) => record.type === "training" && requiredGovernedIds.has(record.id))
778
+ ...records.filter((record) => (
779
+ record.type === "training"
780
+ && !["superseded", "retired"].includes(record.status)
781
+ && (
782
+ requiredGovernedIds.has(record.id)
783
+ || (record.controlIds || []).some((id) => scope.controls.some((control) => control.id === id))
784
+ )
785
+ ))
731
786
  ];
732
787
  const items = [];
733
788
  const documentActivations = [];
789
+ const trainingActivations = [];
734
790
  for (const record of governedRecords) {
735
791
  const source = await readMarkdown(record);
736
792
  const placeholderCount = openPlaceholderCount(source);
@@ -766,7 +822,30 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf, mo
766
822
  effective: Boolean(record.effectiveOn && record.effectiveOn <= asOf),
767
823
  contentComplete: substantiveMarkdown(source) && placeholderCount === 0
768
824
  }
769
- : {
825
+ : modelSupports(model, "governed-training-activation") ? {
826
+ approvalBound: ["approved", "active"].includes(record.status)
827
+ && Boolean(record.approvedOn)
828
+ && Boolean(record.approvedContentRevisions)
829
+ && partiesIndependent(record.ownerIds, record.approverIds, byId),
830
+ owner: currentPartyPeople(record.ownerIds, byId).size > 0,
831
+ active: record.status === "active",
832
+ requirementsImplemented: linkedControlIds.length > 0 && missingImplementationControlIds.length === 0,
833
+ assignmentScheduled: enabledObligations.some((obligation) => (
834
+ obligation.templateResourceId === record.id
835
+ || (obligation.scopeResourceIds || []).includes(record.id)
836
+ )),
837
+ activationRecorded: ["recorded", "legacy-v5"].includes(record.activationBasis),
838
+ activated: record.activationBasis === "legacy-v5" || Boolean(record.activatedOn),
839
+ activator: record.activationBasis === "legacy-v5" || Boolean((record.activatedByIds || []).length)
840
+ && record.activatedByIds.every((id) => personWasActiveOn(byId.get(id), record.activatedOn)),
841
+ activatedContent: record.activationBasis === "legacy-v5" || Boolean(record.activatedContentRevisions),
842
+ activationMatchesApproval: record.activationBasis === "legacy-v5" || contentRevisionBindingsMatch(
843
+ record.approvedContentRevisions,
844
+ record.activatedContentRevisions
845
+ ),
846
+ effective: Boolean(record.effectiveOn && record.effectiveOn <= asOf),
847
+ contentComplete: substantiveMarkdown(source) && placeholderCount === 0
848
+ } : {
770
849
  active: record.status === "active",
771
850
  owner: currentPartyPeople(record.ownerIds, byId).size > 0,
772
851
  approved: Boolean(record.approvedOn && (record.approvedByIds || []).length),
@@ -810,6 +889,37 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf, mo
810
889
  gapCount: record.status === "active" ? missing.length : preActivationGapCount
811
890
  });
812
891
  }
892
+ if (record.type === "training" && modelSupports(model, "governed-training-activation")) {
893
+ const activationComplete = Object.values(checks).every(Boolean);
894
+ const preActivationCheckNames = ["approvalBound", "owner", "requirementsImplemented", "assignmentScheduled", "contentComplete"];
895
+ const preActivationGapCount = preActivationCheckNames.filter((name) => !checks[name]).length;
896
+ const readyToActivate = record.status === "approved"
897
+ && preActivationCheckNames.every((name) => checks[name]);
898
+ const state = record.status === "active" && activationComplete
899
+ ? "active-and-operating"
900
+ : record.status === "active"
901
+ ? "active-with-gaps"
902
+ : readyToActivate
903
+ ? "ready-to-activate"
904
+ : record.status === "approved"
905
+ ? "approved-implementation-pending"
906
+ : "approval-pending";
907
+ trainingActivations.push({
908
+ trainingId: record.id,
909
+ title: record.title,
910
+ state,
911
+ label: documentActivationLabel(state),
912
+ approvedOn: record.approvedOn || null,
913
+ activatedOn: record.activatedOn || null,
914
+ effectiveOn: record.effectiveOn || null,
915
+ linkedControlIds,
916
+ missingImplementationControlIds,
917
+ assignmentScheduled: checks.assignmentScheduled,
918
+ activationRevisionBound: Boolean(record.activatedContentRevisions),
919
+ activatedByIds: record.activatedByIds || [],
920
+ gapCount: record.status === "active" ? missing.length : preActivationGapCount
921
+ });
922
+ }
813
923
  items.push(item(
814
924
  `${record.type}-${record.id}`,
815
925
  missing.length ? "action" : "complete",
@@ -825,7 +935,7 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf, mo
825
935
  {
826
936
  checks,
827
937
  placeholderCount,
828
- ...(record.type === "document" ? { linkedControlIds, missingImplementationControlIds } : {}),
938
+ ...(["document", "training"].includes(record.type) ? { linkedControlIds, missingImplementationControlIds } : {}),
829
939
  commands: [
830
940
  `npx filegrc get ${shellArgument(record.id)} --mutation`,
831
941
  `npx filegrc update ${record.type} ${shellArgument(record.id)} MUTATION.json --json`,
@@ -834,7 +944,7 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf, mo
834
944
  }
835
945
  ));
836
946
  }
837
- return { items, documentActivations };
947
+ return { items, documentActivations, trainingActivations };
838
948
  }
839
949
 
840
950
  function documentActivationLabel(state) {
@@ -986,12 +1096,12 @@ function policyActivationItem(assessment) {
986
1096
  [assessment.plannedOrPartialControlIds.length, "planned or partial Controls"],
987
1097
  [assessment.missingComponentControlIds.length, "Controls missing active Components"],
988
1098
  [assessment.missingEvidenceSourceControlIds.length, "Controls missing ready evidence sources"],
989
- [assessment.missingScheduleControlIds.length, "Controls missing enabled schedules"],
1099
+ [assessment.missingScheduleControlIds.length, "Controls missing enabled Obligations"],
990
1100
  [assessment.missingGovernedDocumentIds?.length || 0, "required governed Documents not active"],
991
1101
  [assessment.unresolvedExceptionIds.length, "unresolved Exceptions"]
992
1102
  ].filter(([count]) => count).map(([count, label]) => `${count} ${label}`);
993
1103
  const message = assessment.state === "active-and-operating"
994
- ? `Active and effective ${assessment.effectiveOn}; all ${assessment.linkedControlIds.length} linked Controls are implemented with Components, evidence sources, and enabled schedules.`
1104
+ ? `Active and effective ${assessment.effectiveOn}; all ${assessment.linkedControlIds.length} linked Controls are implemented with Components, evidence sources, and enabled Obligations.`
995
1105
  : assessment.state === "ready-to-activate"
996
1106
  ? "Implementation checks are complete. Include this approved Policy in the Step 3 cutover when you are ready for it to take effect."
997
1107
  : `${assessment.label}: ${counts.join(", ") || assessment.timingWarnings.join(" ")}. ${assessment.activationWarning || ""}`.trim();
@@ -1104,7 +1214,7 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
1104
1214
  }
1105
1215
  ));
1106
1216
  }
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);
1217
+ return stage("controls", "Implement Controls", "Each implemented Control needs an owner, actual procedure, scope, operation pattern, evidence source, mappings, an implementation date, and any required Obligations enabled. Scheduled work stays dormant until its governing Policy, program Documents, and Training are active and effective.", items);
1108
1218
  }
1109
1219
 
1110
1220
  async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
@@ -1438,6 +1548,7 @@ function governedContentCheckLabel(name) {
1438
1548
  effective: "effective date",
1439
1549
  effectiveContent: "effective content revision",
1440
1550
  requirementsImplemented: "implemented linked requirements",
1551
+ assignmentScheduled: "enabled Training assignment schedule",
1441
1552
  activationRecorded: "recorded activation basis",
1442
1553
  activated: "separate activation date",
1443
1554
  activator: "named activation Person",
@@ -1463,7 +1574,7 @@ function controlCheckLabel(name) {
1463
1574
  implementationReview: "independent implementation review",
1464
1575
  policyMapping: "policy mapping",
1465
1576
  criteriaMapping: "criteria mapping",
1466
- workQueue: "running Work Queue schedules"
1577
+ workQueue: "running Obligation schedules"
1467
1578
  })[name] || name;
1468
1579
  }
1469
1580
 
@@ -1479,6 +1590,10 @@ function isDescriptionRequirement(requirement) {
1479
1590
  || /^DC\d+/i.test(requirement?.reference || "");
1480
1591
  }
1481
1592
 
1593
+ function isAvailabilityRequirement(requirement) {
1594
+ return /^A1\./i.test(requirement?.reference || "");
1595
+ }
1596
+
1482
1597
  function isSecurityRequirement(requirement) {
1483
1598
  const tags = requirement?.tags || [];
1484
1599
  return tags.includes("security") || tags.includes("common-criteria") || /^CC\d+(?:\.|$)/i.test(requirement?.reference || "");
package/src/server.js CHANGED
@@ -5,7 +5,7 @@ import { extname, join, resolve } from "node:path";
5
5
  import { performance } from "node:perf_hooks";
6
6
  import { getResourceDefinition } from "../model/index.js";
7
7
  import { prepareAuditWorkspace } from "./audit-preparation.js";
8
- import { activateDocuments } from "./document-activation.js";
8
+ import { activateDocuments, activateGovernedContent } from "./document-activation.js";
9
9
  import { createNextAuditCycle, planNextAuditCycle } from "./audit-transition.js";
10
10
  import { applyApplicabilityReviewWithContext, planApplicabilityReview } from "./batch-review.js";
11
11
  import { applyCollectionReview, planCollectionReview } from "./collection-review.js";
@@ -338,6 +338,13 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
338
338
  }, () => activateDocuments(input, { ...payload, confirmed: true }));
339
339
  return json(response, 200, result);
340
340
  }
341
+ if (request.method === "POST" && url.pathname === "/api/governed-content-activations") {
342
+ const payload = await readJson(request);
343
+ const result = await browserMutation(input, options, {
344
+ message: (activation) => `Activate ${activation.resourceIds.length} governed-content ${activation.resourceIds.length === 1 ? "record" : "records"}`
345
+ }, () => activateGovernedContent(input, { ...payload, confirmed: true }));
346
+ return json(response, 200, result);
347
+ }
341
348
  if (request.method === "POST" && url.pathname === "/api/resources") {
342
349
  const payload = normalizeResourceMutation(await readJson(request));
343
350
  const { record } = payload;
package/src/setup.js CHANGED
@@ -81,7 +81,7 @@ export function summarizeSetupResult(result) {
81
81
  },
82
82
  system: setupSystemSummary(result.system),
83
83
  target: setupTargetSummary(result.program || result.workspace, {
84
- modelVersion: result.workspace?.dataModelVersion || (result.program ? "5" : "3")
84
+ modelVersion: result.workspace?.dataModelVersion || (result.program ? "6" : "3")
85
85
  }),
86
86
  renderer: result.renderer ? setupRendererSummary(result.renderer) : null,
87
87
  commitment: result.commitment || null,
package/src/validate.js CHANGED
@@ -273,6 +273,20 @@ function validateDocumentWorkflowScopes(resources, model, byId, pathById, diagno
273
273
  ));
274
274
  }
275
275
  }
276
+ for (const training of resources.filter(({ type, activationBasis }) => (
277
+ type === "training" && activationBasis === "recorded"
278
+ ))) {
279
+ const invalidActorIds = (training.activatedByIds || []).filter((id) => (
280
+ !personWasActiveOn(byId.get(id), training.activatedOn)
281
+ ));
282
+ if (invalidActorIds.length) {
283
+ diagnostics.push(error(
284
+ "invalid-training-activation-actor",
285
+ pathById.get(training.id) || `data/${training.id}`,
286
+ `Training activation actors must have been active on ${training.activatedOn}: ${invalidActorIds.join(", ")}.`
287
+ ));
288
+ }
289
+ }
276
290
  }
277
291
 
278
292
  function validateCollectionReview(record, loaded, byId, path, diagnostics) {
@@ -786,7 +800,7 @@ function validateCompletionDates(record, path, diagnostics) {
786
800
  ]);
787
801
  validateOrderedDates(record, path, diagnostics, ["startedAt", "endedAt"]);
788
802
  validateOrderedDates(record, path, diagnostics, ["fieldworkStart", "fieldworkEnd", "reportDate"]);
789
- if (record.type === "document") {
803
+ if (["document", "training"].includes(record.type) && record.activatedOn) {
790
804
  validateOrderedDates(record, path, diagnostics, ["approvedOn", "activatedOn", "effectiveOn"]);
791
805
  }
792
806
  if (record.acceptance) {
@@ -916,17 +930,21 @@ async function validateContentBinding(record, model, root, path, diagnostics, bi
916
930
  }
917
931
  }
918
932
 
919
- function approvalBound(record) {
933
+ function approvalBound(record, model) {
920
934
  if (record.type === "policy") return ["approved", "active", "superseded", "retired"].includes(record.status);
921
935
  if (record.type === "document") return ["approved", "active", "superseded", "retired"].includes(record.status);
922
- if (record.type === "training") return ["active", "retired"].includes(record.status);
936
+ if (record.type === "training") {
937
+ return (modelSupports(model, "governed-training-activation")
938
+ ? ["approved", "active", "superseded", "retired"]
939
+ : ["active", "retired"]).includes(record.status);
940
+ }
923
941
  return false;
924
942
  }
925
943
 
926
944
  function contentBindingFields(record, model) {
927
945
  const fields = [];
928
946
  if (["policy", "document"].includes(record.type)) {
929
- fields.push({ field: "approvedContentRevisions", bound: approvalBound, label: "Approved" });
947
+ fields.push({ field: "approvedContentRevisions", bound: (candidate) => approvalBound(candidate, model), label: "Approved" });
930
948
  }
931
949
  if (record.type === "document" && model.resources.document?.fields?.activatedContentRevisions) {
932
950
  fields.push({
@@ -936,7 +954,17 @@ function contentBindingFields(record, model) {
936
954
  });
937
955
  }
938
956
  if (record.type === "training" && model.resources.training?.fields?.effectiveContentRevisions) {
939
- fields.push({ field: "effectiveContentRevisions", bound: approvalBound, label: "Approved" });
957
+ fields.push({ field: "effectiveContentRevisions", bound: (candidate) => approvalBound(candidate, model), label: "Approved" });
958
+ }
959
+ if (record.type === "training" && model.resources.training?.fields?.approvedContentRevisions) {
960
+ fields.push({ field: "approvedContentRevisions", bound: (candidate) => approvalBound(candidate, model), label: "Approved" });
961
+ }
962
+ if (record.type === "training" && model.resources.training?.fields?.activatedContentRevisions) {
963
+ fields.push({
964
+ field: "activatedContentRevisions",
965
+ bound: (candidate) => ["active", "superseded", "retired"].includes(candidate?.status),
966
+ label: "Activated"
967
+ });
940
968
  }
941
969
  return fields;
942
970
  }