filegrc 0.3.4 → 0.4.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,4 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import { coverageEnd, coverageStart } from "./coverage.js";
2
3
  import { planObligations } from "./obligations.js";
3
4
  import { resolveDataPath } from "./paths.js";
4
5
  import { obligationIsRunning } from "./program-lifecycle.js";
@@ -7,8 +8,6 @@ import { markdownEntries } from "./resource-markdown.js";
7
8
  import { currentCalendarDate } from "./time.js";
8
9
  import { loadWorkspace } from "./workspace.js";
9
10
 
10
- const TEST_EVIDENCE_KINDS = new Set(["test-capture", "test-export"]);
11
-
12
11
  export async function assessProgramReadiness(input, options = {}) {
13
12
  const loaded = input?.resources && input?.model && input?.entries
14
13
  ? input
@@ -25,38 +24,33 @@ export async function assessProgramReadiness(input, options = {}) {
25
24
  return markdown.get(record.id);
26
25
  };
27
26
 
27
+ const controlStage = await controlsStage(scope, byId, readMarkdown, asOf);
28
28
  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
- );
29
+ controlStage.items.push(...sourceStage.items);
30
+ 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
31
  const evidenceGateStages = [
37
32
  scopeStage(workspace, scope, records, byId),
38
33
  await policiesStage(scope, byId, readMarkdown, asOf),
39
- await controlsStage(scope, byId, readMarkdown, asOf),
40
- evidenceStage
34
+ controlStage
41
35
  ];
42
36
  for (const current of evidenceGateStages) finalizeStage(current);
43
37
  const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0);
44
38
  const stages = [
45
39
  ...evidenceGateStages,
46
- operationStage(workspace, scope, records, byId, asOf, evidenceReady)
40
+ operationStage(workspace, scope, records, byId, asOf, evidenceReady, loaded.model)
47
41
  ];
48
42
  finalizeStage(stages.at(-1));
49
43
  const candidateStarted = Boolean(
50
44
  workspace?.assuranceGoal === "soc-2-type-2"
51
- && workspace.candidatePeriodStart
52
- && workspace.candidatePeriodStart <= asOf
45
+ && workspace.candidateCoverage?.kind === "range"
46
+ && coverageStart(workspace.candidateCoverage) <= asOf
53
47
  );
54
- const obligations = planObligations(records, { asOf, through: asOf });
48
+ const obligations = planObligations(records, { asOf, through: asOf, model: loaded.model });
55
49
  const operating = evidenceReady && candidateStarted && obligations.counts.overdue === 0;
56
50
  const canStartCandidatePeriod = Boolean(
57
51
  evidenceReady
58
52
  && workspace?.assuranceGoal === "soc-2-type-2"
59
- && !workspace.candidatePeriodStart
53
+ && !workspace.candidateCoverage
60
54
  );
61
55
  const items = stages.flatMap((current) => current.items);
62
56
  const managedItems = items.filter((current) => !["info", "later"].includes(current.status));
@@ -70,9 +64,7 @@ export async function assessProgramReadiness(input, options = {}) {
70
64
  target: {
71
65
  goal: workspace?.assuranceGoal || "none",
72
66
  label: assuranceGoalLabel(workspace?.assuranceGoal),
73
- candidateTypeOneAsOf: workspace?.candidateTypeOneAsOf || null,
74
- candidatePeriodStart: workspace?.candidatePeriodStart || null,
75
- candidatePeriodEnd: workspace?.candidatePeriodEnd || null
67
+ candidateCoverage: workspace?.candidateCoverage || null
76
68
  },
77
69
  status: operating ? "operating" : evidenceReady ? "evidence-ready" : "needs-work",
78
70
  evidenceReady,
@@ -96,15 +88,37 @@ export async function assessProgramReadiness(input, options = {}) {
96
88
  };
97
89
  }
98
90
 
