filegrc 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,8 +877,7 @@ 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)
@@ -871,12 +899,15 @@ function buildControlCoverage({ audit, byId, controlIds, evidenceIds, model, rec
871
899
  activity: control?.activity || "",
872
900
  status: control?.status || "missing",
873
901
  effectiveOn: control?.effectiveOn || null,
874
- frequency: control?.frequency || "",
902
+ operationPattern: control?.operationPattern || "",
875
903
  operationMode: control?.operationMode || "",
876
904
  requirementIds: control?.requirementIds || [],
877
905
  policyIds: control?.policyIds || [],
878
906
  systemIds: control?.systemIds || [],
879
- riskIds: control?.riskIds || [],
907
+ riskIds: [...byId.values()]
908
+ .filter((record) => record.type === "risk" && (record.controlIds || []).includes(controlId))
909
+ .map(({ id }) => id)
910
+ .sort(),
880
911
  evidenceIds: [...linkedEvidenceIds].sort(),
881
912
  operatingRecordIds: operatingRecords.map(({ id }) => id).sort(),
882
913
  tests: tests.map((test) => ({
@@ -884,9 +915,7 @@ function buildControlCoverage({ audit, byId, controlIds, evidenceIds, model, rec
884
915
  id: test.id,
885
916
  status: test.status,
886
917
  outcome: test.outcome || null,
887
- periodStart: test.periodStart || null,
888
- periodEnd: test.periodEnd || null,
889
- asOfDate: test.asOfDate || null,
918
+ coverage: test.coverage || null,
890
919
  sampleSize: test.sampleSize ?? null,
891
920
  sampleEvidenceIds: test.sampleEvidenceIds || [],
892
921
  exceptionCount: test.exceptionCount ?? null
@@ -1032,10 +1061,13 @@ function packetGaps({
1032
1061
  for (const systemId of audit.systemIds || []) {
1033
1062
  const system = byId.get(systemId);
1034
1063
  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
- }
1064
+ const commitmentIds = new Set(records
1065
+ .filter((record) => (
1066
+ record.type === "commitment"
1067
+ && record.status === "active"
1068
+ && (record.systemIds || []).includes(systemId)
1069
+ ))
1070
+ .map(({ id }) => id));
1039
1071
  if (!commitmentIds.size) {
1040
1072
  gaps.push(gap("error", "system-missing-commitments", `${system.title} has no active service commitment or system requirement.`, system.id));
1041
1073
  }
@@ -1043,8 +1075,8 @@ function packetGaps({
1043
1075
  const recentAssessment = records.some((record) => (
1044
1076
  record.type === "risk-assessment"
1045
1077
  && record.status === "complete"
1046
- && record.assessmentDate <= end
1047
- && record.assessmentDate >= shiftYear(end, -1)
1078
+ && record.completedOn <= end
1079
+ && record.completedOn >= shiftYear(end, -1)
1048
1080
  && (!(audit.systemIds || []).length || !(record.systemIds || []).length || record.systemIds.some((id) => audit.systemIds.includes(id)))
1049
1081
  ));
1050
1082
  if (!recentAssessment) {
@@ -1058,10 +1090,10 @@ function packetGaps({
1058
1090
  }
1059
1091
  const reviews = records.filter((record) => (
1060
1092
  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)
1093
+ && record.vendorId === vendorId
1094
+ && record.status === "complete"
1095
+ && record.completedOn <= end
1096
+ && record.completedOn >= shiftYear(end, -1)
1065
1097
  ));
1066
1098
  if (!reviews.length) {
1067
1099
  gaps.push(gap("error", "missing-subservice-review", `${vendor.title} has no completed review in the year ending ${end}.`, vendor.id));
@@ -1073,7 +1105,10 @@ function packetGaps({
1073
1105
  if (!vendorEvidence.length) {
1074
1106
  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
1107
  } else {
1076
- const assuranceReports = vendorEvidence.filter((item) => /soc|assurance|vendor-report/i.test(item.evidenceKind));
1108
+ const assuranceReports = vendorEvidence.filter((item) => (
1109
+ item.artifactKind === "third-party-report"
1110
+ || /soc|assurance|vendor-report/i.test(item.artifactSubtype || "")
1111
+ ));
1077
1112
  if (!assuranceReports.length) {
1078
1113
  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
1114
  } else if (assuranceReports.every((item) => !item.periodEnd)) {
@@ -1081,7 +1116,7 @@ function packetGaps({
1081
1116
  } else {
1082
1117
  const latestCoverage = assuranceReports.map((item) => item.periodEnd).filter(Boolean).sort().at(-1);
1083
1118
  const bridgeEvidence = vendorEvidence.some((item) => (
1084
- /bridge/i.test(item.evidenceKind)
1119
+ /bridge/i.test(item.artifactSubtype || "")
1085
1120
  && ((item.periodEnd && item.periodEnd >= end) || item.collectedOn >= end)
1086
1121
  ));
1087
1122
  if (latestCoverage && latestCoverage < end && !bridgeEvidence) {
@@ -1130,7 +1165,7 @@ function packetGaps({
1130
1165
  }
1131
1166
  }
1132
1167
  for (const item of evidence) {
1133
- if (item.evidenceKind === "rendered-record" && !item.sourceCommit) {
1168
+ if (item.artifactKind === "rendered-page" && !item.sourceCommit) {
1134
1169
  gaps.push(gap("error", "unbound-rendered-evidence", `${item.title} does not name the Git revision that was rendered.`, item.id));
1135
1170
  } else if (item.sourceCommit && !item.sourceCommitValid) {
1136
1171
  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 +1199,21 @@ function packetGaps({
1164
1199
  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
1200
  }
1166
1201
  }
1167
- } else if (["population-export", "system-export", "configuration-export"].includes(item.evidenceKind)) {
1202
+ } else if (["population-export", "system-export", "configuration-export"].includes(item.artifactKind)) {
1168
1203
  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
1204
  }
1170
1205
  if (item.externalReference && !item.filePaths.length) {
1171
1206
  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
1207
  }
1173
- if (item.evidenceKind === "rendered-record") {
1208
+ if (item.artifactKind === "rendered-page") {
1174
1209
  const captureComplete = item.capture
1175
1210
  && typeof item.capture.route === "string"
1176
1211
  && item.capture.route.trim()
1177
1212
  && item.capture.filters
1178
1213
  && typeof item.capture.filters === "object"
1179
1214
  && !Array.isArray(item.capture.filters)
1180
- && typeof item.capture.periodStart === "string"
1181
- && typeof item.capture.periodEnd === "string"
1215
+ && coverageStart(item.capture.coverage)
1216
+ && coverageEnd(item.capture.coverage)
1182
1217
  && typeof item.capture.capturedAt === "string"
1183
1218
  && typeof item.capture.method === "string"
1184
1219
  && item.capture.method.trim();
@@ -1186,7 +1221,7 @@ function packetGaps({
1186
1221
  gaps.push(gap("error", "missing-render-capture-context", `${item.title} does not record its route, filters, period, capture time, and method.`, item.id));
1187
1222
  }
1188
1223
  }
1189
- if (item.evidenceKind === "population-export") {
1224
+ if (item.artifactKind === "population-export") {
1190
1225
  for (const [field, label] of [
1191
1226
  ["generatedAt", "generation time"],
1192
1227
  ["timezone", "report timezone"],
@@ -1217,22 +1252,23 @@ function controlNeedsExternalEvidence(control, model) {
1217
1252
  const families = (model.evidenceSourceFamilies || []).filter((family) => (
1218
1253
  (family.controlCodes || []).includes(control?.code)
1219
1254
  ));
1220
- return !families.length || families.some((family) => family.collectionTestRequired !== false);
1255
+ return !families.length || families.some((family) => family.filegrcManaged !== true);
1221
1256
  }
1222
1257
 
1223
1258
  function auditGaps(gaps, audit, byId, records, start, end) {
1224
1259
  if (!["soc-2-type-1", "soc-2-type-2"].includes(audit.auditKind)) {
1225
1260
  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
1261
  } else if (audit.auditKind === "soc-2-type-1") {
1227
- if (!audit.typeOneAsOf) {
1262
+ const asOf = coverageStart(audit.coverage);
1263
+ if (audit.coverage?.kind !== "as-of" || !asOf) {
1228
1264
  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));
1265
+ } else if (start !== asOf || end !== asOf) {
1266
+ 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
1267
  }
1232
- } else if (!audit.periodStart || !audit.periodEnd) {
1268
+ } else if (audit.coverage?.kind !== "range") {
1233
1269
  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));
1270
+ } else if (!coverageMatches(audit.coverage, start, end)) {
1271
+ 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
1272
  }
1237
1273
  if (!(audit.systemIds || []).length) gaps.push(gap("error", "audit-systems-missing", `${audit.title} has no in-scope systems.`, audit.id));
1238
1274
  if (!(audit.requirementIds || []).length) gaps.push(gap("error", "audit-requirements-missing", `${audit.title} has no selected criteria.`, audit.id));
@@ -1246,7 +1282,7 @@ function auditGaps(gaps, audit, byId, records, start, end) {
1246
1282
  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
1283
  }
1248
1284
  }
1249
- if (!audit.auditor && !audit.auditorVendorId) gaps.push(gap("error", "auditor-missing", `${audit.title} does not identify the independent CPA firm.`, audit.id));
1285
+ if (!audit.auditorVendorId) gaps.push(gap("error", "auditor-missing", `${audit.title} does not identify the independent CPA firm.`, audit.id));
1250
1286
  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
1287
  if ((audit.subserviceVendorIds || []).length && audit.subserviceMethod === "not-applicable") {
1252
1288
  gaps.push(gap("error", "subservice-scope-conflict", `${audit.title} names subservice organizations but marks their treatment not applicable.`, audit.id));
@@ -1347,12 +1383,15 @@ function evidenceSummary(record, byId, revisionIsValid) {
1347
1383
  id: record.id,
1348
1384
  title: record.title,
1349
1385
  status: record.status,
1350
- evidenceKind: record.evidenceKind,
1351
- source: record.source,
1386
+ artifactKind: record.artifactKind,
1387
+ artifactSubtype: record.artifactSubtype || null,
1388
+ sourceKind: record.sourceKind,
1389
+ sourceDescription: record.sourceDescription,
1352
1390
  collectedOn: record.collectedOn,
1353
- periodStart: record.periodStart,
1354
- periodEnd: record.periodEnd,
1355
- classification: record.classification,
1391
+ coverage: record.coverage || null,
1392
+ periodStart: coverageStart(record.coverage),
1393
+ periodEnd: coverageEnd(record.coverage),
1394
+ classificationId: record.classificationId,
1356
1395
  generatedAt: record.generatedAt || null,
1357
1396
  timezone: record.timezone || null,
1358
1397
  queryDescription: record.queryDescription || null,
@@ -1384,13 +1423,14 @@ function populationSummary(record, byId) {
1384
1423
  status: record.status,
1385
1424
  auditId: record.auditId,
1386
1425
  populationKind: record.populationKind,
1387
- periodStart: record.periodStart,
1388
- periodEnd: record.periodEnd,
1426
+ coverage: record.coverage || null,
1427
+ periodStart: coverageStart(record.coverage),
1428
+ periodEnd: coverageEnd(record.coverage),
1389
1429
  controlIds: record.controlIds || [],
1390
1430
  sourceSystemId,
1391
1431
  sourceSystem: byId.get(sourceSystemId)?.title || null,
1392
1432
  sourceEvidenceId: record.sourceEvidenceId || null,
1393
- source: evidence?.source || null,
1433
+ source: evidence?.sourceDescription || null,
1394
1434
  populationCount: evidence?.populationCount ?? null,
1395
1435
  queryDescription: evidence?.queryDescription || null,
1396
1436
  timezone: evidence?.timezone || null,
@@ -1439,7 +1479,7 @@ function populationGaps(gaps, audit, populations, byId, model) {
1439
1479
  }
1440
1480
  }
1441
1481
  for (const population of populations) {
1442
- if (population.periodStart !== audit.periodStart || population.periodEnd !== audit.periodEnd) {
1482
+ if (!coverageMatches(population.coverage, coverageStart(audit.coverage), coverageEnd(audit.coverage))) {
1443
1483
  gaps.push(gap("error", "population-period-mismatch", `${population.title} does not match the exact audit period.`, population.id));
1444
1484
  }
1445
1485
  if (population.status === "not-applicable") {
@@ -1462,11 +1502,11 @@ function populationGaps(gaps, audit, populations, byId, model) {
1462
1502
  gaps.push(gap("error", "population-exceptions-undocumented", `${population.title} concludes with exceptions but does not describe them in the reconciliation summary.`, population.id));
1463
1503
  }
1464
1504
  const evidence = byId.get(population.sourceEvidenceId);
1465
- if (!evidence || evidence.type !== "evidence" || evidence.evidenceKind !== "population-export") {
1505
+ if (!evidence || evidence.type !== "evidence" || evidence.artifactKind !== "population-export") {
1466
1506
  gaps.push(gap("error", "population-export-missing", `${population.title} does not link a population-export evidence record.`, population.id));
1467
1507
  continue;
1468
1508
  }
1469
- if (evidence.periodStart !== audit.periodStart || evidence.periodEnd !== audit.periodEnd) {
1509
+ if (!coverageMatches(evidence.coverage, coverageStart(audit.coverage), coverageEnd(audit.coverage))) {
1470
1510
  gaps.push(gap("error", "population-evidence-period-mismatch", `${evidence.title} does not cover the exact audit period.`, evidence.id));
1471
1511
  }
1472
1512
  const generatedOn = timestampDate(evidence.generatedAt, evidence.timezone);
@@ -1488,10 +1528,10 @@ function populationGaps(gaps, audit, populations, byId, model) {
1488
1528
 
1489
1529
  function evidenceCoversPacketDate(item, audit, start, end) {
1490
1530
  if (audit?.auditKind === "soc-2-type-1") {
1491
- return (item.periodStart && item.periodEnd && item.periodStart <= start && item.periodEnd >= start)
1531
+ return coverageContains(item.coverage, start)
1492
1532
  || item.collectedOn === start;
1493
1533
  }
1494
- return (item.periodStart && item.periodEnd && item.periodStart <= end && item.periodEnd >= start)
1534
+ return coverageOverlaps(item.coverage, start, end)
1495
1535
  || (item.collectedOn >= start && item.collectedOn <= end);
1496
1536
  }
1497
1537
 
@@ -1502,7 +1542,7 @@ function displaySourceKind(value) {
1502
1542
  function overlapsEvidencePeriod(record, start, end) {
1503
1543
  return record.type === "evidence" && (
1504
1544
  (record.collectedOn >= start && record.collectedOn <= end)
1505
- || (record.periodStart && record.periodEnd && record.periodStart <= end && record.periodEnd >= start)
1545
+ || coverageOverlaps(record.coverage, start, end)
1506
1546
  );
1507
1547
  }
1508
1548
 
@@ -1580,7 +1620,7 @@ function packetHtml(packet) {
1580
1620
  ? `<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
1621
  : "<p>No recurring occurrences intersect this period.</p>";
1582
1622
  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>`
1623
+ ? `<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
1624
  : "<p>No External Evidence records were selected.</p>";
1585
1625
  const sourceSystems = packet.sourceSystems.length
1586
1626
  ? `<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 +1709,11 @@ function requireDate(value, label) {
1669
1709
  function resolvePacketPeriod(options, audit) {
1670
1710
  const typeOne = audit?.auditKind === "soc-2-type-1";
1671
1711
  const start = requireDate(
1672
- options.start || (typeOne ? audit?.typeOneAsOf : audit?.periodStart),
1712
+ options.start || coverageStart(audit?.coverage),
1673
1713
  typeOne ? "Type 1 as-of date" : "packet start date"
1674
1714
  );
1675
1715
  const end = requireDate(
1676
- options.end || (typeOne ? audit?.typeOneAsOf : audit?.periodEnd),
1716
+ options.end || coverageEnd(audit?.coverage),
1677
1717
  typeOne ? "Type 1 as-of date" : "packet end date"
1678
1718
  );
1679
1719
  if (end < start) throw new Error("The packet end date must not be before its start date.");