filegrc 0.5.0 → 0.6.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.
@@ -2,49 +2,58 @@ import { createHash } from "node:crypto";
2
2
  import { applyResourceBatch } from "./files.js";
3
3
  import { getGitSummary } from "./git.js";
4
4
  import { loadWorkspace } from "./workspace.js";
5
+ import { programComponents, resolveProgram, selectedRequirementIds } from "./program.js";
5
6
 
6
- export function collectionRevision(loaded, resourceType) {
7
+ export function collectionRevision(loaded, resourceType, options = {}) {
8
+ const program = resolveProgram(loaded, options.programId);
9
+ const scopedIds = new Set(scopedCollectionRecords(loaded, resourceType, program).map(({ id }) => id));
7
10
  const records = loaded.entries
8
- .filter(({ record }) => record.type === resourceType)
11
+ .filter(({ record }) => record.type === resourceType && scopedIds.has(record.id))
9
12
  .map(({ record, source }) => ({
10
13
  id: record.id,
11
14
  revision: createHash("sha256").update(source).digest("hex")
12
15
  }))
13
16
  .sort((left, right) => left.id.localeCompare(right.id));
14
17
  const workspaceScope = {
15
- assuranceGoal: loaded.workspace?.assuranceGoal ?? null,
16
- candidateCoverage: loaded.workspace?.candidateCoverage ?? null,
17
- systemIds: [...(loaded.workspace?.systemIds || [])].sort(),
18
- frameworkIds: [...(loaded.workspace?.frameworkIds || [])].sort(),
19
- requirementIds: [...(loaded.workspace?.requirementIds || [])].sort(),
20
- controlIds: [...(loaded.workspace?.controlIds || [])].sort()
18
+ programId: program?.id ?? null,
19
+ assuranceGoal: program?.assuranceGoal ?? null,
20
+ candidateCoverage: program?.candidateCoverage ?? null,
21
+ systemIds: [...(program?.systemIds || [])].sort(),
22
+ frameworkIds: [...(program?.frameworkIds || [])].sort(),
23
+ requirementIds: [...selectedRequirementIds(program || {}, loaded.model)].sort(),
24
+ controlIds: [...(program?.controlIds || [])].sort()
21
25
  };
22
26
  return createHash("sha256")
23
27
  .update(JSON.stringify({ resourceType, records, workspaceScope }))
24
28
  .digest("hex");
25
29
  }
26
30
 
27
- export function assessCollectionReviews(input) {
31
+ export function assessCollectionReviews(input, options = {}) {
28
32
  const loaded = input?.resources && input?.model && input?.entries
29
33
  ? input
30
34
  : null;
31
35
  if (!loaded) throw new Error("Collection review assessment requires a loaded workspace.");
32
36
  return Object.keys(loaded.model.collectionReviews || {})
33
- .map((resourceType) => assessCollectionReview(loaded, resourceType));
37
+ .map((resourceType) => assessCollectionReview(loaded, resourceType, options));
34
38
  }
35
39
 
36
- export function assessCollectionReview(loaded, resourceType) {
40
+ export function assessCollectionReview(loaded, resourceType, options = {}) {
37
41
  const configuration = loaded.model.collectionReviews?.[resourceType];
38
42
  if (!configuration) return null;
39
- const records = loaded.resources.filter((record) => record.type === resourceType);
43
+ const program = resolveProgram(loaded, options.programId);
44
+ const records = scopedCollectionRecords(loaded, resourceType, program);
40
45
  const reviewEntry = loaded.entries.find(({ record }) => (
41
46
  record.type === "collection-review"
42
47
  && record.resourceType === resourceType
43
48
  && record.status !== "retired"
49
+ && (String(loaded.model.modelVersion) !== "4" || (record.scopeResourceIds || []).includes(program.id))
44
50
  ));
45
51
  const review = reviewEntry?.record || null;
46
- const currentRevision = collectionRevision(loaded, resourceType);
52
+ const currentRevision = collectionRevision(loaded, resourceType, { programId: program.id });
47
53
  const allowedDecisions = configuration.decisions || ["complete"];
54
+ const allowsEmptyCollection = allowedDecisions.some((decision) => (
55
+ decision === "zero-population" || decision === "externally-managed"
56
+ ));
48
57
  const complete = Boolean(
49
58
  review?.status === "active"
50
59
  && allowedDecisions.includes(review.decision)
@@ -71,14 +80,17 @@ export function assessCollectionReview(loaded, resourceType) {
71
80
  ? `${configuration.title} were reviewed on ${review.reviewedOn}.`
72
81
  : stale
73
82
  ? `${configuration.title} changed after the last confirmation. Review the current records again.`
83
+ : !records.length && !allowsEmptyCollection
84
+ ? `Add at least one ${loaded.model.resources[resourceType].title.toLowerCase()} before confirming this collection.`
74
85
  : `Review ${configuration.title.toLowerCase()} before this page can be ready.`
75
86
  };
76
87
  }
77
88
 
78
89
  export async function scaffoldCollectionReview(input = process.cwd(), options = {}) {
79
90
  const loaded = await loadWorkspace(input);
91
+ const program = resolveProgram(loaded, options.programId);
80
92
  const resourceType = requiredType(loaded, options.resourceType);
81
- const assessment = assessCollectionReview(loaded, resourceType);
93
+ const assessment = assessCollectionReview(loaded, resourceType, { programId: program.id });
82
94
  const allowedDecisions = assessment.configuration.decisions || ["complete"];
83
95
  return {
84
96
  resourceType,
@@ -88,28 +100,37 @@ export async function scaffoldCollectionReview(input = process.cwd(), options =
88
100
  rationale: null,
89
101
  reviewedByIds: [],
90
102
  reviewedOn: null,
91
- authoritativeSystemId: null
103
+ ...(String(loaded.model.modelVersion) === "4"
104
+ ? { authoritativeComponentId: null }
105
+ : { authoritativeSystemId: null })
92
106
  };
93
107
  }
94
108
 
95
109
  export async function planCollectionReview(input = process.cwd(), options = {}) {
96
110
  const loaded = await loadWorkspace(input);
111
+ const program = resolveProgram(loaded, options.programId);
97
112
  const resourceType = requiredType(loaded, options.resourceType);
98
- const assessment = assessCollectionReview(loaded, resourceType);
113
+ const assessment = assessCollectionReview(loaded, resourceType, { programId: program.id });
99
114
  const configuration = assessment.configuration;
100
115
  const decision = String(options.decision || "").trim();
101
116
  const rationale = String(options.rationale || "").trim();
102
117
  const reviewedByIds = [...new Set((options.reviewedByIds || []).map(String).filter(Boolean))];
103
118
  const reviewedOn = String(options.reviewedOn || "").trim();
104
119
  const scopeRevision = String(options.scopeRevision || getGitSummary(loaded.root).commit || "uncommitted").trim();
105
- const authoritativeSystemId = String(options.authoritativeSystemId || "").trim();
120
+ const v4 = String(loaded.model.modelVersion) === "4";
121
+ const authoritativeSourceId = String(v4 ? options.authoritativeComponentId : options.authoritativeSystemId || "").trim();
106
122
  if (!(configuration.decisions || ["complete"]).includes(decision)) {
107
123
  throw new Error(
108
124
  `${configuration.title} review must use one of: ${(configuration.decisions || ["complete"]).join(", ")}.`
109
125
  );
110
126
  }
111
127
  if (!assessment.records.length && decision === "complete") {
112
- throw new Error(`${configuration.title} has no records. Use zero-population or another allowed conclusion.`);
128
+ const emptyDecisions = (configuration.decisions || []).filter((value) => (
129
+ value === "zero-population" || value === "externally-managed"
130
+ ));
131
+ throw new Error(emptyDecisions.length
132
+ ? `${configuration.title} has no records. Use one of the allowed empty-collection conclusions: ${emptyDecisions.join(", ")}.`
133
+ : `${configuration.title} has no records. Add the required records before confirming this collection.`);
113
134
  }
114
135
  if (assessment.records.length && decision === "zero-population") {
115
136
  throw new Error(`${configuration.title} has ${assessment.records.length} records and cannot be confirmed as a zero population.`);
@@ -119,11 +140,11 @@ export async function planCollectionReview(input = process.cwd(), options = {})
119
140
  }
120
141
  if (decision === "externally-managed") {
121
142
  const system = loaded.resources.find((record) => (
122
- record.type === "system"
123
- && record.id === authoritativeSystemId
143
+ record.type === (v4 ? "component" : "system")
144
+ && record.id === authoritativeSourceId
124
145
  && record.status === "active"
125
146
  ));
126
- if (!system) throw new Error(`${configuration.title} review needs an active authoritative System.`);
147
+ if (!system) throw new Error(`${configuration.title} review needs an active authoritative ${v4 ? "Component" : "System"}.`);
127
148
  }
128
149
  const existing = assessment.review;
129
150
  const record = {
@@ -132,7 +153,7 @@ export async function planCollectionReview(input = process.cwd(), options = {})
132
153
  type: "collection-review",
133
154
  title: `${configuration.title} review`,
134
155
  resourceType,
135
- scopeResourceIds: [loaded.workspace.id]
156
+ scopeResourceIds: [program.id]
136
157
  }),
137
158
  status: "active",
138
159
  decision,
@@ -141,9 +162,14 @@ export async function planCollectionReview(input = process.cwd(), options = {})
141
162
  reviewedOn,
142
163
  collectionRevision: assessment.collectionRevision,
143
164
  scopeRevision,
144
- ...(decision === "externally-managed" ? { authoritativeSystemId } : {})
165
+ ...(decision === "externally-managed"
166
+ ? { [v4 ? "authoritativeComponentId" : "authoritativeSystemId"]: authoritativeSourceId }
167
+ : {})
145
168
  };
146
- if (decision !== "externally-managed") delete record.authoritativeSystemId;
169
+ if (decision !== "externally-managed") {
170
+ delete record.authoritativeSystemId;
171
+ delete record.authoritativeComponentId;
172
+ }
147
173
  return {
148
174
  operation: "collection-review",
149
175
  resourceType,
@@ -170,10 +196,30 @@ export async function applyCollectionReview(input = process.cwd(), options = {})
170
196
  return {
171
197
  ...plan,
172
198
  result,
173
- assessment: assessCollectionReview(loaded, plan.resourceType)
199
+ assessment: assessCollectionReview(loaded, plan.resourceType, { programId: options.programId })
174
200
  };
175
201
  }
176
202
 
203
+ function scopedCollectionRecords(loaded, resourceType, program) {
204
+ if (String(loaded.model.modelVersion) !== "4") {
205
+ return loaded.resources.filter((record) => record.type === resourceType);
206
+ }
207
+ const components = programComponents(loaded, program);
208
+ const componentIds = new Set(components.map(({ id }) => id));
209
+ const selected = {
210
+ system: new Set(program.systemIds || []),
211
+ component: componentIds,
212
+ framework: new Set(program.frameworkIds || []),
213
+ vendor: new Set(components.map(({ vendorId }) => vendorId).filter(Boolean)),
214
+ asset: new Set(loaded.resources.filter((record) => (
215
+ record.type === "asset" && (record.componentIds || []).some((id) => componentIds.has(id))
216
+ )).map(({ id }) => id))
217
+ }[resourceType];
218
+ return loaded.resources.filter((record) => (
219
+ record.type === resourceType && (!selected || selected.has(record.id))
220
+ ));
221
+ }
222
+
177
223
  function requiredType(loaded, value) {
178
224
  const resourceType = String(value || "").trim();
179
225
  if (!loaded.model.collectionReviews?.[resourceType]) {
@@ -218,9 +218,12 @@ export async function prepareEvidencePacket(input, options = {}) {
218
218
  return sourceRevisionValidity.get(revision);
219
219
  };
220
220
  const evidence = [...evidenceIds].map((id) => evidenceSummary(byId.get(id), byId, revisionIsValid)).filter(Boolean).sort(byTitle);
221
+ const v4 = String(loaded.model.modelVersion) === "4";
221
222
  const sourceSystemIds = new Set([
222
- ...(audit?.systemIds || []),
223
- ...evidence.map((item) => item.sourceSystemId).filter(Boolean)
223
+ ...(v4
224
+ ? [...controlIds].flatMap((id) => byId.get(id)?.evidenceSourceComponentIds || [])
225
+ : audit?.systemIds || []),
226
+ ...evidence.map((item) => item.sourceComponentId || item.sourceSystemId).filter(Boolean)
224
227
  ]);
225
228
  const sourceSystems = [...sourceSystemIds]
226
229
  .map((id) => sourceSystemSummary(byId.get(id), evidence, audit))
@@ -351,7 +354,7 @@ export async function prepareEvidencePacket(input, options = {}) {
351
354
  controls: controlIds.size,
352
355
  requirements: requirementIds.size,
353
356
  systems: audit?.systemIds?.length || 0,
354
- sourceSystems: sourceSystems.length,
357
+ [v4 ? "sourceComponents" : "sourceSystems"]: sourceSystems.length,
355
358
  obligationOccurrences: obligations.length,
356
359
  eventRuns: eventRuns.length,
357
360
  evidence: evidence.length,
@@ -367,7 +370,8 @@ export async function prepareEvidencePacket(input, options = {}) {
367
370
  obligations,
368
371
  eventRuns,
369
372
  evidence,
370
- sourceSystems,
373
+ [v4 ? "sourceComponents" : "sourceSystems"]: sourceSystems,
374
+ dataModelVersion: String(loaded.model.modelVersion),
371
375
  populations,
372
376
  managementPreparation,
373
377
  controlCoverage,
@@ -412,6 +416,11 @@ function recordRelevantToAudit(record, audit, byId, seen = new Set()) {
412
416
  const auditSystems = new Set(audit.systemIds || []);
413
417
  const recordSystems = new Set([...(record.systemIds || []), record.systemId, record.sourceSystemId].filter(Boolean));
414
418
  if (recordSystems.size && [...recordSystems].some((id) => auditSystems.has(id))) return true;
419
+ const auditComponents = new Set([...auditSystems].flatMap((systemId) => [...byId.values()]
420
+ .filter((candidate) => candidate.type === "component" && (candidate.systemUses || []).some((use) => use.systemId === systemId))
421
+ .map(({ id }) => id)));
422
+ const recordComponents = new Set([...(record.componentIds || []), record.componentId, record.sourceComponentId].filter(Boolean));
423
+ if (recordComponents.size && [...recordComponents].some((id) => auditComponents.has(id))) return true;
415
424
  const auditControls = new Set(audit.controlIds || []);
416
425
  const recordControls = controlIdsForRecord(record, byId);
417
426
  if (recordControls.size && [...recordControls].some((id) => auditControls.has(id))) return true;
@@ -454,7 +463,7 @@ function expandEvidenceWorkflowContext(selectedIds, byId) {
454
463
  for (let index = 0; index < queue.length; index += 1) {
455
464
  const record = byId.get(queue[index]);
456
465
  enqueue(childrenBySource.get(record?.id));
457
- if (record?.type === "evidence") enqueue([...(record.sourceResourceIds || []), record.sourceSystemId]);
466
+ if (record?.type === "evidence") enqueue([...(record.sourceResourceIds || []), record.sourceComponentId, record.sourceSystemId]);
458
467
  if (record?.type === "audit-population") enqueue([record.sourceEvidenceId, ...(record.controlIds || [])]);
459
468
  if (record?.type === "control-test") enqueue([record.populationId, ...(record.sampleEvidenceIds || [])]);
460
469
  if (record?.type === "action-item") {
@@ -505,8 +514,8 @@ export async function writeEvidencePacket(input, packet, options = {}) {
505
514
  await writePacketFile(output, "index.html", packetHtml(packet), files);
506
515
  await writePacketFile(output, "control-matrix.csv", controlMatrixCsv(packet), files);
507
516
  await writePacketFile(output, "evidence-index.csv", evidenceIndexCsv(packet), files);
508
- await writePacketFile(output, "source-system-index.csv", sourceSystemIndexCsv(packet), files);
509
- await writePacketFile(output, "external-evidence-index.csv", externalEvidenceIndexCsv(packet), files);
517
+ await writePacketFile(output, packet.dataModelVersion === "4" ? "source-component-index.csv" : "source-system-index.csv", sourceSystemIndexCsv(packet), files);
518
+ await writePacketFile(output, packet.dataModelVersion === "4" ? "evidence-artifact-index.csv" : "external-evidence-index.csv", externalEvidenceIndexCsv(packet), files);
510
519
  await writePacketFile(output, "population-index.csv", populationIndexCsv(packet), files);
511
520
  await writePacketFile(output, "HANDLING.md", packetHandlingMarkdown(packet), files);
512
521
  for (const item of packet.records) {
@@ -587,7 +596,7 @@ async function writeChecksums(output, files) {
587
596
 
588
597
  function controlMatrixCsv(packet) {
589
598
  return csv([
590
- ["Control ID", "Code", "Control", "Control Statement", "Operating Activity", "Status", "Effective On", "Operation Pattern", "Operation Mode", "System IDs", "Requirement IDs", "Policy IDs", "Risk IDs", "filegrc Evidence IDs", "External Evidence IDs", "Control Test IDs", "Test Outcomes", "Population IDs", "Population Counts", "Sample Sizes", "Exception Counts", "Population Evidence IDs", "Sample Evidence IDs"],
599
+ ["Control ID", "Code", "Control", "Control Statement", "Operating Activity", "Status", "Effective On", "Operation Pattern", "Operation Mode", "System IDs", "Requirement IDs", "Policy IDs", "Risk IDs", "filegrc Evidence IDs", packet.dataModelVersion === "4" ? "Evidence Artifact IDs" : "External Evidence IDs", "Control Test IDs", "Test Outcomes", "Population IDs", "Population Counts", "Sample Sizes", "Exception Counts", "Population Evidence IDs", "Sample Evidence IDs"],
591
600
  ...packet.controlCoverage.map((control) => [
592
601
  control.id,
593
602
  control.code,
@@ -636,16 +645,17 @@ function packetHandlingMarkdown(packet) {
636
645
  }
637
646
 
638
647
  function evidenceIndexCsv(packet) {
648
+ const v4 = packet.dataModelVersion === "4";
639
649
  return csv([
640
- ["Evidence ID", "Evidence", "Status", "Kind", "Source", "Source System ID", "Source System", "Collected On", "Collector IDs", "Verified On", "Verifier IDs", "Period Start", "Period End", "Generated At", "Timezone", "Query or Report Parameters", "Population Count", "Completeness Validation", "Accuracy Validation", "Control IDs", "Source Resource IDs", "Source Commit", "File Paths", "External Reference"],
650
+ ["Evidence ID", v4 ? "Evidence Artifact" : "Evidence", "Status", "Kind", "Source", v4 ? "Source Component ID" : "Source System ID", v4 ? "Source Component" : "Source System", "Collected On", "Collector IDs", "Verified On", "Verifier IDs", "Period Start", "Period End", "Generated At", "Timezone", "Query or Report Parameters", "Population Count", "Completeness Validation", "Accuracy Validation", "Control IDs", "Source Resource IDs", "Source Commit", "File Paths", "External Reference"],
641
651
  ...packet.evidence.map((item) => [
642
652
  item.id,
643
653
  item.title,
644
654
  item.status,
645
655
  item.artifactKind,
646
656
  item.sourceDescription,
647
- item.sourceSystemId,
648
- item.sourceSystem,
657
+ v4 ? item.sourceComponentId : item.sourceSystemId,
658
+ v4 ? item.sourceComponent : item.sourceSystem,
649
659
  item.collectedOn,
650
660
  item.collectorIds.join("\n"),
651
661
  item.verifiedOn,
@@ -668,9 +678,10 @@ function evidenceIndexCsv(packet) {
668
678
  }
669
679
 
670
680
  function sourceSystemIndexCsv(packet) {
681
+ const v4 = packet.dataModelVersion === "4";
671
682
  return csv([
672
- ["System ID", "System", "Status", "Evidence Source Roles", "Evidence Access Owner IDs", "Vendor ID", "In Audit Scope", "Evidence IDs"],
673
- ...packet.sourceSystems.map((item) => [
683
+ [v4 ? "Component ID" : "System ID", v4 ? "Component" : "System", "Status", "Evidence Source Roles", "Evidence Access Owner IDs", "Vendor ID", v4 ? "Supports Audit System" : "In Audit Scope", "Evidence IDs"],
684
+ ...(packet.sourceComponents || packet.sourceSystems || []).map((item) => [
674
685
  item.id,
675
686
  item.title,
676
687
  item.status,
@@ -684,15 +695,16 @@ function sourceSystemIndexCsv(packet) {
684
695
  }
685
696
 
686
697
  function externalEvidenceIndexCsv(packet) {
698
+ const v4 = packet.dataModelVersion === "4";
687
699
  return csv([
688
- ["Evidence ID", "Evidence", "Source System ID", "Source System", "Control IDs", "External Reference", "Fixed Attachment Included", "Delivery Note"],
700
+ ["Evidence ID", v4 ? "Evidence Artifact" : "Evidence", v4 ? "Source Component ID" : "Source System ID", v4 ? "Source Component" : "Source System", "Control IDs", "External Reference", "Fixed Attachment Included", "Delivery Note"],
689
701
  ...packet.evidence
690
702
  .filter((item) => item.externalReference)
691
703
  .map((item) => [
692
704
  item.id,
693
705
  item.title,
694
- item.sourceSystemId,
695
- item.sourceSystem,
706
+ v4 ? item.sourceComponentId : item.sourceSystemId,
707
+ v4 ? item.sourceComponent : item.sourceSystem,
696
708
  item.controlIds.join("\n"),
697
709
  JSON.stringify(item.externalReference),
698
710
  item.filePaths.length ? "yes" : "no",
@@ -704,8 +716,9 @@ function externalEvidenceIndexCsv(packet) {
704
716
  }
705
717
 
706
718
  function populationIndexCsv(packet) {
719
+ const v4 = packet.dataModelVersion === "4";
707
720
  return csv([
708
- ["Population ID", "Population", "Kind", "Status", "Period Start", "Period End", "Source System ID", "Source System", "Authoritative Source", "Query or Report Parameters", "Timezone", "Generated At", "Record Count", "Completeness Validation", "Accuracy Validation", "Reconciled By", "Reconciled On", "Conclusion", "Control IDs", "Evidence ID", "Not Applicable Reason"],
721
+ ["Population ID", "Population", "Kind", "Status", "Period Start", "Period End", v4 ? "Source Component ID" : "Source System ID", v4 ? "Source Component" : "Source System", "Authoritative Source", "Query or Report Parameters", "Timezone", "Generated At", "Record Count", "Completeness Validation", "Accuracy Validation", "Reconciled By", "Reconciled On", "Conclusion", "Control IDs", "Evidence ID", "Not Applicable Reason"],
709
722
  ...packet.populations.map((item) => [
710
723
  item.id,
711
724
  item.title,
@@ -713,8 +726,8 @@ function populationIndexCsv(packet) {
713
726
  item.status,
714
727
  item.periodStart,
715
728
  item.periodEnd,
716
- item.sourceSystemId,
717
- item.sourceSystem,
729
+ v4 ? item.sourceComponentId : item.sourceSystemId,
730
+ v4 ? item.sourceComponent : item.sourceSystem,
718
731
  item.sourceDescription,
719
732
  item.queryDescription,
720
733
  item.timezone,
@@ -1018,7 +1031,7 @@ function packetGaps({
1018
1031
  }
1019
1032
  }
1020
1033
  if (controlNeedsExternalEvidence(control, model) && !coverage.evidenceIds.length) {
1021
- gaps.push(gap("error", "control-missing-external-evidence", `${coverage.code || coverage.title} relies on an external system but has no linked External Evidence in the packet.`, coverage.id));
1034
+ gaps.push(gap("error", "control-missing-external-evidence", `${coverage.code || coverage.title} relies on an external source but has no linked ${String(model.modelVersion) === "4" ? "Evidence Artifact" : "External Evidence"} in the packet.`, coverage.id));
1022
1035
  } else if (!controlNeedsExternalEvidence(control, model) && !coverage.operatingRecordIds.length) {
1023
1036
  gaps.push(gap("error", "control-missing-filegrc-evidence", `${coverage.code || coverage.title} has no dated filegrc operating record in the packet.`, coverage.id));
1024
1037
  }
@@ -1219,10 +1232,12 @@ function packetGaps({
1219
1232
  const dateLabel = audit?.auditKind === "soc-2-type-1" ? `the ${start} as-of date` : `${start} through ${end}`;
1220
1233
  gaps.push(gap("error", "evidence-outside-engagement-date", `${item.title} is linked to a control but does not cover ${dateLabel}.`, item.id));
1221
1234
  }
1222
- if (item.sourceSystemId) {
1223
- const sourceSystem = byId.get(item.sourceSystemId);
1224
- if (!sourceSystem || sourceSystem.type !== "system") {
1225
- gaps.push(gap("error", "evidence-source-system-missing", `${item.title} does not resolve to a cataloged source system.`, item.id));
1235
+ if (item.sourceComponentId || item.sourceSystemId) {
1236
+ const sourceId = item.sourceComponentId || item.sourceSystemId;
1237
+ const sourceSystem = byId.get(sourceId);
1238
+ const expectedType = item.sourceComponentId ? "component" : "system";
1239
+ if (!sourceSystem || sourceSystem.type !== expectedType) {
1240
+ gaps.push(gap("error", "evidence-source-missing", `${item.title} does not resolve to a cataloged source ${expectedType}.`, item.id));
1226
1241
  } else {
1227
1242
  if (!["active", "deprecated"].includes(sourceSystem.status)) {
1228
1243
  gaps.push(gap("warning", "evidence-source-system-inactive", `${item.title} came from ${sourceSystem.title}, which is ${sourceSystem.status}. Confirm that this was the authoritative source when the evidence was generated.`, item.id));
@@ -1232,7 +1247,7 @@ function packetGaps({
1232
1247
  }
1233
1248
  }
1234
1249
  } else if (["population-export", "system-export", "configuration-export"].includes(item.artifactKind)) {
1235
- gaps.push(gap("error", "evidence-source-system-unrecorded", `${item.title} is a source-system export but does not link the cataloged system of record.`, item.id));
1250
+ gaps.push(gap("error", "evidence-source-system-unrecorded", `${item.title} is a source export but does not link the cataloged ${String(model.modelVersion) === "4" ? "source Component" : "system of record"}.`, item.id));
1236
1251
  }
1237
1252
  if (item.externalReference && !item.filePaths.length) {
1238
1253
  gaps.push(gap("warning", "external-only-evidence", `${item.title} relies on an external reference and is not self-contained in the packet.`, item.id));
@@ -1433,6 +1448,8 @@ function evidenceSummary(record, byId, revisionIsValid) {
1433
1448
  capture: record.capture || null,
1434
1449
  sourceSystemId: record.sourceSystemId || null,
1435
1450
  sourceSystem: byId.get(record.sourceSystemId)?.title || null,
1451
+ sourceComponentId: record.sourceComponentId || null,
1452
+ sourceComponent: byId.get(record.sourceComponentId)?.title || null,
1436
1453
  collectorIds: record.collectorIds || [],
1437
1454
  verifierIds: record.verifierIds || [],
1438
1455
  verifiedOn: record.verifiedOn || null,
@@ -1449,6 +1466,7 @@ function evidenceSummary(record, byId, revisionIsValid) {
1449
1466
  function populationSummary(record, byId) {
1450
1467
  const evidence = byId.get(record.sourceEvidenceId);
1451
1468
  const sourceSystemId = record.sourceSystemId || evidence?.sourceSystemId || null;
1469
+ const sourceComponentId = record.sourceComponentId || evidence?.sourceComponentId || null;
1452
1470
  return {
1453
1471
  id: record.id,
1454
1472
  title: record.title,
@@ -1461,6 +1479,8 @@ function populationSummary(record, byId) {
1461
1479
  controlIds: record.controlIds || [],
1462
1480
  sourceSystemId,
1463
1481
  sourceSystem: byId.get(sourceSystemId)?.title || null,
1482
+ sourceComponentId,
1483
+ sourceComponent: byId.get(sourceComponentId)?.title || null,
1464
1484
  sourceEvidenceId: record.sourceEvidenceId || null,
1465
1485
  source: evidence?.sourceDescription || null,
1466
1486
  populationCount: evidence?.populationCount ?? null,
@@ -1477,7 +1497,7 @@ function populationSummary(record, byId) {
1477
1497
  }
1478
1498
 
1479
1499
  function sourceSystemSummary(record, evidence, audit) {
1480
- if (!record || record.type !== "system") return null;
1500
+ if (!record || !["system", "component"].includes(record.type)) return null;
1481
1501
  return {
1482
1502
  id: record.id,
1483
1503
  title: record.title,
@@ -1485,8 +1505,11 @@ function sourceSystemSummary(record, evidence, audit) {
1485
1505
  evidenceSourceKinds: record.evidenceSourceKinds || [],
1486
1506
  evidenceOwnerIds: record.evidenceOwnerIds || [],
1487
1507
  vendorId: record.vendorId || null,
1488
- inAuditScope: (audit?.systemIds || []).includes(record.id),
1489
- evidenceIds: evidence.filter((item) => item.sourceSystemId === record.id).map((item) => item.id)
1508
+ inAuditScope: record.type === "system"
1509
+ ? (audit?.systemIds || []).includes(record.id)
1510
+ : (record.systemUses || []).some(({ systemId }) => (audit?.systemIds || []).includes(systemId)),
1511
+ evidenceIds: evidence.filter((item) => (item.sourceComponentId || item.sourceSystemId) === record.id).map((item) => item.id),
1512
+ resourceType: record.type
1490
1513
  };
1491
1514
  }
1492
1515
 
@@ -1545,13 +1568,15 @@ function populationGaps(gaps, audit, populations, byId, model) {
1545
1568
  if (population.reconciledOn && generatedOn && population.reconciledOn < generatedOn) {
1546
1569
  gaps.push(gap("error", "population-reconciled-before-generation", `${population.title} was reconciled before its population export was generated.`, population.id));
1547
1570
  }
1548
- if (!population.sourceSystemId) {
1549
- gaps.push(gap("error", "population-source-system-missing", `${population.title} does not identify its authoritative source system.`, population.id));
1550
- } else if (evidence.sourceSystemId !== population.sourceSystemId) {
1551
- gaps.push(gap("error", "population-source-system-mismatch", `${population.title} and ${evidence.title} do not name the same authoritative source system.`, population.id));
1571
+ const populationSourceId = population.sourceComponentId || population.sourceSystemId;
1572
+ const evidenceSourceId = evidence.sourceComponentId || evidence.sourceSystemId;
1573
+ if (!populationSourceId) {
1574
+ gaps.push(gap("error", "population-source-missing", `${population.title} does not identify its authoritative source Component.`, population.id));
1575
+ } else if (evidenceSourceId !== populationSourceId) {
1576
+ gaps.push(gap("error", "population-source-mismatch", `${population.title} and ${evidence.title} do not name the same authoritative source Component.`, population.id));
1552
1577
  }
1553
1578
  const template = expected.find((item) => item.kind === population.populationKind);
1554
- const sourceSystem = byId.get(population.sourceSystemId);
1579
+ const sourceSystem = byId.get(populationSourceId);
1555
1580
  if (template?.sourceKind && sourceSystem && !(sourceSystem.evidenceSourceKinds || []).includes(template.sourceKind)) {
1556
1581
  gaps.push(gap("error", "population-source-role-mismatch", `${sourceSystem.title} is not cataloged for the ${displaySourceKind(template.sourceKind)} evidence role required by ${population.title}.`, sourceSystem.id));
1557
1582
  }
@@ -1594,6 +1619,7 @@ function recordSummary(record) {
1594
1619
  }
1595
1620
 
1596
1621
  function packetMarkdown(packet) {
1622
+ const v4 = packet.dataModelVersion === "4";
1597
1623
  const readiness = packet.readiness.status === "delivery-ready"
1598
1624
  ? "filegrc management checks passed. The engagement team still determines whether the evidence is sufficient and appropriate."
1599
1625
  : `${packet.readiness.errors} errors and ${packet.readiness.warnings} warnings require review. This is a draft packet.`;
@@ -1618,15 +1644,17 @@ function packetMarkdown(packet) {
1618
1644
  `- ${packet.summary.filegrcRecords} filegrc Evidence records`,
1619
1645
  `- ${packet.summary.obligationOccurrences} recurring obligation occurrences`,
1620
1646
  `- ${packet.summary.eventRuns} event runs`,
1621
- `- ${packet.summary.evidence} External Evidence records`,
1647
+ `- ${packet.summary.evidence} ${v4 ? "Evidence Artifact" : "External Evidence"} records`,
1622
1648
  `- ${packet.summary.populations} reconciled or planned populations`,
1623
1649
  `- ${packet.summary.policies} policies`,
1624
1650
  `- ${packet.summary.controls} controls`,
1625
1651
  `- ${packet.summary.requirements} criteria`,
1626
1652
  `- ${packet.summary.systems} in-scope systems`,
1627
- `- ${packet.summary.sourceSystems} cataloged source systems`,
1653
+ `- ${v4 ? packet.summary.sourceComponents : packet.summary.sourceSystems} cataloged source ${v4 ? "Components" : "Systems"}`,
1628
1654
  "",
1629
- "Open `index.html` for the auditor-oriented index. `control-matrix.csv` cross-references criteria, controls, filegrc Evidence, External Evidence, and tests. `source-system-index.csv` identifies the systems of record used to produce External Evidence. `external-evidence-index.csv` lists material that must be delivered or accessed outside this packet. For Type 2, `population-index.csv` records management's population reconciliation and fixed source exports. filegrc records, governed Markdown, fixed attachments, and committed historical versions are included in their respective directories.",
1655
+ v4
1656
+ ? "Open `index.html` for the auditor-oriented index. `control-matrix.csv` cross-references criteria, Controls, filegrc Evidence, Evidence Artifacts, and tests. `source-component-index.csv` identifies the Components used to produce Evidence. `evidence-artifact-index.csv` lists material that must be delivered or accessed outside this packet. For Type 2, `population-index.csv` records management's population reconciliation and fixed source exports. FileGRC records, governed Markdown, fixed attachments, and committed historical versions are included in their respective directories."
1657
+ : "Open `index.html` for the auditor-oriented index. `control-matrix.csv` cross-references criteria, controls, filegrc Evidence, External Evidence, and tests. `source-system-index.csv` identifies the systems of record used to produce External Evidence. `external-evidence-index.csv` lists material that must be delivered or accessed outside this packet. For Type 2, `population-index.csv` records management's population reconciliation and fixed source exports. filegrc records, governed Markdown, fixed attachments, and committed historical versions are included in their respective directories.",
1630
1658
  "",
1631
1659
  "After transfer, enter the packet directory and run `shasum -a 256 -c SHA256SUMS` or `sha256sum -c SHA256SUMS`. The checksum file covers every other packet file.",
1632
1660
  ""
@@ -1635,6 +1663,8 @@ function packetMarkdown(packet) {
1635
1663
  }
1636
1664
 
1637
1665
  function packetHtml(packet) {
1666
+ const v4 = packet.dataModelVersion === "4";
1667
+ const artifactLabel = v4 ? "Evidence Artifact" : "External Evidence";
1638
1668
  const section = (title, body) => `<section><h2>${escapeHtml(title)}</h2>${body}</section>`;
1639
1669
  const links = (items) => items.length
1640
1670
  ? `<ul>${items.map((item) => `<li><a href="records/${encodeURIComponent(item.type)}/${encodeURIComponent(item.id)}.json">${escapeHtml(item.title)}</a><small>${escapeHtml(item.type)}</small></li>`).join("")}</ul>`
@@ -1652,11 +1682,14 @@ function packetHtml(packet) {
1652
1682
  ? `<table><thead><tr><th>Obligation</th><th>Allowed window</th><th>Status</th></tr></thead><tbody>${packet.obligations.map((item) => `<tr><td>${escapeHtml(item.title)}</td><td>${item.dueWindowStart} through ${item.dueWindowEnd}<br><small>Overdue ${item.overdueOn}</small></td><td>${escapeHtml(item.status)}</td></tr>`).join("")}</tbody></table>`
1653
1683
  : "<p>No recurring occurrences intersect this period.</p>";
1654
1684
  const evidence = packet.evidence.length
1655
- ? `<table><thead><tr><th>External Evidence</th><th>Source and period</th><th>Controls</th><th>Files</th></tr></thead><tbody>${packet.evidence.map((item) => `<tr><td><a href="records/evidence/${encodeURIComponent(item.id)}.json">${escapeHtml(item.title)}</a><small>${escapeHtml(item.status)} · ${escapeHtml(item.artifactKind)}</small></td><td>${escapeHtml(item.sourceDescription)}<small>${escapeHtml(item.periodStart || item.collectedOn)}${item.periodEnd ? ` through ${escapeHtml(item.periodEnd)}` : ""}</small></td><td>${item.controlIds.map(escapeHtml).join("<br>") || "None"}</td><td>${item.filePaths.map((path) => `<a class="attachment" href="attachments/${path.split("/").map(encodeURIComponent).join("/")}">${escapeHtml(basename(path))}</a>`).join("") || "No fixed attachment"}</td></tr>`).join("")}</tbody></table>`
1656
- : "<p>No External Evidence records were selected.</p>";
1657
- const sourceSystems = packet.sourceSystems.length
1658
- ? `<p><a href="source-system-index.csv">Download source system index CSV</a></p><table><thead><tr><th>System of record</th><th>Evidence roles</th><th>Audit relationship</th><th>Evidence</th></tr></thead><tbody>${packet.sourceSystems.map((item) => `<tr><td><a href="records/system/${encodeURIComponent(item.id)}.json">${escapeHtml(item.title)}</a><small>${escapeHtml(item.status)}</small></td><td>${item.evidenceSourceKinds.map(escapeHtml).join("<br>") || "No evidence role recorded"}</td><td>${item.inAuditScope ? "In-scope system" : "Evidence source"}</td><td>${item.evidenceIds.length}</td></tr>`).join("")}</tbody></table><p><a href="external-evidence-index.csv">Download external evidence delivery index CSV</a></p>`
1659
- : "<p>No source systems were cataloged.</p>";
1685
+ ? `<table><thead><tr><th>${artifactLabel}</th><th>Source and period</th><th>Controls</th><th>Files</th></tr></thead><tbody>${packet.evidence.map((item) => `<tr><td><a href="records/evidence/${encodeURIComponent(item.id)}.json">${escapeHtml(item.title)}</a><small>${escapeHtml(item.status)} · ${escapeHtml(item.artifactKind)}</small></td><td>${escapeHtml(item.sourceDescription)}<small>${escapeHtml(item.periodStart || item.collectedOn)}${item.periodEnd ? ` through ${escapeHtml(item.periodEnd)}` : ""}</small></td><td>${item.controlIds.map(escapeHtml).join("<br>") || "None"}</td><td>${item.filePaths.map((path) => `<a class="attachment" href="attachments/${path.split("/").map(encodeURIComponent).join("/")}">${escapeHtml(basename(path))}</a>`).join("") || "No fixed attachment"}</td></tr>`).join("")}</tbody></table>`
1686
+ : `<p>No ${artifactLabel} records were selected.</p>`;
1687
+ const packetSources = packet.sourceComponents || packet.sourceSystems || [];
1688
+ const sourceIndex = v4 ? "source-component-index.csv" : "source-system-index.csv";
1689
+ const artifactIndex = v4 ? "evidence-artifact-index.csv" : "external-evidence-index.csv";
1690
+ const sourceSystems = packetSources.length
1691
+ ? `<p><a href="${sourceIndex}">Download source ${v4 ? "Component" : "System"} index CSV</a></p><table><thead><tr><th>${v4 ? "Source Component" : "System of record"}</th><th>Evidence roles</th><th>Audit relationship</th><th>Evidence</th></tr></thead><tbody>${packetSources.map((item) => `<tr><td><a href="records/${encodeURIComponent(item.resourceType)}/${encodeURIComponent(item.id)}.json">${escapeHtml(item.title)}</a><small>${escapeHtml(item.status)}</small></td><td>${item.evidenceSourceKinds.map(escapeHtml).join("<br>") || "No evidence role recorded"}</td><td>${item.inAuditScope ? (v4 ? "Supports an in-scope System" : "In-scope system") : "Evidence source"}</td><td>${item.evidenceIds.length}</td></tr>`).join("")}</tbody></table><p><a href="${artifactIndex}">Download ${artifactLabel} delivery index CSV</a></p>`
1692
+ : `<p>No source ${v4 ? "Components" : "Systems"} were cataloged.</p>`;
1660
1693
  const populations = packet.populations.length
1661
1694
  ? `<p><a href="population-index.csv">Download population index CSV</a></p><table><thead><tr><th>Population</th><th>Period and source</th><th>Count</th><th>Reconciliation</th></tr></thead><tbody>${packet.populations.map((item) => `<tr><td><a href="records/audit-population/${encodeURIComponent(item.id)}.json">${escapeHtml(item.title)}</a><small>${escapeHtml(item.status)} · ${escapeHtml(item.populationKind)}</small></td><td>${escapeHtml(item.periodStart)} through ${escapeHtml(item.periodEnd)}<small>${escapeHtml(item.source || "No authoritative source recorded")}</small></td><td>${item.populationCount ?? "Not recorded"}</td><td>${escapeHtml(item.conclusion || item.notApplicableReason || "Not complete")}</td></tr>`).join("")}</tbody></table>`
1662
1695
  : "<p>No audit populations were selected.</p>";
@@ -1674,7 +1707,7 @@ function packetHtml(packet) {
1674
1707
  }).join("")}</tbody></table>`
1675
1708
  : "<p>No filegrc Evidence records matched this period.</p>";
1676
1709
  const controlCoverage = packet.controlCoverage.length
1677
- ? `<p><a href="control-matrix.csv">Download control matrix CSV</a></p><table><thead><tr><th>Control</th><th>Status and scope</th><th>Criteria</th><th>filegrc Evidence</th><th>External Evidence</th><th>Tests</th></tr></thead><tbody>${packet.controlCoverage.map((control) => `<tr><td><a href="records/control/${encodeURIComponent(control.id)}.json">${escapeHtml(control.code || control.id)}</a><small>${escapeHtml(control.title)}</small></td><td>${escapeHtml(control.status)}<small>${control.systemIds.map(escapeHtml).join(", ") || "No system scope"}</small></td><td>${control.requirementIds.map(escapeHtml).join("<br>") || "None"}</td><td>${control.operatingRecordIds.length}</td><td>${control.evidenceIds.length}</td><td>${control.tests.length}</td></tr>`).join("")}</tbody></table>`
1710
+ ? `<p><a href="control-matrix.csv">Download control matrix CSV</a></p><table><thead><tr><th>Control</th><th>Status and scope</th><th>Criteria</th><th>filegrc Evidence</th><th>${artifactLabel}</th><th>Tests</th></tr></thead><tbody>${packet.controlCoverage.map((control) => `<tr><td><a href="records/control/${encodeURIComponent(control.id)}.json">${escapeHtml(control.code || control.id)}</a><small>${escapeHtml(control.title)}</small></td><td>${escapeHtml(control.status)}<small>${control.systemIds.map(escapeHtml).join(", ") || "No system scope"}</small></td><td>${control.requirementIds.map(escapeHtml).join("<br>") || "None"}</td><td>${control.operatingRecordIds.length}</td><td>${control.evidenceIds.length}</td><td>${control.tests.length}</td></tr>`).join("")}</tbody></table>`
1678
1711
  : "<p>No controls were selected.</p>";
1679
1712
  const readinessLabel = packet.readiness.status === "delivery-ready" ? "filegrc management checks passed" : "Draft, do not deliver";
1680
1713
  const packetDate = packet.period.basis === "as-of"
@@ -1682,7 +1715,7 @@ function packetHtml(packet) {
1682
1715
  : `${escapeHtml(packet.period.start)} through ${escapeHtml(packet.period.end)}`;
1683
1716
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Evidence packet</title><style>
1684
1717
  body{font:14px/1.5 system-ui,sans-serif;color:#161825;max-width:1120px;margin:auto;padding:40px;background:#f7f8fc}header,section{background:#fff;border:1px solid #dfe3ef;border-radius:10px;padding:24px;margin:14px 0}h1,h2{margin-top:0}h1{font-size:26px}h2{font-size:17px}ul{padding-left:20px}li{margin:8px 0}small{display:block;color:#656c7e}.attachment{margin-right:10px;font-size:12px}.error{color:#8a2f28}.warning{color:#76500d}.readiness{display:inline-block;padding:5px 9px;border-radius:999px;background:#f7e4e2;color:#7a2520;font-weight:700}.readiness.ready{background:#e2f1e8;color:#245d3b}table{width:100%;border-collapse:collapse}th,td{padding:9px;border:1px solid #dfe3ef;text-align:left;vertical-align:top}code{overflow-wrap:anywhere}dl{display:grid;grid-template-columns:max-content 1fr;gap:8px 16px}dt{font-weight:700}dd{margin:0}
1685
- </style></head><body><header><p>SOC 2 evidence packet</p><span class="readiness ${packet.readiness.status === "delivery-ready" ? "ready" : ""}">${escapeHtml(readinessLabel)}</span><h1>${packetDate}</h1><p>${escapeHtml(packet.workspace.organizationName)} · revision <code>${escapeHtml(packet.revision.commit || "uncommitted")}</code></p></header>${section("Engagement scope", engagement)}${section("Review status", gaps)}${section("Control coverage", controlCoverage)}${section("Systems of record", sourceSystems)}${packet.period.basis === "period" ? section("Management population reconciliation", populations) : ""}${packet.period.basis === "period" ? section("Recurring obligation coverage", obligations) : ""}${packet.period.basis === "period" ? section("Event workflow coverage", eventRuns) : ""}${section("Policies", links(packet.policies))}${section("filegrc Evidence", filegrcRecords)}${section("External Evidence", evidence)}${section("Integrity and history", "<p>Verify all transferred files with <code>SHA256SUMS</code>. Committed prior versions are under <code>history/</code> with an index that records their source paths and Git metadata.</p>")}</body></html>`;
1718
+ </style></head><body><header><p>SOC 2 evidence packet</p><span class="readiness ${packet.readiness.status === "delivery-ready" ? "ready" : ""}">${escapeHtml(readinessLabel)}</span><h1>${packetDate}</h1><p>${escapeHtml(packet.workspace.organizationName)} · revision <code>${escapeHtml(packet.revision.commit || "uncommitted")}</code></p></header>${section("Engagement scope", engagement)}${section("Review status", gaps)}${section("Control coverage", controlCoverage)}${section(v4 ? "Source Components" : "Systems of record", sourceSystems)}${packet.period.basis === "period" ? section("Management population reconciliation", populations) : ""}${packet.period.basis === "period" ? section("Recurring obligation coverage", obligations) : ""}${packet.period.basis === "period" ? section("Event workflow coverage", eventRuns) : ""}${section("Policies", links(packet.policies))}${section("filegrc Evidence", filegrcRecords)}${section(v4 ? "Evidence Artifacts" : "External Evidence", evidence)}${section("Integrity and history", "<p>Verify all transferred files with <code>SHA256SUMS</code>. Committed prior versions are under <code>history/</code> with an index that records their source paths and Git metadata.</p>")}</body></html>`;
1686
1719
  }
1687
1720
 
1688
1721
  async function writePacketFile(output, relativePath, source, files) {
@@ -5,8 +5,8 @@ import { loadWorkspace } from "./workspace.js";
5
5
 
6
6
  export async function scaffoldExternalReviewerGovernance(input = process.cwd()) {
7
7
  const loaded = await loadWorkspace(input);
8
- if (String(loaded.model.modelVersion) !== "3") {
9
- throw new Error("External reviewer setup requires a model v3 workspace.");
8
+ if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
9
+ throw new Error("External reviewer setup requires a model v3 or v4 workspace.");
10
10
  }
11
11
  return {
12
12
  reviewerName: null,
@@ -22,8 +22,8 @@ export async function scaffoldExternalReviewerGovernance(input = process.cwd())
22
22
 
23
23
  export async function planExternalReviewerGovernance(input = process.cwd(), options = {}) {
24
24
  const loaded = await loadWorkspace(input);
25
- if (String(loaded.model.modelVersion) !== "3") {
26
- throw new Error("External reviewer setup requires a model v3 workspace.");
25
+ if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
26
+ throw new Error("External reviewer setup requires a model v3 or v4 workspace.");
27
27
  }
28
28
  const name = required(options.reviewerName, "External reviewer name");
29
29
  const startsOn = required(options.startsOn, "Appointment start date");