filegrc 0.4.0 → 0.5.1

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,10 +1,13 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import { assessRequiredAppointments } from "./appointments.js";
3
+ import { assessCollectionReviews } from "./collection-review.js";
2
4
  import { coverageEnd, coverageStart } from "./coverage.js";
3
5
  import { planObligations } from "./obligations.js";
4
6
  import { resolveDataPath } from "./paths.js";
5
7
  import { obligationIsRunning } from "./program-lifecycle.js";
6
8
  import { currentPartyPeople, partiesIndependent, partyPeople } from "./parties.js";
7
9
  import { markdownEntries } from "./resource-markdown.js";
10
+ import { assessSourceCoverageReadiness } from "./source-coverage.js";
8
11
  import { currentCalendarDate } from "./time.js";
9
12
  import { loadWorkspace } from "./workspace.js";
10
13
 
@@ -17,6 +20,7 @@ export async function assessProgramReadiness(input, options = {}) {
17
20
  const workspace = loaded.workspace || records.find((record) => record.type === "workspace");
18
21
  const asOf = options.asOf || currentCalendarDate(workspace?.timezone || "UTC");
19
22
  const scope = programScope(workspace, records, byId);
23
+ const collectionReviews = assessCollectionReviews(loaded);
20
24
  const markdown = new Map();
21
25
  const readMarkdown = async (record) => {
22
26
  if (!record) return "";
@@ -24,20 +28,30 @@ export async function assessProgramReadiness(input, options = {}) {
24
28
  return markdown.get(record.id);
25
29
  };
26
30
 
27
- const controlStage = await controlsStage(scope, byId, readMarkdown, asOf);
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
36
  controlStage.items.push(...sourceStage.items);
30
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.";
31
38
  const evidenceGateStages = [
32
- scopeStage(workspace, scope, records, byId),
33
- await policiesStage(scope, byId, readMarkdown, asOf),
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),
34
48
  controlStage
35
49
  ];
36
50
  for (const current of evidenceGateStages) finalizeStage(current);
37
51
  const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0);
38
52
  const stages = [
39
53
  ...evidenceGateStages,
40
- operationStage(workspace, scope, records, byId, asOf, evidenceReady, loaded.model)
54
+ operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, loaded.model)
41
55
  ];
42
56
  finalizeStage(stages.at(-1));
43
57
  const candidateStarted = Boolean(
@@ -46,7 +60,7 @@ export async function assessProgramReadiness(input, options = {}) {
46
60
  && coverageStart(workspace.candidateCoverage) <= asOf
47
61
  );
48
62
  const obligations = planObligations(records, { asOf, through: asOf, model: loaded.model });
49
- const operating = evidenceReady && candidateStarted && obligations.counts.overdue === 0;
63
+ const operating = evidenceReady && candidateStarted && stages.at(-1).counts.action === 0;
50
64
  const canStartCandidatePeriod = Boolean(
51
65
  evidenceReady
52
66
  && workspace?.assuranceGoal === "soc-2-type-2"
@@ -124,14 +138,14 @@ function programScope(workspace, records, byId) {
124
138
  )),
125
139
  requirements: select(workspace?.requirementIds, "requirement", (record) => (
126
140
  record.type === "requirement" && record.applicability === "applicable"
127
- )),
141
+ )).filter((record) => record.applicability === "applicable"),
128
142
  controls: select(workspace?.controlIds, "control", (record) => (
129
143
  record.type === "control" && !["not-applicable", "retired"].includes(record.status)
130
- ))
144
+ )).filter((record) => !["not-applicable", "retired"].includes(record.status))
131
145
  };
132
146
  }
133
147
 
