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.
- package/README.md +7 -7
- package/model/index.js +6 -5
- package/model/v6.json +10358 -0
- package/package.json +1 -1
- package/src/cli.js +75 -6
- package/src/document-activation.js +61 -25
- package/src/files.js +54 -25
- package/src/index.js +8 -1
- package/src/model-migration.js +148 -3
- package/src/obligations.js +5 -5
- package/src/policy-library.js +5 -2
- package/src/program-lifecycle.js +41 -3
- package/src/program-path.js +18 -17
- package/src/program-readiness.js +115 -12
- package/src/server.js +8 -1
- package/src/setup.js +1 -1
- package/src/validate.js +33 -5
- package/src/web.js +128 -26
- package/src/workflow.js +21 -4
package/src/program-readiness.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
681
|
-
"Approve Policy
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 ? "
|
|
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
|
|
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")
|
|
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
|
}
|