filegrc 0.7.0 → 0.8.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,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { createReadStream } from "node:fs";
3
3
  import { copyFile, mkdir, readFile, readdir, readlink, rm, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { modelSupports } from "../model/index.js";
5
6
  import { assessAuditPreparation } from "./audit-preparation.js";
6
7
  import {
7
8
  coverageContains,
@@ -10,13 +11,26 @@ import {
10
11
  coverageOverlaps,
11
12
  coverageStart
12
13
  } from "./coverage.js";
13
- import { getFileAtRevision, getGitSummary, getWorkspaceHistories, hasGitRevision } from "./git.js";
14
+ import { getFilesAtRevisions, getGitSummary, getWorkspaceHistories, getWorkspaceRevisionSnapshot, hasGitRevision } from "./git.js";
14
15
  import { planObligations } from "./obligations.js";
15
- import { isWithin, resolveDataPath, resolveWorkspacePath } from "./paths.js";
16
+ import { isWithin, resolveDataPath, resolveWorkspacePath, resolveWorkspaceRoot } from "./paths.js";
16
17
  import { parseCalendarDate } from "./recurrence.js";
17
18
  import { markdownEntries } from "./resource-markdown.js";
18
19
  import { serializeWorkspaceMutation } from "./mutation.js";
20
+ import {
21
+ auditorWasEngaged,
22
+ missingSoc2References,
23
+ recordWasInUseDuringAudit,
24
+ REQUIRED_SOC2_DESCRIPTION_REFERENCES,
25
+ REQUIRED_SOC2_SECURITY_REFERENCES,
26
+ signatoryAppointmentIssue,
27
+ soc2ReportEvidenceIssue,
28
+ subsequentEventsReviewIssue
29
+ } from "./soc2.js";
19
30
  import { validateWorkspace } from "./validate.js";
31
+ import { measureTiming } from "./timing.js";
32
+
33
+ const preparedPacketValidations = new WeakMap();
20
34
 
21
35
  const NON_EVIDENCE_RECORD_TYPES = new Set([
22
36
  "appointment",
@@ -42,7 +56,6 @@ const NON_EVIDENCE_RECORD_TYPES = new Set([
42
56
  "vendor",
43
57
  "workspace"
44
58
  ]);
45
-
46
59
  export async function prepareEvidencePacket(input, options = {}) {
47
60
  const validation = await validateWorkspace(input);
48
61
  if (!validation.ok) throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before generating evidence.`);
@@ -99,7 +112,9 @@ export async function prepareEvidencePacket(input, options = {}) {
99
112
  ...(audit.controlIds || []),
100
113
  ...(audit.contactIds || []),
101
114
  ...(audit.complementaryControlIds || []),
102
- ...(audit.subserviceVendorIds || []),
115
+ ...auditSubserviceVendorIds(audit),
116
+ ...auditSubserviceComponentIds(audit),
117
+ audit.engagementTermsDocumentId,
103
118
  audit.systemDescriptionDocumentId,
104
119
  audit.managementAssertionDocumentId,
105
120
  audit.managementRepresentationDocumentId,
@@ -218,7 +233,7 @@ export async function prepareEvidencePacket(input, options = {}) {
218
233
  return sourceRevisionValidity.get(revision);
219
234
  };
220
235
  const evidence = [...evidenceIds].map((id) => evidenceSummary(byId.get(id), byId, revisionIsValid)).filter(Boolean).sort(byTitle);
221
- const v4 = String(loaded.model.modelVersion) === "4";
236
+ const v4 = modelSupports(loaded.model, "component-sources");
222
237
  const sourceSystemIds = new Set([
223
238
  ...(v4
224
239
  ? [...controlIds].flatMap((id) => byId.get(id)?.evidenceSourceComponentIds || [])
@@ -234,8 +249,13 @@ export async function prepareEvidencePacket(input, options = {}) {
234
249
  `data/${entry.relativePath}`,
235
250
  ...markdownEntries(loaded.model, entry.record).map((markdown) => `data/${markdown.path}`)
236
251
  ]);
237
- const historyRevision = getGitSummary(loaded.root);
238
- const histories = getWorkspaceHistories(loaded.root, selectedPaths, Number.MAX_SAFE_INTEGER);
252
+ const historyRevision = await getWorkspaceRevisionSnapshot(loaded.root);
253
+ const histories = getWorkspaceHistories(
254
+ loaded.root,
255
+ selectedPaths,
256
+ Number.MAX_SAFE_INTEGER,
257
+ { strict: Boolean(historyRevision.commit) }
258
+ );
239
259
  const packetRecords = [...selectedIds]
240
260
  .map((id) => byId.get(id))
241
261
  .filter(Boolean)
@@ -290,6 +310,7 @@ export async function prepareEvidencePacket(input, options = {}) {
290
310
  generatedAt,
291
311
  selectDefault: false
292
312
  });
313
+ const documentLifecycles = auditDocumentLifecycleSummaries(audit, loaded.model, byId);
293
314
  const gaps = packetGaps({
294
315
  audit,
295
316
  byId,
@@ -308,7 +329,7 @@ export async function prepareEvidencePacket(input, options = {}) {
308
329
  });
309
330
  const errorCount = gaps.filter(({ severity }) => severity === "error").length;
310
331
  const warningCount = gaps.filter(({ severity }) => severity === "warning").length;
311
- return {
332
+ const packet = {
312
333
  schemaVersion: 1,
313
334
  generatedAt,
314
335
  period: { start, end, basis },
@@ -327,7 +348,9 @@ export async function prepareEvidencePacket(input, options = {}) {
327
348
  systemIds: audit.systemIds || [],
328
349
  requirementIds: audit.requirementIds || [],
329
350
  controlIds: audit.controlIds || [],
330
- subserviceMethod: audit.subserviceMethod || null
351
+ subserviceMethod: auditSubserviceLabel(audit),
352
+ subserviceConclusion: audit.subserviceConclusion || null,
353
+ subserviceTreatments: audit.subserviceTreatments || []
331
354
  } : null,
332
355
  workspace: {
333
356
  title: loaded.workspace.title,
@@ -359,6 +382,7 @@ export async function prepareEvidencePacket(input, options = {}) {
359
382
  eventRuns: eventRuns.length,
360
383
  evidence: evidence.length,
361
384
  populations: populations.length,
385
+ ...(modelSupports(loaded.model, "governed-document-activation") ? { documents: documentLifecycles.length } : {}),
362
386
  gaps: gaps.length,
363
387
  errors: errorCount,
364
388
  warnings: warningCount
@@ -373,11 +397,64 @@ export async function prepareEvidencePacket(input, options = {}) {
373
397
  [v4 ? "sourceComponents" : "sourceSystems"]: sourceSystems,
374
398
  dataModelVersion: String(loaded.model.modelVersion),
375
399
  populations,
400
+ ...(modelSupports(loaded.model, "governed-document-activation") ? { documentLifecycles } : {}),
376
401
  managementPreparation,
377
402
  controlCoverage,
378
403
  gaps,
379
404
  records: packetRecords
380
405
  };
406
+ preparedPacketValidations.set(packet, {
407
+ validation,
408
+ revision: {
409
+ commit: packet.revision.commit,
410
+ branch: packet.revision.branch,
411
+ dataDigest: packet.revision.dataDigest
412
+ }
413
+ });
414
+ return packet;
415
+ }
416
+
417
+ function auditDocumentLifecycleSummaries(audit, model, byId) {
418
+ if (!audit || !modelSupports(model, "governed-document-activation")) return [];
419
+ const rolesById = new Map();
420
+ const add = (id, role) => {
421
+ if (!id) return;
422
+ const roles = rolesById.get(id) || [];
423
+ roles.push(role);
424
+ rolesById.set(id, roles);
425
+ };
426
+ add(audit.engagementTermsDocumentId, "Engagement Terms");
427
+ for (const definition of model.auditReadiness?.managementDocuments || []) {
428
+ if (!(definition.engagementKinds || []).includes(audit.auditKind)) continue;
429
+ add(audit[definition.field], definition.title || definition.field);
430
+ }
431
+ for (const id of audit.supplementalDocumentIds || []) add(id, "Supplemental");
432
+ return [...rolesById]
433
+ .map(([id, roles]) => {
434
+ const document = byId.get(id);
435
+ if (document?.type !== "document") return null;
436
+ const personLabels = (ids = []) => ids.map((personId) => byId.get(personId)?.title || personId);
437
+ return {
438
+ id: document.id,
439
+ title: document.title,
440
+ workflowScope: document.workflowScope || null,
441
+ roles: [...new Set(roles)],
442
+ status: document.status,
443
+ ownerIds: document.ownerIds || [],
444
+ approverIds: document.approverIds || [],
445
+ approvers: personLabels(document.approverIds),
446
+ approvedOn: document.approvedOn || null,
447
+ approvedContentRevisions: document.approvedContentRevisions || null,
448
+ activationBasis: document.activationBasis || null,
449
+ activatedByIds: document.activatedByIds || [],
450
+ activators: personLabels(document.activatedByIds),
451
+ activatedOn: document.activatedOn || null,
452
+ activatedContentRevisions: document.activatedContentRevisions || null,
453
+ effectiveOn: document.effectiveOn || null
454
+ };
455
+ })
456
+ .filter(Boolean)
457
+ .sort(byTitle);
381
458
  }
382
459
 
383
460
  function expandSupersededPolicyIds(policyIds, byId) {
@@ -403,7 +480,9 @@ function recordRelevantToAudit(record, audit, byId, seen = new Set()) {
403
480
  ...(audit.controlIds || []),
404
481
  ...(audit.contactIds || []),
405
482
  ...(audit.complementaryControlIds || []),
406
- ...(audit.subserviceVendorIds || []),
483
+ ...auditSubserviceVendorIds(audit),
484
+ ...auditSubserviceComponentIds(audit),
485
+ audit.engagementTermsDocumentId,
407
486
  audit.systemDescriptionDocumentId,
408
487
  audit.managementAssertionDocumentId,
409
488
  audit.managementRepresentationDocumentId,
@@ -483,7 +562,17 @@ export async function writeEvidencePacket(input, packet, options = {}) {
483
562
  let outputOption = options.output || `.filegrc/evidence-packets/${baseName}`;
484
563
  requireDerivedOutputPath(outputOption);
485
564
  let output = resolveWorkspacePath(input, outputOption);
486
- const validation = await validateWorkspace(input);
565
+ const prepared = preparedPacketValidations.get(packet);
566
+ const preparedValidation = prepared
567
+ && prepared.validation.loaded.root === resolveWorkspaceRoot(input)
568
+ && prepared.revision.commit === packet.revision?.commit
569
+ && prepared.revision.branch === packet.revision?.branch
570
+ && prepared.revision.dataDigest === packet.revision?.dataDigest
571
+ ? prepared.validation
572
+ : null;
573
+ const validation = preparedValidation
574
+ ? preparedValidation
575
+ : await validateWorkspace(input);
487
576
  if (!validation.ok) throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before writing evidence.`);
488
577
  await assertPacketSourceState(packet, validation.loaded);
489
578
  const entriesById = new Map(validation.loaded.entries.map((entry) => [entry.record.id, entry]));
@@ -513,9 +602,12 @@ export async function writeEvidencePacket(input, packet, options = {}) {
513
602
  await writePacketFile(output, "README.md", packetMarkdown(packet), files);
514
603
  await writePacketFile(output, "index.html", packetHtml(packet), files);
515
604
  await writePacketFile(output, "control-matrix.csv", controlMatrixCsv(packet), files);
605
+ if (modelSupports(packet.dataModelVersion, "governed-document-activation")) {
606
+ await writePacketFile(output, "document-lifecycle-index.csv", documentLifecycleIndexCsv(packet), files);
607
+ }
516
608
  await writePacketFile(output, "evidence-index.csv", evidenceIndexCsv(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);
609
+ await writePacketFile(output, modelSupports(packet.dataModelVersion, "component-sources") ? "source-component-index.csv" : "source-system-index.csv", sourceSystemIndexCsv(packet), files);
610
+ await writePacketFile(output, modelSupports(packet.dataModelVersion, "evidence-artifacts") ? "evidence-artifact-index.csv" : "external-evidence-index.csv", externalEvidenceIndexCsv(packet), files);
519
611
  await writePacketFile(output, "population-index.csv", populationIndexCsv(packet), files);
520
612
  await writePacketFile(output, "HANDLING.md", packetHandlingMarkdown(packet), files);
521
613
  for (const item of packet.records) {
@@ -542,12 +634,28 @@ export async function writeEvidencePacket(input, packet, options = {}) {
542
634
  }
543
635
  }
544
636
  const historyIndex = [];
637
+ const historicalFiles = [];
545
638
  for (const item of packet.records) {
546
- await exportCommittedVersions(validation.loaded.root, output, item, item.path, item.history, historyIndex, files);
639
+ collectCommittedVersions(historicalFiles, item, item.path, item.history);
547
640
  for (const content of item.contentPaths || []) {
548
- await exportCommittedVersions(validation.loaded.root, output, item, content.path, content.history, historyIndex, files);
641
+ collectCommittedVersions(historicalFiles, item, content.path, content.history);
549
642
  }
550
643
  }
644
+ const historicalSources = getFilesAtRevisions(validation.loaded.root, historicalFiles);
645
+ for (let index = 0; index < historicalFiles.length; index += 1) {
646
+ const source = historicalSources[index];
647
+ if (source === null) continue;
648
+ const { item, sourcePath, history } = historicalFiles[index];
649
+ const exportedPath = join("history", item.type, item.id, history.commit, basename(sourcePath));
650
+ await writePacketFile(output, exportedPath, source, files);
651
+ historyIndex.push({
652
+ resourceId: item.id,
653
+ resourceType: item.type,
654
+ sourcePath,
655
+ exportedPath: exportedPath.split("\\").join("/"),
656
+ ...history
657
+ });
658
+ }
551
659
  await writePacketFile(output, "history/index.json", `${JSON.stringify(historyIndex, null, 2)}\n`, files);
552
660
  await assertPacketSourceState(packet, validation.loaded);
553
661
  await writeChecksums(output, files);
@@ -568,35 +676,34 @@ export function generateEvidencePacket(input, options = {}) {
568
676
  });
569
677
  }
570
678
 
571
- async function exportCommittedVersions(root, output, item, sourcePath, history, historyIndex, files) {
679
+ function collectCommittedVersions(target, item, sourcePath, history) {
572
680
  for (const revision of history || []) {
573
- const source = getFileAtRevision(root, revision.commit, sourcePath);
574
- if (source === null) continue;
575
- const exportedPath = join("history", item.type, item.id, revision.commit, basename(sourcePath));
576
- await writePacketFile(output, exportedPath, source, files);
577
- historyIndex.push({
578
- resourceId: item.id,
579
- resourceType: item.type,
580
- sourcePath,
581
- exportedPath: exportedPath.split("\\").join("/"),
582
- ...revision
583
- });
681
+ target.push({ item, sourcePath, relativePath: sourcePath, revision: revision.commit, history: revision });
584
682
  }
585
683
  }
586
684
 
587
685
  async function writeChecksums(output, files) {
588
- const lines = [];
589
- for (const relativePath of [...files].sort()) {
590
- const hash = createHash("sha256");
591
- for await (const chunk of createReadStream(resolvePacketOutputPath(output, relativePath))) hash.update(chunk);
592
- lines.push(`${hash.digest("hex")} ${relativePath}`);
593
- }
594
- await writeFile(resolvePacketOutputPath(output, "SHA256SUMS"), `${lines.join("\n")}\n`, { encoding: "utf8", flag: "wx" });
686
+ await measureTiming("packet-output-hash", async () => {
687
+ const paths = [...files].sort();
688
+ const lines = new Array(paths.length);
689
+ let next = 0;
690
+ const workers = Array.from({ length: Math.min(4, paths.length) }, async () => {
691
+ while (next < paths.length) {
692
+ const index = next++;
693
+ const relativePath = paths[index];
694
+ const hash = createHash("sha256");
695
+ for await (const chunk of createReadStream(resolvePacketOutputPath(output, relativePath))) hash.update(chunk);
696
+ lines[index] = `${hash.digest("hex")} ${relativePath}`;
697
+ }
698
+ });
699
+ await Promise.all(workers);
700
+ await writeFile(resolvePacketOutputPath(output, "SHA256SUMS"), `${lines.join("\n")}\n`, { encoding: "utf8", flag: "wx" });
701
+ });
595
702
  }
596
703
 
597
704
  function controlMatrixCsv(packet) {
598
705
  return csv([
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"],
706
+ ["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", modelSupports(packet.dataModelVersion, "evidence-artifacts") ? "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"],
600
707
  ...packet.controlCoverage.map((control) => [
601
708
  control.id,
602
709
  control.code,
@@ -625,6 +732,30 @@ function controlMatrixCsv(packet) {
625
732
  ]);
626
733
  }
627
734
 
735
+ function documentLifecycleIndexCsv(packet) {
736
+ return csv([
737
+ ["Document ID", "Document", "Workflow Scope", "Audit Roles", "Status", "Owner IDs", "Approver IDs", "Approvers", "Approved On", "Approved Content Revisions", "Activation Basis", "Activator IDs", "Activators", "Activated On", "Activation Content Revisions", "Effective On"],
738
+ ...packet.documentLifecycles.map((document) => [
739
+ document.id,
740
+ document.title,
741
+ document.workflowScope,
742
+ document.roles.join("\n"),
743
+ document.status,
744
+ document.ownerIds.join("\n"),
745
+ document.approverIds.join("\n"),
746
+ document.approvers.join("\n"),
747
+ document.approvedOn,
748
+ document.approvedContentRevisions ? JSON.stringify(document.approvedContentRevisions) : "",
749
+ document.activationBasis,
750
+ document.activatedByIds.join("\n"),
751
+ document.activators.join("\n"),
752
+ document.activatedOn,
753
+ document.activatedContentRevisions ? JSON.stringify(document.activatedContentRevisions) : "",
754
+ document.effectiveOn
755
+ ])
756
+ ]);
757
+ }
758
+
628
759
  function packetHandlingMarkdown(packet) {
629
760
  return [
630
761
  "# Packet Handling",
@@ -645,7 +776,7 @@ function packetHandlingMarkdown(packet) {
645
776
  }
646
777
 
647
778
  function evidenceIndexCsv(packet) {
648
- const v4 = packet.dataModelVersion === "4";
779
+ const v4 = modelSupports(packet.dataModelVersion, "component-sources");
649
780
  return csv([
650
781
  ["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"],
651
782
  ...packet.evidence.map((item) => [
@@ -678,7 +809,7 @@ function evidenceIndexCsv(packet) {
678
809
  }
679
810
 
680
811
  function sourceSystemIndexCsv(packet) {
681
- const v4 = packet.dataModelVersion === "4";
812
+ const v4 = modelSupports(packet.dataModelVersion, "component-sources");
682
813
  return csv([
683
814
  [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
815
  ...(packet.sourceComponents || packet.sourceSystems || []).map((item) => [
@@ -695,7 +826,7 @@ function sourceSystemIndexCsv(packet) {
695
826
  }
696
827
 
697
828
  function externalEvidenceIndexCsv(packet) {
698
- const v4 = packet.dataModelVersion === "4";
829
+ const v4 = modelSupports(packet.dataModelVersion, "component-sources");
699
830
  return csv([
700
831
  ["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"],
701
832
  ...packet.evidence
@@ -716,7 +847,7 @@ function externalEvidenceIndexCsv(packet) {
716
847
  }
717
848
 
718
849
  function populationIndexCsv(packet) {
719
- const v4 = packet.dataModelVersion === "4";
850
+ const v4 = modelSupports(packet.dataModelVersion, "component-sources");
720
851
  return csv([
721
852
  ["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"],
722
853
  ...packet.populations.map((item) => [
@@ -769,7 +900,7 @@ function requireDerivedOutputPath(value) {
769
900
  async function assertPacketSourceState(packet, loaded) {
770
901
  await assertLoadedEntriesCurrent(loaded);
771
902
  const dataDigest = await dataTreeDigest(loaded.root);
772
- const git = getGitSummary(loaded.root);
903
+ const git = await getWorkspaceRevisionSnapshot(loaded.root);
773
904
  if (
774
905
  packet.revision?.dataDigest !== dataDigest
775
906
  || packet.revision?.commit !== git.commit
@@ -789,36 +920,38 @@ async function assertLoadedEntriesCurrent(loaded) {
789
920
  }
790
921
 
791
922
  async function dataTreeDigest(root) {
792
- const hash = createHash("sha256");
793
- updateDigestField(hash, "filegrc-data-tree-v1");
794
- const visit = async (directory, prefix = "") => {
795
- const entries = await readdir(directory, { withFileTypes: true });
796
- entries.sort((a, b) => a.name.localeCompare(b.name));
797
- for (const entry of entries) {
798
- const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
799
- const path = join(directory, entry.name);
800
- if (entry.isDirectory()) {
801
- updateDigestField(hash, "directory");
802
- updateDigestField(hash, relativePath);
803
- await visit(path, relativePath);
804
- } else if (entry.isFile()) {
805
- const fileHash = createHash("sha256");
806
- for await (const chunk of createReadStream(path)) fileHash.update(chunk);
807
- updateDigestField(hash, "file");
808
- updateDigestField(hash, relativePath);
809
- updateDigestField(hash, fileHash.digest("hex"));
810
- } else if (entry.isSymbolicLink()) {
811
- updateDigestField(hash, "symlink");
812
- updateDigestField(hash, relativePath);
813
- updateDigestField(hash, await readlink(path));
814
- } else {
815
- updateDigestField(hash, "other");
816
- updateDigestField(hash, relativePath);
923
+ return measureTiming("packet-data-hash", async () => {
924
+ const hash = createHash("sha256");
925
+ updateDigestField(hash, "filegrc-data-tree-v1");
926
+ const visit = async (directory, prefix = "") => {
927
+ const entries = await readdir(directory, { withFileTypes: true });
928
+ entries.sort((a, b) => a.name.localeCompare(b.name));
929
+ for (const entry of entries) {
930
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
931
+ const path = join(directory, entry.name);
932
+ if (entry.isDirectory()) {
933
+ updateDigestField(hash, "directory");
934
+ updateDigestField(hash, relativePath);
935
+ await visit(path, relativePath);
936
+ } else if (entry.isFile()) {
937
+ const fileHash = createHash("sha256");
938
+ for await (const chunk of createReadStream(path)) fileHash.update(chunk);
939
+ updateDigestField(hash, "file");
940
+ updateDigestField(hash, relativePath);
941
+ updateDigestField(hash, fileHash.digest("hex"));
942
+ } else if (entry.isSymbolicLink()) {
943
+ updateDigestField(hash, "symlink");
944
+ updateDigestField(hash, relativePath);
945
+ updateDigestField(hash, await readlink(path));
946
+ } else {
947
+ updateDigestField(hash, "other");
948
+ updateDigestField(hash, relativePath);
949
+ }
817
950
  }
818
- }
819
- };
820
- await visit(resolveDataPath(root, "."));
821
- return `sha256:${hash.digest("hex")}`;
951
+ };
952
+ await visit(resolveDataPath(root, "."));
953
+ return `sha256:${hash.digest("hex")}`;
954
+ });
822
955
  }
823
956
 
824
957
  function updateDigestField(hash, value) {
@@ -993,7 +1126,7 @@ function packetGaps({
993
1126
  if (!audit) {
994
1127
  gaps.push(gap("error", "missing-audit-scope", "Select an audit record before treating this packet as an auditor delivery."));
995
1128
  } else {
996
- auditGaps(gaps, audit, byId, records, start, end);
1129
+ auditGaps(gaps, audit, byId, records, start, end, model);
997
1130
  }
998
1131
  for (const stage of managementPreparation?.stages || []) {
999
1132
  for (const item of stage.items.filter((entry) => ["action", "later"].includes(entry.status))) {
@@ -1031,7 +1164,7 @@ function packetGaps({
1031
1164
  }
1032
1165
  }
1033
1166
  if (controlNeedsExternalEvidence(control, model) && !coverage.evidenceIds.length) {
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));
1167
+ gaps.push(gap("error", "control-missing-external-evidence", `${coverage.code || coverage.title} relies on an external source but has no linked ${modelSupports(model, "evidence-artifacts") ? "Evidence Artifact" : "External Evidence"} in the packet.`, coverage.id));
1035
1168
  } else if (!controlNeedsExternalEvidence(control, model) && !coverage.operatingRecordIds.length) {
1036
1169
  gaps.push(gap("error", "control-missing-filegrc-evidence", `${coverage.code || coverage.title} has no dated filegrc operating record in the packet.`, coverage.id));
1037
1170
  }
@@ -1089,7 +1222,7 @@ function packetGaps({
1089
1222
 
1090
1223
  for (const requirementId of requirementIds) {
1091
1224
  const requirement = byId.get(requirementId);
1092
- if (!requirement || requirement.applicability !== "applicable" || isDescriptionRequirement(requirement)) continue;
1225
+ if (!requirement || !requirementIsApplicable(requirement, audit, byId, model) || isDescriptionRequirement(requirement)) continue;
1093
1226
  const mapped = controlCoverage.some((coverage) => coverage.requirementIds.includes(requirementId));
1094
1227
  if (!mapped) gaps.push(gap("error", "requirement-missing-control", `${requirement.reference || requirement.title} has no control in the packet.`, requirementId));
1095
1228
  }
@@ -1120,11 +1253,11 @@ function packetGaps({
1120
1253
  if (!recentAssessment) {
1121
1254
  gaps.push(gap("error", "missing-risk-assessment", `No completed in-scope risk assessment was found in the year ending ${end}.`, audit.id));
1122
1255
  }
1123
- for (const vendorId of audit.subserviceVendorIds || []) {
1256
+ for (const vendorId of auditSubserviceVendorIds(audit)) {
1124
1257
  const vendor = byId.get(vendorId);
1125
1258
  if (!vendor) continue;
1126
- if (vendor.status !== "active") {
1127
- gaps.push(gap("error", "inactive-subservice-organization", `${vendor.title} is in audit scope but is ${vendor.status}.`, vendor.id));
1259
+ if (!recordWasInUseDuringAudit(vendor, start, end)) {
1260
+ gaps.push(gap("error", "inactive-subservice-organization", `${vendor.title} was not in use during the engagement period.`, vendor.id));
1128
1261
  }
1129
1262
  const reviews = records.filter((record) => (
1130
1263
  record.type === "vendor-review"
@@ -1247,7 +1380,7 @@ function packetGaps({
1247
1380
  }
1248
1381
  }
1249
1382
  } else if (["population-export", "system-export", "configuration-export"].includes(item.artifactKind)) {
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));
1383
+ gaps.push(gap("error", "evidence-source-system-unrecorded", `${item.title} is a source export but does not link the cataloged ${modelSupports(model, "component-sources") ? "source Component" : "system of record"}.`, item.id));
1251
1384
  }
1252
1385
  if (item.externalReference && !item.filePaths.length) {
1253
1386
  gaps.push(gap("warning", "external-only-evidence", `${item.title} relies on an external reference and is not self-contained in the packet.`, item.id));
@@ -1302,7 +1435,11 @@ function controlNeedsExternalEvidence(control, model) {
1302
1435
  return !families.length || families.some((family) => family.filegrcManaged !== true);
1303
1436
  }
1304
1437
 
1305
- function auditGaps(gaps, audit, byId, records, start, end) {
1438
+ function auditGaps(gaps, audit, byId, records, start, end, model) {
1439
+ const program = byId.get(audit.programId);
1440
+ if (modelSupports(model, "program-scope") && program?.assuranceGoal !== audit.auditKind) {
1441
+ gaps.push(gap("error", "audit-program-goal-mismatch", `${audit.title} is ${audit.auditKind}, but its Program goal is ${program?.assuranceGoal || "missing"}. Align the management objective with the formal engagement before delivery.`, audit.id));
1442
+ }
1306
1443
  if (!["soc-2-type-1", "soc-2-type-2"].includes(audit.auditKind)) {
1307
1444
  gaps.push(gap("error", "not-soc2-examination", `${audit.title} is ${audit.auditKind}; a delivery packet requires a SOC 2 Type 1 or Type 2 engagement.`, audit.id));
1308
1445
  } else if (audit.auditKind === "soc-2-type-1") {
@@ -1321,34 +1458,118 @@ function auditGaps(gaps, audit, byId, records, start, end) {
1321
1458
  if (!(audit.requirementIds || []).length) gaps.push(gap("error", "audit-requirements-missing", `${audit.title} has no selected criteria.`, audit.id));
1322
1459
  if (!(audit.controlIds || []).length) gaps.push(gap("error", "audit-controls-missing", `${audit.title} has no selected controls.`, audit.id));
1323
1460
  const selectedRequirements = (audit.requirementIds || []).map((id) => byId.get(id)).filter(Boolean);
1324
- if (!selectedRequirements.some(isDescriptionRequirement)) {
1461
+ const frameworkRequirements = records.filter((record) => (
1462
+ record.type === "requirement" && (audit.frameworkIds || []).includes(record.frameworkId)
1463
+ ));
1464
+ const descriptionRequirements = frameworkRequirements.filter(isDescriptionRequirement);
1465
+ const missingDescriptionRequirements = descriptionRequirements.filter((requirement) => (
1466
+ !(audit.requirementIds || []).includes(requirement.id)
1467
+ ));
1468
+ const missingRequiredDescriptionReferences = modelSupports(model, "program-scope")
1469
+ ? missingSoc2References(descriptionRequirements, REQUIRED_SOC2_DESCRIPTION_REFERENCES)
1470
+ : [];
1471
+ const securityRequirements = frameworkRequirements.filter(isSecurityRequirement);
1472
+ const missingRequiredSecurityReferences = modelSupports(model, "program-scope")
1473
+ ? missingSoc2References(securityRequirements, REQUIRED_SOC2_SECURITY_REFERENCES)
1474
+ : [];
1475
+ const missingSelectedSecurityReferences = modelSupports(model, "program-scope")
1476
+ ? missingSoc2References(selectedRequirements.filter(isSecurityRequirement), REQUIRED_SOC2_SECURITY_REFERENCES)
1477
+ : [];
1478
+ if (!descriptionRequirements.length) {
1325
1479
  gaps.push(gap("error", "audit-description-criteria-missing", `${audit.title} does not include the SOC 2 description criteria.`, audit.id));
1480
+ } else if (missingDescriptionRequirements.length) {
1481
+ gaps.push(gap("error", "audit-description-criteria-incomplete", `${audit.title} omits ${missingDescriptionRequirements.length} ${missingDescriptionRequirements.length === 1 ? "criterion" : "criteria"} from the selected SOC 2 Description Criteria framework.`, audit.id));
1482
+ } else if (missingRequiredDescriptionReferences.length) {
1483
+ gaps.push(gap("error", "audit-description-criteria-incomplete", `${audit.title} omits ${missingRequiredDescriptionReferences.join(", ")} from the required DC1 through DC9 Description Criteria set.`, audit.id));
1484
+ }
1485
+ if (missingRequiredSecurityReferences.length) {
1486
+ gaps.push(gap("error", "audit-security-criteria-incomplete", `${audit.title}'s selected framework omits ${missingRequiredSecurityReferences.join(", ")} from the required CC1.1 through CC9.2 Security Common Criteria set.`, audit.id));
1487
+ }
1488
+ if (missingSelectedSecurityReferences.length) {
1489
+ gaps.push(gap("error", "audit-security-criteria-incomplete", `${audit.title} omits ${missingSelectedSecurityReferences.join(", ")} from the mandatory Security Common Criteria selected for the engagement.`, audit.id));
1326
1490
  }
1327
1491
  for (const requirement of selectedRequirements) {
1328
- if (!(audit.frameworkIds || []).includes(requirement.frameworkId) || requirement.applicability !== "applicable") {
1492
+ if (!(audit.frameworkIds || []).includes(requirement.frameworkId) || !requirementIsApplicable(requirement, audit, byId, model)) {
1329
1493
  gaps.push(gap("error", "audit-criteria-scope-conflict", `${requirement.reference || requirement.title} is selected but is not an applicable member of the selected frameworks.`, requirement.id));
1330
1494
  }
1331
1495
  }
1332
- if (!audit.auditorVendorId) gaps.push(gap("error", "auditor-missing", `${audit.title} does not identify the independent CPA firm.`, audit.id));
1333
- if (!audit.subserviceMethod) gaps.push(gap("error", "subservice-method-missing", `${audit.title} does not state whether subservice organizations use the carve-out or inclusive method, or are not applicable.`, audit.id));
1334
- if ((audit.subserviceVendorIds || []).length && audit.subserviceMethod === "not-applicable") {
1335
- gaps.push(gap("error", "subservice-scope-conflict", `${audit.title} names subservice organizations but marks their treatment not applicable.`, audit.id));
1496
+ const auditor = audit.auditorVendorId ? byId.get(audit.auditorVendorId) : null;
1497
+ if (!auditor) {
1498
+ gaps.push(gap("error", "auditor-missing", `${audit.title} does not identify the independent CPA firm.`, audit.id));
1499
+ } else if (!auditorWasEngaged(auditor, audit)) {
1500
+ gaps.push(gap("error", "auditor-outside-engagement-period", `${auditor.title} was not active during the recorded fieldwork or report period.`, auditor.id));
1336
1501
  }
1337
- const expectedSubserviceVendorIds = new Set((audit.systemIds || []).flatMap((id) => byId.get(id)?.subserviceVendorIds || []));
1338
- for (const vendorId of expectedSubserviceVendorIds) {
1339
- if (!(audit.subserviceVendorIds || []).includes(vendorId)) {
1340
- gaps.push(gap("error", "subservice-organization-omitted", `${byId.get(vendorId)?.title || vendorId} is identified by an in-scope system but omitted from the engagement's subservice organizations.`, vendorId));
1341
- }
1502
+ if (modelSupports(model, "program-scope") && !audit.scopeRevision) {
1503
+ gaps.push(gap("error", "audit-scope-revision-missing", `${audit.title} does not bind management's reviewed engagement scope to a Git revision.`, audit.id));
1342
1504
  }
1343
- if (audit.subserviceMethod === "inclusive") {
1344
- const subserviceSystemIds = records
1345
- .filter((record) => record.type === "system" && (audit.subserviceVendorIds || []).includes(record.vendorId))
1346
- .map((record) => record.id);
1347
- const includedControls = (audit.controlIds || [])
1348
- .map((id) => byId.get(id))
1349
- .filter((control) => (control?.systemIds || []).some((id) => subserviceSystemIds.includes(id)));
1350
- if (!subserviceSystemIds.length || !includedControls.length) {
1351
- gaps.push(gap("error", "inclusive-subservice-controls-missing", `${audit.title} uses the inclusive method but does not include cataloged subservice systems and their controls.`, audit.id));
1505
+ if (modelSupports(model, "program-scope")) {
1506
+ const treatments = audit.subserviceTreatments || [];
1507
+ const treatmentComponentCounts = new Map();
1508
+ for (const treatment of treatments) {
1509
+ for (const componentId of treatment.componentIds || []) {
1510
+ treatmentComponentCounts.set(componentId, (treatmentComponentCounts.get(componentId) || 0) + 1);
1511
+ }
1512
+ }
1513
+ if (!audit.subserviceConclusion) {
1514
+ gaps.push(gap("error", "subservice-conclusion-missing", `${audit.title} does not state whether subservice organizations are identified.`, audit.id));
1515
+ }
1516
+ if (!audit.subserviceConclusionRationale) {
1517
+ gaps.push(gap("error", "subservice-rationale-missing", `${audit.title} does not explain its subservice conclusion.`, audit.id));
1518
+ }
1519
+ if (audit.subserviceConclusion === "identified" && !treatments.length) {
1520
+ gaps.push(gap("error", "subservice-treatments-missing", `${audit.title} identifies subservice organizations but records no Vendor and Component treatments.`, audit.id));
1521
+ }
1522
+ if (audit.subserviceConclusion === "not-applicable" && treatments.length) {
1523
+ gaps.push(gap("error", "subservice-scope-conflict", `${audit.title} records subservice treatments but marks subservice organizations not applicable.`, audit.id));
1524
+ }
1525
+ for (const treatment of treatments) {
1526
+ const vendor = byId.get(treatment.vendorId);
1527
+ const invalidComponents = (treatment.componentIds || []).filter((componentId) => {
1528
+ const component = byId.get(componentId);
1529
+ return component?.type !== "component"
1530
+ || !recordWasInUseDuringAudit(component, start, end)
1531
+ || component.vendorId !== treatment.vendorId
1532
+ || (treatmentComponentCounts.get(componentId) || 0) > 1
1533
+ || !(component.systemUses || []).some(({ systemId }) => (audit.systemIds || []).includes(systemId));
1534
+ });
1535
+ if (vendor?.type !== "vendor" || !recordWasInUseDuringAudit(vendor, start, end) || !(treatment.componentIds || []).length || invalidComponents.length) {
1536
+ gaps.push(gap(
1537
+ "error",
1538
+ "invalid-subservice-treatment",
1539
+ `${vendor?.title || treatment.vendorId} has a subservice treatment that does not identify that Vendor's supplied Components in use within the selected System boundary during the engagement, or repeats a Component across treatments.`,
1540
+ audit.id
1541
+ ));
1542
+ }
1543
+ }
1544
+ for (const treatment of treatments.filter(({ method }) => method === "inclusive")) {
1545
+ const includedControls = (audit.controlIds || [])
1546
+ .map((id) => byId.get(id))
1547
+ .filter((control) => (control?.componentIds || []).some((id) => (treatment.componentIds || []).includes(id)));
1548
+ if (!includedControls.length) {
1549
+ gaps.push(gap("error", "inclusive-subservice-controls-missing", `${byId.get(treatment.vendorId)?.title || treatment.vendorId} uses the inclusive method but no selected Controls are linked to its included Components.`, audit.id));
1550
+ }
1551
+ }
1552
+ } else {
1553
+ if (!audit.subserviceMethod) gaps.push(gap("error", "subservice-method-missing", `${audit.title} does not state whether subservice organizations use the carve-out or inclusive method, or are not applicable.`, audit.id));
1554
+ if ((audit.subserviceVendorIds || []).length && audit.subserviceMethod === "not-applicable") {
1555
+ gaps.push(gap("error", "subservice-scope-conflict", `${audit.title} names subservice organizations but marks their treatment not applicable.`, audit.id));
1556
+ }
1557
+ const expectedSubserviceVendorIds = new Set((audit.systemIds || []).flatMap((id) => byId.get(id)?.subserviceVendorIds || []));
1558
+ for (const vendorId of expectedSubserviceVendorIds) {
1559
+ if (!(audit.subserviceVendorIds || []).includes(vendorId)) {
1560
+ gaps.push(gap("error", "subservice-organization-omitted", `${byId.get(vendorId)?.title || vendorId} is identified by an in-scope system but omitted from the engagement's subservice organizations.`, vendorId));
1561
+ }
1562
+ }
1563
+ if (audit.subserviceMethod === "inclusive") {
1564
+ const subserviceSystemIds = records
1565
+ .filter((record) => record.type === "system" && (audit.subserviceVendorIds || []).includes(record.vendorId))
1566
+ .map((record) => record.id);
1567
+ const includedControls = (audit.controlIds || [])
1568
+ .map((id) => byId.get(id))
1569
+ .filter((control) => (control?.systemIds || []).some((id) => subserviceSystemIds.includes(id)));
1570
+ if (!subserviceSystemIds.length || !includedControls.length) {
1571
+ gaps.push(gap("error", "inclusive-subservice-controls-missing", `${audit.title} uses the inclusive method but does not include cataloged subservice systems and their controls.`, audit.id));
1572
+ }
1352
1573
  }
1353
1574
  }
1354
1575
  if (!audit.complementaryControlsConclusion) {
@@ -1385,15 +1606,42 @@ function auditGaps(gaps, audit, byId, records, start, end) {
1385
1606
  gaps.push(gap("error", `unapproved-${field}`, `${document.title} is not active with approval and effective dates.`, document.id));
1386
1607
  }
1387
1608
  }
1388
- if (audit.status === "complete") {
1389
- const representation = byId.get(audit.managementRepresentationDocumentId);
1390
- if (!(representation?.evidenceIds || []).length) {
1391
- gaps.push(gap("error", "unsigned-management-representation", `${representation?.title || audit.title} does not link the signed representation letter evidence.`, representation?.id || audit.id));
1609
+ if (["issued", "delivered", "complete"].includes(audit.status)) {
1610
+ const reportEvidence = audit.reportEvidenceId ? byId.get(audit.reportEvidenceId) : null;
1611
+ if (!reportEvidence) {
1612
+ gaps.push(gap("error", "missing-audit-report", `${audit.title} does not link the final service auditor report.`, audit.id));
1613
+ } else {
1614
+ const reportIssue = soc2ReportEvidenceIssue(reportEvidence, audit, model.modelVersion);
1615
+ if (reportIssue) gaps.push(gap("error", reportIssue.code, reportIssue.message, reportEvidence.id));
1392
1616
  }
1393
- if (!audit.reportEvidenceId) gaps.push(gap("error", "missing-audit-report", `${audit.title} does not link the final service auditor report.`, audit.id));
1394
1617
  if (!audit.opinion || audit.opinion === "not-issued" || !audit.opinionDate) {
1395
1618
  gaps.push(gap("error", "missing-audit-opinion", `${audit.title} does not record the issued opinion and opinion date.`, audit.id));
1396
1619
  }
1620
+ }
1621
+ if (modelSupports(model, "program-scope") && ["report-draft", "issued", "delivered", "complete"].includes(audit.status)) {
1622
+ const subsequentEventsIssue = subsequentEventsReviewIssue(audit);
1623
+ if (subsequentEventsIssue) {
1624
+ gaps.push(gap("error", subsequentEventsIssue.code, subsequentEventsIssue.message, audit.id));
1625
+ }
1626
+ const signatoryIssue = signatoryAppointmentIssue(audit, byId);
1627
+ if (signatoryIssue) {
1628
+ gaps.push(gap("error", signatoryIssue.code, signatoryIssue.message, audit.id));
1629
+ }
1630
+ }
1631
+ if (audit.status === "complete") {
1632
+ const representation = byId.get(audit.managementRepresentationDocumentId);
1633
+ const signedRepresentation = (representation?.evidenceIds || [])
1634
+ .map((id) => byId.get(id))
1635
+ .find((record) => (
1636
+ record?.type === "evidence"
1637
+ && record.status === "verified"
1638
+ && record.artifactKind === "signed-record"
1639
+ && record.artifactSubtype === "signed-management-representation"
1640
+ && (record.filePaths || []).length
1641
+ ));
1642
+ if (!signedRepresentation) {
1643
+ gaps.push(gap("error", "unsigned-management-representation", `${representation?.title || audit.title} does not link the signed representation letter evidence.`, representation?.id || audit.id));
1644
+ }
1397
1645
  for (const request of records.filter((record) => record.type === "audit-request" && record.auditId === audit.id)) {
1398
1646
  if (!["accepted", "closed"].includes(request.status)) {
1399
1647
  gaps.push(gap("error", "open-final-audit-request", `${request.title} is ${request.status} after the audit was marked complete.`, request.id));
@@ -1418,6 +1666,39 @@ function isDescriptionRequirement(requirement) {
1418
1666
  return (requirement.tags || []).includes("description-criteria") || /^DC\d+/i.test(requirement.reference || "");
1419
1667
  }
1420
1668
 
1669
+ function isSecurityRequirement(requirement) {
1670
+ const tags = requirement?.tags || [];
1671
+ return tags.includes("security") || tags.includes("common-criteria") || /^CC\d+(?:\.|$)/i.test(requirement?.reference || "");
1672
+ }
1673
+
1674
+ function requirementIsApplicable(requirement, audit, byId, model) {
1675
+ if (!modelSupports(model, "program-scope")) return requirement.applicability === "applicable";
1676
+ const program = audit?.programId ? byId.get(audit.programId) : null;
1677
+ return (program?.requirementApplicability || []).some((decision) => (
1678
+ decision.requirementId === requirement.id && decision.decision === "applicable"
1679
+ ));
1680
+ }
1681
+
1682
+ function auditSubserviceVendorIds(audit) {
1683
+ return [...new Set([
1684
+ ...(audit?.subserviceVendorIds || []),
1685
+ ...(audit?.subserviceTreatments || []).map(({ vendorId }) => vendorId)
1686
+ ].filter(Boolean))];
1687
+ }
1688
+
1689
+ function auditSubserviceComponentIds(audit) {
1690
+ return [...new Set((audit?.subserviceTreatments || []).flatMap(({ componentIds }) => componentIds || []))];
1691
+ }
1692
+
1693
+ function auditSubserviceLabel(audit) {
1694
+ if (audit?.subserviceConclusion === "not-applicable") return "Not applicable";
1695
+ if (audit?.subserviceConclusion === "identified") {
1696
+ const methods = [...new Set((audit.subserviceTreatments || []).map(({ method }) => method))];
1697
+ return methods.length ? methods.join(" and ") : "Identified, treatments incomplete";
1698
+ }
1699
+ return audit?.subserviceMethod || null;
1700
+ }
1701
+
1421
1702
  function shiftYear(value, offset) {
1422
1703
  const date = new Date(`${value}T00:00:00Z`);
1423
1704
  date.setUTCFullYear(date.getUTCFullYear() + offset);
@@ -1619,7 +1900,7 @@ function recordSummary(record) {
1619
1900
  }
1620
1901
 
1621
1902
  function packetMarkdown(packet) {
1622
- const v4 = packet.dataModelVersion === "4";
1903
+ const v4 = modelSupports(packet.dataModelVersion, "component-sources");
1623
1904
  const readiness = packet.readiness.status === "delivery-ready"
1624
1905
  ? "filegrc management checks passed. The engagement team still determines whether the evidence is sufficient and appropriate."
1625
1906
  : `${packet.readiness.errors} errors and ${packet.readiness.warnings} warnings require review. This is a draft packet.`;
@@ -1646,6 +1927,9 @@ function packetMarkdown(packet) {
1646
1927
  `- ${packet.summary.eventRuns} event runs`,
1647
1928
  `- ${packet.summary.evidence} ${v4 ? "Evidence Artifact" : "External Evidence"} records`,
1648
1929
  `- ${packet.summary.populations} reconciled or planned populations`,
1930
+ ...(modelSupports(packet.dataModelVersion, "governed-document-activation")
1931
+ ? [`- ${packet.summary.documents} governed engagement Documents with approval and activation history`]
1932
+ : []),
1649
1933
  `- ${packet.summary.policies} policies`,
1650
1934
  `- ${packet.summary.controls} controls`,
1651
1935
  `- ${packet.summary.requirements} criteria`,
@@ -1653,7 +1937,7 @@ function packetMarkdown(packet) {
1653
1937
  `- ${v4 ? packet.summary.sourceComponents : packet.summary.sourceSystems} cataloged source ${v4 ? "Components" : "Systems"}`,
1654
1938
  "",
1655
1939
  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."
1940
+ ? `Open \`index.html\` for the auditor-oriented index. \`control-matrix.csv\` cross-references criteria, Controls, filegrc Evidence, Evidence Artifacts, and tests.${modelSupports(packet.dataModelVersion, "governed-document-activation") ? " `document-lifecycle-index.csv` lists each engagement Document's approval and activation facts and exact Markdown revisions." : ""} \`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
1941
  : "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.",
1658
1942
  "",
1659
1943
  "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.",
@@ -1663,7 +1947,7 @@ function packetMarkdown(packet) {
1663
1947
  }
1664
1948
 
1665
1949
  function packetHtml(packet) {
1666
- const v4 = packet.dataModelVersion === "4";
1950
+ const v4 = modelSupports(packet.dataModelVersion, "component-sources");
1667
1951
  const artifactLabel = v4 ? "Evidence Artifact" : "External Evidence";
1668
1952
  const section = (title, body) => `<section><h2>${escapeHtml(title)}</h2>${body}</section>`;
1669
1953
  const links = (items) => items.length
@@ -1709,13 +1993,16 @@ function packetHtml(packet) {
1709
1993
  const controlCoverage = packet.controlCoverage.length
1710
1994
  ? `<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>`
1711
1995
  : "<p>No controls were selected.</p>";
1996
+ const documentLifecycles = packet.documentLifecycles?.length
1997
+ ? `<p><a href="document-lifecycle-index.csv">Download Document lifecycle index CSV</a></p><table><thead><tr><th>Document</th><th>Audit role</th><th>Approval</th><th>Activation</th></tr></thead><tbody>${packet.documentLifecycles.map((document) => `<tr><td><a href="records/document/${encodeURIComponent(document.id)}.json">${escapeHtml(document.title)}</a><small>${escapeHtml(document.status)} · ${escapeHtml(document.workflowScope || "scope not recorded")}</small></td><td>${document.roles.map(escapeHtml).join("<br>")}</td><td>${escapeHtml(document.approvedOn || "Not approved")}<small>${document.approvers.map(escapeHtml).join(", ") || "No approver recorded"}</small></td><td>${escapeHtml(document.activatedOn || "Not activated")}<small>${document.activators.map(escapeHtml).join(", ") || "No activator recorded"}${document.effectiveOn ? ` · effective ${escapeHtml(document.effectiveOn)}` : ""}</small></td></tr>`).join("")}</tbody></table>`
1998
+ : "<p>No governed engagement Documents were linked to this audit.</p>";
1712
1999
  const readinessLabel = packet.readiness.status === "delivery-ready" ? "filegrc management checks passed" : "Draft, do not deliver";
1713
2000
  const packetDate = packet.period.basis === "as-of"
1714
2001
  ? `As of ${escapeHtml(packet.period.start)}`
1715
2002
  : `${escapeHtml(packet.period.start)} through ${escapeHtml(packet.period.end)}`;
1716
2003
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Evidence packet</title><style>
1717
2004
  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}
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>`;
2005
+ </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)}${modelSupports(packet.dataModelVersion, "governed-document-activation") ? section("Document lifecycle", documentLifecycles) : ""}${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>`;
1719
2006
  }
1720
2007
 
1721
2008
  async function writePacketFile(output, relativePath, source, files) {