filegrc 0.9.2 → 0.11.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,222 @@
1
+ import { findResourceReferences } from "./agent.js";
2
+ import { resourceReviewRevision, resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
3
+ import { loadWorkspace } from "./workspace.js";
4
+
5
+ const SOURCE_TYPES = new Set(["policy", "document", "framework", "requirement", "commitment"]);
6
+ const DIRECT_DEPENDENT_TYPES = new Set(["requirement", "commitment", "control", "requirement-mapping", "retention-schedule-item", "obligation"]);
7
+
8
+ export async function planProgramAmendment(input, options = {}) {
9
+ const loaded = input?.resources && input?.model ? input : await loadWorkspace(input);
10
+ const sourceId = options.sourceResourceId;
11
+ const source = loaded.resources.find((record) => record.id === sourceId);
12
+ if (!source) throw new Error(`Source resource "${sourceId}" was not found.`);
13
+ if (!SOURCE_TYPES.has(source.type)) {
14
+ throw new Error("A program amendment source must be a Policy, Document, Framework, Requirement, or Commitment.");
15
+ }
16
+ const direct = findResourceReferences(loaded, source.id).references.filter((reference) => DIRECT_DEPENDENT_TYPES.has(reference.type));
17
+ const directById = new Map(direct.map((reference) => [reference.id, reference]));
18
+ const reviewRevision = loaded.root ? await resourceReviewRevision(loaded, source.id) : null;
19
+ const relatedIds = new Set([source.id, ...direct.map(({ id }) => id)]);
20
+ const affectedRequirementIds = new Set([
21
+ ...(source.type === "requirement" ? [source.id] : []),
22
+ ...loaded.resources.filter((record) => relatedIds.has(record.id) && record.type === "requirement").map(({ id }) => id)
23
+ ]);
24
+ for (const record of loaded.resources) {
25
+ if (record.type === "commitment" && (record.requirementIds || []).some((id) => affectedRequirementIds.has(id))) relatedIds.add(record.id);
26
+ if (record.type === "requirement-mapping" && [...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])].some((id) => affectedRequirementIds.has(id))) relatedIds.add(record.id);
27
+ }
28
+ const affectedCommitmentIds = new Set([
29
+ ...(source.type === "commitment" ? [source.id] : []),
30
+ ...loaded.resources.filter((record) => relatedIds.has(record.id) && record.type === "commitment").map(({ id }) => id)
31
+ ]);
32
+ const affectedControlIds = new Set([
33
+ ...direct.filter(({ type }) => type === "control").map(({ id }) => id),
34
+ ...(source.controlIds || []).filter((id) => loaded.resources.some((record) => record.id === id && record.type === "control"))
35
+ ]);
36
+ for (const id of affectedControlIds) relatedIds.add(id);
37
+ for (const commitmentId of affectedCommitmentIds) {
38
+ const commitment = loaded.resources.find(({ id }) => id === commitmentId);
39
+ for (const id of [...(commitment?.systemIds || []), ...(commitment?.requirementIds || []), ...(commitment?.controlIds || [])]) relatedIds.add(id);
40
+ for (const id of commitment?.controlIds || []) affectedControlIds.add(id);
41
+ }
42
+ for (const record of loaded.resources) {
43
+ if (record.type === "requirement-mapping" && [...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])].some((id) => affectedCommitmentIds.has(id))) relatedIds.add(record.id);
44
+ if (record.type === "retention-schedule-item" && (record.sourceResourceIds || []).some((id) => affectedCommitmentIds.has(id))) relatedIds.add(record.id);
45
+ }
46
+ const primaryMappings = loaded.resources.filter((record) => record.type === "requirement-mapping" && relatedIds.has(record.id));
47
+ for (const mapping of primaryMappings) {
48
+ for (const id of [...(mapping.sourceResourceIds || []), ...(mapping.targetResourceIds || [])]) {
49
+ relatedIds.add(id);
50
+ if (loaded.resources.find((record) => record.id === id)?.type === "control") affectedControlIds.add(id);
51
+ }
52
+ }
53
+ for (const record of loaded.resources) {
54
+ if (record.type === "requirement" && relatedIds.has(record.id)) affectedRequirementIds.add(record.id);
55
+ }
56
+ for (const record of loaded.resources) {
57
+ if (record.type === "control" && (record.requirementIds || []).some((id) => affectedRequirementIds.has(id))) {
58
+ affectedControlIds.add(record.id);
59
+ relatedIds.add(record.id);
60
+ }
61
+ }
62
+ for (const record of loaded.resources) {
63
+ if (record.type === "requirement-mapping" && (record.sourceResourceIds || []).some((id) => affectedControlIds.has(id))) {
64
+ relatedIds.add(record.id);
65
+ for (const id of [...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])]) relatedIds.add(id);
66
+ }
67
+ if (record.type === "retention-schedule-item" && (record.sourceResourceIds || []).some((id) => affectedControlIds.has(id))) relatedIds.add(record.id);
68
+ if (record.type === "obligation" && (record.controlIds || []).some((id) => affectedControlIds.has(id))) relatedIds.add(record.id);
69
+ }
70
+ const related = loaded.resources.filter((record) => relatedIds.has(record.id) && record.id !== source.id);
71
+ const affected = related.map((record) => {
72
+ const relationFields = ["sourceResourceIds", "targetResourceIds", "requirementIds", "systemIds", "controlIds", "policyIds", "scopeResourceIds"].filter((field) => (
73
+ (record[field] || []).some((id) => relatedIds.has(id))
74
+ ));
75
+ const directReference = directById.get(record.id);
76
+ return {
77
+ type: record.type,
78
+ id: record.id,
79
+ title: record.title,
80
+ status: record.status,
81
+ field: relationFields.length === 1 ? relationFields[0] : directReference?.field || "transitive",
82
+ ...(relationFields.length ? { relationFields } : {})
83
+ };
84
+ });
85
+ const groups = {};
86
+ for (const record of affected) (groups[record.type] ||= []).push(record);
87
+ const missing = [];
88
+ if (["policy", "document", "framework"].includes(source.type) && !(groups.commitment || []).length) {
89
+ missing.push({
90
+ resourceType: "commitment",
91
+ message: "Record each externally or internally stated promise that changes the program scope or operating duties."
92
+ });
93
+ }
94
+ if ((source.type === "commitment" || (groups.commitment || []).length) && !(groups["requirement-mapping"] || []).length) {
95
+ missing.push({
96
+ resourceType: "requirement-mapping",
97
+ message: "Review how the source Commitments relate to existing Requirements and Controls. Do not assume full equivalence."
98
+ });
99
+ }
100
+ const mappingSources = related
101
+ .filter((record) => record.type === "requirement-mapping")
102
+ .flatMap((record) => [...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])]);
103
+ const retentionSources = related
104
+ .filter((record) => record.type === "retention-schedule-item")
105
+ .flatMap((record) => retentionReviewResourceIds(record, loaded));
106
+ const currentReviewRevisions = await resourceReviewRevisions(loaded, [
107
+ source.id,
108
+ ...mappingSources,
109
+ ...retentionSources
110
+ ]);
111
+ const staleMappings = related.filter((record) => (
112
+ record.type === "requirement-mapping"
113
+ && record.status === "active"
114
+ && reviewBindingsDiffer(
115
+ [...new Set([...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])])],
116
+ record.reviewedSourceRevisions,
117
+ currentReviewRevisions
118
+ )
119
+ ));
120
+ if (staleMappings.length) {
121
+ missing.push({
122
+ resourceType: "requirement-mapping",
123
+ resourceIds: staleMappings.map(({ id }) => id),
124
+ message: "Re-review each affected mapping against the current source revision before relying on it."
125
+ });
126
+ }
127
+ const retention = related.filter((record) => (
128
+ record.type === "retention-schedule-item"
129
+ && record.status === "active"
130
+ && reviewBindingsDiffer(retentionReviewResourceIds(record, loaded), record.reviewedSourceRevisions, currentReviewRevisions)
131
+ ));
132
+ if (retention.length) {
133
+ missing.push({
134
+ resourceType: "retention-schedule-item",
135
+ resourceIds: retention.map(({ id }) => id),
136
+ message: "Re-review each affected retention item and bind it to the current source revision before treating it as active."
137
+ });
138
+ }
139
+ return {
140
+ schemaVersion: 1,
141
+ source: {
142
+ type: source.type,
143
+ id: source.id,
144
+ title: source.title,
145
+ status: source.status,
146
+ reviewRevision
147
+ },
148
+ currentReviewRevisions: Object.fromEntries(currentReviewRevisions),
149
+ affected,
150
+ byResourceType: Object.fromEntries(Object.entries(groups).map(([type, records]) => [type, records.map(({ id }) => id)])),
151
+ reviewWork: missing,
152
+ commands: [
153
+ `npx filegrc references ${source.id} --json`,
154
+ "npx filegrc guide commitment --json",
155
+ "npx filegrc guide requirement-mapping --json",
156
+ "npx filegrc guide retention-schedule-item --json",
157
+ "npx filegrc program-readiness --json"
158
+ ],
159
+ principle: "The plan identifies affected records but never creates promises, mappings, retention periods, or deletion behavior without management review."
160
+ };
161
+ }
162
+
163
+ function reviewBindingsDiffer(expectedIds, reviewed = {}, current) {
164
+ const expected = new Set(expectedIds);
165
+ if (Object.keys(reviewed).length !== expected.size) return true;
166
+ return [...expected].some((id) => !current.get(id) || reviewed[id] !== current.get(id));
167
+ }
168
+
169
+ export async function assessProgramAmendmentReadiness(loaded) {
170
+ if (!loaded.model.resources["requirement-mapping"]) return [];
171
+ const byId = new Map(loaded.resources.map((record) => [record.id, record]));
172
+ const commitments = loaded.resources.filter((record) => (
173
+ record.type === "commitment" && !["superseded", "retired"].includes(record.status)
174
+ ));
175
+ const sourceIds = new Set(commitments.flatMap((record) => record.sourceResourceIds || []));
176
+ for (const record of loaded.resources) {
177
+ if (["policy", "document"].includes(record.type) && record.programRole === "supporting" && !["superseded", "retired"].includes(record.status)) {
178
+ sourceIds.add(record.id);
179
+ }
180
+ }
181
+ const sourceRecords = [...new Set([...sourceIds, ...commitments.map(({ id }) => id)])]
182
+ .map((id) => byId.get(id))
183
+ .filter((record) => record && SOURCE_TYPES.has(record.type));
184
+ const plans = await Promise.all(sourceRecords.map((record) => (
185
+ planProgramAmendment(loaded, { sourceResourceId: record.id })
186
+ )));
187
+ const items = [];
188
+ for (const plan of plans) {
189
+ for (const work of plan.reviewWork.filter((candidate) => (
190
+ ["commitment", "requirement-mapping"].includes(candidate.resourceType)
191
+ && !resourceIdsPresent(candidate, loaded)
192
+ ))) {
193
+ const id = `program-amendment-${plan.source.id}-${work.resourceType}`;
194
+ if (items.some((item) => item.id === id)) continue;
195
+ items.push({
196
+ id,
197
+ status: "action",
198
+ title: work.resourceType === "commitment" ? `Record commitments from ${plan.source.title}` : `Map commitments affected by ${plan.source.title}`,
199
+ message: work.message,
200
+ resourceType: plan.source.type,
201
+ resourceId: plan.source.id,
202
+ createResourceType: work.resourceType,
203
+ sourceResourceIds: work.resourceType === "commitment"
204
+ ? [plan.source.id]
205
+ : plan.byResourceType.commitment || (plan.source.type === "commitment" ? [plan.source.id] : []),
206
+ commands: [
207
+ `npx filegrc scaffold ${work.resourceType} --title ${shellArgument(work.resourceType === "commitment" ? `Commitment from ${plan.source.title}` : `Mapping for ${plan.source.title}`)}`,
208
+ `npx filegrc program-amendment ${plan.source.id} --json`
209
+ ]
210
+ });
211
+ }
212
+ }
213
+ return items;
214
+ }
215
+
216
+ function shellArgument(value) {
217
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
218
+ }
219
+
220
+ function resourceIdsPresent(work, loaded) {
221
+ return (work.resourceIds || []).some((id) => loaded.resources.some((record) => record.id === id));
222
+ }
@@ -22,7 +22,7 @@ export function governedDocumentIsOperating(document, asOf, model) {
22
22
  if (document?.type !== "document" || document.status !== "active") return false;
23
23
  if (!document.effectiveOn || document.effectiveOn > asOf) return false;
24
24
  if (!modelSupports(model, "governed-document-activation")) return true;
25
- if (document.activationBasis === "legacy-v4") {
25
+ if (["legacy-v4", "historical"].includes(document.activationBasis)) {
26
26
  return document.workflowScope === "engagement"
27
27
  && Boolean(document.approvedOn && document.approvedContentRevisions);
28
28
  }
@@ -11,6 +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
+ "requirement-mapping": "Record a reviewed relationship among Requirements, Commitments, and Controls. Choose the comparison method and relationship explicitly, explain the rationale, and bind the review to every mapped source revision.",
14
15
  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
16
  document: "Complete required program Documents in Step 2, assign an owner and separate approver, and bind approval to the intended values and exact Markdown. Implement the linked requirements and activate that approved revision in Step 3. Prepare Audit Documents in Step 5.",
16
17
  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.",
@@ -19,6 +20,7 @@ export const RESOURCE_INSTRUCTIONS = {
19
20
  "risk-assessment": "Complete and approve an assessment of the risks to the in-scope service, systems, vendors, and commitments.",
20
21
  risk: "Record each risk identified by an assessment or operating activity. Assign an owner, rate it, document the chosen response, and link the Controls that treat it from the Risk record.",
21
22
  obligation: "Review the recurring work proposed by effective policies. Confirm who owns it, when it is due, and what proof completion requires.",
23
+ "retention-schedule-item": "Use one structured row for each reviewed retention rule. Name its Information Types, scope, cutoff, period, disposition, sources, owner, and approval. Keep unknown organization values planned for management review.",
22
24
  "obligation-event": "When a policy-triggering event occurs, record it here and complete the actions filegrc creates for it.",
23
25
  "policy-review": "Record scheduled and change-driven reviews of policies and governed documents, including the decision and any follow-up.",
24
26
  meeting: "Record required oversight meetings, including attendees, decisions, minutes, and follow-up work.",
@@ -53,11 +55,13 @@ export const RESOURCE_PAGE_SUMMARIES = {
53
55
  framework: "Confirm the SOC 2 framework.",
54
56
  requirement: "Decide which SOC 2 criteria apply.",
55
57
  commitment: "Record customer promises that affect scope.",
58
+ "requirement-mapping": "Review how supplemental promises relate to Requirements and Controls.",
56
59
  vendor: "List material external providers.",
57
60
  system: "Define the service boundary.",
58
61
  component: "Connect each material Component to a System.",
59
62
  classification: "Define handling levels.",
60
63
  "information-type": "Define information categories.",
64
+ "retention-schedule-item": "Review each structured retention rule.",
61
65
  policy: "Tailor the starter Policy and have someone other than its owner approve it.",
62
66
  document: "Adapt and approve plans.",
63
67
  control: "Describe each Control and its evidence source.",
@@ -77,21 +81,19 @@ export const PROGRAM_PATH = [
77
81
  summary: "Name the owners, criteria, service, Systems, and providers in scope.",
78
82
  sections: [
79
83
  { 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 the company chooses to add them."], types: ["program", "framework", "requirement", "commitment"], defaultOpen: true },
84
+ { 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 reviewed mappings. Keep optional criteria out until the company chooses to add them."], types: ["program", "framework", "requirement", "commitment", "requirement-mapping"], defaultOpen: true },
81
85
  { 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
86
  ],
83
- resourceTypes: ["person", "appointment", "team", "program", "framework", "requirement", "commitment", "system", "component", "vendor", "classification", "information-type"],
87
+ resourceTypes: ["person", "appointment", "team", "program", "framework", "requirement", "commitment", "requirement-mapping", "system", "component", "vendor", "classification", "information-type"],
84
88
  commands: [
85
89
  "filegrc setup",
86
90
  "filegrc guide person --json",
87
91
  "filegrc guide appointment --json",
88
92
  "filegrc guide system --json",
89
93
  "filegrc guide component --json",
90
- "filegrc review-collection person --scaffold",
91
- "filegrc review-collection framework --scaffold",
94
+ "filegrc guide requirement-mapping --json",
92
95
  "filegrc review-collection vendor --scaffold",
93
- "filegrc review-collection system --scaffold",
94
- "filegrc review-collection component --scaffold",
96
+ "filegrc review-collection information-type --scaffold",
95
97
  "filegrc list system --json"
96
98
  ]
97
99
  },
@@ -123,15 +125,18 @@ export const PROGRAM_PATH = [
123
125
  description: "Finish controls and their evidence sources",
124
126
  summary: "Describe each Control and connect its evidence source.",
125
127
  sections: [
126
- { id: "catalog", title: "Control Catalog", description: "Implement approved requirements, configure Obligations, activate approved program content, finish authoritative evidence sources, then activate the Policies at 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.", "Review and 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.", "Implement every requirement linked from an approved program Document or Training record, then activate the unchanged approved revisions with separate activation dates and bindings.", "Use the activation review to inspect planned or partial Controls, inactive governed content, 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."], types: ["control", "complementary-control", "obligation"], defaultOpen: true }
128
+ { id: "catalog", title: "Control Catalog", description: "Implement approved requirements, configure retention and Obligations, activate approved program content, finish authoritative evidence sources, then activate the Policies at 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.", "Review every Retention Schedule Item against current information uses and approved sources. Do not infer periods or disposition behavior.", "Review and 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.", "Implement every requirement linked from an approved program Document or Training record, then activate the unchanged approved revisions with separate activation dates and bindings.", "Use the activation review to inspect planned or partial Controls, inactive governed content, 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."], types: ["control", "complementary-control", "retention-schedule-item", "obligation"], defaultOpen: true }
127
129
  ],
128
- resourceTypes: ["control", "complementary-control", "obligation"],
130
+ resourceTypes: ["control", "complementary-control", "retention-schedule-item", "obligation"],
129
131
  commands: [
130
132
  "filegrc guide control --json",
131
133
  "filegrc list control --json",
132
134
  "filegrc get CONTROL_ID --mutation",
133
135
  "filegrc guide obligation --json",
136
+ "filegrc guide retention-schedule-item --json",
137
+ "filegrc review-collection retention-schedule-item --scaffold",
134
138
  "filegrc list obligation --json",
139
+ "filegrc review-collection component --scaffold",
135
140
  "filegrc review-collection complementary-control --scaffold",
136
141
  "filegrc activate-content --scaffold",
137
142
  "filegrc activate-policies --scaffold",
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { modelSupports } from "../model/index.js";
3
+ import { applicabilityReviewIsCurrent } from "./applicability-scope.js";
3
4
  import { assessRequiredAppointments } from "./appointments.js";
4
5
  import { assessCollectionReviews } from "./collection-review.js";
5
6
  import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
@@ -15,7 +16,10 @@ import {
15
16
  } from "./program-lifecycle.js";
16
17
  import { currentPartyPeople, partiesIndependent, partyPeople } from "./parties.js";
17
18
  import { assessPolicyLibraryUpgrades } from "./policy-library.js";
19
+ import { assessProgramAmendmentReadiness } from "./program-amendment.js";
18
20
  import { programComponents, resolveProgram, selectedRequirementIds } from "./program.js";
21
+ import { assessRequirementMappingReadiness } from "./requirement-mapping.js";
22
+ import { assessRetentionReadiness } from "./retention.js";
19
23
  import { markdownEntries } from "./resource-markdown.js";
20
24
  import {
21
25
  missingSoc2References,
@@ -52,6 +56,12 @@ export async function assessProgramReadiness(input, options = {}) {
52
56
  .map(collectionReviewReadinessItem));
53
57
  const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
54
58
  controlStage.items.push(...sourceStage.items);
59
+ controlStage.items.push(...await assessRetentionReadiness(loaded, program, {
60
+ informationTypesReviewed: collectionReviews.find(({ resourceType }) => resourceType === "information-type")?.complete === true
61
+ }));
62
+ controlStage.items.push(...collectionReviews
63
+ .filter(({ resourceType }) => ["component", "retention-schedule-item"].includes(resourceType))
64
+ .map(collectionReviewReadinessItem));
55
65
  const governedContent = await governedContentItems(scope, records, byId, readMarkdown, asOf, loaded.model);
56
66
  controlStage.items.push(...governedContent.items);
57
67
  const policyActivations = await assessPolicyActivations(
@@ -65,15 +75,18 @@ export async function assessProgramReadiness(input, options = {}) {
65
75
  );
66
76
  controlStage.items.push(...policyActivations.map(policyActivationItem));
67
77
  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
- const evidenceGateStages = [
69
- scopeStage(
78
+ const scopeReadinessStage = scopeStage(
70
79
  program,
71
80
  scope,
72
81
  records,
73
82
  byId,
74
83
  loaded.model,
75
- collectionReviews.filter(({ resourceType }) => resourceType !== "complementary-control")
76
- ),
84
+ collectionReviews.filter(({ resourceType }) => ["person", "framework", "system", "vendor", "information-type"].includes(resourceType))
85
+ );
86
+ scopeReadinessStage.items.push(...await assessRequirementMappingReadiness(loaded));
87
+ scopeReadinessStage.items.push(...await assessProgramAmendmentReadiness(loaded));
88
+ const evidenceGateStages = [
89
+ scopeReadinessStage,
77
90
  policyStage,
78
91
  controlStage
79
92
  ];
@@ -81,7 +94,7 @@ export async function assessProgramReadiness(input, options = {}) {
81
94
  const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0);
82
95
  const stages = [
83
96
  ...evidenceGateStages,
84
- operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model)
97
+ await operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model)
85
98
  ];
86
99
  finalizeStage(stages.at(-1));
87
100
  const candidateStarted = Boolean(
@@ -103,6 +116,7 @@ export async function assessProgramReadiness(input, options = {}) {
103
116
  const firstAction = items.find((current) => current.status === "action") || null;
104
117
 
105
118
  return {
119
+ program,
106
120
  schemaVersion: 1,
107
121
  dataModelVersion: String(loaded.model.modelVersion),
108
122
  generatedAt: options.generatedAt || new Date().toISOString(),
@@ -252,16 +266,22 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
252
266
  ));
253
267
 
254
268
  if (modelSupports(model, "guided-workflow")) {
255
- const commitments = records.filter((record) => (
269
+ const allCommitments = records.filter((record) => (
256
270
  record.type === "commitment"
257
271
  && !["superseded", "retired"].includes(record.status)
258
- && (record.systemIds || []).some((id) => scope.systems.some((system) => system.id === id))
259
272
  ));
273
+ const commitments = allCommitments.filter((record) => (
274
+ (record.systemIds || []).some((id) => scope.systems.some((system) => system.id === id))
275
+ ));
276
+ const unscopedCommitments = allCommitments.filter((record) => !(record.systemIds || []).length);
260
277
  const completeCommitments = commitments.filter((record) => (
261
278
  record.status === "active"
262
279
  && record.statement
263
280
  && record.effectiveOn
264
- && (modelSupports(model, "program-scope") || record.applicabilityReview?.decision === "applicable")
281
+ && (!model.resources.commitment?.fields?.applicabilityReview || (
282
+ record.applicabilityReview?.decision === "applicable"
283
+ && applicabilityReviewIsCurrent(record.applicabilityReview, record, workspace, records, model)
284
+ ))
265
285
  && currentPartyPeople(record.ownerIds, byId).size > 0
266
286
  && (record.requirementIds || []).length > 0
267
287
  && (record.controlIds || []).length > 0
@@ -271,14 +291,15 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
271
291
  )));
272
292
  items.push(item(
273
293
  "commitments",
274
- scope.systems.length && uncoveredSystems.length === 0 ? "complete" : "action",
294
+ scope.systems.length && uncoveredSystems.length === 0 && unscopedCommitments.length === 0 ? "complete" : "action",
275
295
  "Record service commitments and system requirements",
276
296
  scope.systems.length
277
- ? `${completeCommitments.length} complete active ${completeCommitments.length === 1 ? "commitment covers" : "commitments cover"} ${scope.systems.length - uncoveredSystems.length} of ${scope.systems.length} in-scope systems.`
297
+ ? `${completeCommitments.length} complete active ${completeCommitments.length === 1 ? "commitment covers" : "commitments cover"} ${scope.systems.length - uncoveredSystems.length} of ${scope.systems.length} in-scope systems.${unscopedCommitments.length ? ` ${unscopedCommitments.length} Commitment${unscopedCommitments.length === 1 ? " has" : "s have"} no System scope and must be reviewed explicitly.` : ""}`
278
298
  : "Define the service boundary before recording its customer promises and approved system requirements.",
279
299
  commitments[0] || { type: "commitment" },
280
300
  {
281
301
  uncoveredSystemIds: uncoveredSystems.map(({ id }) => id),
302
+ unscopedCommitmentIds: unscopedCommitments.map(({ id }) => id),
282
303
  commands: [
283
304
  "npx filegrc guide commitment --json",
284
305
  "npx filegrc list commitment --workflow --json",
@@ -290,7 +311,15 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
290
311
  }
291
312
 
292
313
  const selectedRequirementIds = new Set(scope.requirements.map((record) => record.id));
293
- const v4Decisions = new Map((workspace?.requirementApplicability || []).map((decision) => [decision.requirementId, decision.decision]));
314
+ const requirementById = new Map(records
315
+ .filter(({ type }) => type === "requirement")
316
+ .map((record) => [record.id, record]));
317
+ const v4Decisions = new Map((workspace?.requirementApplicability || [])
318
+ .filter((decision) => (
319
+ requirementById.has(decision.requirementId)
320
+ && applicabilityReviewIsCurrent(decision, requirementById.get(decision.requirementId), workspace, records, model)
321
+ ))
322
+ .map((decision) => [decision.requirementId, decision.decision]));
294
323
  const applicableRequirements = records.filter((record) => (
295
324
  record.type === "requirement"
296
325
  && scope.frameworks.some((framework) => framework.id === record.frameworkId)
@@ -1162,6 +1191,7 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
1162
1191
  const checks = {
1163
1192
  ...(model.resources.control?.fields?.applicabilityReview ? {
1164
1193
  applicability: control.applicabilityReview?.decision === "applicable"
1194
+ && applicabilityReviewIsCurrent(control.applicabilityReview, control, scope.program, [...byId.values()], model)
1165
1195
  } : {}),
1166
1196
  implemented: control.status === "implemented",
1167
1197
  owner: (control.ownerIds || []).length > 0,
@@ -1316,7 +1346,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
1316
1346
  return stage("sources", "Control Evidence Sources", `Complete the authoritative ${modelSupports(model, "component-sources") ? "Components" : "Systems"} for every selected control family before marking the Controls implemented.`, items);
1317
1347
  }
1318
1348
 
1319
- function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
1349
+ async function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
1320
1350
  const goal = workspace?.assuranceGoal || "none";
1321
1351
  if (!evidenceReady) {
1322
1352
  return stage("operation", "Operate the Program", "Run the controls and preserve dated evidence after the Evidence Ready gate passes.", [
@@ -1355,7 +1385,7 @@ function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceR
1355
1385
  ? coverageEnd(workspace.candidateCoverage)
1356
1386
  : null;
1357
1387
  const startStatus = !start ? "action" : start <= asOf ? "complete" : "later";
1358
- const sourceCoverage = assessSourceCoverageReadiness(loaded, scope.controls.map(({ id }) => id), workspace);
1388
+ const sourceCoverage = await assessSourceCoverageReadiness(loaded, scope.controls.map(({ id }) => id), workspace);
1359
1389
  const incompleteSourceCoverage = sourceCoverage.filter(({ complete }) => !complete);
1360
1390
  return stage("operation", "Operate the Program", "Start the management candidate Type 2 period only after the Evidence Ready gate, then keep collection running.", [
1361
1391
  item(
@@ -11,7 +11,9 @@ const TRANSITIONS = {
11
11
  person: [
12
12
  {
13
13
  eventType: "person-started",
14
- applies: (before, after) => after?.status === "active" && before?.status !== "active",
14
+ applies: (before, after) => after?.status === "active"
15
+ && after?.affiliation !== "external"
16
+ && before?.status !== "active",
15
17
  message: "Confirm whether activating this Person represents a workforce start that needs policy-event work."
16
18
  },
17
19
  {
@@ -95,16 +97,26 @@ export async function planReconciliation(input = process.cwd()) {
95
97
  const loaded = input?.resources && input?.model && input?.entries
96
98
  ? input
97
99
  : await loadWorkspace(input);
100
+ const headRevision = gitRevision(loaded.root);
98
101
  if (!modelSupports(loaded.model, "guided-workflow")) {
99
102
  return {
100
103
  contractVersion: 1,
101
- gitRevision: gitRevision(loaded.root),
104
+ gitRevision: headRevision,
102
105
  changedPaths: [],
103
106
  candidates: [],
104
107
  message: "Direct-file transition reconciliation is available in model v3 and newer workspaces."
105
108
  };
106
109
  }
107
110
  const changedPaths = gitChangedPaths(loaded.root);
111
+ if (!headRevision) {
112
+ return {
113
+ contractVersion: 1,
114
+ gitRevision: null,
115
+ changedPaths,
116
+ candidates: [],
117
+ message: "Commit the initial workspace before FileGRC checks later direct-file changes for Policy Events."
118
+ };
119
+ }
108
120
  const currentByPath = new Map(loaded.entries.map((entry) => [
109
121
  `data/${entry.relativePath}`,
110
122
  entry
@@ -176,7 +188,7 @@ export async function planReconciliation(input = process.cwd()) {
176
188
  }
177
189
  return {
178
190
  contractVersion: 1,
179
- gitRevision: gitRevision(loaded.root),
191
+ gitRevision: headRevision,
180
192
  changedPaths,
181
193
  candidates: candidates.sort((a, b) => a.id.localeCompare(b.id))
182
194
  };
@@ -0,0 +1,61 @@
1
+ import { resourceReviewRevisions } from "./retention.js";
2
+
3
+ export async function assessRequirementMappingReadiness(loaded) {
4
+ if (!loaded.model.resources["requirement-mapping"]) return [];
5
+ const mappings = loaded.resources.filter((record) => (
6
+ record.type === "requirement-mapping" && !["superseded", "retired"].includes(record.status)
7
+ ));
8
+ const ids = mappings.flatMap((record) => [
9
+ ...(record.sourceResourceIds || []),
10
+ ...(record.targetResourceIds || [])
11
+ ]);
12
+ const revisions = await resourceReviewRevisions(loaded, ids);
13
+ const byId = new Map(loaded.resources.map((record) => [record.id, record]));
14
+ return mappings.map((mapping) => {
15
+ const mappedIds = [...new Set([
16
+ ...(mapping.sourceResourceIds || []),
17
+ ...(mapping.targetResourceIds || [])
18
+ ])];
19
+ const staleIds = mappedIds.filter((id) => (
20
+ !revisions.get(id) || mapping.reviewedSourceRevisions?.[id] !== revisions.get(id)
21
+ )).concat(Object.keys(mapping.reviewedSourceRevisions || {}).filter((id) => !mappedIds.includes(id)));
22
+ const structurallyComplete = mappingStructureIsComplete(mapping);
23
+ const complete = mapping.status === "active" && structurallyComplete && staleIds.length === 0;
24
+ return {
25
+ id: `requirement-mapping-${mapping.id}`,
26
+ status: complete ? "complete" : "action",
27
+ title: complete ? `Mapping current: ${mapping.title}` : `Review mapping: ${mapping.title}`,
28
+ message: mapping.status !== "active"
29
+ ? "This mapping is still planned. Review both sides, choose the relationship and comparison method, and bind the current revisions before activation."
30
+ : !structurallyComplete
31
+ ? "This active mapping is incomplete. Add distinct source and target records, the relationship and method, a rationale, owners, reviewer, review date, and current revision bindings."
32
+ : `This mapping no longer matches ${staleIds.length} mapped ${staleIds.length === 1 ? "record" : "records"}. Review it before relying on the stated relationship.`,
33
+ resourceType: "requirement-mapping",
34
+ resourceId: mapping.id,
35
+ staleResourceIds: staleIds,
36
+ commands: [
37
+ `npx filegrc get ${mapping.id} --mutation`,
38
+ `npx filegrc review-bindings ${mapping.id} --json`,
39
+ ...staleIds
40
+ .filter((id) => ["policy", "document", "framework", "requirement", "commitment"].includes(byId.get(id)?.type))
41
+ .map((id) => `npx filegrc program-amendment ${id} --json`)
42
+ ]
43
+ };
44
+ });
45
+ }
46
+
47
+ function mappingStructureIsComplete(mapping) {
48
+ const sourceIds = [...new Set(mapping.sourceResourceIds || [])];
49
+ const targetIds = [...new Set(mapping.targetResourceIds || [])];
50
+ return sourceIds.length > 0
51
+ && targetIds.length > 0
52
+ && !sourceIds.some((id) => targetIds.includes(id))
53
+ && ["equal-to", "equivalent-to", "subset-of", "superset-of", "intersects-with", "no-relationship"].includes(mapping.relationship)
54
+ && ["syntactic", "semantic", "functional"].includes(mapping.method)
55
+ && Boolean(String(mapping.rationale || "").trim())
56
+ && (mapping.ownerIds || []).length > 0
57
+ && (mapping.reviewedByIds || []).length > 0
58
+ && Boolean(mapping.reviewedOn)
59
+ && mapping.reviewedSourceRevisions
60
+ && typeof mapping.reviewedSourceRevisions === "object";
61
+ }