134
- function scopeStage(workspace, scope, records, byId) {
148
+ function scopeStage(workspace, scope, records, byId, model, collectionReviews = []) {
135
149
  const items = [];
136
150
  const goal = workspace?.assuranceGoal || "none";
137
151
  items.push(item(
@@ -141,10 +155,20 @@ function scopeStage(workspace, scope, records, byId) {
141
155
  goal !== "none"
142
156
  ? `Target: ${assuranceGoalLabel(goal)}. This is a management objective, not an active CPA engagement.`
143
157
  : "Choose readiness, SOC 2 Type 1, or SOC 2 Type 2 as the management objective.",
144
- 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
+ }
145
165
  ));
146
166
 
147
167
  items.push(programOwnershipItem(records, byId));
168
+ items.push(requiredAppointmentsItem(records, model));
169
+ for (const assessment of collectionReviews) {
170
+ items.push(collectionReviewReadinessItem(assessment));
171
+ }
148
172
 
149
173
  const completeSystems = scope.systems.filter((system) => (
150
174
  system.status === "active"
@@ -162,6 +186,44 @@ function scopeStage(workspace, scope, records, byId) {
162
186
  scope.systems[0] || { type: "system" }
163
187
  ));
164
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
+
165
227
  const selectedRequirementIds = new Set(scope.requirements.map((record) => record.id));
166
228
  const applicableRequirements = records.filter((record) => (
167
229
  record.type === "requirement"
@@ -188,12 +250,67 @@ function scopeStage(workspace, scope, records, byId) {
188
250
  criteriaComplete
189
251
  ? `${scope.requirements.length} applicable criteria and ${scope.controls.length} controls are in the management program scope.`
190
252
  : `Resolve the program criteria and controls. ${unresolvedRequirements.length} criteria remain undetermined and ${missingRequirements.length} applicable criteria are not selected.`,
191
- 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
+ }
192
262
  ));
193
263
 
194
264
  return stage("scope", "Define Scope", "Set program ownership, the management objective, service boundary, criteria, controls, and dependencies.", items);
195
265
  }
196
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
+
197
314
  function programOwnershipItem(records, byId) {
198
315
  const ownedRecords = records.filter((record) => (
199
316
  ["policy", "control", "obligation"].includes(record.type)
@@ -229,15 +346,32 @@ function programOwnershipItem(records, byId) {
229
346
  ownerIds: record.ownerIds || [],
230
347
  reasons: ownershipResolutionReasons(record.ownerIds || [], byId)
231
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;
232
359
  const detail = [];
233
360
  if (!currentOwners.size) detail.push("No current person owns the program records.");
234
- if (unresolved.length) {
235
- 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.`);
236
363
  }
237
364
  if (missingJobTitles.length) {
238
365
  detail.push(`${missingJobTitles.length} active ${missingJobTitles.length === 1 ? "owner needs" : "owners need"} an organizational job title.`);
239
366
  }
240
- if (!oversightComplete) detail.push("Finish and activate Security and Risk Oversight with a current chair who is separate from policy ownership.");
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
+ );
374
+ }
241
375
  const oversightId = oversight?.id ? shellArgument(oversight.id) : null;
242
376
  return item(
243
377
  "program-ownership",
@@ -287,7 +421,7 @@ function ownershipResolutionReasons(ownerIds, byId) {
287
421
  });
288
422
  }
289
423
 
290
- async function policiesStage(scope, byId, readMarkdown, asOf) {
424
+ async function policiesStage(scope, records, byId, readMarkdown, asOf) {
291
425
  const linkedPolicyIds = new Set(scope.controls.flatMap((control) => control.policyIds || []));
292
426
  const policies = [...linkedPolicyIds].map((id) => byId.get(id)).filter((record) => (
293
427
  record?.type === "policy" && !["superseded", "retired"].includes(record.status)
@@ -320,7 +454,15 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
320
454
  : reviewerNeedsAssignment
321
455
  ? `${availableReviewer.title} chairs Security and Risk Oversight. Assign this person as approver on each policy after review.`
322
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.",
323
- 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
+ }
324
466
  )
325
467
  ];
326
468
  if (!policies.length) {
@@ -329,7 +471,14 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
329
471
  "action",
330
472
  "Link policies to the selected controls",
331
473
  "No applicable policies are linked from the controls in program scope.",
332
- { 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
+ }
333
482
  ));
334
483
  }
335
484
  for (const policy of policies) {
@@ -353,13 +502,93 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
353
502
  ? `Remaining adoption work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}.`
354
503
  : `Reviewed, independently approved, effective ${policy.effectiveOn}, linked to controls, with no open organization placeholders.`,
355
504
  policy,
356
- { 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
+ }
357
581
  ));
358
582
  }
359
- 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
+ );
360
589
  }
361
590
 
362
- async function controlsStage(scope, byId, readMarkdown, asOf) {
591
+ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
363
592
  const items = [];
364
593
  if (!scope.controls.length) {
365
594
  items.push(item("control-scope", "action", "Select the program controls", "No controls are selected for the management program.", { type: "control" }));
@@ -373,6 +602,9 @@ async function controlsStage(scope, byId, readMarkdown, asOf) {
373
602
  && (record.controlIds || []).includes(control.id)
374
603
  ));
375
604
  const checks = {
605
+ ...(model.resources.control?.fields?.applicabilityReview ? {
606
+ applicability: control.applicabilityReview?.decision === "applicable"
607
+ } : {}),
376
608
  implemented: control.status === "implemented",
377
609
  owner: (control.ownerIds || []).length > 0,
378
610
  procedure: substantiveMarkdown(source) && openPlaceholderCount(source) === 0,
@@ -380,6 +612,15 @@ async function controlsStage(scope, byId, readMarkdown, asOf) {
380
612
  operationPattern: Boolean(control.operationPattern),
381
613
  evidenceSource: sourceSystems.length > 0,
382
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
+ } : {}),
383
624
  policyMapping: (control.policyIds || []).length > 0,
384
625
  criteriaMapping: (control.requirementIds || []).length > 0,
385
626
  ...(["scheduled", "event-driven", "mixed"].includes(control.operationPattern) ? {
@@ -393,11 +634,20 @@ async function controlsStage(scope, byId, readMarkdown, asOf) {
393
634
  missing.length ? "action" : "complete",
394
635
  `${control.code ? `${control.code}: ` : ""}${control.title}`,
395
636
  missing.length
396
- ? `Before implementation: ${missing.join(", ")}.`
637
+ ? `Complete ${missing.length} ${missing.length === 1 ? "check" : "checks"} before implementation: ${missing.join(", ")}.`
397
638
  : `Implemented ${control.effectiveOn}; owned, scoped, scheduled, documented, mapped, and tied to ${sourceSystems.length} authoritative ${sourceSystems.length === 1 ? "source" : "sources"}.`,
398
639
  control,
399
640
  {
400
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
+ ],
401
651
  workQueue: queueSchedules.length ? {
402
652
  running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf)).length,
403
653
  total: queueSchedules.length
@@ -414,7 +664,13 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
414
664
  for (const family of families) {
415
665
  const selectedSources = [...new Set(family.controls.flatMap((control) => control.evidenceSourceIds || []))]
416
666
  .map((id) => byId.get(id))
417
- .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
+ ));
418
674
  const completeSources = [];
419
675
  const sourceSystemChecks = [];
420
676
  for (const source of selectedSources) {
@@ -472,7 +728,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
472
728
  complete ? "complete" : "action",
473
729
  family.title,
474
730
  complete
475
- ? `${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.`
476
732
  : `${coveredControls.length} of ${family.controls.length} selected controls have an active authoritative system with the required source role, access owners, and extraction instructions.`,
477
733
  completeSources[0] || selectedSources[0] || { type: "system" },
478
734
  {
@@ -495,7 +751,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
495
751
  return stage("sources", "Control Evidence Sources", "Complete the authoritative Systems for every selected control family before marking the Controls implemented.", items);
496
752
  }
497
753
 
498
- function operationStage(workspace, scope, records, byId, asOf, evidenceReady, model) {
754
+ function operationStage(loaded, workspace, scope, records, byId, asOf, evidenceReady, model) {
499
755
  const goal = workspace?.assuranceGoal || "none";
500
756
  if (!evidenceReady) {
501
757
  return stage("operation", "Operate the Program", "Run the controls and preserve dated evidence after the Evidence Ready gate passes.", [
@@ -534,6 +790,8 @@ function operationStage(workspace, scope, records, byId, asOf, evidenceReady, mo
534
790
  ? coverageEnd(workspace.candidateCoverage)
535
791
  : null;
536
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);
537
795
  return stage("operation", "Operate the Program", "Start the management candidate Type 2 period only after the Evidence Ready gate, then keep collection running.", [
538
796
  item(
539
797
  "evidence-running",
@@ -555,14 +813,44 @@ function operationStage(workspace, scope, records, byId, asOf, evidenceReady, mo
555
813
  : "Add the management target end when useful. Starting reliable evidence collection is the immediate milestone.",
556
814
  workspace
557
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
+ ),
558
836
  item(
559
837
  "ongoing-obligations",
560
- obligations.counts.overdue ? "action" : "complete",
838
+ obligations.counts.overdue || obligations.counts.blocked ? "action" : "complete",
561
839
  "Keep policy work current",
562
840
  obligations.counts.overdue
563
- ? `${obligations.counts.overdue} policy obligations are overdue. Complete the work and retain its dated proof.`
564
- : `${obligations.counts.due} due and ${obligations.counts.upcoming} upcoming obligations; no overdue policy work.`,
565
- { 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
+ }
566
854
  ),
567
855
  riskAssessmentItem(scope, records, byId, asOf)
568
856
  ]);
@@ -687,8 +975,21 @@ function policyCheckLabel(name) {
687
975
  })[name] || name;
688
976
  }
689
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
+
690
990
  function controlCheckLabel(name) {
691
991
  return ({
992
+ applicability: "reviewed applicability decision",
692
993
  implemented: "implemented status",
693
994
  owner: "owner",
694
995
  procedure: "actual procedure in Record Markdown",
@@ -696,6 +997,9 @@ function controlCheckLabel(name) {
696
997
  operationPattern: "operation pattern",
697
998
  evidenceSource: "authoritative evidence source",
698
999
  implementationDate: "implementation date",
1000
+ procedureRevision: "effective procedure revision",
1001
+ procedureEffective: "procedure effective date",
1002
+ implementationReview: "independent implementation review",
699
1003
  policyMapping: "policy mapping",
700
1004
  criteriaMapping: "criteria mapping",
701
1005
  workQueue: "running Work Queue schedules"