filegrc 0.1.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.
@@ -0,0 +1,1642 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { copyFile, mkdir, readFile, readdir, readlink, rm, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { assessAuditPreparation } from "./audit-preparation.js";
6
+ import { getFileAtRevision, getGitSummary, getWorkspaceHistories, hasGitRevision } from "./git.js";
7
+ import { planObligations } from "./obligations.js";
8
+ import { isWithin, resolveDataPath, resolveWorkspacePath } from "./paths.js";
9
+ import { parseCalendarDate } from "./recurrence.js";
10
+ import { markdownEntries } from "./resource-markdown.js";
11
+ import { serializeWorkspaceMutation } from "./mutation.js";
12
+ import { validateWorkspace } from "./validate.js";
13
+
14
+ export async function prepareEvidencePacket(input, options = {}) {
15
+ const validation = await validateWorkspace(input);
16
+ if (!validation.ok) throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before generating evidence.`);
17
+ const { loaded } = validation;
18
+ const records = loaded.resources;
19
+ const byId = new Map(records.map((record) => [record.id, record]));
20
+ const entriesById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
21
+ const audit = options.auditId ? byId.get(options.auditId) : null;
22
+ if (options.auditId && audit?.type !== "audit") throw new Error(`Audit "${options.auditId}" was not found.`);
23
+ const { start, end, basis } = resolvePacketPeriod(options, audit);
24
+ const typeOne = audit?.auditKind === "soc-2-type-1";
25
+ const datedRecords = loaded.entries
26
+ .filter((entry) => !audit || recordRelevantToAudit(entry.record, audit, byId))
27
+ .map((entry) => packetRecord(entry.record, loaded.model, start, end, loaded.workspace.timezone))
28
+ .filter(Boolean);
29
+ const datedRecordIds = new Set(datedRecords.map(({ id }) => id));
30
+ const datedEvidenceSourceIds = new Set(
31
+ [...datedRecordIds].flatMap((id) => (
32
+ byId.get(id)?.type === "evidence" ? byId.get(id).sourceResourceIds || [] : []
33
+ ))
34
+ );
35
+ const plan = planObligations(records, {
36
+ asOf: end,
37
+ from: start,
38
+ through: end,
39
+ includeComplete: true
40
+ });
41
+ const obligations = (typeOne ? [] : plan.calendarItems).filter((item) => (
42
+ item.dueWindowStart <= end
43
+ && item.overdueOn > start
44
+ && (!audit || recordRelevantToAudit(byId.get(item.obligationId), audit, byId))
45
+ ));
46
+ const eventRuns = (typeOne ? [] : plan.eventRuns).filter((run) => (
47
+ (!audit || run.actions.some((action) => recordRelevantToAudit(byId.get(action.obligationId), audit, byId)))
48
+ && (
49
+ (run.occurredOn >= start && run.occurredOn <= end)
50
+ || datedEvidenceSourceIds.has(run.id)
51
+ || run.actions.some((action) => (
52
+ (action.completedOn && action.completedOn >= start && action.completedOn <= end)
53
+ || datedEvidenceSourceIds.has(action.actionItemId)
54
+ || [...action.completionResourceIds, ...action.evidenceIds].some((id) => datedRecordIds.has(id))
55
+ || (action.dueWindowStart <= end && (!action.overdueOn || action.overdueOn > start))
56
+ ))
57
+ )
58
+ ));
59
+ const selectedIds = new Set(datedRecords.map((record) => record.id));
60
+ if (audit) {
61
+ selectedIds.add(audit.id);
62
+ addIds(selectedIds, [
63
+ ...(audit.frameworkIds || []),
64
+ ...(audit.systemIds || []),
65
+ ...(audit.requirementIds || []),
66
+ ...(audit.controlIds || []),
67
+ ...(audit.controlTestIds || []),
68
+ ...(audit.evidenceIds || []),
69
+ ...(audit.findingIds || []),
70
+ ...(audit.contactIds || []),
71
+ ...(audit.complementaryControlIds || []),
72
+ ...(audit.subserviceVendorIds || []),
73
+ audit.systemDescriptionDocumentId,
74
+ audit.managementAssertionDocumentId,
75
+ audit.managementRepresentationDocumentId,
76
+ audit.periodCompletenessDocumentId,
77
+ audit.managementResponseDocumentId,
78
+ audit.reportEvidenceId,
79
+ ...(audit.supplementalDocumentIds || [])
80
+ ]);
81
+ for (const request of records.filter((record) => record.type === "audit-request" && record.auditId === audit.id)) {
82
+ selectedIds.add(request.id);
83
+ }
84
+ }
85
+ for (const item of obligations) {
86
+ selectedIds.add(item.obligationId);
87
+ addIds(selectedIds, item.completionResourceIds);
88
+ addIds(selectedIds, item.evidenceIds);
89
+ }
90
+ for (const run of eventRuns) {
91
+ selectedIds.add(run.id);
92
+ addIds(selectedIds, run.actionItemIds);
93
+ for (const action of run.actions) {
94
+ selectedIds.add(action.obligationId);
95
+ addIds(selectedIds, action.completionResourceIds);
96
+ addIds(selectedIds, action.evidenceIds);
97
+ }
98
+ }
99
+
100
+ const evidenceIds = new Set();
101
+ for (const evidence of records.filter((record) => record.type === "evidence")) {
102
+ if (audit && !recordRelevantToAudit(evidence, audit, byId)) continue;
103
+ const direct = selectedIds.has(evidence.id)
104
+ || (evidence.sourceResourceIds || []).some((id) => selectedIds.has(id))
105
+ || overlapsEvidencePeriod(evidence, start, end);
106
+ if (direct) evidenceIds.add(evidence.id);
107
+ }
108
+ for (const id of [...selectedIds]) {
109
+ const record = byId.get(id);
110
+ addIds(evidenceIds, record?.evidenceIds);
111
+ addIds(evidenceIds, record?.sampleEvidenceIds);
112
+ if (record?.sourceEvidenceId) evidenceIds.add(record.sourceEvidenceId);
113
+ if (record?.populationId) selectedIds.add(record.populationId);
114
+ }
115
+ addIds(selectedIds, evidenceIds);
116
+ expandEvidenceWorkflowContext(selectedIds, byId);
117
+ for (const id of selectedIds) if (byId.get(id)?.type === "evidence") evidenceIds.add(id);
118
+
119
+ const controlIds = new Set(audit?.controlIds || records
120
+ .filter((record) => record.type === "control" && !["not-applicable", "retired"].includes(record.status))
121
+ .map(({ id }) => id));
122
+ if (!audit) {
123
+ for (const id of selectedIds) {
124
+ const record = byId.get(id);
125
+ addIds(controlIds, record?.controlIds);
126
+ if (record?.type === "control") controlIds.add(record.id);
127
+ if (record?.obligationId) addIds(controlIds, byId.get(record.obligationId)?.controlIds);
128
+ }
129
+ }
130
+ addIds(selectedIds, controlIds);
131
+ for (const controlId of controlIds) {
132
+ const control = byId.get(controlId);
133
+ addIds(selectedIds, control?.systemIds);
134
+ addIds(selectedIds, control?.commitmentIds);
135
+ addIds(selectedIds, control?.complementaryControlIds);
136
+ addIds(selectedIds, control?.riskIds);
137
+ }
138
+ for (const systemId of audit?.systemIds || []) {
139
+ const system = byId.get(systemId);
140
+ addIds(selectedIds, system?.commitmentIds);
141
+ addIds(selectedIds, system?.subserviceVendorIds);
142
+ }
143
+
144
+ const policyIds = new Set(audit
145
+ ? []
146
+ : records.filter((record) => record.type === "policy" && ["approved", "active"].includes(record.status)).map((record) => record.id));
147
+ for (const id of selectedIds) addIds(policyIds, policyIdsFor(byId.get(id), byId));
148
+ for (const controlId of controlIds) addIds(policyIds, byId.get(controlId)?.policyIds);
149
+ expandSupersededPolicyIds(policyIds, byId);
150
+ addIds(selectedIds, policyIds);
151
+
152
+ const requirementIds = new Set();
153
+ for (const controlId of controlIds) addIds(requirementIds, byId.get(controlId)?.requirementIds);
154
+ if (audit) addIds(requirementIds, audit.requirementIds);
155
+ addIds(selectedIds, requirementIds);
156
+
157
+ const sourceRevisionValidity = new Map();
158
+ const revisionIsValid = (revision) => {
159
+ if (!sourceRevisionValidity.has(revision)) sourceRevisionValidity.set(revision, hasGitRevision(loaded.root, revision));
160
+ return sourceRevisionValidity.get(revision);
161
+ };
162
+ const evidence = [...evidenceIds].map((id) => evidenceSummary(byId.get(id), byId, revisionIsValid)).filter(Boolean).sort(byTitle);
163
+ const sourceSystemIds = new Set([
164
+ ...(audit?.systemIds || []),
165
+ ...evidence.map((item) => item.sourceSystemId).filter(Boolean)
166
+ ]);
167
+ const sourceSystems = [...sourceSystemIds]
168
+ .map((id) => sourceSystemSummary(byId.get(id), evidence, audit))
169
+ .filter(Boolean)
170
+ .sort(byTitle);
171
+ const selectedEntries = [...selectedIds].map((id) => entriesById.get(id)).filter(Boolean);
172
+ const selectedPaths = selectedEntries.flatMap((entry) => [
173
+ `data/${entry.relativePath}`,
174
+ ...markdownEntries(loaded.model, entry.record).map((markdown) => `data/${markdown.path}`)
175
+ ]);
176
+ const historyRevision = getGitSummary(loaded.root);
177
+ const histories = getWorkspaceHistories(loaded.root, selectedPaths, Number.MAX_SAFE_INTEGER);
178
+ const packetRecords = [...selectedIds]
179
+ .map((id) => byId.get(id))
180
+ .filter(Boolean)
181
+ .map((record) => {
182
+ const path = `data/${entriesById.get(record.id)?.relativePath || ""}`;
183
+ const contentPaths = markdownEntries(loaded.model, record).map((markdown) => `data/${markdown.path}`);
184
+ return {
185
+ id: record.id,
186
+ type: record.type,
187
+ title: record.title,
188
+ path,
189
+ dates: packetRecord(record, loaded.model, start, end, loaded.workspace.timezone)?.dates || [],
190
+ policyIds: policyIdsFor(record, byId),
191
+ evidenceIds: record.evidenceIds || [],
192
+ history: histories.get(path) || [],
193
+ contentPaths: contentPaths.map((contentPath) => ({
194
+ path: contentPath,
195
+ history: histories.get(contentPath) || []
196
+ }))
197
+ };
198
+ })
199
+ .sort((a, b) => a.type.localeCompare(b.type) || a.title.localeCompare(b.title));
200
+ await assertLoadedEntriesCurrent(loaded);
201
+ const dataDigest = await dataTreeDigest(loaded.root);
202
+ await assertLoadedEntriesCurrent(loaded);
203
+ const git = getGitSummary(loaded.root);
204
+ if (historyRevision.commit !== git.commit || historyRevision.branch !== git.branch) {
205
+ throw new Error("The Git revision changed while the evidence packet was being prepared. Try again.");
206
+ }
207
+ const controlCoverage = buildControlCoverage({
208
+ audit,
209
+ byId,
210
+ controlIds,
211
+ evidenceIds,
212
+ model: loaded.model,
213
+ records,
214
+ start,
215
+ end,
216
+ timezone: loaded.workspace.timezone
217
+ });
218
+ const populations = (typeOne ? [] : records)
219
+ .filter((record) => record.type === "audit-population" && (!audit || record.auditId === audit.id))
220
+ .map((record) => populationSummary(record, byId))
221
+ .sort(byTitle);
222
+ const generatedAt = options.generatedAt || new Date().toISOString();
223
+ const managementPreparation = await assessAuditPreparation(loaded, {
224
+ auditId: audit?.id,
225
+ generatedAt,
226
+ selectDefault: false
227
+ });
228
+ const gaps = packetGaps({
229
+ audit,
230
+ byId,
231
+ obligations,
232
+ eventRuns,
233
+ evidence,
234
+ git,
235
+ start,
236
+ end,
237
+ controlCoverage,
238
+ requirementIds,
239
+ records,
240
+ populations,
241
+ model: loaded.model,
242
+ managementPreparation
243
+ });
244
+ const errorCount = gaps.filter(({ severity }) => severity === "error").length;
245
+ const warningCount = gaps.filter(({ severity }) => severity === "warning").length;
246
+ return {
247
+ schemaVersion: 1,
248
+ generatedAt,
249
+ period: { start, end, basis },
250
+ readiness: {
251
+ status: errorCount ? "draft" : warningCount ? "review-required" : "delivery-ready",
252
+ errors: errorCount,
253
+ warnings: warningCount
254
+ },
255
+ audit: audit ? {
256
+ id: audit.id,
257
+ title: audit.title,
258
+ kind: audit.auditKind,
259
+ status: audit.status,
260
+ scope: audit.scope,
261
+ periodStart: audit.periodStart,
262
+ periodEnd: audit.periodEnd,
263
+ typeOneAsOf: audit.typeOneAsOf,
264
+ systemIds: audit.systemIds || [],
265
+ requirementIds: audit.requirementIds || [],
266
+ controlIds: audit.controlIds || [],
267
+ subserviceMethod: audit.subserviceMethod || null
268
+ } : null,
269
+ workspace: {
270
+ title: loaded.workspace.title,
271
+ organizationName: loaded.workspace.organizationName,
272
+ timezone: loaded.workspace.timezone
273
+ },
274
+ revision: {
275
+ commit: git.commit,
276
+ shortCommit: git.shortCommit,
277
+ branch: git.branch,
278
+ clean: git.clean,
279
+ dataDigest
280
+ },
281
+ handling: {
282
+ classifications: [...new Set(evidence.map(({ classification }) => classification).filter(Boolean))].sort(),
283
+ containsExternalReferences: evidence.some(({ externalReference }) => Boolean(externalReference)),
284
+ encrypted: false
285
+ },
286
+ summary: {
287
+ datedRecords: datedRecords.length,
288
+ records: packetRecords.length,
289
+ policies: policyIds.size,
290
+ controls: controlIds.size,
291
+ requirements: requirementIds.size,
292
+ systems: audit?.systemIds?.length || 0,
293
+ sourceSystems: sourceSystems.length,
294
+ obligationOccurrences: obligations.length,
295
+ eventRuns: eventRuns.length,
296
+ evidence: evidence.length,
297
+ populations: populations.length,
298
+ gaps: gaps.length,
299
+ errors: errorCount,
300
+ warnings: warningCount
301
+ },
302
+ datedRecords: datedRecords.sort((a, b) => a.primaryDate.localeCompare(b.primaryDate) || byTitle(a, b)),
303
+ policies: [...policyIds].map((id) => recordSummary(byId.get(id))).filter(Boolean).sort(byTitle),
304
+ controls: [...controlIds].map((id) => recordSummary(byId.get(id))).filter(Boolean).sort(byTitle),
305
+ obligations,
306
+ eventRuns,
307
+ evidence,
308
+ sourceSystems,
309
+ populations,
310
+ managementPreparation,
311
+ controlCoverage,
312
+ gaps,
313
+ records: packetRecords
314
+ };
315
+ }
316
+
317
+ function expandSupersededPolicyIds(policyIds, byId) {
318
+ const queue = [...policyIds];
319
+ for (let index = 0; index < queue.length; index += 1) {
320
+ const supersedesId = byId.get(queue[index])?.supersedesId;
321
+ if (!supersedesId || policyIds.has(supersedesId)) continue;
322
+ policyIds.add(supersedesId);
323
+ queue.push(supersedesId);
324
+ }
325
+ }
326
+
327
+ function recordRelevantToAudit(record, audit, byId, seen = new Set()) {
328
+ if (!record || seen.has(record.id)) return false;
329
+ seen.add(record.id);
330
+ if (record.id === audit.id || record.auditId === audit.id || (record.auditIds || []).includes(audit.id)) return true;
331
+ if (record.auditId && record.auditId !== audit.id) return false;
332
+ if ((record.auditIds || []).length) return false;
333
+ const selectedIds = new Set([
334
+ ...(audit.frameworkIds || []),
335
+ ...(audit.systemIds || []),
336
+ ...(audit.requirementIds || []),
337
+ ...(audit.controlIds || []),
338
+ ...(audit.controlTestIds || []),
339
+ ...(audit.evidenceIds || []),
340
+ ...(audit.findingIds || []),
341
+ ...(audit.contactIds || []),
342
+ ...(audit.complementaryControlIds || []),
343
+ ...(audit.subserviceVendorIds || []),
344
+ audit.systemDescriptionDocumentId,
345
+ audit.managementAssertionDocumentId,
346
+ audit.managementRepresentationDocumentId,
347
+ audit.periodCompletenessDocumentId,
348
+ audit.managementResponseDocumentId,
349
+ audit.reportEvidenceId,
350
+ ...(audit.supplementalDocumentIds || [])
351
+ ].filter(Boolean));
352
+ if (selectedIds.has(record.id)) return true;
353
+ const auditSystems = new Set(audit.systemIds || []);
354
+ const recordSystems = new Set([...(record.systemIds || []), record.systemId, record.sourceSystemId].filter(Boolean));
355
+ if (recordSystems.size && [...recordSystems].some((id) => auditSystems.has(id))) return true;
356
+ const auditControls = new Set(audit.controlIds || []);
357
+ const recordControls = controlIdsForRecord(record, byId);
358
+ if (recordControls.size && [...recordControls].some((id) => auditControls.has(id))) return true;
359
+ const auditRequirements = new Set(audit.requirementIds || []);
360
+ if ((record.requirementIds || []).some((id) => auditRequirements.has(id))) return true;
361
+ const auditControlRecords = [...auditControls].map((id) => byId.get(id)).filter(Boolean);
362
+ const policyIds = new Set(auditControlRecords.flatMap((control) => control.policyIds || []));
363
+ const commitmentIds = new Set(auditControlRecords.flatMap((control) => control.commitmentIds || []));
364
+ const riskIds = new Set(auditControlRecords.flatMap((control) => control.riskIds || []));
365
+ if ((record.type === "policy" && policyIds.has(record.id)) || (record.policyIds || []).some((id) => policyIds.has(id))) return true;
366
+ if ((record.type === "commitment" && commitmentIds.has(record.id)) || (record.commitmentIds || []).some((id) => commitmentIds.has(id))) return true;
367
+ if ((record.type === "risk" && riskIds.has(record.id)) || (record.riskIds || []).some((id) => riskIds.has(id))) return true;
368
+ for (const sourceId of [...(record.sourceResourceIds || []), record.sourceResourceId].filter(Boolean)) {
369
+ if (recordRelevantToAudit(byId.get(sourceId), audit, byId, seen)) return true;
370
+ }
371
+ return false;
372
+ }
373
+
374
+ function expandEvidenceWorkflowContext(selectedIds, byId) {
375
+ const queue = [...selectedIds];
376
+ const enqueue = (ids = []) => {
377
+ for (const id of ids) {
378
+ if (!id || selectedIds.has(id)) continue;
379
+ selectedIds.add(id);
380
+ queue.push(id);
381
+ }
382
+ };
383
+ for (let index = 0; index < queue.length; index += 1) {
384
+ const record = byId.get(queue[index]);
385
+ if (record?.type === "evidence") enqueue([...(record.sourceResourceIds || []), record.sourceSystemId]);
386
+ if (record?.type === "audit-population") enqueue([record.sourceEvidenceId, ...(record.controlIds || [])]);
387
+ if (record?.type === "control-test") enqueue([record.populationId, ...(record.sampleEvidenceIds || [])]);
388
+ if (record?.type === "action-item") {
389
+ enqueue([
390
+ record.sourceResourceId,
391
+ record.obligationId,
392
+ ...(record.completionResourceIds || []),
393
+ ...(record.evidenceIds || [])
394
+ ]);
395
+ }
396
+ if (record?.type === "obligation-event") enqueue([...(record.obligationIds || []), ...(record.actionItemIds || [])]);
397
+ }
398
+ }
399
+
400
+ export async function writeEvidencePacket(input, packet, options = {}) {
401
+ const baseName = `${packet.period.start}-to-${packet.period.end}-${packet.revision.shortCommit || "uncommitted"}`;
402
+ let outputOption = options.output || `.filegrc/evidence-packets/${baseName}`;
403
+ requireDerivedOutputPath(outputOption);
404
+ let output = resolveWorkspacePath(input, outputOption);
405
+ const validation = await validateWorkspace(input);
406
+ if (!validation.ok) throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before writing evidence.`);
407
+ await assertPacketSourceState(packet, validation.loaded);
408
+ const entriesById = new Map(validation.loaded.entries.map((entry) => [entry.record.id, entry]));
409
+ const byId = new Map(validation.loaded.resources.map((record) => [record.id, record]));
410
+ const files = [];
411
+ let outputCreated = false;
412
+ try {
413
+ await mkdir(dirname(output), { recursive: true });
414
+ if (options.output) {
415
+ await mkdir(output);
416
+ outputCreated = true;
417
+ } else {
418
+ let suffix = 2;
419
+ while (true) {
420
+ try {
421
+ await mkdir(output);
422
+ outputCreated = true;
423
+ break;
424
+ } catch (error) {
425
+ if (error.code !== "EEXIST") throw error;
426
+ outputOption = `.filegrc/evidence-packets/${baseName}-${suffix++}`;
427
+ output = resolveWorkspacePath(input, outputOption);
428
+ }
429
+ }
430
+ }
431
+ await writePacketFile(output, "manifest.json", `${JSON.stringify(packet, null, 2)}\n`, files);
432
+ await writePacketFile(output, "README.md", packetMarkdown(packet), files);
433
+ await writePacketFile(output, "index.html", packetHtml(packet), files);
434
+ await writePacketFile(output, "control-matrix.csv", controlMatrixCsv(packet), files);
435
+ await writePacketFile(output, "evidence-index.csv", evidenceIndexCsv(packet), files);
436
+ await writePacketFile(output, "source-system-index.csv", sourceSystemIndexCsv(packet), files);
437
+ await writePacketFile(output, "external-evidence-index.csv", externalEvidenceIndexCsv(packet), files);
438
+ await writePacketFile(output, "population-index.csv", populationIndexCsv(packet), files);
439
+ await writePacketFile(output, "HANDLING.md", packetHandlingMarkdown(packet), files);
440
+ for (const item of packet.records) {
441
+ const entry = entriesById.get(item.id);
442
+ if (!entry) continue;
443
+ await writePacketFile(output, join("records", item.type, `${item.id}.json`), entry.source, files);
444
+ for (const markdown of markdownEntries(validation.loaded.model, entry.record)) {
445
+ try {
446
+ await copyPacketFile(
447
+ resolveDataPath(validation.loaded.root, markdown.path),
448
+ output,
449
+ join("content", markdown.path.replace(/^content\//, "")),
450
+ files
451
+ );
452
+ } catch (error) {
453
+ if (error.code !== "ENOENT") throw error;
454
+ }
455
+ }
456
+ }
457
+ for (const item of packet.evidence) {
458
+ const record = byId.get(item.id);
459
+ for (const path of record?.filePaths || []) {
460
+ await copyPacketFile(resolveDataPath(validation.loaded.root, path), output, join("attachments", path), files);
461
+ }
462
+ }
463
+ const historyIndex = [];
464
+ for (const item of packet.records) {
465
+ await exportCommittedVersions(validation.loaded.root, output, item, item.path, item.history, historyIndex, files);
466
+ for (const content of item.contentPaths || []) {
467
+ await exportCommittedVersions(validation.loaded.root, output, item, content.path, content.history, historyIndex, files);
468
+ }
469
+ }
470
+ await writePacketFile(output, "history/index.json", `${JSON.stringify(historyIndex, null, 2)}\n`, files);
471
+ await assertPacketSourceState(packet, validation.loaded);
472
+ await writeChecksums(output, files);
473
+ files.push("SHA256SUMS");
474
+ return { output, files };
475
+ } catch (error) {
476
+ if (outputCreated) await rm(output, { recursive: true, force: true });
477
+ error.message = `Evidence packet generation failed in ${outputOption}. ${error.message}`;
478
+ throw error;
479
+ }
480
+ }
481
+
482
+ export function generateEvidencePacket(input, options = {}) {
483
+ return serializeWorkspaceMutation(input, async (root) => {
484
+ const packet = await prepareEvidencePacket(root, options);
485
+ const written = await writeEvidencePacket(root, packet, { output: options.output });
486
+ return { packet, ...written };
487
+ });
488
+ }
489
+
490
+ async function exportCommittedVersions(root, output, item, sourcePath, history, historyIndex, files) {
491
+ for (const revision of history || []) {
492
+ const source = getFileAtRevision(root, revision.commit, sourcePath);
493
+ if (source === null) continue;
494
+ const exportedPath = join("history", item.type, item.id, revision.commit, basename(sourcePath));
495
+ await writePacketFile(output, exportedPath, source, files);
496
+ historyIndex.push({
497
+ resourceId: item.id,
498
+ resourceType: item.type,
499
+ sourcePath,
500
+ exportedPath: exportedPath.split("\\").join("/"),
501
+ ...revision
502
+ });
503
+ }
504
+ }
505
+
506
+ async function writeChecksums(output, files) {
507
+ const lines = [];
508
+ for (const relativePath of [...files].sort()) {
509
+ const hash = createHash("sha256");
510
+ for await (const chunk of createReadStream(resolvePacketOutputPath(output, relativePath))) hash.update(chunk);
511
+ lines.push(`${hash.digest("hex")} ${relativePath}`);
512
+ }
513
+ await writeFile(resolvePacketOutputPath(output, "SHA256SUMS"), `${lines.join("\n")}\n`, { encoding: "utf8", flag: "wx" });
514
+ }
515
+
516
+ function controlMatrixCsv(packet) {
517
+ return csv([
518
+ ["Control ID", "Code", "Control", "Control Statement", "Operating Activity", "Status", "Effective On", "Frequency", "Operation Mode", "System IDs", "Requirement IDs", "Policy IDs", "Risk IDs", "Operating Record IDs", "Evidence IDs", "Control Test IDs", "Test Outcomes", "Population IDs", "Population Counts", "Sample Sizes", "Exception Counts", "Population Evidence IDs", "Sample Evidence IDs"],
519
+ ...packet.controlCoverage.map((control) => [
520
+ control.id,
521
+ control.code,
522
+ control.title,
523
+ control.statement,
524
+ control.activity,
525
+ control.status,
526
+ control.effectiveOn,
527
+ control.frequency,
528
+ control.operationMode,
529
+ control.systemIds.join("\n"),
530
+ control.requirementIds.join("\n"),
531
+ control.policyIds.join("\n"),
532
+ control.riskIds.join("\n"),
533
+ control.operatingRecordIds.join("\n"),
534
+ control.evidenceIds.join("\n"),
535
+ control.tests.map(({ id }) => id).join("\n"),
536
+ control.tests.map(({ outcome }) => outcome || "").join("\n"),
537
+ control.tests.map(({ populationId }) => populationId || "").join("\n"),
538
+ control.tests.map(({ populationCount }) => populationCount ?? "").join("\n"),
539
+ control.tests.map(({ sampleSize }) => sampleSize ?? "").join("\n"),
540
+ control.tests.map(({ exceptionCount }) => exceptionCount ?? "").join("\n"),
541
+ control.tests.map(({ populationEvidenceId }) => populationEvidenceId || "").join("\n"),
542
+ control.tests.flatMap(({ sampleEvidenceIds }) => sampleEvidenceIds).join("\n")
543
+ ])
544
+ ]);
545
+ }
546
+
547
+ function packetHandlingMarkdown(packet) {
548
+ return [
549
+ "# Packet Handling",
550
+ "",
551
+ `Evidence classifications: ${packet.handling.classifications.join(", ") || "none recorded"}`,
552
+ `External references present: ${packet.handling.containsExternalReferences ? "yes" : "no"}`,
553
+ "Encrypted by FileGRC: no",
554
+ "",
555
+ "Review every included record and attachment for secrets, unnecessary personal data, customer data, and material outside the audit scope before transfer.",
556
+ "",
557
+ "Review `external-evidence-index.csv` before delivery. It identifies references that FileGRC did not copy. Reconcile those items to the auditor portal or other approved system so the engagement team can confirm it received the same evidence indexed here.",
558
+ "",
559
+ "Transfer this directory through the auditor's approved encrypted channel. Do not email an unencrypted packet. Give access only to the engagement team and retain or remove exported copies under the organization's evidence-retention rules.",
560
+ "",
561
+ "After transfer, enter the packet directory and run `shasum -a 256 -c SHA256SUMS` or `sha256sum -c SHA256SUMS`. FileGRC does not sign or encrypt the packet because those operations require organization-controlled keys and transfer-system choices.",
562
+ ""
563
+ ].join("\n");
564
+ }
565
+
566
+ function evidenceIndexCsv(packet) {
567
+ return csv([
568
+ ["Evidence ID", "Evidence", "Status", "Kind", "Source", "Source System ID", "Source System", "Collected On", "Collector IDs", "Verified On", "Verifier IDs", "Period Start", "Period End", "Generated At", "Timezone", "Query or Report Parameters", "Population Count", "Completeness Validation", "Accuracy Validation", "Control IDs", "Source Resource IDs", "Source Commit", "File Paths", "External Reference"],
569
+ ...packet.evidence.map((item) => [
570
+ item.id,
571
+ item.title,
572
+ item.status,
573
+ item.evidenceKind,
574
+ item.source,
575
+ item.sourceSystemId,
576
+ item.sourceSystem,
577
+ item.collectedOn,
578
+ item.collectorIds.join("\n"),
579
+ item.verifiedOn,
580
+ item.verifierIds.join("\n"),
581
+ item.periodStart,
582
+ item.periodEnd,
583
+ item.generatedAt,
584
+ item.timezone,
585
+ item.queryDescription,
586
+ item.populationCount,
587
+ item.completenessValidation,
588
+ item.accuracyValidation,
589
+ item.controlIds.join("\n"),
590
+ item.sourceResourceIds.join("\n"),
591
+ item.sourceCommit,
592
+ item.filePaths.join("\n"),
593
+ item.externalReference ? JSON.stringify(item.externalReference) : ""
594
+ ])
595
+ ]);
596
+ }
597
+
598
+ function sourceSystemIndexCsv(packet) {
599
+ return csv([
600
+ ["System ID", "System", "Status", "Evidence Source Roles", "Evidence Access Owner IDs", "Vendor ID", "In Audit Scope", "Evidence IDs"],
601
+ ...packet.sourceSystems.map((item) => [
602
+ item.id,
603
+ item.title,
604
+ item.status,
605
+ item.evidenceSourceKinds.join("\n"),
606
+ item.evidenceOwnerIds.join("\n"),
607
+ item.vendorId,
608
+ item.inAuditScope ? "yes" : "no",
609
+ item.evidenceIds.join("\n")
610
+ ])
611
+ ]);
612
+ }
613
+
614
+ function externalEvidenceIndexCsv(packet) {
615
+ return csv([
616
+ ["Evidence ID", "Evidence", "Source System ID", "Source System", "Control IDs", "External Reference", "Fixed Attachment Included", "Delivery Note"],
617
+ ...packet.evidence
618
+ .filter((item) => item.externalReference)
619
+ .map((item) => [
620
+ item.id,
621
+ item.title,
622
+ item.sourceSystemId,
623
+ item.sourceSystem,
624
+ item.controlIds.join("\n"),
625
+ JSON.stringify(item.externalReference),
626
+ item.filePaths.length ? "yes" : "no",
627
+ item.filePaths.length
628
+ ? "A fixed attachment is included; confirm it matches the external source."
629
+ : "Deliver through the auditor-approved external system and reconcile receipt to this reference."
630
+ ])
631
+ ]);
632
+ }
633
+
634
+ function populationIndexCsv(packet) {
635
+ return csv([
636
+ ["Population ID", "Population", "Kind", "Status", "Period Start", "Period End", "Source System ID", "Source System", "Authoritative Source", "Query or Report Parameters", "Timezone", "Generated At", "Record Count", "Completeness Validation", "Accuracy Validation", "Reconciled By", "Reconciled On", "Conclusion", "Control IDs", "Evidence ID", "Not Applicable Reason"],
637
+ ...packet.populations.map((item) => [
638
+ item.id,
639
+ item.title,
640
+ item.populationKind,
641
+ item.status,
642
+ item.periodStart,
643
+ item.periodEnd,
644
+ item.sourceSystemId,
645
+ item.sourceSystem,
646
+ item.source,
647
+ item.queryDescription,
648
+ item.timezone,
649
+ item.generatedAt,
650
+ item.populationCount,
651
+ item.completenessValidation,
652
+ item.accuracyValidation,
653
+ item.reconciledByIds.join("\n"),
654
+ item.reconciledOn,
655
+ item.conclusion,
656
+ item.controlIds.join("\n"),
657
+ item.sourceEvidenceId,
658
+ item.notApplicableReason
659
+ ])
660
+ ]);
661
+ }
662
+
663
+ function csv(rows) {
664
+ return `${rows.map((row) => row.map(csvCell).join(",")).join("\n")}\n`;
665
+ }
666
+
667
+ function csvCell(value) {
668
+ const source = String(value ?? "");
669
+ const safe = /^[\t\r ]*[=+\-@]/.test(source) ? `'${source}` : source;
670
+ return /[",\r\n]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
671
+ }
672
+
673
+ function requireDerivedOutputPath(value) {
674
+ const segments = typeof value === "string" ? value.split("/") : [];
675
+ if (
676
+ segments.length < 2
677
+ || segments[0] !== ".filegrc"
678
+ || segments.some((segment) => !segment || segment === "." || segment === ".." || segment.includes("\\") || segment.includes("\0"))
679
+ ) {
680
+ throw new Error("Evidence packet output must be a directory under .filegrc/.");
681
+ }
682
+ }
683
+
684
+ async function assertPacketSourceState(packet, loaded) {
685
+ await assertLoadedEntriesCurrent(loaded);
686
+ const dataDigest = await dataTreeDigest(loaded.root);
687
+ const git = getGitSummary(loaded.root);
688
+ if (
689
+ packet.revision?.dataDigest !== dataDigest
690
+ || packet.revision?.commit !== git.commit
691
+ || packet.revision?.branch !== git.branch
692
+ ) {
693
+ throw new Error("The workspace source changed after this packet was prepared. Prepare the packet again.");
694
+ }
695
+ }
696
+
697
+ async function assertLoadedEntriesCurrent(loaded) {
698
+ for (const entry of loaded.entries) {
699
+ const source = await readFile(resolveDataPath(loaded.root, entry.relativePath), "utf8");
700
+ if (source !== entry.source) {
701
+ throw new Error(`The workspace source changed while the evidence packet was being prepared: ${entry.relativePath}. Try again.`);
702
+ }
703
+ }
704
+ }
705
+
706
+ async function dataTreeDigest(root) {
707
+ const hash = createHash("sha256");
708
+ updateDigestField(hash, "filegrc-data-tree-v1");
709
+ const visit = async (directory, prefix = "") => {
710
+ const entries = await readdir(directory, { withFileTypes: true });
711
+ entries.sort((a, b) => a.name.localeCompare(b.name));
712
+ for (const entry of entries) {
713
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
714
+ const path = join(directory, entry.name);
715
+ if (entry.isDirectory()) {
716
+ updateDigestField(hash, "directory");
717
+ updateDigestField(hash, relativePath);
718
+ await visit(path, relativePath);
719
+ } else if (entry.isFile()) {
720
+ const fileHash = createHash("sha256");
721
+ for await (const chunk of createReadStream(path)) fileHash.update(chunk);
722
+ updateDigestField(hash, "file");
723
+ updateDigestField(hash, relativePath);
724
+ updateDigestField(hash, fileHash.digest("hex"));
725
+ } else if (entry.isSymbolicLink()) {
726
+ updateDigestField(hash, "symlink");
727
+ updateDigestField(hash, relativePath);
728
+ updateDigestField(hash, await readlink(path));
729
+ } else {
730
+ updateDigestField(hash, "other");
731
+ updateDigestField(hash, relativePath);
732
+ }
733
+ }
734
+ };
735
+ await visit(resolveDataPath(root, "."));
736
+ return `sha256:${hash.digest("hex")}`;
737
+ }
738
+
739
+ function updateDigestField(hash, value) {
740
+ const bytes = Buffer.from(String(value));
741
+ hash.update(`${bytes.length}:`);
742
+ hash.update(bytes);
743
+ }
744
+
745
+ function packetRecord(record, model, start, end, timezone) {
746
+ const definition = model.resources[record.type];
747
+ if (!definition) return null;
748
+ const fields = { ...model.commonFields, ...definition.fields };
749
+ const dates = [];
750
+ for (const [name, field] of Object.entries(fields)) {
751
+ const value = record[name];
752
+ if (field.type === "date" && parseCalendarDate(value) && value >= start && value <= end) {
753
+ dates.push({ field: name, value });
754
+ }
755
+ if (field.type === "timestamp" && typeof value === "string") {
756
+ const date = timestampDate(value, timezone);
757
+ if (date && date >= start && date <= end) dates.push({ field: name, value, date });
758
+ }
759
+ }
760
+ const overlaps = parseCalendarDate(record.periodStart)
761
+ && parseCalendarDate(record.periodEnd)
762
+ && record.periodStart <= end
763
+ && record.periodEnd >= start;
764
+ if (!dates.length && !overlaps) return null;
765
+ dates.sort((a, b) => (a.date || a.value).localeCompare(b.date || b.value) || a.field.localeCompare(b.field));
766
+ return {
767
+ id: record.id,
768
+ type: record.type,
769
+ title: record.title,
770
+ primaryDate: dates[0]?.date || dates[0]?.value || record.periodStart,
771
+ dates,
772
+ ...(overlaps ? { period: { start: record.periodStart, end: record.periodEnd } } : {})
773
+ };
774
+ }
775
+
776
+ function timestampDate(value, timezone) {
777
+ const parsed = new Date(value);
778
+ if (Number.isNaN(parsed.valueOf())) return null;
779
+ try {
780
+ const parts = new Intl.DateTimeFormat("en-US", {
781
+ timeZone: timezone,
782
+ year: "numeric",
783
+ month: "2-digit",
784
+ day: "2-digit"
785
+ }).formatToParts(parsed);
786
+ const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
787
+ return `${values.year}-${values.month}-${values.day}`;
788
+ } catch {
789
+ return parsed.toISOString().slice(0, 10);
790
+ }
791
+ }
792
+
793
+ function buildControlCoverage({ audit, byId, controlIds, evidenceIds, model, records, start, end, timezone }) {
794
+ const evidenceByControl = new Map([...controlIds].map((id) => [id, new Set()]));
795
+ for (const evidenceId of evidenceIds) {
796
+ const evidenceRecord = byId.get(evidenceId);
797
+ const linkedControlIds = controlIdsForRecord(evidenceRecord, byId);
798
+ for (const record of records) {
799
+ if ((record.evidenceIds || []).includes(evidenceId)) addIds(linkedControlIds, controlIdsForRecord(record, byId));
800
+ }
801
+ for (const controlId of linkedControlIds) evidenceByControl.get(controlId)?.add(evidenceId);
802
+ }
803
+ return [...controlIds].map((controlId) => {
804
+ const control = byId.get(controlId);
805
+ const tests = records
806
+ .filter((record) => record.type === "control-test" && record.controlId === controlId)
807
+ .filter((record) => (
808
+ record.auditId === audit?.id
809
+ || (!record.auditId && record.periodStart && record.periodEnd && record.periodStart <= end && record.periodEnd >= start)
810
+ || (!record.auditId && record.asOfDate >= start && record.asOfDate <= end)
811
+ ));
812
+ const operatingRecords = records.filter((record) => (
813
+ record.type !== "control"
814
+ && controlIdsForRecord(record, byId).has(controlId)
815
+ && packetRecord(record, model, start, end, timezone)
816
+ ));
817
+ const linkedEvidenceIds = new Set(evidenceByControl.get(controlId) || []);
818
+ for (const test of tests) {
819
+ addIds(linkedEvidenceIds, test.evidenceIds);
820
+ addIds(linkedEvidenceIds, test.sampleEvidenceIds);
821
+ const population = byId.get(test.populationId);
822
+ if (population?.sourceEvidenceId) linkedEvidenceIds.add(population.sourceEvidenceId);
823
+ }
824
+ return {
825
+ id: controlId,
826
+ code: control?.code || "",
827
+ title: control?.title || controlId,
828
+ statement: control?.statement || "",
829
+ activity: control?.activity || "",
830
+ status: control?.status || "missing",
831
+ effectiveOn: control?.effectiveOn || null,
832
+ frequency: control?.frequency || "",
833
+ operationMode: control?.operationMode || "",
834
+ requirementIds: control?.requirementIds || [],
835
+ policyIds: control?.policyIds || [],
836
+ systemIds: control?.systemIds || [],
837
+ riskIds: control?.riskIds || [],
838
+ evidenceIds: [...linkedEvidenceIds].sort(),
839
+ operatingRecordIds: operatingRecords.map(({ id }) => id).sort(),
840
+ tests: tests.map((test) => ({
841
+ ...testPopulationSummary(test, byId),
842
+ id: test.id,
843
+ status: test.status,
844
+ outcome: test.outcome || null,
845
+ periodStart: test.periodStart || null,
846
+ periodEnd: test.periodEnd || null,
847
+ asOfDate: test.asOfDate || null,
848
+ sampleSize: test.sampleSize ?? null,
849
+ sampleEvidenceIds: test.sampleEvidenceIds || [],
850
+ exceptionCount: test.exceptionCount ?? null
851
+ }))
852
+ };
853
+ }).sort((a, b) => a.code.localeCompare(b.code) || a.title.localeCompare(b.title));
854
+ }
855
+
856
+ function controlIdsForRecord(record, byId, seen = new Set()) {
857
+ const ids = new Set();
858
+ if (!record || seen.has(record.id)) return ids;
859
+ seen.add(record.id);
860
+ if (record.type === "control") ids.add(record.id);
861
+ addIds(ids, record.controlIds);
862
+ if (record.controlId) ids.add(record.controlId);
863
+ for (const sourceId of record.sourceResourceIds || []) addIds(ids, controlIdsForRecord(byId.get(sourceId), byId, seen));
864
+ if (record.sourceResourceId) addIds(ids, controlIdsForRecord(byId.get(record.sourceResourceId), byId, seen));
865
+ if (record.obligationId) addIds(ids, byId.get(record.obligationId)?.controlIds);
866
+ return ids;
867
+ }
868
+
869
+ function packetGaps({
870
+ audit,
871
+ byId,
872
+ obligations,
873
+ eventRuns,
874
+ evidence,
875
+ git,
876
+ start,
877
+ end,
878
+ controlCoverage,
879
+ requirementIds,
880
+ records,
881
+ populations,
882
+ model,
883
+ managementPreparation
884
+ }) {
885
+ const gaps = [];
886
+ if (!git.commit) gaps.push(gap("error", "uncommitted-workspace", "The workspace has no Git revision to bind this packet to."));
887
+ else if (!git.clean) gaps.push(gap("error", "dirty-workspace", "Commit or discard workspace changes before treating this packet as audit evidence."));
888
+
889
+ if (!audit) {
890
+ gaps.push(gap("error", "missing-audit-scope", "Select an audit record before treating this packet as an auditor delivery."));
891
+ } else {
892
+ auditGaps(gaps, audit, byId, records, start, end);
893
+ }
894
+ for (const stage of managementPreparation?.stages || []) {
895
+ for (const item of stage.items.filter((entry) => ["action", "later"].includes(entry.status))) {
896
+ gaps.push(gap(
897
+ "error",
898
+ `management-${stage.id}-${item.id}`,
899
+ item.status === "later" ? `${item.message} Complete this work before delivering the packet.` : item.message,
900
+ item.resourceId || audit?.id
901
+ ));
902
+ }
903
+ }
904
+
905
+ for (const coverage of controlCoverage) {
906
+ const control = byId.get(coverage.id);
907
+ if (coverage.status !== "implemented") {
908
+ gaps.push(gap("error", "control-not-implemented", `${coverage.code || coverage.title} is ${coverage.status}, not implemented.`, coverage.id));
909
+ }
910
+ if (coverage.status === "implemented" && (!coverage.effectiveOn || coverage.effectiveOn > start)) {
911
+ gaps.push(gap("error", "control-not-effective-for-period", `${coverage.code || coverage.title} does not have an effective date on or before ${start}.`, coverage.id));
912
+ }
913
+ if (!(coverage.systemIds || []).length && audit?.systemIds?.length) {
914
+ gaps.push(gap("error", "control-missing-system-scope", `${coverage.code || coverage.title} is not linked to an in-scope system.`, coverage.id));
915
+ } else if (audit?.systemIds?.length && !coverage.systemIds.some((id) => audit.systemIds.includes(id))) {
916
+ gaps.push(gap("error", "control-outside-audit-system-scope", `${coverage.code || coverage.title} is not linked to a system selected by the audit.`, coverage.id));
917
+ }
918
+ if (!(coverage.policyIds || []).length) {
919
+ gaps.push(gap("error", "control-missing-policy", `${coverage.code || coverage.title} is not linked to a policy.`, coverage.id));
920
+ } else {
921
+ for (const policyId of coverage.policyIds) {
922
+ const policy = byId.get(policyId);
923
+ if (!policy) continue;
924
+ if (!policyCoversPeriod(policy, start, end, byId)) {
925
+ gaps.push(gap("error", "policy-not-effective-for-period", `${policy.title} does not show approved policy coverage for the full packet period.`, policy.id));
926
+ }
927
+ }
928
+ }
929
+ if (!coverage.evidenceIds.length) {
930
+ gaps.push(gap("error", "control-missing-evidence", `${coverage.code || coverage.title} has no linked evidence in the packet.`, coverage.id));
931
+ }
932
+ if (audit?.auditKind !== "soc-2-type-1" && coverage.status === "implemented" && !coverage.operatingRecordIds.length && coverage.operationMode !== "automated") {
933
+ gaps.push(gap("warning", "control-missing-operating-record", `${coverage.code || coverage.title} has no dated operating record in the packet period.`, coverage.id));
934
+ }
935
+ for (const test of coverage.tests) {
936
+ const testRecord = byId.get(test.id);
937
+ if (test.status !== "complete") {
938
+ gaps.push(gap("warning", "incomplete-control-test", `${coverage.code || coverage.title} has an audit-period control test that is ${test.status}.`, test.id));
939
+ continue;
940
+ }
941
+ if (!testRecord?.completedOn) {
942
+ gaps.push(gap("error", "control-test-completion-date-missing", `${coverage.code || coverage.title} has a completed test without a completion date.`, test.id));
943
+ }
944
+ if (!(testRecord?.testerIds || []).length && !testRecord?.externalTester) {
945
+ gaps.push(gap("error", "control-test-tester-missing", `${coverage.code || coverage.title} has a completed test without an identified tester.`, test.id));
946
+ }
947
+ if (testRecord?.reviewedOn && testRecord?.completedOn && testRecord.reviewedOn < testRecord.completedOn) {
948
+ gaps.push(gap("error", "control-test-review-sequence-invalid", `${coverage.code || coverage.title} records review before test completion.`, test.id));
949
+ }
950
+ if (Number.isInteger(testRecord?.sampleSize) && testRecord.sampleSize < 0) {
951
+ gaps.push(gap("error", "control-test-sample-size-invalid", `${coverage.code || coverage.title} records a negative sample size.`, test.id));
952
+ }
953
+ if (Number.isInteger(testRecord?.exceptionCount) && testRecord.exceptionCount < 0) {
954
+ gaps.push(gap("error", "control-test-exception-count-invalid", `${coverage.code || coverage.title} records a negative exception count.`, test.id));
955
+ }
956
+ if ((testRecord?.exceptionCount > 0 || ["failed", "passed-with-exceptions"].includes(test.outcome)) && !(testRecord?.findingIds || []).length) {
957
+ gaps.push(gap("error", "control-test-finding-missing", `${coverage.code || coverage.title} records exceptions or failure without a linked finding.`, test.id));
958
+ }
959
+ const samplingTest = Number.isInteger(test.sampleSize) && test.sampleSize > 0;
960
+ if (samplingTest && !test.populationId) {
961
+ gaps.push(gap("warning", "undocumented-test-population", `${coverage.code || coverage.title} does not link the population from which samples were selected.`, test.id));
962
+ } else if (test.populationId && !test.populationEvidenceId) {
963
+ gaps.push(gap("error", "missing-test-population", `${coverage.code || coverage.title} links a population without a fixed population export.`, test.id));
964
+ }
965
+ if (Number.isInteger(test.sampleSize) && test.sampleSize > 0 && !test.sampleEvidenceIds.length) {
966
+ gaps.push(gap("error", "missing-test-samples", `${coverage.code || coverage.title} records a sample of ${test.sampleSize} without item-level sample evidence.`, test.id));
967
+ }
968
+ if (Number.isInteger(test.populationCount) && Number.isInteger(test.sampleSize) && test.sampleSize > test.populationCount) {
969
+ gaps.push(gap("error", "sample-exceeds-population", `${coverage.code || coverage.title} records a sample larger than its population.`, test.id));
970
+ }
971
+ }
972
+ if (control && !(control.requirementIds || []).length) {
973
+ gaps.push(gap("error", "control-missing-requirement", `${coverage.code || coverage.title} is not mapped to an applicable criterion.`, coverage.id));
974
+ }
975
+ }
976
+
977
+ for (const requirementId of requirementIds) {
978
+ const requirement = byId.get(requirementId);
979
+ if (!requirement || requirement.applicability !== "applicable" || isDescriptionRequirement(requirement)) continue;
980
+ const mapped = controlCoverage.some((coverage) => coverage.requirementIds.includes(requirementId));
981
+ if (!mapped) gaps.push(gap("error", "requirement-missing-control", `${requirement.reference || requirement.title} has no control in the packet.`, requirementId));
982
+ }
983
+
984
+ if (audit) {
985
+ if (audit.auditKind === "soc-2-type-2") populationGaps(gaps, audit, populations, byId, model);
986
+ for (const systemId of audit.systemIds || []) {
987
+ const system = byId.get(systemId);
988
+ if (!system) continue;
989
+ const commitmentIds = new Set(system.commitmentIds || []);
990
+ for (const record of records) {
991
+ if (record.type === "commitment" && (record.systemIds || []).includes(systemId) && record.status === "active") commitmentIds.add(record.id);
992
+ }
993
+ if (!commitmentIds.size) {
994
+ gaps.push(gap("error", "system-missing-commitments", `${system.title} has no active service commitment or system requirement.`, system.id));
995
+ }
996
+ }
997
+ const recentAssessment = records.some((record) => (
998
+ record.type === "risk-assessment"
999
+ && record.status === "complete"
1000
+ && record.assessmentDate <= end
1001
+ && record.assessmentDate >= shiftYear(end, -1)
1002
+ && (!(audit.systemIds || []).length || !(record.systemIds || []).length || record.systemIds.some((id) => audit.systemIds.includes(id)))
1003
+ ));
1004
+ if (!recentAssessment) {
1005
+ gaps.push(gap("error", "missing-risk-assessment", `No completed in-scope risk assessment was found in the year ending ${end}.`, audit.id));
1006
+ }
1007
+ for (const vendorId of audit.subserviceVendorIds || []) {
1008
+ const vendor = byId.get(vendorId);
1009
+ if (!vendor) continue;
1010
+ if (vendor.status !== "active") {
1011
+ gaps.push(gap("error", "inactive-subservice-organization", `${vendor.title} is in audit scope but is ${vendor.status}.`, vendor.id));
1012
+ }
1013
+ const reviews = records.filter((record) => (
1014
+ record.type === "vendor-review"
1015
+ && (record.vendorIds || []).includes(vendorId)
1016
+ && ["approved", "conditional", "complete"].includes(record.status)
1017
+ && record.reviewedOn <= end
1018
+ && record.reviewedOn >= shiftYear(end, -1)
1019
+ ));
1020
+ if (!reviews.length) {
1021
+ gaps.push(gap("error", "missing-subservice-review", `${vendor.title} has no completed review in the year ending ${end}.`, vendor.id));
1022
+ }
1023
+ const vendorEvidence = evidence.filter((item) => (
1024
+ item.sourceResourceIds.includes(vendorId)
1025
+ || reviews.some((review) => item.sourceResourceIds.includes(review.id) || (review.evidenceIds || []).includes(item.id))
1026
+ ));
1027
+ if (!vendorEvidence.length) {
1028
+ gaps.push(gap("error", "missing-subservice-evidence", `${vendor.title} has no linked assurance evidence, such as its report and applicable bridge coverage.`, vendor.id));
1029
+ } else {
1030
+ const assuranceReports = vendorEvidence.filter((item) => /soc|assurance|vendor-report/i.test(item.evidenceKind));
1031
+ if (!assuranceReports.length) {
1032
+ 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));
1033
+ } else if (assuranceReports.every((item) => !item.periodEnd)) {
1034
+ gaps.push(gap("error", "subservice-report-period-missing", `${vendor.title}'s assurance evidence does not record the report coverage period.`, vendor.id));
1035
+ } else {
1036
+ const latestCoverage = assuranceReports.map((item) => item.periodEnd).filter(Boolean).sort().at(-1);
1037
+ const bridgeEvidence = vendorEvidence.some((item) => (
1038
+ /bridge/i.test(item.evidenceKind)
1039
+ && ((item.periodEnd && item.periodEnd >= end) || item.collectedOn >= end)
1040
+ ));
1041
+ if (latestCoverage && latestCoverage < end && !bridgeEvidence) {
1042
+ gaps.push(gap("error", "subservice-bridge-coverage-missing", `${vendor.title}'s assurance report ends ${latestCoverage}, before the engagement date or period ends ${end}, and no bridge coverage is linked.`, vendor.id));
1043
+ }
1044
+ }
1045
+ }
1046
+ const complementary = records.some((record) => (
1047
+ record.type === "complementary-control"
1048
+ && record.responsibleParty === "subservice-organization"
1049
+ && record.vendorId === vendorId
1050
+ && record.status === "active"
1051
+ ));
1052
+ if (!complementary) {
1053
+ gaps.push(gap("error", "missing-subservice-complementary-controls", `${vendor.title} has no active complementary subservice controls.`, vendor.id));
1054
+ }
1055
+ }
1056
+ }
1057
+
1058
+ for (const item of obligations) {
1059
+ if (item.dueWindowEnd <= end && item.status !== "complete") {
1060
+ gaps.push(gap("error", "missing-obligation-completion", `${item.title} has no linked completion in ${item.dueWindowStart} through ${item.dueWindowEnd}.`, item.obligationId));
1061
+ }
1062
+ }
1063
+ for (const run of eventRuns) {
1064
+ if (run.status === "canceled") continue;
1065
+ for (const action of run.actions) {
1066
+ if (action.canceledAction) {
1067
+ gaps.push(gap(
1068
+ "error",
1069
+ "canceled-event-action",
1070
+ `${run.title}: ${action.title} was canceled. Complete the requirement or cancel the event with a documented reason.`,
1071
+ action.actionItemId
1072
+ ));
1073
+ } else if (action.missingCompletion) {
1074
+ gaps.push(gap(
1075
+ "error",
1076
+ "missing-event-completion",
1077
+ `${run.title}: ${action.title} is marked ${action.recordedStatus} but has no linked ${action.expectedCompletionTypes.join(" or ")} completion record.`,
1078
+ action.actionItemId
1079
+ ));
1080
+ } else if (action.dueWindowEnd && action.dueWindowEnd <= end && action.status !== "complete") {
1081
+ const cutoff = action.dueWindowEndAt || action.dueWindowEnd;
1082
+ gaps.push(gap("error", "incomplete-event-action", `${run.title}: ${action.title} was not completed by ${cutoff}.`, action.actionItemId));
1083
+ }
1084
+ }
1085
+ }
1086
+ for (const item of evidence) {
1087
+ if (item.evidenceKind === "rendered-record" && !item.sourceCommit) {
1088
+ gaps.push(gap("error", "unbound-rendered-evidence", `${item.title} does not name the Git revision that was rendered.`, item.id));
1089
+ } else if (item.sourceCommit && !item.sourceCommitValid) {
1090
+ gaps.push(gap("error", "invalid-evidence-revision", `${item.title} names a source Git revision that is not available in this repository.`, item.id));
1091
+ }
1092
+ if (item.status !== "verified") gaps.push(gap("error", "unverified-evidence", `${item.title} is ${item.status}, not verified.`, item.id));
1093
+ if (!item.collectorIds.length) gaps.push(gap("error", "evidence-collector-missing", `${item.title} does not identify who collected it.`, item.id));
1094
+ if (item.status === "verified" && (!item.verifierIds.length || !item.verifiedOn)) {
1095
+ gaps.push(gap("error", "evidence-verification-missing", `${item.title} is marked verified without a verifier and verification date.`, item.id));
1096
+ }
1097
+ if (item.verifiedOn && item.collectedOn && item.verifiedOn < item.collectedOn) {
1098
+ gaps.push(gap("error", "evidence-verification-sequence-invalid", `${item.title} was verified before it was collected.`, item.id));
1099
+ }
1100
+ const hasManagementPurpose = item.auditIds.includes(audit?.id)
1101
+ || item.sourceResourceIds.some((id) => ["audit", "document", "vendor", "vendor-review"].includes(byId.get(id)?.type));
1102
+ if (!item.controlIds.length && !hasManagementPurpose) {
1103
+ gaps.push(gap("warning", "evidence-missing-control-link", `${item.title} does not resolve to a control or another scoped management purpose.`, item.id));
1104
+ }
1105
+ if (item.controlIds.length && !evidenceCoversPacketDate(item, audit, start, end)) {
1106
+ const dateLabel = audit?.auditKind === "soc-2-type-1" ? `the ${start} as-of date` : `${start} through ${end}`;
1107
+ gaps.push(gap("error", "evidence-outside-engagement-date", `${item.title} is linked to a control but does not cover ${dateLabel}.`, item.id));
1108
+ }
1109
+ if (item.sourceSystemId) {
1110
+ const sourceSystem = byId.get(item.sourceSystemId);
1111
+ if (!sourceSystem || sourceSystem.type !== "system") {
1112
+ gaps.push(gap("error", "evidence-source-system-missing", `${item.title} does not resolve to a cataloged source system.`, item.id));
1113
+ } else {
1114
+ if (!["active", "deprecated"].includes(sourceSystem.status)) {
1115
+ gaps.push(gap("warning", "evidence-source-system-inactive", `${item.title} came from ${sourceSystem.title}, which is ${sourceSystem.status}. Confirm that this was the authoritative source when the evidence was generated.`, item.id));
1116
+ }
1117
+ if (!(sourceSystem.evidenceSourceKinds || []).length) {
1118
+ 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));
1119
+ }
1120
+ }
1121
+ } else if (["population-export", "system-export", "configuration-export"].includes(item.evidenceKind)) {
1122
+ 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));
1123
+ }
1124
+ if (item.externalReference && !item.filePaths.length) {
1125
+ gaps.push(gap("warning", "external-only-evidence", `${item.title} relies on an external reference and is not self-contained in the packet.`, item.id));
1126
+ }
1127
+ if (item.evidenceKind === "rendered-record") {
1128
+ const captureComplete = item.capture
1129
+ && typeof item.capture.route === "string"
1130
+ && item.capture.route.trim()
1131
+ && item.capture.filters
1132
+ && typeof item.capture.filters === "object"
1133
+ && !Array.isArray(item.capture.filters)
1134
+ && typeof item.capture.periodStart === "string"
1135
+ && typeof item.capture.periodEnd === "string"
1136
+ && typeof item.capture.capturedAt === "string"
1137
+ && typeof item.capture.method === "string"
1138
+ && item.capture.method.trim();
1139
+ if (!captureComplete) {
1140
+ gaps.push(gap("error", "missing-render-capture-context", `${item.title} does not record its route, filters, period, capture time, and method.`, item.id));
1141
+ }
1142
+ }
1143
+ if (item.evidenceKind === "population-export") {
1144
+ for (const [field, label] of [
1145
+ ["generatedAt", "generation time"],
1146
+ ["timezone", "report timezone"],
1147
+ ["queryDescription", "query or report parameters"],
1148
+ ["populationCount", "population count"],
1149
+ ["completenessValidation", "completeness validation"],
1150
+ ["accuracyValidation", "accuracy validation"]
1151
+ ]) {
1152
+ if (item[field] === undefined || item[field] === null || item[field] === "") {
1153
+ gaps.push(gap("error", `population-missing-${field}`, `${item.title} is missing its ${label}.`, item.id));
1154
+ }
1155
+ }
1156
+ if (item.populationCount !== null && (!Number.isInteger(item.populationCount) || item.populationCount < 0)) {
1157
+ gaps.push(gap("error", "population-count-invalid", `${item.title} must record a non-negative whole-number population count.`, item.id));
1158
+ }
1159
+ const generatedOn = timestampDate(item.generatedAt, item.timezone);
1160
+ if (generatedOn && generatedOn <= end) {
1161
+ gaps.push(gap("error", "population-generated-before-period-end", `${item.title} was generated before the audit period ended, so it cannot prove the complete period population.`, item.id));
1162
+ }
1163
+ }
1164
+ }
1165
+ if (!evidence.length) gaps.push(gap("error", "missing-evidence", "The packet contains no evidence records."));
1166
+
1167
+ return deduplicateGaps(gaps);
1168
+ }
1169
+
1170
+ function auditGaps(gaps, audit, byId, records, start, end) {
1171
+ if (!["soc-2-type-1", "soc-2-type-2"].includes(audit.auditKind)) {
1172
+ 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));
1173
+ } else if (audit.auditKind === "soc-2-type-1") {
1174
+ if (!audit.typeOneAsOf) {
1175
+ gaps.push(gap("error", "audit-as-of-date-missing", `${audit.title} does not define a Type 1 as-of date.`, audit.id));
1176
+ } else if (start !== audit.typeOneAsOf || end !== audit.typeOneAsOf) {
1177
+ 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));
1178
+ }
1179
+ } else if (!audit.periodStart || !audit.periodEnd) {
1180
+ gaps.push(gap("error", "audit-period-missing", `${audit.title} does not define a Type 2 examination period.`, audit.id));
1181
+ } else if (audit.periodStart !== start || audit.periodEnd !== end) {
1182
+ 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));
1183
+ }
1184
+ if (!(audit.systemIds || []).length) gaps.push(gap("error", "audit-systems-missing", `${audit.title} has no in-scope systems.`, audit.id));
1185
+ if (!(audit.requirementIds || []).length) gaps.push(gap("error", "audit-requirements-missing", `${audit.title} has no selected criteria.`, audit.id));
1186
+ if (!(audit.controlIds || []).length) gaps.push(gap("error", "audit-controls-missing", `${audit.title} has no selected controls.`, audit.id));
1187
+ const selectedRequirements = (audit.requirementIds || []).map((id) => byId.get(id)).filter(Boolean);
1188
+ if (!selectedRequirements.some(isDescriptionRequirement)) {
1189
+ gaps.push(gap("error", "audit-description-criteria-missing", `${audit.title} does not include the SOC 2 description criteria.`, audit.id));
1190
+ }
1191
+ for (const requirement of selectedRequirements) {
1192
+ if (!(audit.frameworkIds || []).includes(requirement.frameworkId) || requirement.applicability !== "applicable") {
1193
+ 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));
1194
+ }
1195
+ }
1196
+ if (!audit.auditor && !audit.auditorVendorId) gaps.push(gap("error", "auditor-missing", `${audit.title} does not identify the independent CPA firm.`, audit.id));
1197
+ 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));
1198
+ if ((audit.subserviceVendorIds || []).length && audit.subserviceMethod === "not-applicable") {
1199
+ gaps.push(gap("error", "subservice-scope-conflict", `${audit.title} names subservice organizations but marks their treatment not applicable.`, audit.id));
1200
+ }
1201
+ const expectedSubserviceVendorIds = new Set((audit.systemIds || []).flatMap((id) => byId.get(id)?.subserviceVendorIds || []));
1202
+ for (const vendorId of expectedSubserviceVendorIds) {
1203
+ if (!(audit.subserviceVendorIds || []).includes(vendorId)) {
1204
+ gaps.push(gap("error", "subservice-organization-omitted", `${byId.get(vendorId)?.title || vendorId} is identified by an in-scope system but omitted from the engagement's subservice organizations.`, vendorId));
1205
+ }
1206
+ }
1207
+ if (audit.subserviceMethod === "inclusive") {
1208
+ const subserviceSystemIds = records
1209
+ .filter((record) => record.type === "system" && (audit.subserviceVendorIds || []).includes(record.vendorId))
1210
+ .map((record) => record.id);
1211
+ const includedControls = (audit.controlIds || [])
1212
+ .map((id) => byId.get(id))
1213
+ .filter((control) => (control?.systemIds || []).some((id) => subserviceSystemIds.includes(id)));
1214
+ if (!subserviceSystemIds.length || !includedControls.length) {
1215
+ gaps.push(gap("error", "inclusive-subservice-controls-missing", `${audit.title} uses the inclusive method but does not include cataloged subservice systems and their controls.`, audit.id));
1216
+ }
1217
+ }
1218
+ if (!audit.complementaryControlsConclusion) {
1219
+ gaps.push(gap("error", "complementary-controls-conclusion-missing", `${audit.title} does not state whether complementary customer or subservice controls apply.`, audit.id));
1220
+ } else if (audit.complementaryControlsConclusion === "identified" && !(audit.complementaryControlIds || []).length) {
1221
+ gaps.push(gap("error", "complementary-controls-missing", `${audit.title} says complementary controls were identified but does not select them.`, audit.id));
1222
+ } else if (audit.complementaryControlsConclusion === "not-applicable") {
1223
+ const relevant = records.some((record) => (
1224
+ record.type === "complementary-control"
1225
+ && record.status === "active"
1226
+ && (record.systemIds || []).some((id) => (audit.systemIds || []).includes(id))
1227
+ ));
1228
+ if (relevant) {
1229
+ gaps.push(gap("error", "complementary-controls-scope-conflict", `${audit.title} marks complementary controls not applicable even though active complementary controls are linked to in-scope systems.`, audit.id));
1230
+ }
1231
+ }
1232
+ const requiredDocuments = [
1233
+ ["systemDescriptionDocumentId", "management's system description"],
1234
+ ["managementAssertionDocumentId", "management's assertion"],
1235
+ ["managementRepresentationDocumentId", "management representation letter"]
1236
+ ];
1237
+ if (audit.auditKind === "soc-2-type-2") {
1238
+ requiredDocuments.splice(2, 0, ["periodCompletenessDocumentId", "period completeness statement"]);
1239
+ }
1240
+ for (const [field, label] of requiredDocuments) {
1241
+ const documentId = audit[field];
1242
+ if (!documentId) {
1243
+ gaps.push(gap("error", `missing-${field}`, `${audit.title} does not link ${label}.`, audit.id));
1244
+ continue;
1245
+ }
1246
+ const document = byId.get(documentId);
1247
+ if (!document || document.type !== "document") continue;
1248
+ if (document.status !== "active" || !document.approvedOn || !document.effectiveOn) {
1249
+ gaps.push(gap("error", `unapproved-${field}`, `${document.title} is not active with approval and effective dates.`, document.id));
1250
+ }
1251
+ }
1252
+ if (audit.status === "complete") {
1253
+ const representation = byId.get(audit.managementRepresentationDocumentId);
1254
+ if (!(representation?.evidenceIds || []).length) {
1255
+ gaps.push(gap("error", "unsigned-management-representation", `${representation?.title || audit.title} does not link the signed representation letter evidence.`, representation?.id || audit.id));
1256
+ }
1257
+ if (!audit.reportEvidenceId) gaps.push(gap("error", "missing-audit-report", `${audit.title} does not link the final service auditor report.`, audit.id));
1258
+ if (!audit.opinion || audit.opinion === "not-issued" || !audit.opinionDate) {
1259
+ gaps.push(gap("error", "missing-audit-opinion", `${audit.title} does not record the issued opinion and opinion date.`, audit.id));
1260
+ }
1261
+ for (const request of records.filter((record) => record.type === "audit-request" && record.auditId === audit.id)) {
1262
+ if (!["accepted", "closed"].includes(request.status)) {
1263
+ gaps.push(gap("error", "open-final-audit-request", `${request.title} is ${request.status} after the audit was marked complete.`, request.id));
1264
+ }
1265
+ }
1266
+ }
1267
+ }
1268
+
1269
+ function policyCoversPeriod(policy, start, end, byId, seen = new Set()) {
1270
+ if (seen.has(policy.id)) return false;
1271
+ seen.add(policy.id);
1272
+ if (!["approved", "active", "superseded"].includes(policy.status) || !policy.approvedOn || !policy.effectiveOn) return false;
1273
+ const predecessor = policy.supersedesId ? byId.get(policy.supersedesId) : null;
1274
+ if (policy.effectiveOn > end) {
1275
+ return predecessor?.type === "policy" ? policyCoversPeriod(predecessor, start, end, byId, seen) : false;
1276
+ }
1277
+ if (policy.effectiveOn <= start) return true;
1278
+ return predecessor?.type === "policy" ? policyCoversPeriod(predecessor, start, policy.effectiveOn, byId, seen) : false;
1279
+ }
1280
+
1281
+ function isDescriptionRequirement(requirement) {
1282
+ return (requirement.tags || []).includes("description-criteria") || /^DC\d+/i.test(requirement.reference || "");
1283
+ }
1284
+
1285
+ function shiftYear(value, offset) {
1286
+ const date = new Date(`${value}T00:00:00Z`);
1287
+ date.setUTCFullYear(date.getUTCFullYear() + offset);
1288
+ return date.toISOString().slice(0, 10);
1289
+ }
1290
+
1291
+ function evidenceSummary(record, byId, revisionIsValid) {
1292
+ if (!record || record.type !== "evidence") return null;
1293
+ return {
1294
+ id: record.id,
1295
+ title: record.title,
1296
+ status: record.status,
1297
+ evidenceKind: record.evidenceKind,
1298
+ source: record.source,
1299
+ collectedOn: record.collectedOn,
1300
+ periodStart: record.periodStart,
1301
+ periodEnd: record.periodEnd,
1302
+ classification: record.classification,
1303
+ generatedAt: record.generatedAt || null,
1304
+ timezone: record.timezone || null,
1305
+ queryDescription: record.queryDescription || null,
1306
+ populationCount: record.populationCount ?? null,
1307
+ completenessValidation: record.completenessValidation || null,
1308
+ accuracyValidation: record.accuracyValidation || null,
1309
+ capture: record.capture || null,
1310
+ sourceSystemId: record.sourceSystemId || null,
1311
+ sourceSystem: byId.get(record.sourceSystemId)?.title || null,
1312
+ collectorIds: record.collectorIds || [],
1313
+ verifierIds: record.verifierIds || [],
1314
+ verifiedOn: record.verifiedOn || null,
1315
+ sourceCommit: record.sourceCommit,
1316
+ sourceCommitValid: record.sourceCommit ? revisionIsValid(record.sourceCommit) : false,
1317
+ auditIds: record.auditIds || [],
1318
+ sourceResourceIds: record.sourceResourceIds || [],
1319
+ controlIds: [...controlIdsForRecord(record, byId)].sort(),
1320
+ filePaths: record.filePaths || [],
1321
+ externalReference: record.externalReference || null
1322
+ };
1323
+ }
1324
+
1325
+ function populationSummary(record, byId) {
1326
+ const evidence = byId.get(record.sourceEvidenceId);
1327
+ const sourceSystemId = record.sourceSystemId || evidence?.sourceSystemId || null;
1328
+ return {
1329
+ id: record.id,
1330
+ title: record.title,
1331
+ status: record.status,
1332
+ auditId: record.auditId,
1333
+ populationKind: record.populationKind,
1334
+ periodStart: record.periodStart,
1335
+ periodEnd: record.periodEnd,
1336
+ controlIds: record.controlIds || [],
1337
+ sourceSystemId,
1338
+ sourceSystem: byId.get(sourceSystemId)?.title || null,
1339
+ sourceEvidenceId: record.sourceEvidenceId || null,
1340
+ source: evidence?.source || null,
1341
+ populationCount: evidence?.populationCount ?? null,
1342
+ queryDescription: evidence?.queryDescription || null,
1343
+ timezone: evidence?.timezone || null,
1344
+ generatedAt: evidence?.generatedAt || null,
1345
+ completenessValidation: evidence?.completenessValidation || null,
1346
+ accuracyValidation: evidence?.accuracyValidation || null,
1347
+ reconciledByIds: record.reconciledByIds || [],
1348
+ reconciledOn: record.reconciledOn || null,
1349
+ conclusion: record.conclusion || null,
1350
+ notApplicableReason: record.notApplicableReason || null
1351
+ };
1352
+ }
1353
+
1354
+ function sourceSystemSummary(record, evidence, audit) {
1355
+ if (!record || record.type !== "system") return null;
1356
+ return {
1357
+ id: record.id,
1358
+ title: record.title,
1359
+ status: record.status,
1360
+ evidenceSourceKinds: record.evidenceSourceKinds || [],
1361
+ evidenceOwnerIds: record.evidenceOwnerIds || [],
1362
+ vendorId: record.vendorId || null,
1363
+ inAuditScope: (audit?.systemIds || []).includes(record.id),
1364
+ evidenceIds: evidence.filter((item) => item.sourceSystemId === record.id).map((item) => item.id)
1365
+ };
1366
+ }
1367
+
1368
+ function testPopulationSummary(test, byId) {
1369
+ const population = byId.get(test.populationId);
1370
+ const evidence = byId.get(population?.sourceEvidenceId);
1371
+ return {
1372
+ populationId: test.populationId || null,
1373
+ populationCount: evidence?.populationCount ?? null,
1374
+ populationEvidenceId: population?.sourceEvidenceId || null
1375
+ };
1376
+ }
1377
+
1378
+ function populationGaps(gaps, audit, populations, byId, model) {
1379
+ const expected = model.auditReadiness?.populationTemplates || [];
1380
+ for (const template of expected) {
1381
+ const matching = populations.filter((population) => population.populationKind === template.kind);
1382
+ if (!matching.length) {
1383
+ gaps.push(gap("error", "missing-audit-population", `${audit.title} is missing the ${template.title} population.`, audit.id));
1384
+ } else if (matching.length > 1) {
1385
+ gaps.push(gap("error", "duplicate-audit-population", `${audit.title} has more than one ${template.title} population.`, audit.id));
1386
+ }
1387
+ }
1388
+ for (const population of populations) {
1389
+ if (population.periodStart !== audit.periodStart || population.periodEnd !== audit.periodEnd) {
1390
+ gaps.push(gap("error", "population-period-mismatch", `${population.title} does not match the exact audit period.`, population.id));
1391
+ }
1392
+ if (population.status === "not-applicable") {
1393
+ if (population.controlIds.length) {
1394
+ gaps.push(gap("error", "population-not-applicable-with-controls", `${population.title} is marked not applicable but is linked to in-scope controls.`, population.id));
1395
+ }
1396
+ if (!population.notApplicableReason) {
1397
+ gaps.push(gap("error", "population-missing-not-applicable-reason", `${population.title} is marked not applicable without a reason.`, population.id));
1398
+ }
1399
+ continue;
1400
+ }
1401
+ if (population.status !== "reconciled") {
1402
+ gaps.push(gap("error", "population-not-reconciled", `${population.title} is ${population.status}, not reconciled.`, population.id));
1403
+ continue;
1404
+ }
1405
+ if (!population.reconciledByIds.length || !population.reconciledOn || !["complete", "complete-with-exceptions"].includes(population.conclusion)) {
1406
+ gaps.push(gap("error", "population-reconciliation-incomplete", `${population.title} does not record a completed management reconciliation.`, population.id));
1407
+ }
1408
+ if (population.conclusion === "complete-with-exceptions" && !population.reconciliationSummary) {
1409
+ gaps.push(gap("error", "population-exceptions-undocumented", `${population.title} concludes with exceptions but does not describe them in the reconciliation summary.`, population.id));
1410
+ }
1411
+ const evidence = byId.get(population.sourceEvidenceId);
1412
+ if (!evidence || evidence.type !== "evidence" || evidence.evidenceKind !== "population-export") {
1413
+ gaps.push(gap("error", "population-export-missing", `${population.title} does not link a population-export evidence record.`, population.id));
1414
+ continue;
1415
+ }
1416
+ if (evidence.periodStart !== audit.periodStart || evidence.periodEnd !== audit.periodEnd) {
1417
+ gaps.push(gap("error", "population-evidence-period-mismatch", `${evidence.title} does not cover the exact audit period.`, evidence.id));
1418
+ }
1419
+ const generatedOn = timestampDate(evidence.generatedAt, evidence.timezone);
1420
+ if (population.reconciledOn && generatedOn && population.reconciledOn < generatedOn) {
1421
+ gaps.push(gap("error", "population-reconciled-before-generation", `${population.title} was reconciled before its population export was generated.`, population.id));
1422
+ }
1423
+ if (!population.sourceSystemId) {
1424
+ gaps.push(gap("error", "population-source-system-missing", `${population.title} does not identify its authoritative source system.`, population.id));
1425
+ } else if (evidence.sourceSystemId !== population.sourceSystemId) {
1426
+ gaps.push(gap("error", "population-source-system-mismatch", `${population.title} and ${evidence.title} do not name the same authoritative source system.`, population.id));
1427
+ }
1428
+ const template = expected.find((item) => item.kind === population.populationKind);
1429
+ const sourceSystem = byId.get(population.sourceSystemId);
1430
+ if (template?.sourceKind && sourceSystem && !(sourceSystem.evidenceSourceKinds || []).includes(template.sourceKind)) {
1431
+ gaps.push(gap("error", "population-source-role-mismatch", `${sourceSystem.title} is not cataloged for the ${displaySourceKind(template.sourceKind)} evidence role required by ${population.title}.`, sourceSystem.id));
1432
+ }
1433
+ }
1434
+ }
1435
+
1436
+ function evidenceCoversPacketDate(item, audit, start, end) {
1437
+ if (audit?.auditKind === "soc-2-type-1") {
1438
+ return (item.periodStart && item.periodEnd && item.periodStart <= start && item.periodEnd >= start)
1439
+ || item.collectedOn === start;
1440
+ }
1441
+ return (item.periodStart && item.periodEnd && item.periodStart <= end && item.periodEnd >= start)
1442
+ || (item.collectedOn >= start && item.collectedOn <= end);
1443
+ }
1444
+
1445
+ function displaySourceKind(value) {
1446
+ return String(value || "").replaceAll("-", " ");
1447
+ }
1448
+
1449
+ function overlapsEvidencePeriod(record, start, end) {
1450
+ return record.type === "evidence" && (
1451
+ (record.collectedOn >= start && record.collectedOn <= end)
1452
+ || (record.periodStart && record.periodEnd && record.periodStart <= end && record.periodEnd >= start)
1453
+ );
1454
+ }
1455
+
1456
+ function policyIdsFor(record, byId, seen = new Set()) {
1457
+ if (!record || seen.has(record.id)) return [];
1458
+ seen.add(record.id);
1459
+ const ids = new Set(record.policyIds || []);
1460
+ if (record.type === "policy") ids.add(record.id);
1461
+ for (const controlId of record.controlIds || []) addIds(ids, byId.get(controlId)?.policyIds);
1462
+ if (record.obligationId) addIds(ids, policyIdsFor(byId.get(record.obligationId), byId, seen));
1463
+ if (record.sourceResourceId) addIds(ids, policyIdsFor(byId.get(record.sourceResourceId), byId, seen));
1464
+ return [...ids];
1465
+ }
1466
+
1467
+ function recordSummary(record) {
1468
+ return record ? { id: record.id, type: record.type, title: record.title, status: record.status } : null;
1469
+ }
1470
+
1471
+ function packetMarkdown(packet) {
1472
+ const readiness = packet.readiness.status === "delivery-ready"
1473
+ ? "FileGRC management checks passed. The engagement team still determines whether the evidence is sufficient and appropriate."
1474
+ : `${packet.readiness.errors} errors and ${packet.readiness.warnings} warnings require review. This is a draft packet.`;
1475
+ const periodLabel = packet.period.basis === "as-of"
1476
+ ? `as of ${packet.period.start}`
1477
+ : `${packet.period.start} through ${packet.period.end}`;
1478
+ const lines = [
1479
+ `# Evidence packet: ${periodLabel}`,
1480
+ "",
1481
+ `Status: ${packet.readiness.status}`,
1482
+ `Workspace: ${packet.workspace.organizationName}`,
1483
+ `Revision: ${packet.revision.commit || "uncommitted"}`,
1484
+ `Generated: ${packet.generatedAt}`,
1485
+ `Audit: ${packet.audit?.title || "none selected"}`,
1486
+ "",
1487
+ "## Review status",
1488
+ "",
1489
+ readiness,
1490
+ "",
1491
+ "## Coverage",
1492
+ "",
1493
+ `- ${packet.summary.datedRecords} dated records`,
1494
+ `- ${packet.summary.obligationOccurrences} recurring obligation occurrences`,
1495
+ `- ${packet.summary.eventRuns} event runs`,
1496
+ `- ${packet.summary.evidence} evidence records`,
1497
+ `- ${packet.summary.populations} reconciled or planned populations`,
1498
+ `- ${packet.summary.policies} policies`,
1499
+ `- ${packet.summary.controls} controls`,
1500
+ `- ${packet.summary.requirements} criteria`,
1501
+ `- ${packet.summary.systems} in-scope systems`,
1502
+ `- ${packet.summary.sourceSystems} cataloged source systems`,
1503
+ "",
1504
+ "Open `index.html` for the auditor-oriented index. `control-matrix.csv` cross-references criteria, controls, operating records, tests, and evidence. `source-system-index.csv` identifies the systems of record used to produce 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. Raw source records, governed Markdown, fixed attachments, and committed historical versions are included in their respective directories.",
1505
+ "",
1506
+ "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.",
1507
+ ""
1508
+ ];
1509
+ return lines.join("\n");
1510
+ }
1511
+
1512
+ function packetHtml(packet) {
1513
+ const section = (title, body) => `<section><h2>${escapeHtml(title)}</h2>${body}</section>`;
1514
+ const links = (items) => items.length
1515
+ ? `<ul>${items.map((item) => `<li><a href="records/${encodeURIComponent(item.type)}/${encodeURIComponent(item.id)}.json">${escapeHtml(item.title)}</a><small>${escapeHtml(item.type)}</small></li>`).join("")}</ul>`
1516
+ : "<p>None.</p>";
1517
+ const gaps = packet.gaps.length
1518
+ ? `<ul>${packet.gaps.map((item) => `<li class="${item.severity}"><strong>${escapeHtml(item.severity)}</strong> ${escapeHtml(item.message)}</li>`).join("")}</ul>`
1519
+ : "<p>FileGRC management checks passed. The engagement team still evaluates sufficiency and appropriateness.</p>";
1520
+ const engagementDate = packet.period.basis === "as-of"
1521
+ ? `As of ${escapeHtml(packet.period.start)}`
1522
+ : `${escapeHtml(packet.period.start)} through ${escapeHtml(packet.period.end)}`;
1523
+ const engagement = packet.audit
1524
+ ? `<dl><dt>Audit</dt><dd>${escapeHtml(packet.audit.title)}</dd><dt>Kind</dt><dd>${escapeHtml(packet.audit.kind)}</dd><dt>Scope</dt><dd>${escapeHtml(packet.audit.scope)}</dd><dt>Audit date or period</dt><dd>${engagementDate}</dd><dt>Subservice method</dt><dd>${escapeHtml(packet.audit.subserviceMethod || "not recorded")}</dd></dl>`
1525
+ : "<p>No audit record was selected.</p>";
1526
+ const obligations = packet.obligations.length
1527
+ ? `<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>`
1528
+ : "<p>No recurring occurrences intersect this period.</p>";
1529
+ const evidence = packet.evidence.length
1530
+ ? `<table><thead><tr><th>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>`
1531
+ : "<p>No evidence records were selected.</p>";
1532
+ const sourceSystems = packet.sourceSystems.length
1533
+ ? `<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>`
1534
+ : "<p>No source systems were cataloged.</p>";
1535
+ const populations = packet.populations.length
1536
+ ? `<p><a href="population-index.csv">Download population index CSV</a></p><table><thead><tr><th>Population</th><th>Period and source</th><th>Count</th><th>Reconciliation</th></tr></thead><tbody>${packet.populations.map((item) => `<tr><td><a href="records/audit-population/${encodeURIComponent(item.id)}.json">${escapeHtml(item.title)}</a><small>${escapeHtml(item.status)} · ${escapeHtml(item.populationKind)}</small></td><td>${escapeHtml(item.periodStart)} through ${escapeHtml(item.periodEnd)}<small>${escapeHtml(item.source || "No authoritative source recorded")}</small></td><td>${item.populationCount ?? "Not recorded"}</td><td>${escapeHtml(item.conclusion || item.notApplicableReason || "Not complete")}</td></tr>`).join("")}</tbody></table>`
1537
+ : "<p>No audit populations were selected.</p>";
1538
+ const eventRuns = packet.eventRuns.length
1539
+ ? packet.eventRuns.map((run) => `<article><h3><a href="records/obligation-event/${encodeURIComponent(run.id)}.json">${escapeHtml(run.title)}</a></h3><p>${escapeHtml(run.occurredAt || run.occurredOn)} · ${escapeHtml(run.status)} · ${run.completeCount} of ${run.actions.length} complete</p><table><thead><tr><th>Required action</th><th>Policy cutoff</th><th>Status</th></tr></thead><tbody>${run.actions.map((action) => `<tr><td><a href="records/action-item/${encodeURIComponent(action.actionItemId)}.json">${escapeHtml(action.title)}</a></td><td>${escapeHtml(action.dueWindowEndAt || action.dueWindowEnd)}</td><td>${escapeHtml(action.status)}</td></tr>`).join("")}</tbody></table></article>`).join("")
1540
+ : "<p>No event workflows intersect this period.</p>";
1541
+ const recordsById = new Map(packet.records.map((record) => [record.id, record]));
1542
+ const datedRecords = packet.datedRecords.length
1543
+ ? `<table><thead><tr><th>Date</th><th>Operating record</th><th>Latest committed change</th></tr></thead><tbody>${packet.datedRecords.map((item) => {
1544
+ const history = recordsById.get(item.id)?.history?.[0];
1545
+ const source = history
1546
+ ? `${history.timestamp} · ${history.author} · ${history.subject}`
1547
+ : "No committed file history";
1548
+ return `<tr><td>${escapeHtml(item.primaryDate)}</td><td><a href="records/${encodeURIComponent(item.type)}/${encodeURIComponent(item.id)}.json">${escapeHtml(item.title)}</a><br><small>${escapeHtml(item.type)}</small></td><td>${escapeHtml(source)}</td></tr>`;
1549
+ }).join("")}</tbody></table>`
1550
+ : "<p>No dated operating records matched this period.</p>";
1551
+ const controlCoverage = packet.controlCoverage.length
1552
+ ? `<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>Operating records</th><th>Evidence</th><th>Tests</th></tr></thead><tbody>${packet.controlCoverage.map((control) => `<tr><td><a href="records/control/${encodeURIComponent(control.id)}.json">${escapeHtml(control.code || control.id)}</a><small>${escapeHtml(control.title)}</small></td><td>${escapeHtml(control.status)}<small>${control.systemIds.map(escapeHtml).join(", ") || "No system scope"}</small></td><td>${control.requirementIds.map(escapeHtml).join("<br>") || "None"}</td><td>${control.operatingRecordIds.length}</td><td>${control.evidenceIds.length}</td><td>${control.tests.length}</td></tr>`).join("")}</tbody></table>`
1553
+ : "<p>No controls were selected.</p>";
1554
+ const readinessLabel = packet.readiness.status === "delivery-ready" ? "FileGRC management checks passed" : "Draft, do not deliver";
1555
+ const packetDate = packet.period.basis === "as-of"
1556
+ ? `As of ${escapeHtml(packet.period.start)}`
1557
+ : `${escapeHtml(packet.period.start)} through ${escapeHtml(packet.period.end)}`;
1558
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Evidence packet</title><style>
1559
+ 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}
1560
+ </style></head><body><header><p>SOC 2 evidence packet</p><span class="readiness ${packet.readiness.status === "delivery-ready" ? "ready" : ""}">${escapeHtml(readinessLabel)}</span><h1>${packetDate}</h1><p>${escapeHtml(packet.workspace.organizationName)} · revision <code>${escapeHtml(packet.revision.commit || "uncommitted")}</code></p></header>${section("Engagement scope", engagement)}${section("Review status", gaps)}${section("Control coverage", controlCoverage)}${section("Systems of record", sourceSystems)}${packet.period.basis === "period" ? section("Management population reconciliation", populations) : ""}${packet.period.basis === "period" ? section("Recurring obligation coverage", obligations) : ""}${packet.period.basis === "period" ? section("Event workflow coverage", eventRuns) : ""}${section("Policies", links(packet.policies))}${section("Dated operating records", datedRecords)}${section("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>`;
1561
+ }
1562
+
1563
+ async function writePacketFile(output, relativePath, source, files) {
1564
+ const path = resolvePacketOutputPath(output, relativePath);
1565
+ await mkdir(dirname(path), { recursive: true });
1566
+ await writeFile(path, source, { encoding: "utf8", flag: "wx" });
1567
+ files.push(relativePath.split("\\").join("/"));
1568
+ }
1569
+
1570
+ async function copyPacketFile(source, output, relativePath, files) {
1571
+ const target = resolvePacketOutputPath(output, relativePath);
1572
+ await mkdir(dirname(target), { recursive: true });
1573
+ await copyFile(source, target);
1574
+ files.push(relativePath.split("\\").join("/"));
1575
+ }
1576
+
1577
+ function resolvePacketOutputPath(output, relativePath) {
1578
+ if (
1579
+ typeof relativePath !== "string"
1580
+ || !relativePath
1581
+ || isAbsolute(relativePath)
1582
+ || relativePath.includes("\\")
1583
+ || relativePath.includes("\0")
1584
+ || /[\r\n]/.test(relativePath)
1585
+ ) {
1586
+ throw new Error("Evidence packet files must use safe relative paths.");
1587
+ }
1588
+ const target = resolve(output, relativePath);
1589
+ if (!isWithin(output, target)) throw new Error("Evidence packet files must stay inside the packet directory.");
1590
+ return target;
1591
+ }
1592
+
1593
+ function gap(severity, code, message, resourceId) {
1594
+ return { severity, code, message, ...(resourceId ? { resourceId } : {}) };
1595
+ }
1596
+
1597
+ function deduplicateGaps(gaps) {
1598
+ const seen = new Set();
1599
+ return gaps.filter((item) => {
1600
+ const key = `${item.code}\0${item.resourceId || item.message}`;
1601
+ if (seen.has(key)) return false;
1602
+ seen.add(key);
1603
+ return true;
1604
+ });
1605
+ }
1606
+
1607
+ function addIds(target, values = []) {
1608
+ for (const value of values) if (value) target.add(value);
1609
+ }
1610
+
1611
+ function requireDate(value, label) {
1612
+ if (!parseCalendarDate(value)) throw new Error(`A valid ${label} is required.`);
1613
+ return value;
1614
+ }
1615
+
1616
+ function resolvePacketPeriod(options, audit) {
1617
+ const typeOne = audit?.auditKind === "soc-2-type-1";
1618
+ const start = requireDate(
1619
+ options.start || (typeOne ? audit?.typeOneAsOf : audit?.periodStart),
1620
+ typeOne ? "Type 1 as-of date" : "packet start date"
1621
+ );
1622
+ const end = requireDate(
1623
+ options.end || (typeOne ? audit?.typeOneAsOf : audit?.periodEnd),
1624
+ typeOne ? "Type 1 as-of date" : "packet end date"
1625
+ );
1626
+ if (end < start) throw new Error("The packet end date must not be before its start date.");
1627
+ return { start, end, basis: typeOne ? "as-of" : "period" };
1628
+ }
1629
+
1630
+ function byTitle(a, b) {
1631
+ return a.title.localeCompare(b.title);
1632
+ }
1633
+
1634
+ function escapeHtml(value) {
1635
+ return String(value ?? "").replace(/[&<>"']/g, (character) => ({
1636
+ "&": "&amp;",
1637
+ "<": "&lt;",
1638
+ ">": "&gt;",
1639
+ '"': "&quot;",
1640
+ "'": "&#39;"
1641
+ })[character]);
1642
+ }