filegrc 0.8.0 → 0.9.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.
@@ -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)])
@@ -675,10 +679,46 @@ async function policiesStage(scope, records, byId, readMarkdown, model) {
675
679
  }
676
680
  ));
677
681
  }
682
+ if (modelSupports(model, "governed-training-activation")) {
683
+ for (const training of trainings) {
684
+ const source = await readMarkdown(training);
685
+ const placeholderCount = openPlaceholderCount(source);
686
+ const checks = {
687
+ independentlyApproved: ["approved", "active"].includes(training.status)
688
+ && Boolean(training.approvedOn)
689
+ && Boolean(training.approvedContentRevisions)
690
+ && partiesIndependent(training.ownerIds, training.approverIds, byId),
691
+ owner: currentPartyPeople(training.ownerIds, byId).size > 0,
692
+ linkedControls: (training.controlIds || []).some((id) => scope.controls.some((control) => control.id === id)),
693
+ contentComplete: substantiveMarkdown(source) && placeholderCount === 0
694
+ };
695
+ const missing = Object.entries(checks)
696
+ .filter(([, value]) => !value)
697
+ .map(([name]) => governedApprovalCheckLabel(name));
698
+ items.push(item(
699
+ `training-approval-${training.id}`,
700
+ missing.length ? "action" : "complete",
701
+ training.title,
702
+ missing.length
703
+ ? `Remaining Step 2 approval work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}. Review and approve the exact Training content before implementation.`
704
+ : `Independently approved on ${training.approvedOn} and bound to the exact Training revision. Activation remains in Step 3.`,
705
+ training,
706
+ {
707
+ checks,
708
+ placeholderCount,
709
+ commands: [
710
+ `npx filegrc get ${shellArgument(training.id)} --mutation`,
711
+ `npx filegrc update training ${shellArgument(training.id)} MUTATION.json --json`,
712
+ "npx filegrc program-readiness --json"
713
+ ]
714
+ }
715
+ ));
716
+ }
717
+ }
678
718
  return stage(
679
719
  "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.",
720
+ "Approve Policies",
721
+ "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
722
  items
683
723
  );
684
724
  }
@@ -727,10 +767,18 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf, mo
727
767
  const documents = requiredGovernedDocuments(scope, records, byId, model);
728
768
  const governedRecords = [
729
769
  ...documents,
730
- ...records.filter((record) => record.type === "training" && requiredGovernedIds.has(record.id))
770
+ ...records.filter((record) => (
771
+ record.type === "training"
772
+ && !["superseded", "retired"].includes(record.status)
773
+ && (
774
+ requiredGovernedIds.has(record.id)
775
+ || (record.controlIds || []).some((id) => scope.controls.some((control) => control.id === id))
776
+ )
777
+ ))
731
778
  ];
732
779
  const items = [];
733
780
  const documentActivations = [];
781
+ const trainingActivations = [];
734
782
  for (const record of governedRecords) {
735
783
  const source = await readMarkdown(record);
736
784
  const placeholderCount = openPlaceholderCount(source);
@@ -766,7 +814,30 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf, mo
766
814
  effective: Boolean(record.effectiveOn && record.effectiveOn <= asOf),
767
815
  contentComplete: substantiveMarkdown(source) && placeholderCount === 0
768
816
  }
769
- : {
817
+ : modelSupports(model, "governed-training-activation") ? {
818
+ approvalBound: ["approved", "active"].includes(record.status)
819
+ && Boolean(record.approvedOn)
820
+ && Boolean(record.approvedContentRevisions)
821
+ && partiesIndependent(record.ownerIds, record.approverIds, byId),
822
+ owner: currentPartyPeople(record.ownerIds, byId).size > 0,
823
+ active: record.status === "active",
824
+ requirementsImplemented: linkedControlIds.length > 0 && missingImplementationControlIds.length === 0,
825
+ assignmentScheduled: enabledObligations.some((obligation) => (
826
+ obligation.templateResourceId === record.id
827
+ || (obligation.scopeResourceIds || []).includes(record.id)
828
+ )),
829
+ activationRecorded: ["recorded", "legacy-v5"].includes(record.activationBasis),
830
+ activated: record.activationBasis === "legacy-v5" || Boolean(record.activatedOn),
831
+ activator: record.activationBasis === "legacy-v5" || Boolean((record.activatedByIds || []).length)
832
+ && record.activatedByIds.every((id) => personWasActiveOn(byId.get(id), record.activatedOn)),
833
+ activatedContent: record.activationBasis === "legacy-v5" || Boolean(record.activatedContentRevisions),
834
+ activationMatchesApproval: record.activationBasis === "legacy-v5" || contentRevisionBindingsMatch(
835
+ record.approvedContentRevisions,
836
+ record.activatedContentRevisions
837
+ ),
838
+ effective: Boolean(record.effectiveOn && record.effectiveOn <= asOf),
839
+ contentComplete: substantiveMarkdown(source) && placeholderCount === 0
840
+ } : {
770
841
  active: record.status === "active",
771
842
  owner: currentPartyPeople(record.ownerIds, byId).size > 0,
772
843
  approved: Boolean(record.approvedOn && (record.approvedByIds || []).length),
@@ -810,6 +881,37 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf, mo
810
881
  gapCount: record.status === "active" ? missing.length : preActivationGapCount
811
882
  });
