filegrc 0.3.4 → 0.5.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.
@@ -1,14 +1,16 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import { assessRequiredAppointments } from "./appointments.js";
3
+ import { assessCollectionReviews } from "./collection-review.js";
4
+ import { coverageEnd, coverageStart } from "./coverage.js";
2
5
  import { planObligations } from "./obligations.js";
3
6
  import { resolveDataPath } from "./paths.js";
4
7
  import { obligationIsRunning } from "./program-lifecycle.js";
5
8
  import { currentPartyPeople, partiesIndependent, partyPeople } from "./parties.js";
6
9
  import { markdownEntries } from "./resource-markdown.js";
10
+ import { assessSourceCoverageReadiness } from "./source-coverage.js";
7
11
  import { currentCalendarDate } from "./time.js";
8
12
  import { loadWorkspace } from "./workspace.js";
9
13
 
10
- const TEST_EVIDENCE_KINDS = new Set(["test-capture", "test-export"]);
11
-
12
14
  export async function assessProgramReadiness(input, options = {}) {
13
15
  const loaded = input?.resources && input?.model && input?.entries
14
16
  ? input
@@ -18,6 +20,7 @@ export async function assessProgramReadiness(input, options = {}) {
18
20
  const workspace = loaded.workspace || records.find((record) => record.type === "workspace");
19
21
  const asOf = options.asOf || currentCalendarDate(workspace?.timezone || "UTC");
20
22
  const scope = programScope(workspace, records, byId);
23
+ const collectionReviews = assessCollectionReviews(loaded);
21
24
  const markdown = new Map();
22
25
  const readMarkdown = async (record) => {
23
26
  if (!record) return "";
@@ -25,38 +28,43 @@ export async function assessProgramReadiness(input, options = {}) {
25
28
  return markdown.get(record.id);
26
29
  };
27
30
 
31
+ const controlStage = await controlsStage(scope, byId, readMarkdown, asOf, loaded.model);
32
+ controlStage.items.unshift(...collectionReviews
33
+ .filter(({ resourceType }) => resourceType === "complementary-control")
34
+ .map(collectionReviewReadinessItem));
28
35
  const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
29
- const collectionStage = evidenceCollectionStage(scope, records, byId, loaded.model);
30
- const evidenceStage = stage(
31
- "evidence",
32
- "Test Evidence Collection",
33
- "For external evidence without a dedicated Step 5 record, catalog the authoritative Systems, document repeatable extraction, and verify one test capture before operation begins.",
34
- [...sourceStage.items, ...collectionStage.items]
35
- );
36
+ controlStage.items.push(...sourceStage.items);
37
+ controlStage.description = "Each implemented control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, and complete authoritative source Systems with the required evidence roles, access owners, and retrieval instructions.";
36
38
  const evidenceGateStages = [
37
- scopeStage(workspace, scope, records, byId),
38
- await policiesStage(scope, byId, readMarkdown, asOf),
39
- await controlsStage(scope, byId, readMarkdown, asOf),
40
- evidenceStage
39
+ scopeStage(
40
+ workspace,
41
+ scope,
42
+ records,
43
+ byId,
44
+ loaded.model,
45
+ collectionReviews.filter(({ resourceType }) => resourceType !== "complementary-control")
46
+ ),
47
+ await policiesStage(scope, records, byId, readMarkdown, asOf),
48
+ controlStage
41
49
  ];
42
50
  for (const current of evidenceGateStages) finalizeStage(current);
43
51
  const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0);
44
52
  const stages = [
45
53
  ...evidenceGateStages,
46
- operationStage(workspace, scope, records, byId, asOf, evidenceReady)
54
+ operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, loaded.model)
47
55
  ];
48
56
  finalizeStage(stages.at(-1));
49
57
  const candidateStarted = Boolean(
50
58
  workspace?.assuranceGoal === "soc-2-type-2"
51
- && workspace.candidatePeriodStart
52
- && workspace.candidatePeriodStart <= asOf
59
+ && workspace.candidateCoverage?.kind === "range"
60
+ && coverageStart(workspace.candidateCoverage) <= asOf
53
61
  );
54
- const obligations = planObligations(records, { asOf, through: asOf });
55
- const operating = evidenceReady && candidateStarted && obligations.counts.overdue === 0;
62
+ const obligations = planObligations(records, { asOf, through: asOf, model: loaded.model });
63
+ const operating = evidenceReady && candidateStarted && stages.at(-1).counts.action === 0;
56
64
  const canStartCandidatePeriod = Boolean(
57
65
  evidenceReady
58
66
  && workspace?.assuranceGoal === "soc-2-type-2"
59
- && !workspace.candidatePeriodStart
67
+ && !workspace.candidateCoverage
60
68
  );
61
69
  const items = stages.flatMap((current) => current.items);
62
70
  const managedItems = items.filter((current) => !["info", "later"].includes(current.status));
@@ -70,9 +78,7 @@ export async function assessProgramReadiness(input, options = {}) {
70
78
  target: {
71
79
  goal: workspace?.assuranceGoal || "none",
72
80
  label: assuranceGoalLabel(workspace?.assuranceGoal),
73
- candidateTypeOneAsOf: workspace?.candidateTypeOneAsOf || null,
74
- candidatePeriodStart: workspace?.candidatePeriodStart || null,
75
- candidatePeriodEnd: workspace?.candidatePeriodEnd || null
81
+ candidateCoverage: workspace?.candidateCoverage || null
76
82
  },
77
83
  status: operating ? "operating" : evidenceReady ? "evidence-ready" : "needs-work",
78
84
  evidenceReady,
@@ -96,28 +102,50 @@ export async function assessProgramReadiness(input, options = {}) {
96
102
  };
97
103
  }
98
104
 
105
+ export async function assessEvidenceMap(input, options = {}) {
106
+ const readiness = await assessProgramReadiness(input, options);
107
+ const evidenceItems = readiness.stages
108
+ .find((stage) => stage.id === "controls")
109
+ ?.items.filter((current) => current.id.startsWith("source-family-")) || [];
110
+ const counts = countStatuses(evidenceItems);
111
+ return {
112
+ schemaVersion: 1,
113
+ generatedAt: readiness.generatedAt,
114
+ asOf: readiness.asOf,
115
+ status: counts.action ? "action" : "complete",
116
+ counts,
117
+ workflow: [
118
+ "Choose an existing System or create the System that is authoritative for each evidence family.",
119
+ "On every source System, set an evidence source role, name current evidence access owners, and write repeatable retrieval instructions in Record Markdown.",
120
+ "Set each selected Control's evidenceSourceIds to the authoritative Systems that produce its evidence.",
121
+ "Run program-readiness again and resolve every incomplete source check and control mapping before marking the Controls implemented."
122
+ ],
123
+ items: evidenceItems
124
+ };
125
+ }
126
+
99
127
  function programScope(workspace, records, byId) {
100
128
  const select = (ids, type, fallback) => {
101
129
  if (ids?.length) return ids.map((id) => byId.get(id)).filter((record) => record?.type === type);
102
130
  return records.filter(fallback);
103
131
  };
104
132
  return {
105
- systems: select(workspace?.systemIds, "system", (record) => (
106
- record.type === "system" && record.inScope === true && record.status !== "retired"
107
- )),
133
+ systems: (workspace?.systemIds || [])
134
+ .map((id) => byId.get(id))
135
+ .filter((record) => record?.type === "system" && record.status !== "retired"),
108
136
  frameworks: select(workspace?.frameworkIds, "framework", (record) => (
109
137
  record.type === "framework" && record.status === "active"
110
138
  )),
111
139
  requirements: select(workspace?.requirementIds, "requirement", (record) => (
112
140
  record.type === "requirement" && record.applicability === "applicable"
113
- )),
141
+ )).filter((record) => record.applicability === "applicable"),
114
142
  controls: select(workspace?.controlIds, "control", (record) => (
115
143
  record.type === "control" && !["not-applicable", "retired"].includes(record.status)
116
- ))
144
+ )).filter((record) => !["not-applicable", "retired"].includes(record.status))
117
145
  };
