filegrc 0.7.1 → 0.9.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,9 +11,9 @@ 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";
@@ -27,6 +28,9 @@ import {
27
28
  subsequentEventsReviewIssue
28
29
  } from "./soc2.js";
29
30
  import { validateWorkspace } from "./validate.js";
31
+ import { measureTiming } from "./timing.js";
32
+
33
+ const preparedPacketValidations = new WeakMap();
30
34
 
31
35
  const NON_EVIDENCE_RECORD_TYPES = new Set([
32
36
  "appointment",
@@ -110,6 +114,7 @@ export async function prepareEvidencePacket(input, options = {}) {
110
114
  ...(audit.complementaryControlIds || []),
111
115
  ...auditSubserviceVendorIds(audit),
112
116
  ...auditSubserviceComponentIds(audit),
117
+ audit.engagementTermsDocumentId,
113
118
  audit.systemDescriptionDocumentId,
114
119
  audit.managementAssertionDocumentId,
115
120
  audit.managementRepresentationDocumentId,
@@ -228,7 +233,7 @@ export async function prepareEvidencePacket(input, options = {}) {
228
233
  return sourceRevisionValidity.get(revision);
229
234
  };
230
235
  const evidence = [...evidenceIds].map((id) => evidenceSummary(byId.get(id), byId, revisionIsValid)).filter(Boolean).sort(byTitle);
231
- const v4 = String(loaded.model.modelVersion) === "4";
236
+ const v4 = modelSupports(loaded.model, "component-sources");
232
237
  const sourceSystemIds = new Set([
233
238
  ...(v4
234
239
  ? [...controlIds].flatMap((id) => byId.get(id)?.evidenceSourceComponentIds || [])
@@ -244,7 +249,7 @@ export async function prepareEvidencePacket(input, options = {}) {
244
249
  `data/${entry.relativePath}`,
245
250
  ...markdownEntries(loaded.model, entry.record).map((markdown) => `data/${markdown.path}`)
246
251
  ]);
247
- const historyRevision = getGitSummary(loaded.root);
252
+ const historyRevision = await getWorkspaceRevisionSnapshot(loaded.root);
248
253
  const histories = getWorkspaceHistories(
249
254
  loaded.root,
250
255
  selectedPaths,
@@ -305,6 +310,7 @@ export async function prepareEvidencePacket(input, options = {}) {
305
310
  generatedAt,
306
311
  selectDefault: false
307
312
  });
313
+ const documentLifecycles = auditDocumentLifecycleSummaries(audit, loaded.model, byId);
308
314
  const gaps = packetGaps({
309
315
  audit,
310
316
  byId,
@@ -323,7 +329,7 @@ export async function prepareEvidencePacket(input, options = {}) {
323
329
  });
324
330
  const errorCount = gaps.filter(({ severity }) => severity === "error").length;
325
331
  const warningCount = gaps.filter(({ severity }) => severity === "warning").length;
326
- return {
332
+ const packet = {
327
333
  schemaVersion: 1,
328
334
  generatedAt,
329
335
  period: { start, end, basis },
@@ -376,6 +382,7 @@ export async function prepareEvidencePacket(input, options = {}) {
376
382
  eventRuns: eventRuns.length,
377
383
  evidence: evidence.length,
378
384
  populations: populations.length,
385
+ ...(modelSupports(loaded.model, "governed-document-activation") ? { documents: documentLifecycles.length } : {}),
379
386
  gaps: gaps.length,
380
387
  errors: errorCount,
381
388
  warnings: warningCount
@@ -390,11 +397,64 @@ export async function prepareEvidencePacket(input, options = {}) {
390
397
  [v4 ? "sourceComponents" : "sourceSystems"]: sourceSystems,
391
398
  dataModelVersion: String(loaded.model.modelVersion),
392
399
  populations,
400
+ ...(modelSupports(loaded.model, "governed-document-activation") ? { documentLifecycles } : {}),
393
401
  managementPreparation,
394
402
  controlCoverage,
395
403
  gaps,
396
404
  records: packetRecords
397
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);
398
458
  }
399
459
 
400
460
  function expandSupersededPolicyIds(policyIds, byId) {
@@ -422,6 +482,7 @@ function recordRelevantToAudit(record, audit, byId, seen = new Set()) {
422
482
  ...(audit.complementaryControlIds || []),
423
483
  ...auditSubserviceVendorIds(audit),
424
484
  ...auditSubserviceComponentIds(audit),
485
+ audit.engagementTermsDocumentId,
425
486
  audit.systemDescriptionDocumentId,
426
487
  audit.managementAssertionDocumentId,
427
488
  audit.managementRepresentationDocumentId,
@@ -501,7 +562,17 @@ export async function writeEvidencePacket(input, packet, options = {}) {
501
562
  let outputOption = options.output || `.filegrc/evidence-packets/${baseName}`;
502
563
  requireDerivedOutputPath(outputOption);
503
564
  let output = resolveWorkspacePath(input, outputOption);
504
- 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);
505
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.`);
506
577
  await assertPacketSourceState(packet, validation.loaded);
507
578
  const entriesById = new Map(validation.loaded.entries.map((entry) => [entry.record.id, entry]));
@@ -531,9 +602,12 @@ export async function writeEvidencePacket(input, packet, options = {}) {
531
602
  await writePacketFile(output, "README.md", packetMarkdown(packet), files);
532
603
  await writePacketFile(output, "index.html", packetHtml(packet), files);
533
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
+ }
534
608
  await writePacketFile(output, "evidence-index.csv", evidenceIndexCsv(packet), files);
535
- await writePacketFile(output, packet.dataModelVersion === "4" ? "source-component-index.csv" : "source-system-index.csv", sourceSystemIndexCsv(packet), files);
536
- 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);
537
611
  await writePacketFile(output, "population-index.csv", populationIndexCsv(packet), files);
538
612
  await writePacketFile(output, "HANDLING.md", packetHandlingMarkdown(packet), files);
539
613
  for (const item of packet.records) {
@@ -560,12 +634,28 @@ export async function writeEvidencePacket(input, packet, options = {}) {
560
634
  }
561
635
  }
562
636
  const historyIndex = [];
637
+ const historicalFiles = [];
563
638
  for (const item of packet.records) {
564
- await exportCommittedVersions(validation.loaded.root, output, item, item.path, item.history, historyIndex, files);
639
+ collectCommittedVersions(historicalFiles, item, item.path, item.history);
565
640
  for (const content of item.contentPaths || []) {
566
- await exportCommittedVersions(validation.loaded.root, output, item, content.path, content.history, historyIndex, files);
641
+ collectCommittedVersions(historicalFiles, item, content.path, content.history);
567
642
  }
568
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
+ }
569
659
  await writePacketFile(output, "history/index.json", `${JSON.stringify(historyIndex, null, 2)}\n`, files);
570
660
  await assertPacketSourceState(packet, validation.loaded);
571
661
  await writeChecksums(output, files);
@@ -586,35 +676,34 @@ export function generateEvidencePacket(input, options = {}) {
586
676
  });
587
677
  }
588
678
 
589
- async function exportCommittedVersions(root, output, item, sourcePath, history, historyIndex, files) {
679
+ function collectCommittedVersions(target, item, sourcePath, history) {
590
680
  for (const revision of history || []) {
591
- const source = getFileAtRevision(root, revision.commit, sourcePath);
592
- if (source === null) continue;
593
- const exportedPath = join("history", item.type, item.id, revision.commit, basename(sourcePath));
594
- await writePacketFile(output, exportedPath, source, files);
595
- historyIndex.push({
596
- resourceId: item.id,
597
- resourceType: item.type,
598
- sourcePath,
599
- exportedPath: exportedPath.split("\\").join("/"),
600
- ...revision
601
- });
681
+ target.push({ item, sourcePath, relativePath: sourcePath, revision: revision.commit, history: revision });
602
682
  }
603
683
  }
604
684
 
605
685
  async function writeChecksums(output, files) {
606
- const lines = [];
607
- for (const relativePath of [...files].sort()) {
608
- const hash = createHash("sha256");
609
- for await (const chunk of createReadStream(resolvePacketOutputPath(output, relativePath))) hash.update(chunk);
610
- lines.push(`${hash.digest("hex")} ${relativePath}`);
611
- }
612
- 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
+ });
613
702
  }
614
703
 
615
704
  function controlMatrixCsv(packet) {
616
705
  return csv([
617
- ["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"],
618
707
  ...packet.controlCoverage.map((control) => [
619
708
  control.id,
620
709
  control.code,
@@ -643,6 +732,30 @@ function controlMatrixCsv(packet) {
643
732
  ]);
644
733
  }
645
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
+
646
759
  function packetHandlingMarkdown(packet) {
647
760
  return [
648
761
  "# Packet Handling",
@@ -663,7 +776,7 @@ function packetHandlingMarkdown(packet) {
663
776
  }
664
777
 
665
778
  function evidenceIndexCsv(packet) {
666
- const v4 = packet.dataModelVersion === "4";
779
+ const v4 = modelSupports(packet.dataModelVersion, "component-sources");
667
780
  return csv([
668
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"],
669
782
  ...packet.evidence.map((item) => [
@@ -696,7 +809,7 @@ function evidenceIndexCsv(packet) {
696
809
  }
697
810
 
698
811
  function sourceSystemIndexCsv(packet) {
699
- const v4 = packet.dataModelVersion === "4";
812
+ const v4 = modelSupports(packet.dataModelVersion, "component-sources");
700
813
  return csv([
701
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"],
702
815
  ...(packet.sourceComponents || packet.sourceSystems || []).map((item) => [
@@ -713,7 +826,7 @@ function sourceSystemIndexCsv(packet) {
713
826
  }
714
827
 
715
828
  function externalEvidenceIndexCsv(packet) {
716
- const v4 = packet.dataModelVersion === "4";
829
+ const v4 = modelSupports(packet.dataModelVersion, "component-sources");
717
830
  return csv([
718
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"],
719
832
  ...packet.evidence
@@ -734,7 +847,7 @@ function externalEvidenceIndexCsv(packet) {
734
847
  }
735
848
 
736
849
  function populationIndexCsv(packet) {
737
- const v4 = packet.dataModelVersion === "4";
850
+ const v4 = modelSupports(packet.dataModelVersion, "component-sources");
738
851
  return csv([
739
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"],
740
853
  ...packet.populations.map((item) => [
@@ -787,7 +900,7 @@ function requireDerivedOutputPath(value) {
787
900
  async function assertPacketSourceState(packet, loaded) {
788
901
  await assertLoadedEntriesCurrent(loaded);
789
902
  const dataDigest = await dataTreeDigest(loaded.root);
790
- const git = getGitSummary(loaded.root);
903
+ const git = await getWorkspaceRevisionSnapshot(loaded.root);
791
904
  if (
792
905
  packet.revision?.dataDigest !== dataDigest
793
906
  || packet.revision?.commit !== git.commit
@@ -807,36 +920,38 @@ async function assertLoadedEntriesCurrent(loaded) {
807
920
  }
808
921
 
809
922
  async function dataTreeDigest(root) {
810
- const hash = createHash("sha256");
811
- updateDigestField(hash, "filegrc-data-tree-v1");
812
- const visit = async (directory, prefix = "") => {
813
- const entries = await readdir(directory, { withFileTypes: true });
814
- entries.sort((a, b) => a.name.localeCompare(b.name));
815
- for (const entry of entries) {
816
- const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
817
- const path = join(directory, entry.name);
818
- if (entry.isDirectory()) {
819
- updateDigestField(hash, "directory");
820
- updateDigestField(hash, relativePath);
821
- await visit(path, relativePath);
822
- } else if (entry.isFile()) {
823
- const fileHash = createHash("sha256");
824
- for await (const chunk of createReadStream(path)) fileHash.update(chunk);
825
- updateDigestField(hash, "file");
826
- updateDigestField(hash, relativePath);
827
- updateDigestField(hash, fileHash.digest("hex"));
828
- } else if (entry.isSymbolicLink()) {
829
- updateDigestField(hash, "symlink");
830
- updateDigestField(hash, relativePath);
831
- updateDigestField(hash, await readlink(path));
832
- } else {
833
- updateDigestField(hash, "other");
834
- 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
+ }
835
950
  }
836
- }
837
- };
838
- await visit(resolveDataPath(root, "."));
839
- return `sha256:${hash.digest("hex")}`;
951
+ };
952
+ await visit(resolveDataPath(root, "."));
953
+ return `sha256:${hash.digest("hex")}`;
954
+ });
840
955
  }
841
956
 
842
957
  function updateDigestField(hash, value) {
@@ -1049,7 +1164,7 @@ function packetGaps({
1049
1164
  }
1050
1165
  }
1051
1166
  if (controlNeedsExternalEvidence(control, model) && !coverage.evidenceIds.length) {
1052
- 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));
1053
1168
  } else if (!controlNeedsExternalEvidence(control, model) && !coverage.operatingRecordIds.length) {
1054
1169
  gaps.push(gap("error", "control-missing-filegrc-evidence", `${coverage.code || coverage.title} has no dated filegrc operating record in the packet.`, coverage.id));
1055
1170
  }
@@ -1265,7 +1380,7 @@ function packetGaps({
1265
1380
  }
1266
1381
  }
1267
1382
  } else if (["population-export", "system-export", "configuration-export"].includes(item.artifactKind)) {
1268
- 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));
1269
1384
  }
1270
1385
  if (item.externalReference && !item.filePaths.length) {
1271
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));
@@ -1322,7 +1437,7 @@ function controlNeedsExternalEvidence(control, model) {
1322
1437
 
1323
1438
  function auditGaps(gaps, audit, byId, records, start, end, model) {
1324
1439
  const program = byId.get(audit.programId);
1325
- if (String(model.modelVersion) === "4" && program?.assuranceGoal !== audit.auditKind) {
1440
+ if (modelSupports(model, "program-scope") && program?.assuranceGoal !== audit.auditKind) {
1326
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));
1327
1442
  }
1328
1443
  if (!["soc-2-type-1", "soc-2-type-2"].includes(audit.auditKind)) {
@@ -1350,14 +1465,14 @@ function auditGaps(gaps, audit, byId, records, start, end, model) {
1350
1465
  const missingDescriptionRequirements = descriptionRequirements.filter((requirement) => (
1351
1466
  !(audit.requirementIds || []).includes(requirement.id)
1352
1467
  ));
1353
- const missingRequiredDescriptionReferences = String(model.modelVersion) === "4"
1468
+ const missingRequiredDescriptionReferences = modelSupports(model, "program-scope")
1354
1469
  ? missingSoc2References(descriptionRequirements, REQUIRED_SOC2_DESCRIPTION_REFERENCES)
1355
1470
  : [];
1356
1471
  const securityRequirements = frameworkRequirements.filter(isSecurityRequirement);
1357
- const missingRequiredSecurityReferences = String(model.modelVersion) === "4"
1472
+ const missingRequiredSecurityReferences = modelSupports(model, "program-scope")
1358
1473
  ? missingSoc2References(securityRequirements, REQUIRED_SOC2_SECURITY_REFERENCES)
1359
1474
  : [];
1360
- const missingSelectedSecurityReferences = String(model.modelVersion) === "4"
1475
+ const missingSelectedSecurityReferences = modelSupports(model, "program-scope")
1361
1476
  ? missingSoc2References(selectedRequirements.filter(isSecurityRequirement), REQUIRED_SOC2_SECURITY_REFERENCES)
1362
1477
  : [];
1363
1478
  if (!descriptionRequirements.length) {
@@ -1384,10 +1499,10 @@ function auditGaps(gaps, audit, byId, records, start, end, model) {
1384
1499
  } else if (!auditorWasEngaged(auditor, audit)) {
1385
1500
  gaps.push(gap("error", "auditor-outside-engagement-period", `${auditor.title} was not active during the recorded fieldwork or report period.`, auditor.id));
1386
1501
  }
1387
- if (String(model.modelVersion) === "4" && !audit.scopeRevision) {
1502
+ if (modelSupports(model, "program-scope") && !audit.scopeRevision) {
1388
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));
1389
1504
  }
1390
- if (String(model.modelVersion) === "4") {
1505
+ if (modelSupports(model, "program-scope")) {
1391
1506
  const treatments = audit.subserviceTreatments || [];
1392
1507
  const treatmentComponentCounts = new Map();
1393
1508
  for (const treatment of treatments) {
@@ -1503,7 +1618,7 @@ function auditGaps(gaps, audit, byId, records, start, end, model) {
1503
1618
  gaps.push(gap("error", "missing-audit-opinion", `${audit.title} does not record the issued opinion and opinion date.`, audit.id));
1504
1619
  }
1505
1620
  }
1506
- if (String(model.modelVersion) === "4" && ["report-draft", "issued", "delivered", "complete"].includes(audit.status)) {
1621
+ if (modelSupports(model, "program-scope") && ["report-draft", "issued", "delivered", "complete"].includes(audit.status)) {
1507
1622
  const subsequentEventsIssue = subsequentEventsReviewIssue(audit);
1508
1623
  if (subsequentEventsIssue) {
1509
1624
  gaps.push(gap("error", subsequentEventsIssue.code, subsequentEventsIssue.message, audit.id));
@@ -1557,7 +1672,7 @@ function isSecurityRequirement(requirement) {
1557
1672
  }
1558
1673
 
1559
1674
  function requirementIsApplicable(requirement, audit, byId, model) {
1560
- if (String(model.modelVersion) !== "4") return requirement.applicability === "applicable";
1675
+ if (!modelSupports(model, "program-scope")) return requirement.applicability === "applicable";
1561
1676
  const program = audit?.programId ? byId.get(audit.programId) : null;
1562
1677
  return (program?.requirementApplicability || []).some((decision) => (
1563
1678
  decision.requirementId === requirement.id && decision.decision === "applicable"
@@ -1785,7 +1900,7 @@ function recordSummary(record) {
1785
1900
  }
1786
1901
 
1787
1902
  function packetMarkdown(packet) {
1788
- const v4 = packet.dataModelVersion === "4";
1903
+ const v4 = modelSupports(packet.dataModelVersion, "component-sources");
1789
1904
  const readiness = packet.readiness.status === "delivery-ready"
1790
1905
  ? "filegrc management checks passed. The engagement team still determines whether the evidence is sufficient and appropriate."
1791
1906
  : `${packet.readiness.errors} errors and ${packet.readiness.warnings} warnings require review. This is a draft packet.`;
@@ -1812,6 +1927,9 @@ function packetMarkdown(packet) {
1812
1927
  `- ${packet.summary.eventRuns} event runs`,
1813
1928
  `- ${packet.summary.evidence} ${v4 ? "Evidence Artifact" : "External Evidence"} records`,
1814
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
+ : []),
1815
1933
  `- ${packet.summary.policies} policies`,
1816
1934
  `- ${packet.summary.controls} controls`,
1817
1935
  `- ${packet.summary.requirements} criteria`,
@@ -1819,7 +1937,7 @@ function packetMarkdown(packet) {
1819
1937
  `- ${v4 ? packet.summary.sourceComponents : packet.summary.sourceSystems} cataloged source ${v4 ? "Components" : "Systems"}`,
1820
1938
  "",
1821
1939
  v4
1822
- ? "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.`
1823
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.",
1824
1942
  "",
1825
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.",
@@ -1829,7 +1947,7 @@ function packetMarkdown(packet) {
1829
1947
  }
1830
1948
 
1831
1949
  function packetHtml(packet) {
1832
- const v4 = packet.dataModelVersion === "4";
1950
+ const v4 = modelSupports(packet.dataModelVersion, "component-sources");
1833
1951
  const artifactLabel = v4 ? "Evidence Artifact" : "External Evidence";
1834
1952
  const section = (title, body) => `<section><h2>${escapeHtml(title)}</h2>${body}</section>`;
1835
1953
  const links = (items) => items.length
@@ -1875,13 +1993,16 @@ function packetHtml(packet) {
1875
1993
  const controlCoverage = packet.controlCoverage.length
1876
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>`
1877
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>";
1878
1999
  const readinessLabel = packet.readiness.status === "delivery-ready" ? "filegrc management checks passed" : "Draft, do not deliver";
1879
2000
  const packetDate = packet.period.basis === "as-of"
1880
2001
  ? `As of ${escapeHtml(packet.period.start)}`
1881
2002
  : `${escapeHtml(packet.period.start)} through ${escapeHtml(packet.period.end)}`;
1882
2003
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Evidence packet</title><style>
1883
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}
1884
- </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>`;
1885
2006
  }
1886
2007
 
1887
2008
  async function writePacketFile(output, relativePath, source, files) {
@@ -1,3 +1,4 @@
1
+ import { modelSupports } from "../model/index.js";
1
2
  import { applyResourceBatch } from "./files.js";
2
3
  import { createResourceId } from "./id.js";
3
4
  import { currentCalendarDate } from "./time.js";
@@ -5,8 +6,8 @@ import { loadWorkspace } from "./workspace.js";
5
6
 
6
7
  export async function scaffoldExternalReviewerGovernance(input = process.cwd()) {
7
8
  const loaded = await loadWorkspace(input);
8
- if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
9
- throw new Error("External reviewer setup requires a model v3 or v4 workspace.");
9
+ if (!modelSupports(loaded.model, "guided-workflow")) {
10
+ throw new Error("External reviewer setup requires a model v3 or newer workspace.");
10
11
  }
11
12
  return {
12
13
  reviewerName: null,
@@ -22,8 +23,8 @@ export async function scaffoldExternalReviewerGovernance(input = process.cwd())
22
23
 
23
24
  export async function planExternalReviewerGovernance(input = process.cwd(), options = {}) {
24
25
  const loaded = await loadWorkspace(input);
25
- if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
26
- throw new Error("External reviewer setup requires a model v3 or v4 workspace.");
26
+ if (!modelSupports(loaded.model, "guided-workflow")) {
27
+ throw new Error("External reviewer setup requires a model v3 or newer workspace.");
27
28
  }
28
29
  const name = required(options.reviewerName, "External reviewer name");
29
30
  const startsOn = required(options.startsOn, "Appointment start date");