91
+ export async function assessEvidenceMap(input, options = {}) {
92
+ const readiness = await assessProgramReadiness(input, options);
93
+ const evidenceItems = readiness.stages
94
+ .find((stage) => stage.id === "controls")
95
+ ?.items.filter((current) => current.id.startsWith("source-family-")) || [];
96
+ const counts = countStatuses(evidenceItems);
97
+ return {
98
+ schemaVersion: 1,
99
+ generatedAt: readiness.generatedAt,
100
+ asOf: readiness.asOf,
101
+ status: counts.action ? "action" : "complete",
102
+ counts,
103
+ workflow: [
104
+ "Choose an existing System or create the System that is authoritative for each evidence family.",
105
+ "On every source System, set an evidence source role, name current evidence access owners, and write repeatable retrieval instructions in Record Markdown.",
106
+ "Set each selected Control's evidenceSourceIds to the authoritative Systems that produce its evidence.",
107
+ "Run program-readiness again and resolve every incomplete source check and control mapping before marking the Controls implemented."
108
+ ],
109
+ items: evidenceItems
110
+ };
111
+ }
112
+
99
113
  function programScope(workspace, records, byId) {
100
114
  const select = (ids, type, fallback) => {
101
115
  if (ids?.length) return ids.map((id) => byId.get(id)).filter((record) => record?.type === type);
102
116
  return records.filter(fallback);
103
117
  };
104
118
  return {
105
- systems: select(workspace?.systemIds, "system", (record) => (
106
- record.type === "system" && record.inScope === true && record.status !== "retired"
107
- )),
119
+ systems: (workspace?.systemIds || [])
120
+ .map((id) => byId.get(id))
121
+ .filter((record) => record?.type === "system" && record.status !== "retired"),
108
122
  frameworks: select(workspace?.frameworkIds, "framework", (record) => (
109
123
  record.type === "framework" && record.status === "active"
110
124
  )),
@@ -134,9 +148,8 @@ function scopeStage(workspace, scope, records, byId) {
134
148
 
135
149
  const completeSystems = scope.systems.filter((system) => (
136
150
  system.status === "active"
137
- && system.inScope === true
138
151
  && system.description
139
- && system.dataClassification
152
+ && system.classificationId
140
153
  && (system.ownerIds || []).length
141
154
  ));
142
155
  items.push(item(
@@ -188,6 +201,9 @@ function programOwnershipItem(records, byId) {
188
201
  ));
189
202
  const unresolved = ownedRecords.filter((record) => currentPartyPeople(record.ownerIds, byId).size === 0);
190
203
  const currentOwners = new Set(ownedRecords.flatMap((record) => [...currentPartyPeople(record.ownerIds, byId)]));
204
+ const missingJobTitles = [...currentOwners]
205
+ .map((id) => byId.get(id))
206
+ .filter((record) => record?.type === "person" && record.status === "active" && !String(record.jobTitle || "").trim());
191
207
  const oversight = byId.get("team-security-risk-oversight");
192
208
  const policyOwnerIds = new Set(records
193
209
  .filter((record) => record.type === "policy" && !["retired", "superseded"].includes(record.status))
@@ -202,7 +218,10 @@ function programOwnershipItem(records, byId) {
202
218
  && oversightChairs.size > 0
203
219
  && ![...oversightChairs].some((id) => policyOwnerIds.has(id))
204
220
  );
205
- const complete = currentOwners.size > 0 && unresolved.length === 0 && oversightComplete;
221
+ const complete = currentOwners.size > 0
222
+ && unresolved.length === 0
223
+ && missingJobTitles.length === 0
224
+ && oversightComplete;
206
225
  const unresolvedAssignments = unresolved.map((record) => ({
207
226
  resourceType: record.type,
208
227
  resourceId: record.id,
@@ -215,6 +234,9 @@ function programOwnershipItem(records, byId) {
215
234
  if (unresolved.length) {
216
235
  detail.push(`${unresolved.length} ${unresolved.length === 1 ? "record has" : "records have"} no current person owner.`);
217
236
  }
237
+ if (missingJobTitles.length) {
238
+ detail.push(`${missingJobTitles.length} active ${missingJobTitles.length === 1 ? "owner needs" : "owners need"} an organizational job title.`);
239
+ }
218
240
  if (!oversightComplete) detail.push("Finish and activate Security and Risk Oversight with a current chair who is separate from policy ownership.");
219
241
  const oversightId = oversight?.id ? shellArgument(oversight.id) : null;
220
242
  return item(
@@ -227,9 +249,11 @@ function programOwnershipItem(records, byId) {
227
249
  !oversightComplete ? oversight : unresolved[0] || { type: "person" },
228
250
  {
229
251
  unresolvedAssignments,
252
+ missingJobTitleIds: missingJobTitles.map(({ id }) => id),
230
253
  ...(!oversightComplete && oversight ? {
231
254
  commands: [
232
255
  "npx filegrc guide person --json",
256
+ "npx filegrc guide appointment --json",
233
257
  "npx filegrc list person --json",
234
258
  'npx filegrc scaffold person --title "REVIEWER NAME" | npx filegrc create - --json',
235
259
  `npx filegrc get ${oversightId} --mutation`,
@@ -251,8 +275,13 @@ function ownershipResolutionReasons(ownerIds, byId) {
251
275
  if (owner.type === "team" && owner.status !== "active") {
252
276
  return [{ ownerId, reason: "inactive-team" }];
253
277
  }
278
+ if (owner.type === "appointment" && owner.status !== "active") {
279
+ return [{ ownerId, reason: "inactive-appointment" }];
280
+ }
254
281
  if (currentPartyPeople([ownerId], byId).size === 0) {
255
- return [{ ownerId, reason: owner.type === "team" ? "team-has-no-current-members" : "no-current-person" }];
282
+ if (owner.type === "team") return [{ ownerId, reason: "team-has-no-current-members" }];
283
+ if (owner.type === "appointment") return [{ ownerId, reason: "appointment-has-no-current-holder" }];
284
+ return [{ ownerId, reason: "no-current-person" }];
256
285
  }
257
286
  return [];
258
287
  });
@@ -312,7 +341,7 @@ async function policiesStage(scope, byId, readMarkdown, asOf) {
312
341
  && policy.approvedOn
313
342
  && partiesIndependent(policy.ownerIds, policy.approverIds, byId),
314
343
  effective: policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf,
315
- linkedControls: (policy.controlIds || []).some((id) => scope.controls.some((control) => control.id === id)),
344
+ linkedControls: scope.controls.some((control) => (control.policyIds || []).includes(policy.id)),
316
345
  contentComplete: Boolean(source.trim()) && placeholderCount === 0
317
346
  };
318
347
  const missing = Object.entries(checks).filter(([, value]) => !value).map(([name]) => policyCheckLabel(name));
@@ -348,13 +377,14 @@ async function controlsStage(scope, byId, readMarkdown, asOf) {
348
377
  owner: (control.ownerIds || []).length > 0,
349
378
  procedure: substantiveMarkdown(source) && openPlaceholderCount(source) === 0,
350
379
  scope: (control.systemIds || []).some((id) => scope.systems.some((system) => system.id === id)),
351
- cadence: Boolean(control.frequency),
380
+ operationPattern: Boolean(control.operationPattern),
352
381
  evidenceSource: sourceSystems.length > 0,
353
382
  implementationDate: Boolean(control.effectiveOn && control.effectiveOn <= asOf),
354
383
  policyMapping: (control.policyIds || []).length > 0,
355
384
  criteriaMapping: (control.requirementIds || []).length > 0,
356
- ...(queueSchedules.length ? {
357
- workQueue: queueSchedules.every((obligation) => obligationIsRunning(obligation, byId, asOf))
385
+ ...(["scheduled", "event-driven", "mixed"].includes(control.operationPattern) ? {
386
+ workQueue: queueSchedules.length > 0
387
+ && queueSchedules.every((obligation) => obligationIsRunning(obligation, byId, asOf))
358
388
  } : {})
359
389
  };
360
390
  const missing = Object.entries(checks).filter(([, value]) => !value).map(([name]) => controlCheckLabel(name));
@@ -375,36 +405,68 @@ async function controlsStage(scope, byId, readMarkdown, asOf) {
375
405
  }
376
406
  ));
377
407
  }
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);
408
+ 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
409
  }
380
410
 
381
411
  async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
382
- const families = selectedControlFamilies(scope.controls, model).filter(requiresCollectionTest);
412
+ const families = selectedControlFamilies(scope.controls, model);
383
413
  const items = [];
384
414
  for (const family of families) {
385
415
  const selectedSources = [...new Set(family.controls.flatMap((control) => control.evidenceSourceIds || []))]
386
416
  .map((id) => byId.get(id))
387
417
  .filter((record) => record?.type === "system");
388
418
  const completeSources = [];
419
+ const sourceSystemChecks = [];
389
420
  for (const source of selectedSources) {
390
421
  const instructions = await readMarkdown(source);
391
422
  const matchesRole = !family.sourceKinds.length
392
423
  || 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
- ) {
424
+ const checks = {
425
+ active: source.status === "active",
426
+ sourceRole: matchesRole && (source.evidenceSourceKinds || []).length > 0,
427
+ accessOwners: (source.evidenceOwnerIds || []).length > 0,
428
+ retrievalInstructions: substantiveMarkdown(instructions) && openPlaceholderCount(instructions) === 0
429
+ };
430
+ const complete = Object.values(checks).every(Boolean);
431
+ sourceSystemChecks.push({
432
+ sourceSystemId: source.id,
433
+ complete,
434
+ checks
435
+ });
436
+ if (complete) {
401
437
  completeSources.push(source);
402
438
  }
403
439
  }
404
- const coveredControls = family.controls.filter((control) => (
405
- (control.evidenceSourceIds || []).some((id) => completeSources.some((source) => source.id === id))
406
- ));
440
+ const controlMappings = family.controls.map((control) => {
441
+ const sourceSystemIds = (control.evidenceSourceIds || []).filter((id) => selectedSources.some((source) => source.id === id));
442
+ const completeSourceSystemIds = sourceSystemIds.filter((id) => completeSources.some((source) => source.id === id));
443
+ return {
444
+ controlId: control.id,
445
+ sourceSystemIds,
446
+ completeSourceSystemIds,
447
+ mapped: sourceSystemIds.length > 0,
448
+ complete: completeSourceSystemIds.length > 0
449
+ };
450
+ });
451
+ const coveredControls = controlMappings.filter(({ complete }) => complete);
407
452
  const complete = completeSources.length > 0 && coveredControls.length === family.controls.length;
453
+ const commands = [
454
+ ...(selectedSources.length
455
+ ? sourceSystemChecks.filter(({ complete: sourceComplete }) => !sourceComplete).flatMap(({ sourceSystemId }) => [
456
+ `npx filegrc get ${shellArgument(sourceSystemId)} --mutation > /tmp/${shellArgument(sourceSystemId)}.json`,
457
+ `npx filegrc update system ${shellArgument(sourceSystemId)} /tmp/${shellArgument(sourceSystemId)}.json --json`
458
+ ])
459
+ : [
460
+ "npx filegrc list system --json",
461
+ 'npx filegrc scaffold system --title "SYSTEM NAME" > /tmp/filegrc-system.json',
462
+ "npx filegrc create /tmp/filegrc-system.json --json"
463
+ ]),
464
+ ...controlMappings.filter(({ mapped }) => !mapped).flatMap(({ controlId }) => [
465
+ `npx filegrc get ${shellArgument(controlId)} --mutation > /tmp/${shellArgument(controlId)}.json`,
466
+ `npx filegrc update control ${shellArgument(controlId)} /tmp/${shellArgument(controlId)}.json --json`
467
+ ]),
468
+ "npx filegrc program-readiness --json"
469
+ ];
408
470
  items.push(item(
409
471
  `source-family-${family.id}`,
410
472
  complete ? "complete" : "action",
@@ -413,57 +475,27 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
413
475
  ? `${completeSources.map((source) => source.title).join(", ")} cover all ${family.controls.length} selected controls and record access owners and extraction instructions.`
414
476
  : `${coveredControls.length} of ${family.controls.length} selected controls have an active authoritative system with the required source role, access owners, and extraction instructions.`,
415
477
  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
478
  {
453
479
  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
480
+ sourceKinds: family.sourceKinds,
481
+ controlIds: family.controls.map((control) => control.id),
482
+ sourceSystemIds: selectedSources.map((source) => source.id),
483
+ completeSourceSystemIds: completeSources.map((source) => source.id),
484
+ sourceSystemChecks,
485
+ controlMappings,
486
+ evidenceForm: family.evidenceForm,
487
+ evidencePrompt: family.evidencePrompt,
488
+ description: family.description,
489
+ timing: family.timing,
490
+ operationRecordTypes: family.operationRecordTypes,
491
+ commands
460
492
  }
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);
493
+ ));
494
+ }
495
+ return stage("sources", "Control Evidence Sources", "Complete the authoritative Systems for every selected control family before marking the Controls implemented.", items);
464
496
  }
465
497
 
466
- function operationStage(workspace, scope, records, byId, asOf, evidenceReady) {
498
+ function operationStage(workspace, scope, records, byId, asOf, evidenceReady, model) {
467
499
  const goal = workspace?.assuranceGoal || "none";
468
500
  if (!evidenceReady) {
469
501
  return stage("operation", "Operate the Program", "Run the controls and preserve dated evidence after the Evidence Ready gate passes.", [
@@ -471,13 +503,15 @@ function operationStage(workspace, scope, records, byId, asOf, evidenceReady) {
471
503
  "operation-later",
472
504
  "later",
473
505
  "Begin reliable evidence collection",
474
- "Finish scope, policy adoption, control implementation, source configuration, and test captures before recording the candidate period.",
506
+ "Finish scope, policy adoption, and control implementation, including complete authoritative evidence sources, before recording the candidate period.",
475
507
  workspace || { type: "workspace" }
476
508
  )
477
509
  ]);
478
510
  }
479
511
  if (goal !== "soc-2-type-2") {
480
- const date = workspace?.candidateTypeOneAsOf;
512
+ const date = workspace?.candidateCoverage?.kind === "as-of"
513
+ ? workspace.candidateCoverage.on
514
+ : null;
481
515
  return stage("operation", "Operate the Program", "Run the controls and preserve dated evidence before engaging the CPA firm.", [
482
516
  item(
483
517
  "candidate-type-one-date",
@@ -492,9 +526,13 @@ function operationStage(workspace, scope, records, byId, asOf, evidenceReady) {
492
526
  ]);
493
527
  }
494
528
 
495
- const obligations = planObligations(records, { asOf, through: asOf });
496
- const start = workspace.candidatePeriodStart;
497
- const end = workspace.candidatePeriodEnd;
529
+ const obligations = planObligations(records, { asOf, through: asOf, model });
530
+ const start = workspace.candidateCoverage?.kind === "range"
531
+ ? coverageStart(workspace.candidateCoverage)
532
+ : null;
533
+ const end = workspace.candidateCoverage?.kind === "range"
534
+ ? coverageEnd(workspace.candidateCoverage)
535
+ : null;
498
536
  const startStatus = !start ? "action" : start <= asOf ? "complete" : "later";
499
537
  return stage("operation", "Operate the Program", "Start the management candidate Type 2 period only after the Evidence Ready gate, then keep collection running.", [
500
538
  item(
@@ -536,7 +574,7 @@ function riskAssessmentItem(scope, records, byId, asOf) {
536
574
  && record.status === "complete"
537
575
  && record.methodology
538
576
  && record.approvedOn
539
- && record.assessmentDate >= shiftYear(asOf, -1)
577
+ && record.completedOn >= shiftYear(asOf, -1)
540
578
  && partiesIndependent(record.assessorIds, record.reviewerIds, byId)
541
579
  && (!scope.systems.length || !(record.systemIds || []).length || record.systemIds.some((id) => scope.systems.some((system) => system.id === id)))
542
580
  ));
@@ -562,9 +600,10 @@ export function selectedControlFamilies(controls, model) {
562
600
  id: definition.id,
563
601
  title: definition.title,
564
602
  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,
603
+ evidenceForm: definition.evidenceForm || "capture",
604
+ evidencePrompt: definition.evidencePrompt || `Retain usable evidence for ${definition.title.toLowerCase()}.`,
605
+ description: definition.description || "",
606
+ timing: definition.timing || "",
568
607
  operationRecordTypes: definition.operationRecordTypes || [],
569
608
  controls: selected
570
609
  });
@@ -586,9 +625,10 @@ export function selectedControlFamilies(controls, model) {
586
625
  id: `control-${prefix}`,
587
626
  title,
588
627
  sourceKinds: [],
589
- testEvidenceKind: "test-capture",
590
- testPrompt: `Capture usable evidence for the selected ${title.toLowerCase()} controls.`,
591
- collectionTestRequired: true,
628
+ evidenceForm: "capture",
629
+ evidencePrompt: `Retain usable evidence for the selected ${title.toLowerCase()} controls.`,
630
+ description: `Record the authoritative Systems that produce evidence for the selected ${title.toLowerCase()} controls.`,
631
+ timing: "Map the source and document repeatable retrieval before program operation begins.",
592
632
  operationRecordTypes: [],
593
633
  controls: selected
594
634
  });
@@ -596,10 +636,6 @@ export function selectedControlFamilies(controls, model) {
596
636
  return families;
597
637
  }
598
638
 
599
- function requiresCollectionTest(family) {
600
- return family.collectionTestRequired !== false;
601
- }
602
-
603
639
  async function primaryMarkdown(loaded, record) {
604
640
  const definition = loaded.model.resources[record.type];
605
641
  const selected = markdownEntries(loaded.model, record).find((entry) => (
@@ -657,7 +693,7 @@ function controlCheckLabel(name) {
657
693
  owner: "owner",
658
694
  procedure: "actual procedure in Record Markdown",
659
695
  scope: "in-scope systems",
660
- cadence: "cadence",
696
+ operationPattern: "operation pattern",
661
697
  evidenceSource: "authoritative evidence source",
662
698
  implementationDate: "implementation date",
663
699
  policyMapping: "policy mapping",
@@ -0,0 +1,17 @@
1
+ export function effectiveResourceStatus(record, asOf) {
2
+ if (
3
+ record?.type === "attestation"
4
+ && record.status === "pending"
5
+ && record.dueOn
6
+ && asOf
7
+ && record.dueOn < asOf
8
+ ) return "overdue";
9
+ if (
10
+ record?.type === "evidence"
11
+ && ["collected", "verified"].includes(record.status)
12
+ && record.expiresOn
13
+ && asOf
14
+ && record.expiresOn < asOf
15
+ ) return "expired";
16
+ return record?.status;
17
+ }