filegrc 0.10.0 → 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
+ }
@@ -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,17 +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",
94
+ "filegrc guide requirement-mapping --json",
90
95
  "filegrc review-collection vendor --scaffold",
96
+ "filegrc review-collection information-type --scaffold",
91
97
  "filegrc list system --json"
92
98
  ]
93
99
  },
@@ -119,14 +125,16 @@ export const PROGRAM_PATH = [
119
125
  description: "Finish controls and their evidence sources",
120
126
  summary: "Describe each Control and connect its evidence source.",
121
127
  sections: [
122
- { 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 }
123
129
  ],
124
- resourceTypes: ["control", "complementary-control", "obligation"],
130
+ resourceTypes: ["control", "complementary-control", "retention-schedule-item", "obligation"],
125
131
  commands: [
126
132
  "filegrc guide control --json",
127
133
  "filegrc list control --json",
128
134
  "filegrc get CONTROL_ID --mutation",
129
135
  "filegrc guide obligation --json",
136
+ "filegrc guide retention-schedule-item --json",
137
+ "filegrc review-collection retention-schedule-item --scaffold",
130
138
  "filegrc list obligation --json",
131
139
  "filegrc review-collection component --scaffold",
132
140
  "filegrc review-collection complementary-control --scaffold",
@@ -16,7 +16,10 @@ import {
16
16
  } from "./program-lifecycle.js";
17
17
  import { currentPartyPeople, partiesIndependent, partyPeople } from "./parties.js";
18
18
  import { assessPolicyLibraryUpgrades } from "./policy-library.js";
19
+ import { assessProgramAmendmentReadiness } from "./program-amendment.js";
19
20
  import { programComponents, resolveProgram, selectedRequirementIds } from "./program.js";
21
+ import { assessRequirementMappingReadiness } from "./requirement-mapping.js";
22
+ import { assessRetentionReadiness } from "./retention.js";
20
23
  import { markdownEntries } from "./resource-markdown.js";
21
24
  import {
22
25
  missingSoc2References,
@@ -53,8 +56,11 @@ export async function assessProgramReadiness(input, options = {}) {
53
56
  .map(collectionReviewReadinessItem));
54
57
  const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
55
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
+ }));
56
62
  controlStage.items.push(...collectionReviews
57
- .filter(({ resourceType }) => resourceType === "component")
63
+ .filter(({ resourceType }) => ["component", "retention-schedule-item"].includes(resourceType))
58
64
  .map(collectionReviewReadinessItem));
59
65
  const governedContent = await governedContentItems(scope, records, byId, readMarkdown, asOf, loaded.model);
60
66
  controlStage.items.push(...governedContent.items);
@@ -69,15 +75,18 @@ export async function assessProgramReadiness(input, options = {}) {
69
75
  );
70
76
  controlStage.items.push(...policyActivations.map(policyActivationItem));
71
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.`;
72
- const evidenceGateStages = [
73
- scopeStage(
78
+ const scopeReadinessStage = scopeStage(
74
79
  program,
75
80
  scope,
76
81
  records,
77
82
  byId,
78
83
  loaded.model,
79
- collectionReviews.filter(({ resourceType }) => ["person", "framework", "system", "vendor"].includes(resourceType))
80
- ),
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,
81
90
  policyStage,
82
91
  controlStage
83
92
  ];
@@ -85,7 +94,7 @@ export async function assessProgramReadiness(input, options = {}) {
85
94
  const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0);
86
95
  const stages = [
87
96
  ...evidenceGateStages,
88
- operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model)
97
+ await operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model)
89
98
  ];
90
99
  finalizeStage(stages.at(-1));
91
100
  const candidateStarted = Boolean(
@@ -257,11 +266,14 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
257
266
  ));
258
267
 
259
268
  if (modelSupports(model, "guided-workflow")) {
260
- const commitments = records.filter((record) => (
269
+ const allCommitments = records.filter((record) => (
261
270
  record.type === "commitment"
262
271
  && !["superseded", "retired"].includes(record.status)
263
- && (record.systemIds || []).some((id) => scope.systems.some((system) => system.id === id))
264
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);
265
277
  const completeCommitments = commitments.filter((record) => (
266
278
  record.status === "active"
267
279
  && record.statement
@@ -279,14 +291,15 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
279
291
  )));
280
292
  items.push(item(
281
293
  "commitments",
282
- scope.systems.length && uncoveredSystems.length === 0 ? "complete" : "action",
294
+ scope.systems.length && uncoveredSystems.length === 0 && unscopedCommitments.length === 0 ? "complete" : "action",
283
295
  "Record service commitments and system requirements",
284
296
  scope.systems.length
285
- ? `${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.` : ""}`
286
298
  : "Define the service boundary before recording its customer promises and approved system requirements.",
287
299
  commitments[0] || { type: "commitment" },
288
300
  {
289
301
  uncoveredSystemIds: uncoveredSystems.map(({ id }) => id),
302
+ unscopedCommitmentIds: unscopedCommitments.map(({ id }) => id),
290
303
  commands: [
291
304
  "npx filegrc guide commitment --json",
292
305
  "npx filegrc list commitment --workflow --json",
@@ -1333,7 +1346,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
1333
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);
1334
1347
  }
1335
1348
 
1336
- function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
1349
+ async function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
1337
1350
  const goal = workspace?.assuranceGoal || "none";
1338
1351
  if (!evidenceReady) {
1339
1352
  return stage("operation", "Operate the Program", "Run the controls and preserve dated evidence after the Evidence Ready gate passes.", [
@@ -1372,7 +1385,7 @@ function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceR
1372
1385
  ? coverageEnd(workspace.candidateCoverage)
1373
1386
  : null;
1374
1387
  const startStatus = !start ? "action" : start <= asOf ? "complete" : "later";
1375
- const sourceCoverage = assessSourceCoverageReadiness(loaded, scope.controls.map(({ id }) => id), workspace);
1388
+ const sourceCoverage = await assessSourceCoverageReadiness(loaded, scope.controls.map(({ id }) => id), workspace);
1376
1389
  const incompleteSourceCoverage = sourceCoverage.filter(({ complete }) => !complete);
1377
1390
  return stage("operation", "Operate the Program", "Start the management candidate Type 2 period only after the Evidence Ready gate, then keep collection running.", [
1378
1391
  item(
@@ -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
+ }