filegrc 0.3.4 → 0.5.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.
@@ -3,6 +3,13 @@ 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
5
  import { assessAuditPreparation } from "./audit-preparation.js";
6
+ import {
7
+ coverageContains,
8
+ coverageEnd,
9
+ coverageMatches,
10
+ coverageOverlaps,
11
+ coverageStart
12
+ } from "./coverage.js";
6
13
  import { getFileAtRevision, getGitSummary, getWorkspaceHistories, hasGitRevision } from "./git.js";
7
14
  import { planObligations } from "./obligations.js";
8
15
  import { isWithin, resolveDataPath, resolveWorkspacePath } from "./paths.js";
@@ -12,6 +19,7 @@ import { serializeWorkspaceMutation } from "./mutation.js";
12
19
  import { validateWorkspace } from "./validate.js";
13
20
 
14
21
  const NON_EVIDENCE_RECORD_TYPES = new Set([
22
+ "appointment",
15
23
  "audit",
16
24
  "audit-population",
17
25
  "audit-request",
@@ -60,7 +68,8 @@ export async function prepareEvidencePacket(input, options = {}) {
60
68
  asOf: end,
61
69
  from: start,
62
70
  through: end,
63
- includeComplete: true
71
+ includeComplete: true,
72
+ model: loaded.model
64
73
  });
65
74
  const obligations = (typeOne ? [] : plan.calendarItems).filter((item) => (
66
75
  item.dueWindowStart <= end
@@ -88,8 +97,6 @@ export async function prepareEvidencePacket(input, options = {}) {
88
97
  ...(audit.systemIds || []),
89
98
  ...(audit.requirementIds || []),
90
99
  ...(audit.controlIds || []),
91
- ...(audit.controlTestIds || []),
92
- ...(audit.evidenceIds || []),
93
100
  ...(audit.contactIds || []),
94
101
  ...(audit.complementaryControlIds || []),
95
102
  ...(audit.subserviceVendorIds || []),
@@ -104,6 +111,11 @@ export async function prepareEvidencePacket(input, options = {}) {
104
111
  for (const request of records.filter((record) => record.type === "audit-request" && record.auditId === audit.id)) {
105
112
  selectedIds.add(request.id);
106
113
  }
114
+ for (const record of records.filter((candidate) => (
115
+ candidate.auditId === audit.id || (candidate.auditIds || []).includes(audit.id)
116
+ ))) {
117
+ selectedIds.add(record.id);
118
+ }
107
119
  for (const record of records.filter((candidate) => ["finding", "action-item"].includes(candidate.type))) {
108
120
  if (recordRelevantToAudit(record, audit, byId)) selectedIds.add(record.id);
109
121
  }
@@ -157,8 +169,20 @@ export async function prepareEvidencePacket(input, options = {}) {
157
169
  for (const controlId of controlIds) {
158
170
  const control = byId.get(controlId);
159
171
  addIds(selectedIds, control?.systemIds);
160
- addIds(selectedIds, control?.commitmentIds);
161
- addIds(selectedIds, control?.riskIds);
172
+ }
173
+ for (const record of records) {
174
+ if (
175
+ record.type === "commitment"
176
+ && (record.controlIds || []).some((id) => controlIds.has(id))
177
+ ) {
178
+ selectedIds.add(record.id);
179
+ }
180
+ if (
181
+ record.type === "risk"
182
+ && (record.controlIds || []).some((id) => controlIds.has(id))
183
+ ) {
184
+ selectedIds.add(record.id);
185
+ }
162
186
  }
163
187
  for (const complementaryControl of records.filter((record) => record.type === "complementary-control")) {
164
188
  if ((complementaryControl.relatedControlIds || []).some((id) => controlIds.has(id))) {
@@ -167,8 +191,12 @@ export async function prepareEvidencePacket(input, options = {}) {
167
191
  }
168
192
  for (const systemId of audit?.systemIds || []) {
169
193
  const system = byId.get(systemId);
170
- addIds(selectedIds, system?.commitmentIds);
171
194
  addIds(selectedIds, system?.subserviceVendorIds);
195
+ for (const commitment of records.filter((record) => (
196
+ record.type === "commitment" && (record.systemIds || []).includes(systemId)
197
+ ))) {
198
+ selectedIds.add(commitment.id);
199
+ }
172
200
  }
173
201
 
174
202
  const policyIds = new Set(audit
@@ -292,9 +320,7 @@ export async function prepareEvidencePacket(input, options = {}) {
292
320
  kind: audit.auditKind,
293
321
  status: audit.status,
294
322
  scope: audit.scope,
295
- periodStart: audit.periodStart,
296
- periodEnd: audit.periodEnd,
297
- typeOneAsOf: audit.typeOneAsOf,
323
+ coverage: audit.coverage,
298
324
  systemIds: audit.systemIds || [],
299
325
  requirementIds: audit.requirementIds || [],
300
326
  controlIds: audit.controlIds || [],
@@ -313,7 +339,7 @@ export async function prepareEvidencePacket(input, options = {}) {
313
339
  dataDigest
314
340
  },
315
341
  handling: {
316
- classifications: [...new Set(evidence.map(({ classification }) => classification).filter(Boolean))].sort(),
342
+ classifications: [...new Set(evidence.map(({ classificationId }) => classificationId).filter(Boolean))].sort(),
317
343
  containsExternalReferences: evidence.some(({ externalReference }) => Boolean(externalReference)),
318
344
  encrypted: false
319
345
  },
@@ -371,8 +397,6 @@ function recordRelevantToAudit(record, audit, byId, seen = new Set()) {
371
397
  ...(audit.systemIds || []),
372
398
  ...(audit.requirementIds || []),
373
399
  ...(audit.controlIds || []),
374
- ...(audit.controlTestIds || []),
375
- ...(audit.evidenceIds || []),
376
400
  ...(audit.contactIds || []),
377
401
  ...(audit.complementaryControlIds || []),
378
402
  ...(audit.subserviceVendorIds || []),
@@ -395,8 +419,14 @@ function recordRelevantToAudit(record, audit, byId, seen = new Set()) {
395
419
  if ((record.requirementIds || []).some((id) => auditRequirements.has(id))) return true;
396
420
  const auditControlRecords = [...auditControls].map((id) => byId.get(id)).filter(Boolean);
397
421
  const policyIds = new Set(auditControlRecords.flatMap((control) => control.policyIds || []));
398
- const commitmentIds = new Set(auditControlRecords.flatMap((control) => control.commitmentIds || []));
399
- const riskIds = new Set(auditControlRecords.flatMap((control) => control.riskIds || []));
422
+ const commitmentIds = new Set([...byId.values()]
423
+ .filter((candidate) => candidate.type === "commitment"
424
+ && (candidate.controlIds || []).some((id) => auditControls.has(id)))
425
+ .map(({ id }) => id));
426
+ const riskIds = new Set([...byId.values()]
427
+ .filter((candidate) => candidate.type === "risk"
428
+ && (candidate.controlIds || []).some((id) => auditControls.has(id)))
429
+ .map(({ id }) => id));
400
430
  if ((record.type === "policy" && policyIds.has(record.id)) || (record.policyIds || []).some((id) => policyIds.has(id))) return true;
401
431
  if ((record.type === "commitment" && commitmentIds.has(record.id)) || (record.commitmentIds || []).some((id) => commitmentIds.has(id))) return true;
402
432
  if ((record.type === "risk" && riskIds.has(record.id)) || (record.riskIds || []).some((id) => riskIds.has(id))) return true;
@@ -557,7 +587,7 @@ async function writeChecksums(output, files) {
557
587
 
558
588
  function controlMatrixCsv(packet) {
559
589
  return csv([
560
- ["Control ID", "Code", "Control", "Control Statement", "Operating Activity", "Status", "Effective On", "Frequency", "Operation Mode", "System IDs", "Requirement IDs", "Policy IDs", "Risk IDs", "filegrc Evidence IDs", "External Evidence IDs", "Control Test IDs", "Test Outcomes", "Population IDs", "Population Counts", "Sample Sizes", "Exception Counts", "Population Evidence IDs", "Sample Evidence IDs"],
590
+ ["Control ID", "Code", "Control", "Control Statement", "Operating Activity", "Status", "Effective On", "Operation Pattern", "Operation Mode", "System IDs", "Requirement IDs", "Policy IDs", "Risk IDs", "filegrc Evidence IDs", "External Evidence IDs", "Control Test IDs", "Test Outcomes", "Population IDs", "Population Counts", "Sample Sizes", "Exception Counts", "Population Evidence IDs", "Sample Evidence IDs"],
561
591
  ...packet.controlCoverage.map((control) => [
562
592
  control.id,
563
593
  control.code,
@@ -566,7 +596,7 @@ function controlMatrixCsv(packet) {
566
596
  control.activity,
567
597
  control.status,
568
598
  control.effectiveOn,
569
- control.frequency,
599
+ control.operationPattern,
570
600
  control.operationMode,
571
601
  control.systemIds.join("\n"),
572
602
  control.requirementIds.join("\n"),
@@ -612,8 +642,8 @@ function evidenceIndexCsv(packet) {
612
642
  item.id,
613
643
  item.title,
614
644
  item.status,
615
- item.evidenceKind,
616
- item.source,
645
+ item.artifactKind,
646
+ item.sourceDescription,
617
647
  item.sourceSystemId,
618
648
  item.sourceSystem,
619
649
  item.collectedOn,
@@ -685,7 +715,7 @@ function populationIndexCsv(packet) {
685
715
  item.periodEnd,
686
716
  item.sourceSystemId,
687
717
  item.sourceSystem,
688
- item.source,
718
+ item.sourceDescription,
689
719
  item.queryDescription,
690
720
  item.timezone,
691
721
  item.generatedAt,
@@ -799,19 +829,18 @@ function packetRecord(record, model, start, end, timezone) {
799
829
  if (date && date >= start && date <= end) dates.push({ field: name, value, date });
800
830
  }
801
831
  }
802
- const overlaps = parseCalendarDate(record.periodStart)
803
- && parseCalendarDate(record.periodEnd)
804
- && record.periodStart <= end
805
- && record.periodEnd >= start;
832
+ const overlaps = coverageOverlaps(record.coverage, start, end);
833
+ const coverageStartDate = coverageStart(record.coverage);
834
+ const coverageEndDate = coverageEnd(record.coverage);
806
835
  if (!dates.length && !overlaps) return null;
807
836
  dates.sort((a, b) => (a.date || a.value).localeCompare(b.date || b.value) || a.field.localeCompare(b.field));
808
837
  return {
809
838
  id: record.id,
810
839
  type: record.type,
811
840
  title: record.title,
812
- primaryDate: dates[0]?.date || dates[0]?.value || record.periodStart,
841
+ primaryDate: dates[0]?.date || dates[0]?.value || coverageStartDate,
813
842
  dates,
814
- ...(overlaps ? { period: { start: record.periodStart, end: record.periodEnd } } : {})
843
+ ...(overlaps ? { period: { start: coverageStartDate, end: coverageEndDate } } : {})
815
844
  };
816
845
  }
817
846
 
@@ -848,14 +877,21 @@ function buildControlCoverage({ audit, byId, controlIds, evidenceIds, model, rec
848
877
  .filter((record) => record.type === "control-test" && record.controlId === controlId)
849
878
  .filter((record) => (
850
879
  record.auditId === audit?.id
851
- || (!record.auditId && record.periodStart && record.periodEnd && record.periodStart <= end && record.periodEnd >= start)
852
- || (!record.auditId && record.asOfDate >= start && record.asOfDate <= end)
880
+ || (!record.auditId && coverageOverlaps(record.coverage, start, end))
853
881
  ));
854
882
  const operatingRecords = records.filter((record) => (
855
883
  !NON_EVIDENCE_RECORD_TYPES.has(record.type)
856
884
  && controlIdsForRecord(record, byId).has(controlId)
857
885
  && packetRecord(record, model, start, end, timezone)
858
886
  ));
887
+ const zeroPopulationRecords = records.filter((record) => (
888
+ record.type === "audit-population"
889
+ && record.auditId === audit?.id
890
+ && record.status === "reconciled"
891
+ && record.conclusion === "complete"
892
+ && (record.controlIds || []).includes(controlId)
893
+ && byId.get(record.sourceEvidenceId)?.populationCount === 0
894
+ ));
859
895
  const linkedEvidenceIds = new Set(evidenceByControl.get(controlId) || []);
860
896
  for (const test of tests) {
861
897
  addIds(linkedEvidenceIds, test.evidenceIds);
@@ -871,22 +907,23 @@ function buildControlCoverage({ audit, byId, controlIds, evidenceIds, model, rec
871
907
  activity: control?.activity || "",
872
908
  status: control?.status || "missing",
873
909
  effectiveOn: control?.effectiveOn || null,
874
- frequency: control?.frequency || "",
910
+ operationPattern: control?.operationPattern || "",
875
911
  operationMode: control?.operationMode || "",
876
912
  requirementIds: control?.requirementIds || [],
877
913
  policyIds: control?.policyIds || [],
878
914
  systemIds: control?.systemIds || [],
879
- riskIds: control?.riskIds || [],
915
+ riskIds: [...byId.values()]
916
+ .filter((record) => record.type === "risk" && (record.controlIds || []).includes(controlId))
917
+ .map(({ id }) => id)
918
+ .sort(),
880
919
  evidenceIds: [...linkedEvidenceIds].sort(),
881
- operatingRecordIds: operatingRecords.map(({ id }) => id).sort(),
920
+ operatingRecordIds: [...operatingRecords, ...zeroPopulationRecords].map(({ id }) => id).sort(),
882
921
  tests: tests.map((test) => ({
883
922
  ...testPopulationSummary(test, byId),
884
923
  id: test.id,
885
924
  status: test.status,
886
925
  outcome: test.outcome || null,
887
- periodStart: test.periodStart || null,
888
- periodEnd: test.periodEnd || null,
889
- asOfDate: test.asOfDate || null,
926
+ coverage: test.coverage || null,
890
927
  sampleSize: test.sampleSize ?? null,
891
928
  sampleEvidenceIds: test.sampleEvidenceIds || [],
892
929
  exceptionCount: test.exceptionCount ?? null
@@ -906,6 +943,17 @@ function controlIdsForRecord(record, byId, seen = new Set()) {
906
943
  for (const sourceId of record.sourceResourceIds || []) addIds(ids, controlIdsForRecord(byId.get(sourceId), byId, seen));
907
944
  if (record.sourceResourceId) addIds(ids, controlIdsForRecord(byId.get(record.sourceResourceId), byId, seen));
908
945
  if (record.obligationId) addIds(ids, byId.get(record.obligationId)?.controlIds);
946
+ for (const candidate of byId.values()) {
947
+ if (
948
+ candidate.type === "obligation"
949
+ && (candidate.completionResourceIds || []).includes(record.id)
950
+ ) {
951
+ addIds(ids, candidate.controlIds);
952
+ }
953
+ }
954
+ for (const subjectId of record.subjectResourceIds || []) {
955
+ addIds(ids, controlIdsForRecord(byId.get(subjectId), byId, seen));
956
+ }
909
957
  return ids;
910
958
  }
911
959
 
@@ -974,7 +1022,13 @@ function packetGaps({
974
1022
  } else if (!controlNeedsExternalEvidence(control, model) && !coverage.operatingRecordIds.length) {
975
1023
  gaps.push(gap("error", "control-missing-filegrc-evidence", `${coverage.code || coverage.title} has no dated filegrc operating record in the packet.`, coverage.id));
976
1024
  }
977
- if (audit?.auditKind !== "soc-2-type-1" && coverage.status === "implemented" && !coverage.operatingRecordIds.length && coverage.operationMode !== "automated") {
1025
+ if (
1026
+ audit?.auditKind !== "soc-2-type-1"
1027
+ && coverage.status === "implemented"
1028
+ && !coverage.operatingRecordIds.length
1029
+ && !coverage.evidenceIds.length
1030
+ && coverage.operationMode !== "automated"
1031
+ ) {
978
1032
  gaps.push(gap("warning", "control-missing-operating-record", `${coverage.code || coverage.title} has no dated operating record in the packet period.`, coverage.id));
979
1033
  }
980
1034
  for (const test of coverage.tests) {
@@ -1032,10 +1086,13 @@ function packetGaps({
1032
1086
  for (const systemId of audit.systemIds || []) {
1033
1087
  const system = byId.get(systemId);
1034
1088
  if (!system) continue;
1035
- const commitmentIds = new Set(system.commitmentIds || []);
1036
- for (const record of records) {
1037
- if (record.type === "commitment" && (record.systemIds || []).includes(systemId) && record.status === "active") commitmentIds.add(record.id);
1038
- }
1089
+ const commitmentIds = new Set(records
1090
+ .filter((record) => (
1091
+ record.type === "commitment"
1092
+ && record.status === "active"
1093
+ && (record.systemIds || []).includes(systemId)
1094
+ ))
1095
+ .map(({ id }) => id));
1039
1096
  if (!commitmentIds.size) {
1040
1097
  gaps.push(gap("error", "system-missing-commitments", `${system.title} has no active service commitment or system requirement.`, system.id));
1041
1098
  }
@@ -1043,8 +1100,8 @@ function packetGaps({
1043
1100
  const recentAssessment = records.some((record) => (
1044
1101
  record.type === "risk-assessment"
1045
1102
  && record.status === "complete"
1046
- && record.assessmentDate <= end
1047
- && record.assessmentDate >= shiftYear(end, -1)
1103
+ && record.completedOn <= end
1104
+ && record.completedOn >= shiftYear(end, -1)
1048
1105
  && (!(audit.systemIds || []).length || !(record.systemIds || []).length || record.systemIds.some((id) => audit.systemIds.includes(id)))
1049
1106
  ));
1050
1107
  if (!recentAssessment) {
@@ -1058,10 +1115,10 @@ function packetGaps({
1058
1115
  }
1059
1116
  const reviews = records.filter((record) => (
1060
1117
  record.type === "vendor-review"
1061
- && (record.vendorIds || []).includes(vendorId)
1062
- && ["approved", "conditional", "complete"].includes(record.status)
1063
- && record.reviewedOn <= end
1064
- && record.reviewedOn >= shiftYear(end, -1)
1118
+ && record.vendorId === vendorId
1119
+ && record.status === "complete"
1120
+ && record.completedOn <= end
1121
+ && record.completedOn >= shiftYear(end, -1)
1065
1122
  ));
1066
1123
  if (!reviews.length) {
1067
1124
  gaps.push(gap("error", "missing-subservice-review", `${vendor.title} has no completed review in the year ending ${end}.`, vendor.id));
@@ -1073,7 +1130,10 @@ function packetGaps({
1073
1130
  if (!vendorEvidence.length) {
1074
1131
  gaps.push(gap("error", "missing-subservice-evidence", `${vendor.title} has no linked assurance evidence, such as its report and applicable bridge coverage.`, vendor.id));
1075
1132
  } else {
1076
- const assuranceReports = vendorEvidence.filter((item) => /soc|assurance|vendor-report/i.test(item.evidenceKind));
1133
+ const assuranceReports = vendorEvidence.filter((item) => (
1134
+ item.artifactKind === "third-party-report"
1135
+ || /soc|assurance|vendor-report/i.test(item.artifactSubtype || "")
1136
+ ));
1077
1137
  if (!assuranceReports.length) {
1078
1138
  gaps.push(gap("error", "missing-subservice-assurance-report", `${vendor.title} has linked evidence but no item identified as a SOC or other assurance report.`, vendor.id));
1079
1139
  } else if (assuranceReports.every((item) => !item.periodEnd)) {
@@ -1081,7 +1141,7 @@ function packetGaps({
1081
1141
  } else {
1082
1142
  const latestCoverage = assuranceReports.map((item) => item.periodEnd).filter(Boolean).sort().at(-1);
1083
1143
  const bridgeEvidence = vendorEvidence.some((item) => (
1084
- /bridge/i.test(item.evidenceKind)
1144
+ /bridge/i.test(item.artifactSubtype || "")
1085
1145
  && ((item.periodEnd && item.periodEnd >= end) || item.collectedOn >= end)
1086
1146
  ));
1087
1147
  if (latestCoverage && latestCoverage < end && !bridgeEvidence) {
@@ -1116,6 +1176,13 @@ function packetGaps({
1116
1176
  `${run.title}: ${action.title} was canceled. Complete the requirement or cancel the event with a documented reason.`,
1117
1177
  action.actionItemId
1118
1178
  ));
1179
+ } else if (action.status === "blocked") {
1180
+ gaps.push(gap(
1181
+ "error",
1182
+ "blocked-event-action",
1183
+ `${run.title}: ${action.title} is blocked. ${action.blockingReason || "Open the Action Item and resolve its named blockers."}`,
1184
+ action.actionItemId
1185
+ ));
1119
1186
  } else if (action.missingCompletion) {
1120
1187
  gaps.push(gap(
1121
1188
  "error",
@@ -1130,7 +1197,7 @@ function packetGaps({
1130
1197
  }
1131
1198
  }
1132
1199
  for (const item of evidence) {
1133
- if (item.evidenceKind === "rendered-record" && !item.sourceCommit) {
1200
+ if (item.artifactKind === "rendered-page" && !item.sourceCommit) {
1134
1201
  gaps.push(gap("error", "unbound-rendered-evidence", `${item.title} does not name the Git revision that was rendered.`, item.id));
1135
1202
  } else if (item.sourceCommit && !item.sourceCommitValid) {
1136
1203
  gaps.push(gap("error", "invalid-evidence-revision", `${item.title} names a source Git revision that is not available in this repository.`, item.id));
@@ -1164,21 +1231,21 @@ function packetGaps({
1164
1231
  gaps.push(gap("warning", "evidence-source-role-missing", `${sourceSystem.title} has no evidence source role. Record what authoritative reports or records it supplies and keep extraction instructions in its Record Markdown.`, sourceSystem.id));
1165
1232
  }
1166
1233
  }
1167
- } else if (["population-export", "system-export", "configuration-export"].includes(item.evidenceKind)) {
1234
+ } else if (["population-export", "system-export", "configuration-export"].includes(item.artifactKind)) {
1168
1235
  gaps.push(gap("error", "evidence-source-system-unrecorded", `${item.title} is a source-system export but does not link the cataloged system of record.`, item.id));
1169
1236
  }
1170
1237
  if (item.externalReference && !item.filePaths.length) {
1171
1238
  gaps.push(gap("warning", "external-only-evidence", `${item.title} relies on an external reference and is not self-contained in the packet.`, item.id));
1172
1239
  }
1173
- if (item.evidenceKind === "rendered-record") {
1240
+ if (item.artifactKind === "rendered-page") {
1174
1241
  const captureComplete = item.capture
1175
1242
  && typeof item.capture.route === "string"
1176
1243
  && item.capture.route.trim()
1177
1244
  && item.capture.filters
1178
1245
  && typeof item.capture.filters === "object"
1179
1246
  && !Array.isArray(item.capture.filters)
1180
- && typeof item.capture.periodStart === "string"
1181
- && typeof item.capture.periodEnd === "string"
1247
+ && coverageStart(item.capture.coverage)
1248
+ && coverageEnd(item.capture.coverage)
1182
1249
  && typeof item.capture.capturedAt === "string"
1183
1250
  && typeof item.capture.method === "string"
1184
1251
  && item.capture.method.trim();
@@ -1186,7 +1253,7 @@ function packetGaps({
1186
1253
  gaps.push(gap("error", "missing-render-capture-context", `${item.title} does not record its route, filters, period, capture time, and method.`, item.id));
1187
1254
  }
1188
1255
  }
1189
- if (item.evidenceKind === "population-export") {
1256
+ if (item.artifactKind === "population-export") {
1190
1257
  for (const [field, label] of [
1191
1258
  ["generatedAt", "generation time"],
1192
1259
  ["timezone", "report timezone"],
@@ -1217,22 +1284,23 @@ function controlNeedsExternalEvidence(control, model) {
1217
1284
  const families = (model.evidenceSourceFamilies || []).filter((family) => (
1218
1285
  (family.controlCodes || []).includes(control?.code)
1219
1286
  ));
1220
- return !families.length || families.some((family) => family.collectionTestRequired !== false);
1287
+ return !families.length || families.some((family) => family.filegrcManaged !== true);
1221
1288
  }
1222
1289
 
1223
1290
  function auditGaps(gaps, audit, byId, records, start, end) {
1224
1291
  if (!["soc-2-type-1", "soc-2-type-2"].includes(audit.auditKind)) {
1225
1292
  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));
1226
1293
  } else if (audit.auditKind === "soc-2-type-1") {
1227
- if (!audit.typeOneAsOf) {
1294
+ const asOf = coverageStart(audit.coverage);
1295
+ if (audit.coverage?.kind !== "as-of" || !asOf) {
1228
1296
  gaps.push(gap("error", "audit-as-of-date-missing", `${audit.title} does not define a Type 1 as-of date.`, audit.id));
1229
- } else if (start !== audit.typeOneAsOf || end !== audit.typeOneAsOf) {
1230
- gaps.push(gap("error", "packet-date-mismatch", `Packet date ${start}${end !== start ? ` through ${end}` : ""} does not match the selected Type 1 as-of date ${audit.typeOneAsOf}.`, audit.id));
1297
+ } else if (start !== asOf || end !== asOf) {
1298
+ gaps.push(gap("error", "packet-date-mismatch", `Packet date ${start}${end !== start ? ` through ${end}` : ""} does not match the selected Type 1 as-of date ${asOf}.`, audit.id));
1231
1299
  }
1232
- } else if (!audit.periodStart || !audit.periodEnd) {
1300
+ } else if (audit.coverage?.kind !== "range") {
1233
1301
  gaps.push(gap("error", "audit-period-missing", `${audit.title} does not define a Type 2 examination period.`, audit.id));
1234
- } else if (audit.periodStart !== start || audit.periodEnd !== end) {
1235
- gaps.push(gap("error", "packet-period-mismatch", `Packet dates ${start} through ${end} do not match the selected audit period ${audit.periodStart} through ${audit.periodEnd}.`, audit.id));
1302
+ } else if (!coverageMatches(audit.coverage, start, end)) {
1303
+ gaps.push(gap("error", "packet-period-mismatch", `Packet dates ${start} through ${end} do not match the selected audit period ${coverageStart(audit.coverage)} through ${coverageEnd(audit.coverage)}.`, audit.id));
1236
1304
  }
1237
1305
  if (!(audit.systemIds || []).length) gaps.push(gap("error", "audit-systems-missing", `${audit.title} has no in-scope systems.`, audit.id));
1238
1306
  if (!(audit.requirementIds || []).length) gaps.push(gap("error", "audit-requirements-missing", `${audit.title} has no selected criteria.`, audit.id));
@@ -1246,7 +1314,7 @@ function auditGaps(gaps, audit, byId, records, start, end) {
1246
1314
  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));
1247
1315
  }
1248
1316
  }
1249
- if (!audit.auditor && !audit.auditorVendorId) gaps.push(gap("error", "auditor-missing", `${audit.title} does not identify the independent CPA firm.`, audit.id));
1317
+ if (!audit.auditorVendorId) gaps.push(gap("error", "auditor-missing", `${audit.title} does not identify the independent CPA firm.`, audit.id));
1250
1318
  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));
1251
1319
  if ((audit.subserviceVendorIds || []).length && audit.subserviceMethod === "not-applicable") {
1252
1320
  gaps.push(gap("error", "subservice-scope-conflict", `${audit.title} names subservice organizations but marks their treatment not applicable.`, audit.id));
@@ -1347,12 +1415,15 @@ function evidenceSummary(record, byId, revisionIsValid) {
1347
1415
  id: record.id,
1348
1416
  title: record.title,
1349
1417
  status: record.status,
1350
- evidenceKind: record.evidenceKind,
1351
- source: record.source,
1418
+ artifactKind: record.artifactKind,
1419
+ artifactSubtype: record.artifactSubtype || null,
1420
+ sourceKind: record.sourceKind,
1421
+ sourceDescription: record.sourceDescription,
1352
1422
  collectedOn: record.collectedOn,
1353
- periodStart: record.periodStart,
1354
- periodEnd: record.periodEnd,
1355
- classification: record.classification,
1423
+ coverage: record.coverage || null,
1424
+ periodStart: coverageStart(record.coverage),
1425
+ periodEnd: coverageEnd(record.coverage),
1426
+ classificationId: record.classificationId,
1356
1427
  generatedAt: record.generatedAt || null,
1357
1428
  timezone: record.timezone || null,
1358
1429
  queryDescription: record.queryDescription || null,
@@ -1384,13 +1455,14 @@ function populationSummary(record, byId) {
1384
1455
  status: record.status,
1385
1456
  auditId: record.auditId,
1386
1457
  populationKind: record.populationKind,
1387
- periodStart: record.periodStart,
1388
- periodEnd: record.periodEnd,
1458
+ coverage: record.coverage || null,
1459
+ periodStart: coverageStart(record.coverage),
1460
+ periodEnd: coverageEnd(record.coverage),
1389
1461
  controlIds: record.controlIds || [],
1390
1462
  sourceSystemId,
1391
1463
  sourceSystem: byId.get(sourceSystemId)?.title || null,
1392
1464
  sourceEvidenceId: record.sourceEvidenceId || null,
1393
- source: evidence?.source || null,
1465
+ source: evidence?.sourceDescription || null,
1394
1466
  populationCount: evidence?.populationCount ?? null,
1395
1467
  queryDescription: evidence?.queryDescription || null,
1396
1468
  timezone: evidence?.timezone || null,
@@ -1439,7 +1511,7 @@ function populationGaps(gaps, audit, populations, byId, model) {
1439
1511
  }
1440
1512
  }
1441
1513
  for (const population of populations) {
1442
- if (population.periodStart !== audit.periodStart || population.periodEnd !== audit.periodEnd) {
1514
+ if (!coverageMatches(population.coverage, coverageStart(audit.coverage), coverageEnd(audit.coverage))) {
1443
1515
  gaps.push(gap("error", "population-period-mismatch", `${population.title} does not match the exact audit period.`, population.id));
1444
1516
  }
1445
1517
  if (population.status === "not-applicable") {
@@ -1462,11 +1534,11 @@ function populationGaps(gaps, audit, populations, byId, model) {
1462
1534
  gaps.push(gap("error", "population-exceptions-undocumented", `${population.title} concludes with exceptions but does not describe them in the reconciliation summary.`, population.id));
1463
1535
  }
1464
1536
  const evidence = byId.get(population.sourceEvidenceId);
1465
- if (!evidence || evidence.type !== "evidence" || evidence.evidenceKind !== "population-export") {
1537
+ if (!evidence || evidence.type !== "evidence" || evidence.artifactKind !== "population-export") {
1466
1538
  gaps.push(gap("error", "population-export-missing", `${population.title} does not link a population-export evidence record.`, population.id));
1467
1539
  continue;
1468
1540
  }
1469
- if (evidence.periodStart !== audit.periodStart || evidence.periodEnd !== audit.periodEnd) {
1541
+ if (!coverageMatches(evidence.coverage, coverageStart(audit.coverage), coverageEnd(audit.coverage))) {
1470
1542
  gaps.push(gap("error", "population-evidence-period-mismatch", `${evidence.title} does not cover the exact audit period.`, evidence.id));
1471
1543
  }
1472
1544
  const generatedOn = timestampDate(evidence.generatedAt, evidence.timezone);
@@ -1488,10 +1560,10 @@ function populationGaps(gaps, audit, populations, byId, model) {
1488
1560
 
1489
1561
  function evidenceCoversPacketDate(item, audit, start, end) {
1490
1562
  if (audit?.auditKind === "soc-2-type-1") {
1491
- return (item.periodStart && item.periodEnd && item.periodStart <= start && item.periodEnd >= start)
1563
+ return coverageContains(item.coverage, start)
1492
1564
  || item.collectedOn === start;
1493
1565
  }
1494
- return (item.periodStart && item.periodEnd && item.periodStart <= end && item.periodEnd >= start)
1566
+ return coverageOverlaps(item.coverage, start, end)
1495
1567
  || (item.collectedOn >= start && item.collectedOn <= end);
1496
1568
  }
1497
1569
 
@@ -1502,7 +1574,7 @@ function displaySourceKind(value) {
1502
1574
  function overlapsEvidencePeriod(record, start, end) {
1503
1575
  return record.type === "evidence" && (
1504
1576
  (record.collectedOn >= start && record.collectedOn <= end)
1505
- || (record.periodStart && record.periodEnd && record.periodStart <= end && record.periodEnd >= start)
1577
+ || coverageOverlaps(record.coverage, start, end)
1506
1578
  );
1507
1579
  }
1508
1580
 
@@ -1580,7 +1652,7 @@ function packetHtml(packet) {
1580
1652
  ? `<table><thead><tr><th>Obligation</th><th>Allowed window</th><th>Status</th></tr></thead><tbody>${packet.obligations.map((item) => `<tr><td>${escapeHtml(item.title)}</td><td>${item.dueWindowStart} through ${item.dueWindowEnd}<br><small>Overdue ${item.overdueOn}</small></td><td>${escapeHtml(item.status)}</td></tr>`).join("")}</tbody></table>`
1581
1653
  : "<p>No recurring occurrences intersect this period.</p>";
1582
1654
  const evidence = packet.evidence.length
1583
- ? `<table><thead><tr><th>External Evidence</th><th>Source and period</th><th>Controls</th><th>Files</th></tr></thead><tbody>${packet.evidence.map((item) => `<tr><td><a href="records/evidence/${encodeURIComponent(item.id)}.json">${escapeHtml(item.title)}</a><small>${escapeHtml(item.status)} · ${escapeHtml(item.evidenceKind)}</small></td><td>${escapeHtml(item.source)}<small>${escapeHtml(item.periodStart || item.collectedOn)}${item.periodEnd ? ` through ${escapeHtml(item.periodEnd)}` : ""}</small></td><td>${item.controlIds.map(escapeHtml).join("<br>") || "None"}</td><td>${item.filePaths.map((path) => `<a class="attachment" href="attachments/${path.split("/").map(encodeURIComponent).join("/")}">${escapeHtml(basename(path))}</a>`).join("") || "No fixed attachment"}</td></tr>`).join("")}</tbody></table>`
1655
+ ? `<table><thead><tr><th>External Evidence</th><th>Source and period</th><th>Controls</th><th>Files</th></tr></thead><tbody>${packet.evidence.map((item) => `<tr><td><a href="records/evidence/${encodeURIComponent(item.id)}.json">${escapeHtml(item.title)}</a><small>${escapeHtml(item.status)} · ${escapeHtml(item.artifactKind)}</small></td><td>${escapeHtml(item.sourceDescription)}<small>${escapeHtml(item.periodStart || item.collectedOn)}${item.periodEnd ? ` through ${escapeHtml(item.periodEnd)}` : ""}</small></td><td>${item.controlIds.map(escapeHtml).join("<br>") || "None"}</td><td>${item.filePaths.map((path) => `<a class="attachment" href="attachments/${path.split("/").map(encodeURIComponent).join("/")}">${escapeHtml(basename(path))}</a>`).join("") || "No fixed attachment"}</td></tr>`).join("")}</tbody></table>`
1584
1656
  : "<p>No External Evidence records were selected.</p>";
1585
1657
  const sourceSystems = packet.sourceSystems.length
1586
1658
  ? `<p><a href="source-system-index.csv">Download source system index CSV</a></p><table><thead><tr><th>System of record</th><th>Evidence roles</th><th>Audit relationship</th><th>Evidence</th></tr></thead><tbody>${packet.sourceSystems.map((item) => `<tr><td><a href="records/system/${encodeURIComponent(item.id)}.json">${escapeHtml(item.title)}</a><small>${escapeHtml(item.status)}</small></td><td>${item.evidenceSourceKinds.map(escapeHtml).join("<br>") || "No evidence role recorded"}</td><td>${item.inAuditScope ? "In-scope system" : "Evidence source"}</td><td>${item.evidenceIds.length}</td></tr>`).join("")}</tbody></table><p><a href="external-evidence-index.csv">Download external evidence delivery index CSV</a></p>`
@@ -1669,11 +1741,11 @@ function requireDate(value, label) {
1669
1741
  function resolvePacketPeriod(options, audit) {
1670
1742
  const typeOne = audit?.auditKind === "soc-2-type-1";
1671
1743
  const start = requireDate(
1672
- options.start || (typeOne ? audit?.typeOneAsOf : audit?.periodStart),
1744
+ options.start || coverageStart(audit?.coverage),
1673
1745
  typeOne ? "Type 1 as-of date" : "packet start date"
1674
1746
  );
1675
1747
  const end = requireDate(
1676
- options.end || (typeOne ? audit?.typeOneAsOf : audit?.periodEnd),
1748
+ options.end || coverageEnd(audit?.coverage),
1677
1749
  typeOne ? "Type 1 as-of date" : "packet end date"
1678
1750
  );
1679
1751
  if (end < start) throw new Error("The packet end date must not be before its start date.");