118
146
  }
119
147
 
120
- function scopeStage(workspace, scope, records, byId) {
148
+ function scopeStage(workspace, scope, records, byId, model, collectionReviews = []) {
121
149
  const items = [];
122
150
  const goal = workspace?.assuranceGoal || "none";
123
151
  items.push(item(
@@ -127,16 +155,25 @@ function scopeStage(workspace, scope, records, byId) {
127
155
  goal !== "none"
128
156
  ? `Target: ${assuranceGoalLabel(goal)}. This is a management objective, not an active CPA engagement.`
129
157
  : "Choose readiness, SOC 2 Type 1, or SOC 2 Type 2 as the management objective.",
130
- workspace || { type: "workspace" }
158
+ workspace || { type: "workspace" },
159
+ {
160
+ commands: [
161
+ "npx filegrc setup",
162
+ `npx filegrc get ${shellArgument(workspace?.id || "workspace")} --mutation`
163
+ ]
164
+ }
131
165
  ));
132
166
 
133
167
  items.push(programOwnershipItem(records, byId));
168
+ items.push(requiredAppointmentsItem(records, model));
169
+ for (const assessment of collectionReviews) {
170
+ items.push(collectionReviewReadinessItem(assessment));
171
+ }
134
172
 
135
173
  const completeSystems = scope.systems.filter((system) => (
136
174
  system.status === "active"
137
- && system.inScope === true
138
175
  && system.description
139
- && system.dataClassification
176
+ && system.classificationId
140
177
  && (system.ownerIds || []).length
141
178
  ));
142
179
  items.push(item(
@@ -149,6 +186,44 @@ function scopeStage(workspace, scope, records, byId) {
149
186
  scope.systems[0] || { type: "system" }
150
187
  ));
151
188
 
189
+ if (String(model.modelVersion) === "3") {
190
+ const commitments = records.filter((record) => (
191
+ record.type === "commitment"
192
+ && !["superseded", "retired"].includes(record.status)
193
+ && (record.systemIds || []).some((id) => scope.systems.some((system) => system.id === id))
194
+ ));
195
+ const completeCommitments = commitments.filter((record) => (
196
+ record.status === "active"
197
+ && record.statement
198
+ && record.effectiveOn
199
+ && record.applicabilityReview?.decision === "applicable"
200
+ && currentPartyPeople(record.ownerIds, byId).size > 0
201
+ && (record.requirementIds || []).length > 0
202
+ && (record.controlIds || []).length > 0
203
+ ));
204
+ const uncoveredSystems = scope.systems.filter((system) => !completeCommitments.some((record) => (
205
+ (record.systemIds || []).includes(system.id)
206
+ )));
207
+ items.push(item(
208
+ "commitments",
209
+ scope.systems.length && uncoveredSystems.length === 0 ? "complete" : "action",
210
+ "Record service commitments and system requirements",
211
+ scope.systems.length
212
+ ? `${completeCommitments.length} complete active ${completeCommitments.length === 1 ? "commitment covers" : "commitments cover"} ${scope.systems.length - uncoveredSystems.length} of ${scope.systems.length} in-scope systems.`
213
+ : "Define the service boundary before recording its customer promises and approved system requirements.",
214
+ commitments[0] || { type: "commitment" },
215
+ {
216
+ uncoveredSystemIds: uncoveredSystems.map(({ id }) => id),
217
+ commands: [
218
+ "npx filegrc guide commitment --json",
219
+ "npx filegrc list commitment --workflow --json",
220
+ 'npx filegrc scaffold commitment --title "SERVICE COMMITMENT"',
221
+ "npx filegrc program-readiness --json"
222
+ ]
223
+ }
224
+ ));
225
+ }
226
+
152
227
  const selectedRequirementIds = new Set(scope.requirements.map((record) => record.id));
153
228
  const applicableRequirements = records.filter((record) => (
154
229
  record.type === "requirement"
@@ -175,12 +250,67 @@ function scopeStage(workspace, scope, records, byId) {
175
250
  criteriaComplete
176
251
  ? `${scope.requirements.length} applicable criteria and ${scope.controls.length} controls are in the management program scope.`
177
252
  : `Resolve the program criteria and controls. ${unresolvedRequirements.length} criteria remain undetermined and ${missingRequirements.length} applicable criteria are not selected.`,
178
- workspace || { type: "workspace" }
253
+ workspace || { type: "workspace" },
254
+ {
255
+ commands: [
256
+ "npx filegrc review-applicability --scaffold --type requirement > decisions.json",
257
+ "npx filegrc review-applicability decisions.json --preview --json",
258
+ "npx filegrc review-applicability decisions.json --yes --json",
259
+ "npx filegrc get workspace --mutation"
260
+ ]
261
+ }
179
262
  ));
180
263
 
181
264
  return stage("scope", "Define Scope", "Set program ownership, the management objective, service boundary, criteria, controls, and dependencies.", items);
182
265
  }
183
266
 
267
+ function collectionReviewReadinessItem(assessment) {
268
+ return item(
269
+ `collection-review-${assessment.resourceType}`,
270
+ assessment.complete ? "complete" : "action",
271
+ assessment.status === "stale"
272
+ ? `Review ${assessment.configuration.title.toLowerCase()} again`
273
+ : `Review ${assessment.configuration.title.toLowerCase()}`,
274
+ assessment.message,
275
+ assessment.review || { type: assessment.resourceType },
276
+ {
277
+ resourceType: assessment.resourceType,
278
+ reviewPoints: assessment.configuration.reviewPoints,
279
+ commands: [
280
+ `npx filegrc review-collection ${assessment.resourceType} --scaffold`,
281
+ `npx filegrc review-collection ${assessment.resourceType} REVIEW.json --preview --json`
282
+ ]
283
+ }
284
+ );
285
+ }
286
+
287
+ function requiredAppointmentsItem(records, model) {
288
+ const assessments = assessRequiredAppointments(records, model);
289
+ const incomplete = assessments.filter(({ requiredness, state }) => (
290
+ ["core", "required"].includes(requiredness) && state !== "complete"
291
+ ));
292
+ const complete = incomplete.length === 0;
293
+ const first = incomplete[0];
294
+ return item(
295
+ "required-appointments",
296
+ complete ? "complete" : "action",
297
+ "Assign required program authority",
298
+ complete
299
+ ? "Every authority required by the current scope has an active dated Appointment."
300
+ : `${incomplete.length} required ${incomplete.length === 1 ? "Appointment needs" : "Appointments need"} a current holder: ${incomplete.map(({ template }) => template.title).join(", ")}.`,
301
+ first?.record || { type: "appointment" },
302
+ {
303
+ commands: [
304
+ "npx filegrc guide appointment --json",
305
+ "npx filegrc list appointment --workflow --json",
306
+ first?.record
307
+ ? `npx filegrc get ${first.record.id} --mutation`
308
+ : `npx filegrc scaffold appointment --title "${first?.template.title || "APPOINTMENT TITLE"}"`
309
+ ]
310
+ }
311
+ );
312
+ }
313
+
184
314
  function programOwnershipItem(records, byId) {
185
315
  const ownedRecords = records.filter((record) => (
186
316
  ["policy", "control", "obligation"].includes(record.type)
@@ -188,6 +318,9 @@ function programOwnershipItem(records, byId) {
188
318
  ));
189
319
  const unresolved = ownedRecords.filter((record) => currentPartyPeople(record.ownerIds, byId).size === 0);
190
320
  const currentOwners = new Set(ownedRecords.flatMap((record) => [...currentPartyPeople(record.ownerIds, byId)]));
321
+ const missingJobTitles = [...currentOwners]
322
+ .map((id) => byId.get(id))
323
+ .filter((record) => record?.type === "person" && record.status === "active" && !String(record.jobTitle || "").trim());
191
324
  const oversight = byId.get("team-security-risk-oversight");
192
325
  const policyOwnerIds = new Set(records
193
326
  .filter((record) => record.type === "policy" && !["retired", "superseded"].includes(record.status))
@@ -202,7 +335,10 @@ function programOwnershipItem(records, byId) {
202
335
  && oversightChairs.size > 0
203
336
  && ![...oversightChairs].some((id) => policyOwnerIds.has(id))
204
337
  );
205
- const complete = currentOwners.size > 0 && unresolved.length === 0 && oversightComplete;
338
+ const complete = currentOwners.size > 0
339
+ && unresolved.length === 0
340
+ && missingJobTitles.length === 0
341
+ && oversightComplete;
206
342
  const unresolvedAssignments = unresolved.map((record) => ({
207
343
  resourceType: record.type,
208
344
  resourceId: record.id,
@@ -210,12 +346,32 @@ function programOwnershipItem(records, byId) {
210
346
  ownerIds: record.ownerIds || [],
211
347
  reasons: ownershipResolutionReasons(record.ownerIds || [], byId)
212
348
  }));
349
+ const oversightDependent = oversight?.id
350
+ ? unresolvedAssignments.filter(({ reasons }) => (
351
+ reasons.length > 0
352
+ && reasons.every(({ ownerId, reason }) => (
353
+ ownerId === oversight.id
354
+ && ["inactive-team", "team-has-no-current-members"].includes(reason)
355
+ ))
356
+ ))
357
+ : [];
358
+ const separatelyUnresolved = unresolved.length - oversightDependent.length;
213
359
  const detail = [];
214
360
  if (!currentOwners.size) detail.push("No current person owns the program records.");
215
- if (unresolved.length) {
216
- detail.push(`${unresolved.length} ${unresolved.length === 1 ? "record has" : "records have"} no current person owner.`);
361
+ if (separatelyUnresolved) {
362
+ detail.push(`${separatelyUnresolved} ${separatelyUnresolved === 1 ? "record has" : "records have"} no current person owner.`);
363
+ }
364
+ if (missingJobTitles.length) {
365
+ detail.push(`${missingJobTitles.length} active ${missingJobTitles.length === 1 ? "owner needs" : "owners need"} an organizational job title.`);
366
+ }
367
+ if (!oversightComplete) {
368
+ detail.push(
369
+ "Activate Security and Risk Oversight with current members and a chair separate from policy ownership."
370
+ + (oversightDependent.length
371
+ ? ` This team owns ${oversightDependent.length} proposed ${oversightDependent.length === 1 ? "obligation" : "obligations"}.`
372
+ : "")
373
+ );
217
374
  }
218
- if (!oversightComplete) detail.push("Finish and activate Security and Risk Oversight with a current chair who is separate from policy ownership.");
219
375
  const oversightId = oversight?.id ? shellArgument(oversight.id) : null;
220
376
  return item(
221
377
  "program-ownership",
@@ -227,9 +383,11 @@ function programOwnershipItem(records, byId) {
227
383
  !oversightComplete ? oversight : unresolved[0] || { type: "person" },
228
384
  {
229
385
  unresolvedAssignments,
386
+ missingJobTitleIds: missingJobTitles.map(({ id }) => id),
230
387
  ...(!oversightComplete && oversight ? {
231
388
  commands: [
232
389
  "npx filegrc guide person --json",
390
+ "npx filegrc guide appointment --json",
233
391
  "npx filegrc list person --json",
234
392
  'npx filegrc scaffold person --title "REVIEWER NAME" | npx filegrc create - --json',
235
393
  `npx filegrc get ${oversightId} --mutation`,
@@ -251,14 +409,19 @@ function ownershipResolutionReasons(ownerIds, byId) {
251
409
  if (owner.type === "team" && owner.status !== "active") {
252
410
  return [{ ownerId, reason: "inactive-team" }];
253
411
  }
412
+ if (owner.type === "appointment" && owner.status !== "active") {
413
+ return [{ ownerId, reason: "inactive-appointment" }];
414
+ }
254
415
  if (currentPartyPeople([ownerId], byId).size === 0) {
255
- return [{ ownerId, reason: owner.type === "team" ? "team-has-no-current-members" : "no-current-person" }];
416
+ if (owner.type === "team") return [{ ownerId, reason: "team-has-no-current-members" }];
417
+ if (owner.type === "appointment") return [{ ownerId, reason: "appointment-has-no-current-holder" }];
418
+ return [{ ownerId, reason: "no-current-person" }];
256
419
  }
257
420
  return [];
258
421
  });
259
422
  }
260
423
 
261
- async function policiesStage(scope, byId, readMarkdown, asOf) {
424
+ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
262
425
  const linkedPolicyIds = new Set(scope.controls.flatMap((control) => control.policyIds || []));
263
426
  const policies = [...linkedPolicyIds].map((id) => byId.get(id)).filter((record) => (
264
427
  record?.type === "policy" && !["superseded", "retired"].includes(record.status)
@@ -291,7 +454,15 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
291
454
  : reviewerNeedsAssignment
292
455
  ? `${availableReviewer.title} chairs Security and Risk Oversight. Assign this person as approver on each policy after review.`
293
456
  : "Appoint a reviewer who is separate from the policy owner. The reviewer may be another person in the organization or an external person, and is separate from the CPA firm that may later perform the audit.",
294
- appointedReviewer || (reviewerNeedsAssignment ? policies[0] : { type: "person" })
457
+ appointedReviewer || (reviewerNeedsAssignment ? policies[0] : { type: "person" }),
458
+ {
459
+ commands: [
460
+ "npx filegrc list appointment --workflow --json",
461
+ "npx filegrc guide appointment --json",
462
+ "npx filegrc external-reviewer-setup --scaffold > reviewer.json",
463
+ "npx filegrc external-reviewer-setup reviewer.json --preview --json"
464
+ ]
465
+ }
295
466
  )
296
467
  ];
297
468
  if (!policies.length) {
@@ -300,7 +471,14 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
300
471
  "action",
301
472
  "Link policies to the selected controls",
302
473
  "No applicable policies are linked from the controls in program scope.",
303
- { type: "policy" }
474
+ { type: "policy" },
475
+ {
476
+ commands: [
477
+ "npx filegrc list policy --workflow --json",
478
+ "npx filegrc list control --workflow --json",
479
+ "npx filegrc program-readiness --json"
480
+ ]
481
+ }
304
482
  ));
305
483
  }
306
484
  for (const policy of policies) {
@@ -312,7 +490,7 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
312
490
  && policy.approvedOn
313
491
  && partiesIndependent(policy.ownerIds, policy.approverIds, byId),
314
492
  effective: policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf,
315
- linkedControls: (policy.controlIds || []).some((id) => scope.controls.some((control) => control.id === id)),
493
+ linkedControls: scope.controls.some((control) => (control.policyIds || []).includes(policy.id)),
316
494
  contentComplete: Boolean(source.trim()) && placeholderCount === 0
317
495
  };
318
496
  const missing = Object.entries(checks).filter(([, value]) => !value).map(([name]) => policyCheckLabel(name));
@@ -324,13 +502,93 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
324
502
  ? `Remaining adoption work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}.`
325
503
  : `Reviewed, independently approved, effective ${policy.effectiveOn}, linked to controls, with no open organization placeholders.`,
326
504
  policy,
327
- { checks, placeholderCount }
505
+ {
506
+ checks,
507
+ placeholderCount,
508
+ commands: [
509
+ `npx filegrc get ${shellArgument(policy.id)} --mutation`,
510
+ `npx filegrc update policy ${shellArgument(policy.id)} MUTATION.json --json`,
511
+ "npx filegrc program-readiness --json"
512
+ ]
513
+ }
514
+ ));
515
+ }
516
+ const selectedControlIds = new Set(scope.controls.map(({ id }) => id));
517
+ const activeObligations = records.filter((record) => (
518
+ record.type === "obligation" && obligationIsRunning(record, byId, asOf)
519
+ ));
520
+ const requiredGovernedIds = new Set(activeObligations.flatMap((record) => [
521
+ ...(record.scopeResourceIds || []),
522
+ ...(record.templateResourceId ? [record.templateResourceId] : [])
523
+ ]));
524
+ const governedRecords = records.filter((record) => (
525
+ (
526
+ record.type === "document"
527
+ && (
528
+ requiredGovernedIds.has(record.id)
529
+ || (
530
+ record.programRole === "required"
531
+ && (record.controlIds || []).some((id) => selectedControlIds.has(id))
532
+ )
533
+ )
534
+ )
535
+ || (record.type === "training" && requiredGovernedIds.has(record.id))
536
+ ));
537
+ for (const record of governedRecords) {
538
+ const source = await readMarkdown(record);
539
+ const placeholderCount = openPlaceholderCount(source);
540
+ const checks = record.type === "document"
541
+ ? {
542
+ active: record.status === "active",
543
+ owner: currentPartyPeople(record.ownerIds, byId).size > 0,
544
+ independentlyApproved: Boolean(
545
+ record.approvedOn
546
+ && partiesIndependent(record.ownerIds, record.approverIds, byId)
547
+ ),
548
+ effective: Boolean(record.effectiveOn && record.effectiveOn <= asOf),
549
+ contentComplete: substantiveMarkdown(source) && placeholderCount === 0
550
+ }
551
+ : {
552
+ active: record.status === "active",
553
+ owner: currentPartyPeople(record.ownerIds, byId).size > 0,
554
+ approved: Boolean(record.approvedOn && (record.approvedByIds || []).length),
555
+ effective: Boolean(record.effectiveOn && record.effectiveOn <= asOf),
556
+ effectiveContent: Boolean(record.effectiveContentRevisions),
557
+ contentComplete: substantiveMarkdown(source) && placeholderCount === 0
558
+ };
559
+ const missing = Object.entries(checks)
560
+ .filter(([, value]) => !value)
561
+ .map(([name]) => governedContentCheckLabel(name));
562
+ items.push(item(
563
+ `${record.type}-${record.id}`,
564
+ missing.length ? "action" : "complete",
565
+ record.title,
566
+ missing.length
567
+ ? `Remaining governed-content work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}.`
568
+ : record.type === "document"
569
+ ? `Active, independently approved, effective ${record.effectiveOn}, and ready for the selected controls or running schedule.`
570
+ : `Active, approved, effective ${record.effectiveOn}, revision-bound, and ready for the running training schedule.`,
571
+ record,
572
+ {
573
+ checks,
574
+ placeholderCount,
575
+ commands: [
576
+ `npx filegrc get ${shellArgument(record.id)} --mutation`,
577
+ `npx filegrc update ${record.type} ${shellArgument(record.id)} MUTATION.json --json`,
578
+ "npx filegrc program-readiness --json"
579
+ ]
580
+ }
328
581
  ));
329
582
  }
330
- return stage("policies", "Approve Policies", "Review the draft, obtain independent management approval, set the effective date, link controls, and clear placeholders.", items);
583
+ return stage(
584
+ "policies",
585
+ "Approve Policies",
586
+ "Review and approve the policies, governed plans, and training content required by selected controls and running schedules.",
587
+ items
588
+ );
331
589
  }
332
590
 
333
- async function controlsStage(scope, byId, readMarkdown, asOf) {
591
+ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
334
592
  const items = [];
335
593
  if (!scope.controls.length) {
336
594
  items.push(item("control-scope", "action", "Select the program controls", "No controls are selected for the management program.", { type: "control" }));
@@ -344,17 +602,30 @@ async function controlsStage(scope, byId, readMarkdown, asOf) {
344
602
  && (record.controlIds || []).includes(control.id)
345
603
  ));
346
604
  const checks = {
605
+ ...(model.resources.control?.fields?.applicabilityReview ? {
606
+ applicability: control.applicabilityReview?.decision === "applicable"
607
+ } : {}),
347
608
  implemented: control.status === "implemented",
348
609
  owner: (control.ownerIds || []).length > 0,
349
610
  procedure: substantiveMarkdown(source) && openPlaceholderCount(source) === 0,
350
611
  scope: (control.systemIds || []).some((id) => scope.systems.some((system) => system.id === id)),
351
- cadence: Boolean(control.frequency),
612
+ operationPattern: Boolean(control.operationPattern),
352
613
  evidenceSource: sourceSystems.length > 0,
353
614
  implementationDate: Boolean(control.effectiveOn && control.effectiveOn <= asOf),
615
+ ...(model.resources.control?.fields?.procedureRevision ? {
616
+ procedureRevision: Boolean(control.procedureRevision),
617
+ procedureEffective: Boolean(control.procedureEffectiveOn && control.procedureEffectiveOn <= asOf),
618
+ implementationReview: Boolean(
619
+ control.implementationReviewedOn
620
+ && control.implementationReviewedOn <= asOf
621
+ && partiesIndependent(control.ownerIds, control.implementationReviewedByIds, byId)
622
+ )
623
+ } : {}),
354
624
  policyMapping: (control.policyIds || []).length > 0,
355
625
  criteriaMapping: (control.requirementIds || []).length > 0,
356
- ...(queueSchedules.length ? {
357
- workQueue: queueSchedules.every((obligation) => obligationIsRunning(obligation, byId, asOf))
626
+ ...(["scheduled", "event-driven", "mixed"].includes(control.operationPattern) ? {
627
+ workQueue: queueSchedules.length > 0
628
+ && queueSchedules.every((obligation) => obligationIsRunning(obligation, byId, asOf))
358
629
  } : {})
359
630
  };
360
631
  const missing = Object.entries(checks).filter(([, value]) => !value).map(([name]) => controlCheckLabel(name));
@@ -363,11 +634,20 @@ async function controlsStage(scope, byId, readMarkdown, asOf) {
363
634
  missing.length ? "action" : "complete",
364
635
  `${control.code ? `${control.code}: ` : ""}${control.title}`,
365
636
  missing.length
366
- ? `Before implementation: ${missing.join(", ")}.`
637
+ ? `Complete ${missing.length} ${missing.length === 1 ? "check" : "checks"} before implementation: ${missing.join(", ")}.`
367
638
  : `Implemented ${control.effectiveOn}; owned, scoped, scheduled, documented, mapped, and tied to ${sourceSystems.length} authoritative ${sourceSystems.length === 1 ? "source" : "sources"}.`,
368
639
  control,
369
640
  {
370
641
  checks,
642
+ commands: [
643
+ ...(Object.hasOwn(checks, "applicability") && !checks.applicability ? [
644
+ "npx filegrc review-applicability --scaffold --type control > control-decisions.json",
645
+ "npx filegrc review-applicability control-decisions.json --preview --json",
646
+ "npx filegrc review-applicability control-decisions.json --yes --json"
647
+ ] : []),
648
+ "npx filegrc evidence-map --json",
649
+ `npx filegrc get ${shellArgument(control.id)} --mutation`
650
+ ],
371
651
  workQueue: queueSchedules.length ? {
372
652
  running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf)).length,
373
653
  total: queueSchedules.length
@@ -375,95 +655,103 @@ async function controlsStage(scope, byId, readMarkdown, asOf) {
375
655
  }
376
656
  ));
377
657
  }
378
- return stage("controls", "Implement Controls", "Each implemented control needs an owner, actual procedure, scope, cadence, evidence source, mappings, an implementation date, and any linked Work Queue schedules running.", items);
658
+ return stage("controls", "Implement Controls", "Each implemented control needs an owner, actual procedure, scope, operation pattern, evidence source, mappings, an implementation date, and any required Work Queue schedules running.", items);
379
659
  }
380
660
 
381
661
  async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
382
- const families = selectedControlFamilies(scope.controls, model).filter(requiresCollectionTest);
662
+ const families = selectedControlFamilies(scope.controls, model);
383
663
  const items = [];
384
664
  for (const family of families) {
385
665
  const selectedSources = [...new Set(family.controls.flatMap((control) => control.evidenceSourceIds || []))]
386
666
  .map((id) => byId.get(id))
387
- .filter((record) => record?.type === "system");
667
+ .filter((record) => (
668
+ record?.type === "system"
669
+ && (
670
+ !(record.evidenceSourceKinds || []).length
671
+ || family.sourceKinds.some((kind) => (record.evidenceSourceKinds || []).includes(kind))
672
+ )
673
+ ));
388
674
  const completeSources = [];
675
+ const sourceSystemChecks = [];
389
676
  for (const source of selectedSources) {
390
677
  const instructions = await readMarkdown(source);
391
678
  const matchesRole = !family.sourceKinds.length
392
679
  || family.sourceKinds.some((kind) => (source.evidenceSourceKinds || []).includes(kind));
393
- if (
394
- source.status === "active"
395
- && matchesRole
396
- && (source.evidenceSourceKinds || []).length
397
- && (source.evidenceOwnerIds || []).length
398
- && substantiveMarkdown(instructions)
399
- && openPlaceholderCount(instructions) === 0
400
- ) {
680
+ const checks = {
681
+ active: source.status === "active",
682
+ sourceRole: matchesRole && (source.evidenceSourceKinds || []).length > 0,
683
+ accessOwners: (source.evidenceOwnerIds || []).length > 0,
684
+ retrievalInstructions: substantiveMarkdown(instructions) && openPlaceholderCount(instructions) === 0
685
+ };
686
+ const complete = Object.values(checks).every(Boolean);
687
+ sourceSystemChecks.push({
688
+ sourceSystemId: source.id,
689
+ complete,
690
+ checks
691
+ });
692
+ if (complete) {
401
693
  completeSources.push(source);
402
694
  }
403
695
  }
404
- const coveredControls = family.controls.filter((control) => (
405
- (control.evidenceSourceIds || []).some((id) => completeSources.some((source) => source.id === id))
406
- ));
696
+ const controlMappings = family.controls.map((control) => {
697
+ const sourceSystemIds = (control.evidenceSourceIds || []).filter((id) => selectedSources.some((source) => source.id === id));
698
+ const completeSourceSystemIds = sourceSystemIds.filter((id) => completeSources.some((source) => source.id === id));
699
+ return {
700
+ controlId: control.id,
701
+ sourceSystemIds,
702
+ completeSourceSystemIds,
703
+ mapped: sourceSystemIds.length > 0,
704
+ complete: completeSourceSystemIds.length > 0
705
+ };
706
+ });
707
+ const coveredControls = controlMappings.filter(({ complete }) => complete);
407
708
  const complete = completeSources.length > 0 && coveredControls.length === family.controls.length;
709
+ const commands = [
710
+ ...(selectedSources.length
711
+ ? sourceSystemChecks.filter(({ complete: sourceComplete }) => !sourceComplete).flatMap(({ sourceSystemId }) => [
712
+ `npx filegrc get ${shellArgument(sourceSystemId)} --mutation > /tmp/${shellArgument(sourceSystemId)}.json`,
713
+ `npx filegrc update system ${shellArgument(sourceSystemId)} /tmp/${shellArgument(sourceSystemId)}.json --json`
714
+ ])
715
+ : [
716
+ "npx filegrc list system --json",
717
+ 'npx filegrc scaffold system --title "SYSTEM NAME" > /tmp/filegrc-system.json',
718
+ "npx filegrc create /tmp/filegrc-system.json --json"
719
+ ]),
720
+ ...controlMappings.filter(({ mapped }) => !mapped).flatMap(({ controlId }) => [
721
+ `npx filegrc get ${shellArgument(controlId)} --mutation > /tmp/${shellArgument(controlId)}.json`,
722
+ `npx filegrc update control ${shellArgument(controlId)} /tmp/${shellArgument(controlId)}.json --json`
723
+ ]),
724
+ "npx filegrc program-readiness --json"
725
+ ];
408
726
  items.push(item(
409
727
  `source-family-${family.id}`,
410
728
  complete ? "complete" : "action",
411
729
  family.title,
412
730
  complete
413
- ? `${completeSources.map((source) => source.title).join(", ")} cover all ${family.controls.length} selected controls and record access owners and extraction instructions.`
731
+ ? `${completeSources.map((source) => source.title).join(", ")} ${completeSources.length === 1 ? "covers" : "cover"} all ${family.controls.length} selected controls and record access owners and extraction instructions.`
414
732
  : `${coveredControls.length} of ${family.controls.length} selected controls have an active authoritative system with the required source role, access owners, and extraction instructions.`,
415
733
  completeSources[0] || selectedSources[0] || { type: "system" },
416
- { controlIds: family.controls.map((control) => control.id), sourceSystemIds: selectedSources.map((source) => source.id) }
417
- ));
418
- }
419
- return stage("sources", "Configure Evidence Sources", "Catalog the authoritative systems, name who can export from them, and write repeatable extraction instructions.", items);
420
- }
421
-
422
- function evidenceCollectionStage(scope, records, byId, model) {
423
- const families = selectedControlFamilies(scope.controls, model).filter(requiresCollectionTest);
424
- const captures = records.filter((record) => (
425
- record.type === "evidence"
426
- && TEST_EVIDENCE_KINDS.has(record.evidenceKind)
427
- ));
428
- const items = families.map((family) => {
429
- const configuredSourceIds = new Set(family.controls.flatMap((control) => control.evidenceSourceIds || []));
430
- const controlIds = new Set(family.controls.map((control) => control.id));
431
- const capture = captures.find((record) => record.collectionTestFamilyId === family.id)
432
- || captures.find((record) => (
433
- [...controlIdsForRecord(record, byId)].some((id) => controlIds.has(id))
434
- ));
435
- const sourceIds = new Set([
436
- ...configuredSourceIds,
437
- ...(capture?.sourceSystemId ? [capture.sourceSystemId] : [])
438
- ]);
439
- const verified = capture?.status === "verified";
440
- return item(
441
- `test-family-${family.id}`,
442
- verified ? "complete" : "action",
443
- family.title,
444
- verified
445
- ? `${capture.title} proves that management successfully captured and verified evidence from ${byId.get(capture.sourceSystemId)?.title || "the authoritative source"}.`
446
- : capture?.status === "draft"
447
- ? `${capture.title} is a draft. Open it, select the authoritative source System, collect the named artifact, and have another person verify it.`
448
- : capture
449
- ? `${capture.title} is ${capture.status} but must be verified before this family is ready.`
450
- : `Run and verify one test export or test capture from an authoritative source outside filegrc, then link it to a family control.`,
451
- capture || { type: "evidence" },
452
734
  {
453
735
  familyId: family.id,
454
- controlIds: [...controlIds],
455
- sourceSystemIds: [...sourceIds],
456
- evidenceId: capture?.id || null,
457
- evidenceStatus: capture?.status || null,
458
- testEvidenceKind: family.testEvidenceKind,
459
- testPrompt: family.testPrompt
736
+ sourceKinds: family.sourceKinds,
737
+ controlIds: family.controls.map((control) => control.id),
738
+ sourceSystemIds: selectedSources.map((source) => source.id),
739
+ completeSourceSystemIds: completeSources.map((source) => source.id),
740
+ sourceSystemChecks,
741
+ controlMappings,
742
+ evidenceForm: family.evidenceForm,
743
+ evidencePrompt: family.evidencePrompt,
744
+ description: family.description,
745
+ timing: family.timing,
746
+ operationRecordTypes: family.operationRecordTypes,
747
+ commands
460
748
  }
461
- );
462
- });
463
- return stage("collection", "Test Evidence Collection", "A verified test export or capture is required for each external evidence family that does not already have a dedicated Step 5 operating record.", items);
749
+ ));
750
+ }
751
+ return stage("sources", "Control Evidence Sources", "Complete the authoritative Systems for every selected control family before marking the Controls implemented.", items);
464
752
  }
465
753
 
466
- function operationStage(workspace, scope, records, byId, asOf, evidenceReady) {
754
+ function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
467
755
  const goal = workspace?.assuranceGoal || "none";
468
756
  if (!evidenceReady) {
469
757
  return stage("operation", "Operate the Program", "Run the controls and preserve dated evidence after the Evidence Ready gate passes.", [
@@ -471,13 +759,15 @@ function operationStage(workspace, scope, records, byId, asOf, evidenceReady) {
471
759
  "operation-later",
472
760
  "later",
473
761
  "Begin reliable evidence collection",
474
- "Finish scope, policy adoption, control implementation, source configuration, and test captures before recording the candidate period.",
762
+ "Finish scope, policy adoption, and control implementation, including complete authoritative evidence sources, before recording the candidate period.",
475
763
  workspace || { type: "workspace" }
476
764
  )
477
765
  ]);
478
766
  }
479
767
  if (goal !== "soc-2-type-2") {
480
- const date = workspace?.candidateTypeOneAsOf;
768
+ const date = workspace?.candidateCoverage?.kind === "as-of"
769
+ ? workspace.candidateCoverage.on
770
+ : null;
481
771
  return stage("operation", "Operate the Program", "Run the controls and preserve dated evidence before engaging the CPA firm.", [
482
772
  item(
483
773
  "candidate-type-one-date",
@@ -492,10 +782,16 @@ function operationStage(workspace, scope, records, byId, asOf, evidenceReady) {
492
782
  ]);
493
783
  }
494
784
 
495
- const obligations = planObligations(records, { asOf, through: asOf });
496
- const start = workspace.candidatePeriodStart;
497
- const end = workspace.candidatePeriodEnd;
785
+ const obligations = planObligations(records, { asOf, through: asOf, model });
786
+ const start = workspace.candidateCoverage?.kind === "range"
787
+ ? coverageStart(workspace.candidateCoverage)
788
+ : null;
789
+ const end = workspace.candidateCoverage?.kind === "range"
790
+ ? coverageEnd(workspace.candidateCoverage)
791
+ : null;
498
792
  const startStatus = !start ? "action" : start <= asOf ? "complete" : "later";
793
+ const sourceCoverage = assessSourceCoverageReadiness(loaded, scope.controls.map(({ id }) => id));
794
+ const incompleteSourceCoverage = sourceCoverage.filter(({ complete }) => !complete);
499
795
  return stage("operation", "Operate the Program", "Start the management candidate Type 2 period only after the Evidence Ready gate, then keep collection running.", [
500
796
  item(
501
797
  "evidence-running",
@@ -517,14 +813,44 @@ function operationStage(workspace, scope, records, byId, asOf, evidenceReady) {
517
813
  : "Add the management target end when useful. Starting reliable evidence collection is the immediate milestone.",
518
814
  workspace
519
815
  ),
816
+ item(
817
+ "source-readiness-tests",
818
+ !start ? "later" : incompleteSourceCoverage.length ? "action" : "complete",
819
+ "Pass the evidence-source retrieval dry runs",
820
+ !start
821
+ ? "Set the candidate period before recording the pre-period source retrieval tests."
822
+ : incompleteSourceCoverage.length
823
+ ? `${incompleteSourceCoverage.length} source ${incompleteSourceCoverage.length === 1 ? "family needs" : "families need"} a passed retrieval test with confirmed access before the program is operating.`
824
+ : `${sourceCoverage.length} source ${sourceCoverage.length === 1 ? "family has" : "families have"} passed retrieval tests with confirmed access.`,
825
+ { type: "source-coverage" },
826
+ {
827
+ sourceFamilyIds: incompleteSourceCoverage.map(({ family }) => family.id),
828
+ resourceIds: incompleteSourceCoverage.map(({ record }) => record?.id).filter(Boolean),
829
+ commands: [
830
+ "npx filegrc list source-coverage --workflow --json",
831
+ "npx filegrc guide evidence --json",
832
+ "npx filegrc program-readiness --json"
833
+ ]
834
+ }
835
+ ),
520
836
  item(
521
837
  "ongoing-obligations",
522
- obligations.counts.overdue ? "action" : "complete",
838
+ obligations.counts.overdue || obligations.counts.blocked ? "action" : "complete",
523
839
  "Keep policy work current",
524
840
  obligations.counts.overdue
525
- ? `${obligations.counts.overdue} policy obligations are overdue. Complete the work and retain its dated proof.`
526
- : `${obligations.counts.due} due and ${obligations.counts.upcoming} upcoming obligations; no overdue policy work.`,
527
- { type: "obligation" }
841
+ ? `${obligations.counts.overdue} Work Queue ${obligations.counts.overdue === 1 ? "item is" : "items are"} overdue`
842
+ + (obligations.counts.blocked ? ` and ${obligations.counts.blocked} ${obligations.counts.blocked === 1 ? "is" : "are"} blocked` : "")
843
+ + ". Resolve the work and retain its dated proof."
844
+ : obligations.counts.blocked
845
+ ? `${obligations.counts.blocked} Work Queue ${obligations.counts.blocked === 1 ? "item is" : "items are"} blocked. Open each task, review its named blockers, and resolve them before completion.`
846
+ : `${obligations.counts.due} due and ${obligations.counts.upcoming} upcoming Work Queue items; none are overdue or blocked.`,
847
+ { type: "obligation" },
848
+ {
849
+ commands: [
850
+ "npx filegrc obligations --json",
851
+ "npx filegrc workflow --json"
852
+ ]
853
+ }
528
854
  ),
529
855
  riskAssessmentItem(scope, records, byId, asOf)
530
856
  ]);
@@ -536,7 +862,7 @@ function riskAssessmentItem(scope, records, byId, asOf) {
536
862
  && record.status === "complete"
537
863
  && record.methodology
538
864
  && record.approvedOn
539
- && record.assessmentDate >= shiftYear(asOf, -1)
865
+ && record.completedOn >= shiftYear(asOf, -1)
540
866
  && partiesIndependent(record.assessorIds, record.reviewerIds, byId)
541
867
  && (!scope.systems.length || !(record.systemIds || []).length || record.systemIds.some((id) => scope.systems.some((system) => system.id === id)))
542
868
  ));
@@ -562,9 +888,10 @@ export function selectedControlFamilies(controls, model) {
562
888
  id: definition.id,
563
889
  title: definition.title,
564
890
  sourceKinds: definition.sourceKinds || [],
565
- testEvidenceKind: definition.testEvidenceKind || "test-capture",
566
- testPrompt: definition.testPrompt || `Capture usable evidence for ${definition.title.toLowerCase()}.`,
567
- collectionTestRequired: definition.collectionTestRequired !== false,
891
+ evidenceForm: definition.evidenceForm || "capture",
892
+ evidencePrompt: definition.evidencePrompt || `Retain usable evidence for ${definition.title.toLowerCase()}.`,
893
+ description: definition.description || "",
894
+ timing: definition.timing || "",
568
895
  operationRecordTypes: definition.operationRecordTypes || [],
569
896
  controls: selected
570
897
  });
@@ -586,9 +913,10 @@ export function selectedControlFamilies(controls, model) {
586
913
  id: `control-${prefix}`,
587
914
  title,
588
915
  sourceKinds: [],
589
- testEvidenceKind: "test-capture",
590
- testPrompt: `Capture usable evidence for the selected ${title.toLowerCase()} controls.`,
591
- collectionTestRequired: true,
916
+ evidenceForm: "capture",
917
+ evidencePrompt: `Retain usable evidence for the selected ${title.toLowerCase()} controls.`,
918
+ description: `Record the authoritative Systems that produce evidence for the selected ${title.toLowerCase()} controls.`,
919
+ timing: "Map the source and document repeatable retrieval before program operation begins.",
592
920
  operationRecordTypes: [],
593
921
  controls: selected
594
922
  });
@@ -596,10 +924,6 @@ export function selectedControlFamilies(controls, model) {
596
924
  return families;
597
925
  }
598
926
 
599
- function requiresCollectionTest(family) {
600
- return family.collectionTestRequired !== false;
601
- }
602
-
603
927
  async function primaryMarkdown(loaded, record) {
604
928
  const definition = loaded.model.resources[record.type];
605
929
  const selected = markdownEntries(loaded.model, record).find((entry) => (
@@ -651,15 +975,31 @@ function policyCheckLabel(name) {
651
975
  })[name] || name;
652
976
  }
653
977
 
978
+ function governedContentCheckLabel(name) {
979
+ return ({
980
+ active: "active status",
981
+ owner: "current owner",
982
+ independentlyApproved: "independent approval and approval date",
983
+ approved: "approval and approval date",
984
+ effective: "effective date",
985
+ effectiveContent: "effective content revision",
986
+ contentComplete: "content and organization placeholders"
987
+ })[name] || name;
988
+ }
989
+
654
990
  function controlCheckLabel(name) {
655
991
  return ({
992
+ applicability: "reviewed applicability decision",
656
993
  implemented: "implemented status",
657
994
  owner: "owner",
658
995
  procedure: "actual procedure in Record Markdown",
659
996
  scope: "in-scope systems",
660
- cadence: "cadence",
997
+ operationPattern: "operation pattern",
661
998
  evidenceSource: "authoritative evidence source",
662
999
  implementationDate: "implementation date",
1000
+ procedureRevision: "effective procedure revision",
1001
+ procedureEffective: "procedure effective date",
1002
+ implementationReview: "independent implementation review",
663
1003
  policyMapping: "policy mapping",
664
1004
  criteriaMapping: "criteria mapping",
665
1005
  workQueue: "running Work Queue schedules"