812
883
  }
884
+ if (record.type === "training" && modelSupports(model, "governed-training-activation")) {
885
+ const activationComplete = Object.values(checks).every(Boolean);
886
+ const preActivationCheckNames = ["approvalBound", "owner", "requirementsImplemented", "assignmentScheduled", "contentComplete"];
887
+ const preActivationGapCount = preActivationCheckNames.filter((name) => !checks[name]).length;
888
+ const readyToActivate = record.status === "approved"
889
+ && preActivationCheckNames.every((name) => checks[name]);
890
+ const state = record.status === "active" && activationComplete
891
+ ? "active-and-operating"
892
+ : record.status === "active"
893
+ ? "active-with-gaps"
894
+ : readyToActivate
895
+ ? "ready-to-activate"
896
+ : record.status === "approved"
897
+ ? "approved-implementation-pending"
898
+ : "approval-pending";
899
+ trainingActivations.push({
900
+ trainingId: record.id,
901
+ title: record.title,
902
+ state,
903
+ label: documentActivationLabel(state),
904
+ approvedOn: record.approvedOn || null,
905
+ activatedOn: record.activatedOn || null,
906
+ effectiveOn: record.effectiveOn || null,
907
+ linkedControlIds,
908
+ missingImplementationControlIds,
909
+ assignmentScheduled: checks.assignmentScheduled,
910
+ activationRevisionBound: Boolean(record.activatedContentRevisions),
911
+ activatedByIds: record.activatedByIds || [],
912
+ gapCount: record.status === "active" ? missing.length : preActivationGapCount
913
+ });
914
+ }
813
915
  items.push(item(
814
916
  `${record.type}-${record.id}`,
815
917
  missing.length ? "action" : "complete",
@@ -825,7 +927,7 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf, mo
825
927
  {
826
928
  checks,
827
929
  placeholderCount,
828
- ...(record.type === "document" ? { linkedControlIds, missingImplementationControlIds } : {}),
930
+ ...(["document", "training"].includes(record.type) ? { linkedControlIds, missingImplementationControlIds } : {}),
829
931
  commands: [
830
932
  `npx filegrc get ${shellArgument(record.id)} --mutation`,
831
933
  `npx filegrc update ${record.type} ${shellArgument(record.id)} MUTATION.json --json`,
@@ -834,7 +936,7 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf, mo
834
936
  }
835
937
  ));
836
938
  }
837
- return { items, documentActivations };
939
+ return { items, documentActivations, trainingActivations };
838
940
  }
839
941
 
840
942
  function documentActivationLabel(state) {
@@ -986,12 +1088,12 @@ function policyActivationItem(assessment) {
986
1088
  [assessment.plannedOrPartialControlIds.length, "planned or partial Controls"],
987
1089
  [assessment.missingComponentControlIds.length, "Controls missing active Components"],
988
1090
  [assessment.missingEvidenceSourceControlIds.length, "Controls missing ready evidence sources"],
989
- [assessment.missingScheduleControlIds.length, "Controls missing enabled schedules"],
1091
+ [assessment.missingScheduleControlIds.length, "Controls missing enabled Obligations"],
990
1092
  [assessment.missingGovernedDocumentIds?.length || 0, "required governed Documents not active"],
991
1093
  [assessment.unresolvedExceptionIds.length, "unresolved Exceptions"]
992
1094
  ].filter(([count]) => count).map(([count, label]) => `${count} ${label}`);
993
1095
  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.`
1096
+ ? `Active and effective ${assessment.effectiveOn}; all ${assessment.linkedControlIds.length} linked Controls are implemented with Components, evidence sources, and enabled Obligations.`
995
1097
  : assessment.state === "ready-to-activate"
996
1098
  ? "Implementation checks are complete. Include this approved Policy in the Step 3 cutover when you are ready for it to take effect."
997
1099
  : `${assessment.label}: ${counts.join(", ") || assessment.timingWarnings.join(" ")}. ${assessment.activationWarning || ""}`.trim();
@@ -1104,7 +1206,7 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
1104
1206
  }
1105
1207
  ));
1106
1208
  }
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);
1209
+ 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
1210
  }
1109
1211
 
1110
1212
  async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
@@ -1438,6 +1540,7 @@ function governedContentCheckLabel(name) {
1438
1540
  effective: "effective date",
1439
1541
  effectiveContent: "effective content revision",
1440
1542
  requirementsImplemented: "implemented linked requirements",
1543
+ assignmentScheduled: "enabled Training assignment schedule",
1441
1544
  activationRecorded: "recorded activation basis",
1442
1545
  activated: "separate activation date",
1443
1546
  activator: "named activation Person",
@@ -1463,7 +1566,7 @@ function controlCheckLabel(name) {
1463
1566
  implementationReview: "independent implementation review",
1464
1567
  policyMapping: "policy mapping",
1465
1568
  criteriaMapping: "criteria mapping",
1466
- workQueue: "running Work Queue schedules"
1569
+ workQueue: "running Obligation schedules"
1467
1570
  })[name] || name;
1468
1571
  }
1469
1572
 
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
  }