filegrc 0.11.0 → 0.12.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.
@@ -11,10 +11,11 @@ import {
11
11
  coverageOverlaps,
12
12
  coverageStart
13
13
  } from "./coverage.js";
14
- import { getFilesAtRevisions, getGitSummary, getWorkspaceHistories, getWorkspaceRevisionSnapshot, hasGitRevision } from "./git.js";
14
+ import { getFileHistoryWithPaths, getFilesAtRevisions, getGitSummary, getRecordIdentityHistories, getWorkspaceHistories, getWorkspaceRevisionSnapshot, hasGitRevision, isDataHistoryAncestor } from "./git.js";
15
15
  import { planObligations } from "./obligations.js";
16
16
  import { isWithin, resolveDataPath, resolveWorkspacePath, resolveWorkspaceRoot } from "./paths.js";
17
17
  import { parseCalendarDate } from "./recurrence.js";
18
+ import { resolveProgram, selectedRequirementIds } from "./program.js";
18
19
  import { markdownEntries } from "./resource-markdown.js";
19
20
  import { serializeWorkspaceMutation } from "./mutation.js";
20
21
  import {
@@ -29,6 +30,8 @@ import {
29
30
  } from "./soc2.js";
30
31
  import { validateWorkspace } from "./validate.js";
31
32
  import { measureTiming } from "./timing.js";
33
+ import { assessReportingRoutePeriod } from "./reporting-route-sets.js";
34
+ import { reportingRouteEventCommit, reportingRouteSourceAppliesToProgram, reportingRouteSourceMayApply } from "./reporting-route-integrity.js";
32
35
 
33
36
  const preparedPacketValidations = new WeakMap();
34
37
 
@@ -45,11 +48,15 @@ const NON_EVIDENCE_RECORD_TYPES = new Set([
45
48
  "evidence",
46
49
  "framework",
47
50
  "obligation",
51
+ "obligation-occurrence",
52
+ "obligation-rule",
48
53
  "organization",
49
54
  "person",
50
55
  "policy",
51
56
  "renderer-settings",
52
57
  "requirement",
58
+ "reporting-route",
59
+ "reporting-route-set",
53
60
  "system",
54
61
  "team",
55
62
  "training",
@@ -65,10 +72,25 @@ export async function prepareEvidencePacket(input, options = {}) {
65
72
  const entriesById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
66
73
  const audit = options.auditId ? byId.get(options.auditId) : null;
67
74
  if (options.auditId && audit?.type !== "audit") throw new Error(`Audit "${options.auditId}" was not found.`);
75
+ if (audit?.programId && options.programId && audit.programId !== options.programId) {
76
+ throw new Error(`Audit "${audit.id}" belongs to Program "${audit.programId}", not "${options.programId}".`);
77
+ }
78
+ const program = modelSupports(loaded.model, "program-scope")
79
+ ? resolveProgram(loaded, options.programId || audit?.programId)
80
+ : null;
81
+ const scope = audit || (program ? {
82
+ id: `program-scope:${program.id}`,
83
+ programScope: true,
84
+ programId: program.id,
85
+ frameworkIds: program.frameworkIds || [],
86
+ systemIds: program.systemIds || [],
87
+ requirementIds: selectedRequirementIds(program, loaded.model),
88
+ controlIds: program.controlIds || []
89
+ } : null);
68
90
  const { start, end, basis } = resolvePacketPeriod(options, audit);
69
91
  const typeOne = audit?.auditKind === "soc-2-type-1";
70
92
  const datedRecords = loaded.entries
71
- .filter((entry) => !audit || recordRelevantToAudit(entry.record, audit, byId))
93
+ .filter((entry) => !scope || recordRelevantToAudit(entry.record, scope, byId))
72
94
  .map((entry) => packetRecord(entry.record, loaded.model, start, end, loaded.workspace.timezone))
73
95
  .filter(Boolean);
74
96
  const datedRecordIds = new Set(datedRecords.map(({ id }) => id));
@@ -78,19 +100,21 @@ export async function prepareEvidencePacket(input, options = {}) {
78
100
  ))
79
101
  );
80
102
  const plan = planObligations(records, {
103
+ programId: program?.id || options.programId || audit?.programId,
81
104
  asOf: end,
82
105
  from: start,
83
106
  through: end,
84
107
  includeComplete: true,
108
+ additionalControlIds: audit?.controlIds || [],
85
109
  model: loaded.model
86
110
  });
87
111
  const obligations = (typeOne ? [] : plan.calendarItems).filter((item) => (
88
112
  item.dueWindowStart <= end
89
113
  && item.overdueOn > start
90
- && (!audit || recordRelevantToAudit(byId.get(item.obligationId), audit, byId))
114
+ && (!scope || recordRelevantToAudit(byId.get(item.obligationId), scope, byId))
91
115
  ));
92
116
  const eventRuns = (typeOne ? [] : plan.eventRuns).filter((run) => (
93
- (!audit || run.actions.some((action) => recordRelevantToAudit(byId.get(action.obligationId), audit, byId)))
117
+ (!scope || run.actions.some((action) => recordRelevantToAudit(byId.get(action.obligationId), scope, byId)))
94
118
  && (
95
119
  (run.occurredOn >= start && run.occurredOn <= end)
96
120
  || datedEvidenceSourceIds.has(run.id)
@@ -103,6 +127,7 @@ export async function prepareEvidencePacket(input, options = {}) {
103
127
  )
104
128
  ));
105
129
  const selectedIds = new Set(datedRecords.map((record) => record.id));
130
+ if (program) selectedIds.add(program.id);
106
131
  if (audit) {
107
132
  selectedIds.add(audit.id);
108
133
  addIds(selectedIds, [
@@ -137,6 +162,8 @@ export async function prepareEvidencePacket(input, options = {}) {
137
162
  }
138
163
  for (const item of obligations) {
139
164
  selectedIds.add(item.obligationId);
165
+ if (item.ruleId) selectedIds.add(item.ruleId);
166
+ if (item.occurrenceId) selectedIds.add(item.occurrenceId);
140
167
  addIds(selectedIds, item.completionResourceIds);
141
168
  addIds(selectedIds, item.evidenceIds);
142
169
  }
@@ -151,28 +178,12 @@ export async function prepareEvidencePacket(input, options = {}) {
151
178
  }
152
179
 
153
180
  const evidenceIds = new Set();
154
- for (const evidence of records.filter((record) => record.type === "evidence")) {
155
- if (audit && !recordRelevantToAudit(evidence, audit, byId)) continue;
156
- const direct = selectedIds.has(evidence.id)
157
- || (evidence.sourceResourceIds || []).some((id) => selectedIds.has(id))
158
- || overlapsEvidencePeriod(evidence, start, end);
159
- if (direct) evidenceIds.add(evidence.id);
160
- }
161
- for (const id of [...selectedIds]) {
162
- const record = byId.get(id);
163
- addIds(evidenceIds, record?.evidenceIds);
164
- addIds(evidenceIds, record?.sampleEvidenceIds);
165
- if (record?.sourceEvidenceId) evidenceIds.add(record.sourceEvidenceId);
166
- if (record?.populationId) selectedIds.add(record.populationId);
167
- }
168
- addIds(selectedIds, evidenceIds);
169
- expandEvidenceWorkflowContext(selectedIds, byId);
170
- for (const id of selectedIds) if (byId.get(id)?.type === "evidence") evidenceIds.add(id);
181
+ closeEvidenceSelection({ selectedIds, evidenceIds, records, scope, byId, start, end });
171
182
 
172
- const controlIds = new Set(audit?.controlIds || records
183
+ const controlIds = new Set(scope?.controlIds || records
173
184
  .filter((record) => record.type === "control" && !["not-applicable", "retired"].includes(record.status))
174
185
  .map(({ id }) => id));
175
- if (!audit) {
186
+ if (!scope) {
176
187
  for (const id of selectedIds) {
177
188
  const record = byId.get(id);
178
189
  addIds(controlIds, record?.controlIds);
@@ -204,7 +215,7 @@ export async function prepareEvidencePacket(input, options = {}) {
204
215
  selectedIds.add(complementaryControl.id);
205
216
  }
206
217
  }
207
- for (const systemId of audit?.systemIds || []) {
218
+ for (const systemId of scope?.systemIds || []) {
208
219
  const system = byId.get(systemId);
209
220
  addIds(selectedIds, system?.subserviceVendorIds);
210
221
  for (const commitment of records.filter((record) => (
@@ -214,9 +225,8 @@ export async function prepareEvidencePacket(input, options = {}) {
214
225
  }
215
226
  }
216
227
 
217
- const policyIds = new Set(audit
218
- ? []
219
- : records.filter((record) => record.type === "policy" && ["approved", "active"].includes(record.status)).map((record) => record.id));
228
+ const policyIds = new Set(program?.policyIds || []);
229
+ if (!scope) addIds(policyIds, records.filter((record) => record.type === "policy" && ["approved", "active"].includes(record.status)).map((record) => record.id));
220
230
  for (const id of selectedIds) addIds(policyIds, policyIdsFor(byId.get(id), byId));
221
231
  for (const controlId of controlIds) addIds(policyIds, byId.get(controlId)?.policyIds);
222
232
  expandSupersededPolicyIds(policyIds, byId);
@@ -224,12 +234,67 @@ export async function prepareEvidencePacket(input, options = {}) {
224
234
 
225
235
  const requirementIds = new Set();
226
236
  for (const controlId of controlIds) addIds(requirementIds, byId.get(controlId)?.requirementIds);
227
- if (audit) addIds(requirementIds, audit.requirementIds);
237
+ if (scope) addIds(requirementIds, scope.requirementIds);
228
238
  addIds(selectedIds, requirementIds);
229
239
 
240
+ const routeRequirementsApply = records.some((record) => (
241
+ reportingRouteSourceMayApply(record)
242
+ && record.reportingRouteRequirements?.length
243
+ && (!program || reportingRouteSourceAppliesToProgram(record, program, records))
244
+ ));
245
+ const reportingRouteCoverage = modelSupports(loaded.model, "reporting-route-sets")
246
+ ? audit && routeRequirementsApply && !audit.timezone
247
+ ? {
248
+ supported: true,
249
+ start,
250
+ end,
251
+ timezone: null,
252
+ snapshots: [],
253
+ issues: [{
254
+ code: "missing-audit-timezone",
255
+ resourceId: audit.id,
256
+ message: `${audit.title} needs an IANA timezone before FileGRC can assess reporting-route coverage across its date boundaries.`
257
+ }]
258
+ }
259
+ : await assessReportingRoutePeriod(loaded, {
260
+ start,
261
+ end,
262
+ programId: program?.id || audit?.programId,
263
+ timezone: audit?.timezone || loaded.workspace.timezone
264
+ })
265
+ : null;
266
+ const reportingRouteSets = new Map();
267
+ for (const assessment of (reportingRouteCoverage?.snapshots || []).flatMap((snapshot) => governingReportingRouteSets(snapshot))) {
268
+ const current = reportingRouteSets.get(assessment.record.id);
269
+ reportingRouteSets.set(assessment.record.id, current ? {
270
+ ...assessment,
271
+ authorities: [...new Map([...current.authorities, ...assessment.authorities].map((item) => [item.id, item])).values()]
272
+ } : assessment);
273
+ }
274
+ for (const { record: routeSet, authorities } of reportingRouteSets.values()) {
275
+ selectedIds.add(routeSet.id);
276
+ addIds(selectedIds, routeSet.sourceResourceIds);
277
+ addIds(selectedIds, authorities.map(({ id }) => id));
278
+ addIds(selectedIds, authorities.map(({ holderId }) => holderId));
279
+ for (const exception of records.filter((record) => (
280
+ record.type === "exception" && record.reportingRouteSetId === routeSet.id
281
+ ))) selectedIds.add(exception.id);
282
+ }
283
+ for (const snapshot of reportingRouteCoverage?.snapshots || []) {
284
+ addIds(selectedIds, snapshot.requirements.map(({ sourceId }) => sourceId));
285
+ }
286
+ closeEvidenceSelection({ selectedIds, evidenceIds, records, scope, byId, start, end });
287
+
288
+ const historyRevision = await getWorkspaceRevisionSnapshot(loaded.root);
230
289
  const sourceRevisionValidity = new Map();
231
290
  const revisionIsValid = (revision) => {
232
- if (!sourceRevisionValidity.has(revision)) sourceRevisionValidity.set(revision, hasGitRevision(loaded.root, revision));
291
+ if (!sourceRevisionValidity.has(revision)) {
292
+ sourceRevisionValidity.set(revision, Boolean(
293
+ historyRevision.commit
294
+ && hasGitRevision(loaded.root, revision)
295
+ && isDataHistoryAncestor(loaded, revision, historyRevision.commit)
296
+ ));
297
+ }
233
298
  return sourceRevisionValidity.get(revision);
234
299
  };
235
300
  const evidence = [...evidenceIds].map((id) => evidenceSummary(byId.get(id), byId, revisionIsValid)).filter(Boolean).sort(byTitle);
@@ -237,7 +302,7 @@ export async function prepareEvidencePacket(input, options = {}) {
237
302
  const sourceSystemIds = new Set([
238
303
  ...(v4
239
304
  ? [...controlIds].flatMap((id) => byId.get(id)?.evidenceSourceComponentIds || [])
240
- : audit?.systemIds || []),
305
+ : scope?.systemIds || []),
241
306
  ...evidence.map((item) => item.sourceComponentId || item.sourceSystemId).filter(Boolean)
242
307
  ]);
243
308
  const sourceSystems = [...sourceSystemIds]
@@ -249,13 +314,13 @@ export async function prepareEvidencePacket(input, options = {}) {
249
314
  `data/${entry.relativePath}`,
250
315
  ...markdownEntries(loaded.model, entry.record).map((markdown) => `data/${markdown.path}`)
251
316
  ]);
252
- const historyRevision = await getWorkspaceRevisionSnapshot(loaded.root);
253
317
  const histories = getWorkspaceHistories(
254
318
  loaded.root,
255
319
  selectedPaths,
256
320
  Number.MAX_SAFE_INTEGER,
257
321
  { strict: Boolean(historyRevision.commit) }
258
322
  );
323
+ const identityHistories = getRecordIdentityHistories(loaded.root, selectedIds);
259
324
  const packetRecords = [...selectedIds]
260
325
  .map((id) => byId.get(id))
261
326
  .filter(Boolean)
@@ -270,10 +335,10 @@ export async function prepareEvidencePacket(input, options = {}) {
270
335
  dates: packetRecord(record, loaded.model, start, end, loaded.workspace.timezone)?.dates || [],
271
336
  policyIds: policyIdsFor(record, byId),
272
337
  evidenceIds: record.evidenceIds || [],
273
- history: histories.get(path) || [],
338
+ history: identityHistories.get(record.id) || [],
274
339
  contentPaths: contentPaths.map((contentPath) => ({
275
340
  path: contentPath,
276
- history: histories.get(contentPath) || []
341
+ history: getFileHistoryWithPaths(loaded.root, contentPath, Number.MAX_SAFE_INTEGER) || histories.get(contentPath) || []
277
342
  }))
278
343
  };
279
344
  })
@@ -300,13 +365,21 @@ export async function prepareEvidencePacket(input, options = {}) {
300
365
  !NON_EVIDENCE_RECORD_TYPES.has(record.type)
301
366
  && controlIdsForRecord(byId.get(record.id), byId).size
302
367
  ));
368
+ const referencedPopulationIds = new Set(controlCoverage.flatMap(({ tests }) => (
369
+ tests.map(({ populationId }) => populationId).filter(Boolean)
370
+ )));
303
371
  const populations = (typeOne ? [] : records)
304
- .filter((record) => record.type === "audit-population" && (!audit || record.auditId === audit.id))
372
+ .filter((record) => (
373
+ record.type === "audit-population"
374
+ && (record.status !== "superseded" || referencedPopulationIds.has(record.id))
375
+ && (!scope || recordRelevantToAudit(record, scope, byId))
376
+ ))
305
377
  .map((record) => populationSummary(record, byId))
306
378
  .sort(byTitle);
307
379
  const generatedAt = options.generatedAt || new Date().toISOString();
308
380
  const managementPreparation = await assessAuditPreparation(loaded, {
309
381
  auditId: audit?.id,
382
+ programId: program?.id,
310
383
  generatedAt,
311
384
  selectDefault: false
312
385
  });
@@ -325,7 +398,8 @@ export async function prepareEvidencePacket(input, options = {}) {
325
398
  records,
326
399
  populations,
327
400
  model: loaded.model,
328
- managementPreparation
401
+ managementPreparation,
402
+ reportingRouteCoverage
329
403
  });
330
404
  const errorCount = gaps.filter(({ severity }) => severity === "error").length;
331
405
  const warningCount = gaps.filter(({ severity }) => severity === "warning").length;
@@ -376,7 +450,7 @@ export async function prepareEvidencePacket(input, options = {}) {
376
450
  policies: policyIds.size,
377
451
  controls: controlIds.size,
378
452
  requirements: requirementIds.size,
379
- systems: audit?.systemIds?.length || 0,
453
+ systems: scope?.systemIds?.length || 0,
380
454
  [v4 ? "sourceComponents" : "sourceSystems"]: sourceSystems.length,
381
455
  obligationOccurrences: obligations.length,
382
456
  eventRuns: eventRuns.length,
@@ -398,6 +472,54 @@ export async function prepareEvidencePacket(input, options = {}) {
398
472
  dataModelVersion: String(loaded.model.modelVersion),
399
473
  populations,
400
474
  ...(modelSupports(loaded.model, "governed-document-activation") ? { documentLifecycles } : {}),
475
+ ...(reportingRouteCoverage ? {
476
+ reportingRouteCoverage: {
477
+ start: reportingRouteCoverage.start,
478
+ end: reportingRouteCoverage.end,
479
+ timezone: reportingRouteCoverage.timezone,
480
+ snapshots: reportingRouteCoverage.snapshots.map((snapshot) => ({
481
+ at: snapshot.at,
482
+ requirements: snapshot.requirements,
483
+ routeSets: governingReportingRouteSets(snapshot).map(({
484
+ record,
485
+ committed,
486
+ effective,
487
+ canceled,
488
+ authorities,
489
+ approvalAssertionTiming,
490
+ cancellationAssertionTiming
491
+ }) => ({
492
+ id: record.id,
493
+ title: record.title,
494
+ status: record.status,
495
+ purposeKey: record.purposeKey,
496
+ predecessorId: record.predecessorId || null,
497
+ proposalCommit: record.proposalCommit || null,
498
+ approval: record.approval
499
+ ? {
500
+ ...record.approval,
501
+ assertionTiming: approvalAssertionTiming,
502
+ authorityRevision: reportingRouteEventCommit(loaded, record, "approval")
503
+ }
504
+ : null,
505
+ cancellation: record.cancellation
506
+ ? {
507
+ ...record.cancellation,
508
+ assertionTiming: cancellationAssertionTiming,
509
+ authorityRevision: reportingRouteEventCommit(loaded, record, "cancellation")
510
+ }
511
+ : null,
512
+ primaryLane: record.primaryLane,
513
+ alternateLane: record.alternateLane || null,
514
+ committed,
515
+ effective,
516
+ canceled,
517
+ authorityAppointmentIds: authorities.map(({ id }) => id)
518
+ })),
519
+ issues: snapshot.issues
520
+ }))
521
+ }
522
+ } : {}),
401
523
  managementPreparation,
402
524
  controlCoverage,
403
525
  gaps,
@@ -473,6 +595,7 @@ function recordRelevantToAudit(record, audit, byId, seen = new Set()) {
473
595
  if (record.id === audit.id || record.auditId === audit.id || (record.auditIds || []).includes(audit.id)) return true;
474
596
  if (record.auditId && record.auditId !== audit.id) return false;
475
597
  if ((record.auditIds || []).length) return false;
598
+ if (audit.programScope && audit.programId && record.programId) return record.programId === audit.programId;
476
599
  const selectedIds = new Set([
477
600
  ...(audit.frameworkIds || []),
478
601
  ...(audit.systemIds || []),
@@ -542,9 +665,48 @@ function expandEvidenceWorkflowContext(selectedIds, byId) {
542
665
  for (let index = 0; index < queue.length; index += 1) {
543
666
  const record = byId.get(queue[index]);
544
667
  enqueue(childrenBySource.get(record?.id));
668
+ enqueue([
669
+ ...(record?.completionResourceIds || []),
670
+ ...(record?.evidenceIds || []),
671
+ ...(record?.sampleEvidenceIds || []),
672
+ ...(record?.exceptionIds || []),
673
+ ...(record?.attestationIds || []),
674
+ record?.sourceEvidenceId,
675
+ record?.exceptionId
676
+ ]);
545
677
  if (record?.type === "evidence") enqueue([...(record.sourceResourceIds || []), record.sourceComponentId, record.sourceSystemId]);
546
678
  if (record?.type === "audit-population") enqueue([record.sourceEvidenceId, ...(record.controlIds || [])]);
547
679
  if (record?.type === "control-test") enqueue([record.populationId, ...(record.sampleEvidenceIds || [])]);
680
+ if (record?.type === "obligation-occurrence") {
681
+ enqueue((record.members || []).flatMap((member) => [
682
+ member.resourceId,
683
+ member.exceptionId,
684
+ ...(member.completionResourceIds || [])
685
+ ]));
686
+ enqueue([
687
+ record.obligationId,
688
+ record.ruleId,
689
+ record.collectionReviewId,
690
+ record.supersedesId
691
+ ]);
692
+ }
693
+ if (record?.type === "attestation") enqueue([record.programId, record.reportingRouteId, record.reportingRouteSetId]);
694
+ if (record?.type === "reporting-route-set") {
695
+ enqueue([
696
+ ...(record.sourceResourceIds || []),
697
+ record.predecessorId,
698
+ record.approval?.approvalAppointmentId,
699
+ record.approval?.approvedById,
700
+ ...(record.approval?.evidenceIds || []),
701
+ record.cancellation?.authorityAppointmentId,
702
+ record.cancellation?.canceledById,
703
+ ...(record.cancellation?.evidenceIds || []),
704
+ ...(record.primaryLane?.dependencySystemIds || []),
705
+ ...(record.alternateLane?.dependencySystemIds || [])
706
+ ]);
707
+ }
708
+ if (record?.type === "appointment") enqueue([record.holderId]);
709
+ if (record?.type === "exception") enqueue([...(record.evidenceIds || []), ...(record.attestationIds || [])]);
548
710
  if (record?.type === "action-item") {
549
711
  enqueue([
550
712
  record.sourceResourceId,
@@ -557,6 +719,30 @@ function expandEvidenceWorkflowContext(selectedIds, byId) {
557
719
  }
558
720
  }
559
721
 
722
+ function closeEvidenceSelection({ selectedIds, evidenceIds, records, scope, byId, start, end }) {
723
+ let previousSize = -1;
724
+ while (previousSize !== selectedIds.size + evidenceIds.size) {
725
+ previousSize = selectedIds.size + evidenceIds.size;
726
+ for (const evidence of records.filter((record) => record.type === "evidence")) {
727
+ if (scope && !recordRelevantToAudit(evidence, scope, byId)) continue;
728
+ const direct = selectedIds.has(evidence.id)
729
+ || (evidence.sourceResourceIds || []).some((id) => selectedIds.has(id))
730
+ || overlapsEvidencePeriod(evidence, start, end);
731
+ if (direct) evidenceIds.add(evidence.id);
732
+ }
733
+ for (const id of [...selectedIds]) {
734
+ const record = byId.get(id);
735
+ addIds(evidenceIds, record?.evidenceIds);
736
+ addIds(evidenceIds, record?.sampleEvidenceIds);
737
+ if (record?.sourceEvidenceId) evidenceIds.add(record.sourceEvidenceId);
738
+ if (record?.populationId) selectedIds.add(record.populationId);
739
+ }
740
+ addIds(selectedIds, evidenceIds);
741
+ expandEvidenceWorkflowContext(selectedIds, byId);
742
+ for (const id of selectedIds) if (byId.get(id)?.type === "evidence") evidenceIds.add(id);
743
+ }
744
+ }
745
+
560
746
  export async function writeEvidencePacket(input, packet, options = {}) {
561
747
  const baseName = `${packet.period.start}-to-${packet.period.end}-${packet.revision.shortCommit || "uncommitted"}`;
562
748
  let outputOption = options.output || `.filegrc/evidence-packets/${baseName}`;
@@ -641,11 +827,16 @@ export async function writeEvidencePacket(input, packet, options = {}) {
641
827
  collectCommittedVersions(historicalFiles, item, content.path, content.history);
642
828
  }
643
829
  }
644
- const historicalSources = getFilesAtRevisions(validation.loaded.root, historicalFiles);
645
- for (let index = 0; index < historicalFiles.length; index += 1) {
830
+ const uniqueHistoricalFiles = historicalFiles.filter((file, index, all) => all.findIndex((candidate) => (
831
+ candidate.item.id === file.item.id
832
+ && candidate.history.commit === file.history.commit
833
+ && candidate.sourcePath === file.sourcePath
834
+ )) === index);
835
+ const historicalSources = getFilesAtRevisions(validation.loaded.root, uniqueHistoricalFiles);
836
+ for (let index = 0; index < uniqueHistoricalFiles.length; index += 1) {
646
837
  const source = historicalSources[index];
647
838
  if (source === null) continue;
648
- const { item, sourcePath, history } = historicalFiles[index];
839
+ const { item, sourcePath, history } = uniqueHistoricalFiles[index];
649
840
  const exportedPath = join("history", item.type, item.id, history.commit, basename(sourcePath));
650
841
  await writePacketFile(output, exportedPath, source, files);
651
842
  historyIndex.push({
@@ -678,7 +869,13 @@ export function generateEvidencePacket(input, options = {}) {
678
869
 
679
870
  function collectCommittedVersions(target, item, sourcePath, history) {
680
871
  for (const revision of history || []) {
681
- target.push({ item, sourcePath, relativePath: sourcePath, revision: revision.commit, history: revision });
872
+ target.push({
873
+ item,
874
+ sourcePath: revision.path || sourcePath,
875
+ relativePath: revision.path || sourcePath,
876
+ revision: revision.commit,
877
+ history: revision
878
+ });
682
879
  }
683
880
  }
684
881
 
@@ -1117,12 +1314,53 @@ function packetGaps({
1117
1314
  records,
1118
1315
  populations,
1119
1316
  model,
1120
- managementPreparation
1317
+ managementPreparation,
1318
+ reportingRouteCoverage
1121
1319
  }) {
1122
1320
  const gaps = [];
1123
1321
  if (!git.commit) gaps.push(gap("error", "uncommitted-workspace", "The workspace has no Git revision to bind this packet to."));
1124
1322
  else if (!git.clean) gaps.push(gap("error", "dirty-workspace", "Commit or discard workspace changes before treating this packet as audit evidence."));
1125
1323
 
1324
+ for (const issue of reportingRouteCoverage?.issues || []) {
1325
+ gaps.push(gap("error", issue.code, issue.message, issue.resourceId));
1326
+ }
1327
+ const routeAssessments = (reportingRouteCoverage?.snapshots || []).flatMap(({ routeSets }) => routeSets);
1328
+ for (const assessment of [...new Map(routeAssessments.map((item) => [item.record.id, item])).values()]) {
1329
+ const route = assessment.record;
1330
+ if (assessment.approvalAssertionTiming === "git-recorded-later") {
1331
+ gaps.push(gap(
1332
+ "warning",
1333
+ "non-contemporaneous-reporting-route-approval",
1334
+ `${route.title} has a Git committer timestamp more than one day after the stated approval time. Committer timestamps are user-controlled metadata, so management and the engagement team must evaluate the linked fixed evidence itself.`,
1335
+ route.id
1336
+ ));
1337
+ }
1338
+ if (assessment.approvalAssertionTiming === "git-recorded-before-event") {
1339
+ gaps.push(gap(
1340
+ "warning",
1341
+ "reporting-route-approval-recorded-before-event",
1342
+ `${route.title} has a Git committer timestamp before its stated approval time. Committer timestamps are user-controlled metadata; review the linked fixed evidence before relying on this approval.`,
1343
+ route.id
1344
+ ));
1345
+ }
1346
+ if (assessment.cancellationAssertionTiming === "git-recorded-later") {
1347
+ gaps.push(gap(
1348
+ "warning",
1349
+ "non-contemporaneous-reporting-route-cancellation",
1350
+ `${route.title} has a Git committer timestamp more than one day after the stated cancellation time. Committer timestamps are user-controlled metadata; review the linked fixed evidence.`,
1351
+ route.id
1352
+ ));
1353
+ }
1354
+ if (assessment.cancellationAssertionTiming === "git-recorded-before-event") {
1355
+ gaps.push(gap(
1356
+ "warning",
1357
+ "reporting-route-cancellation-recorded-before-event",
1358
+ `${route.title} has a Git committer timestamp before its stated cancellation time. Committer timestamps are user-controlled metadata; review the linked fixed evidence before relying on this cancellation.`,
1359
+ route.id
1360
+ ));
1361
+ }
1362
+ }
1363
+
1126
1364
  if (!audit) {
1127
1365
  gaps.push(gap("error", "missing-audit-scope", "Select an audit record before treating this packet as an auditor delivery."));
1128
1366
  } else {
@@ -1198,6 +1436,14 @@ function packetGaps({
1198
1436
  if (Number.isInteger(testRecord?.exceptionCount) && testRecord.exceptionCount < 0) {
1199
1437
  gaps.push(gap("error", "control-test-exception-count-invalid", `${coverage.code || coverage.title} records a negative exception count.`, test.id));
1200
1438
  }
1439
+ if (test.populationStatus === "superseded") {
1440
+ gaps.push(gap(
1441
+ "error",
1442
+ "superseded-test-population",
1443
+ `${coverage.code || coverage.title} test ${test.id} used superseded population ${test.populationId}. Perform and review a replacement test against ${test.replacementPopulationId || "the corrected population"}.`,
1444
+ test.id
1445
+ ));
1446
+ }
1201
1447
  const testFindings = records.filter((record) => record.type === "finding" && record.sourceResourceId === test.id);
1202
1448
  if ((testRecord?.exceptionCount > 0 || ["failed", "passed-with-exceptions"].includes(test.outcome)) && !testFindings.length) {
1203
1449
  gaps.push(gap("error", "control-test-finding-missing", `${coverage.code || coverage.title} records exceptions or failure without a linked finding.`, test.id));
@@ -1309,7 +1555,12 @@ function packetGaps({
1309
1555
 
1310
1556
  for (const item of obligations) {
1311
1557
  if (item.dueWindowEnd <= end && item.status !== "complete") {
1312
- gaps.push(gap("error", "missing-obligation-completion", `${item.title} has no linked completion in ${item.dueWindowStart} through ${item.dueWindowEnd}.`, item.obligationId));
1558
+ gaps.push(gap(
1559
+ "error",
1560
+ "missing-obligation-completion",
1561
+ `${item.title} is not reconciled for ${item.dueWindowStart} through ${item.dueWindowEnd} (${item.completedCount || 0} of ${item.expectedCount || 0} expected members passed).`,
1562
+ item.occurrenceId || item.obligationId
1563
+ ));
1313
1564
  }
1314
1565
  }
1315
1566
  for (const run of eventRuns) {
@@ -1336,6 +1587,20 @@ function packetGaps({
1336
1587
  `${run.title}: ${action.title} is marked ${action.recordedStatus} but has no linked ${action.expectedCompletionTypes.join(" or ")} completion record.`,
1337
1588
  action.actionItemId
1338
1589
  ));
1590
+ } else if (action.timelinessStatus === "unknown") {
1591
+ gaps.push(gap(
1592
+ "error",
1593
+ "event-completion-time-missing",
1594
+ `${run.title}: ${action.title} has an hour-based deadline but its completion proof has no exact timestamp. Link proof with an RFC 3339 completion time or record and resolve a timeliness exception.`,
1595
+ action.actionItemId
1596
+ ));
1597
+ } else if (action.lateCompletion) {
1598
+ gaps.push(gap(
1599
+ "error",
1600
+ "late-event-completion",
1601
+ `${run.title}: ${action.title} was completed after ${action.dueWindowEndAt || action.dueWindowEnd}. Record and resolve the timeliness exception.`,
1602
+ action.actionItemId
1603
+ ));
1339
1604
  } else if (action.dueWindowEnd && action.dueWindowEnd <= end && action.status !== "complete") {
1340
1605
  const cutoff = action.dueWindowEndAt || action.dueWindowEnd;
1341
1606
  gaps.push(gap("error", "incomplete-event-action", `${run.title}: ${action.title} was not completed by ${cutoff}.`, action.actionItemId));
@@ -1343,7 +1608,7 @@ function packetGaps({
1343
1608
  }
1344
1609
  }
1345
1610
  for (const item of evidence) {
1346
- if (item.artifactKind === "rendered-page" && !item.sourceCommit) {
1611
+ if (item.sourceKind === "rendered-page" && !item.sourceCommit) {
1347
1612
  gaps.push(gap("error", "unbound-rendered-evidence", `${item.title} does not name the Git revision that was rendered.`, item.id));
1348
1613
  } else if (item.sourceCommit && !item.sourceCommitValid) {
1349
1614
  gaps.push(gap("error", "invalid-evidence-revision", `${item.title} names a source Git revision that is not available in this repository.`, item.id));
@@ -1385,7 +1650,7 @@ function packetGaps({
1385
1650
  if (item.externalReference && !item.filePaths.length) {
1386
1651
  gaps.push(gap("warning", "external-only-evidence", `${item.title} relies on an external reference and is not self-contained in the packet.`, item.id));
1387
1652
  }
1388
- if (item.artifactKind === "rendered-page") {
1653
+ if (item.sourceKind === "rendered-page") {
1389
1654
  const captureComplete = item.capture
1390
1655
  && typeof item.capture.route === "string"
1391
1656
  && item.capture.route.trim()
@@ -1797,8 +2062,17 @@ function sourceSystemSummary(record, evidence, audit) {
1797
2062
  function testPopulationSummary(test, byId) {
1798
2063
  const population = byId.get(test.populationId);
1799
2064
  const evidence = byId.get(population?.sourceEvidenceId);
2065
+ const replacement = population?.status === "superseded"
2066
+ ? [...byId.values()].find((record) => (
2067
+ record.type === "audit-population"
2068
+ && record.supersedesId === population.id
2069
+ && record.status !== "superseded"
2070
+ ))
2071
+ : null;
1800
2072
  return {
1801
2073
  populationId: test.populationId || null,
2074
+ populationStatus: population?.status || null,
2075
+ replacementPopulationId: replacement?.id || null,
1802
2076
  populationCount: evidence?.populationCount ?? null,
1803
2077
  populationEvidenceId: population?.sourceEvidenceId || null
1804
2078
  };
@@ -1806,15 +2080,16 @@ function testPopulationSummary(test, byId) {
1806
2080
 
1807
2081
  function populationGaps(gaps, audit, populations, byId, model) {
1808
2082
  const expected = model.auditReadiness?.populationTemplates || [];
2083
+ const currentPopulations = populations.filter(({ status }) => status !== "superseded");
1809
2084
  for (const template of expected) {
1810
- const matching = populations.filter((population) => population.populationKind === template.kind);
2085
+ const matching = currentPopulations.filter((population) => population.populationKind === template.kind);
1811
2086
  if (!matching.length) {
1812
2087
  gaps.push(gap("error", "missing-audit-population", `${audit.title} is missing the ${template.title} population.`, audit.id));
1813
2088
  } else if (matching.length > 1) {
1814
2089
  gaps.push(gap("error", "duplicate-audit-population", `${audit.title} has more than one ${template.title} population.`, audit.id));
1815
2090
  }
1816
2091
  }
1817
- for (const population of populations) {
2092
+ for (const population of currentPopulations) {
1818
2093
  if (!coverageMatches(population.coverage, coverageStart(audit.coverage), coverageEnd(audit.coverage))) {
1819
2094
  gaps.push(gap("error", "population-period-mismatch", `${population.title} does not match the exact audit period.`, population.id));
1820
2095
  }
@@ -2053,6 +2328,13 @@ function addIds(target, values = []) {
2053
2328
  for (const value of values) if (value) target.add(value);
2054
2329
  }
2055
2330
 
2331
+ function governingReportingRouteSets(snapshot) {
2332
+ const requiredPurposes = new Set(snapshot.requirements.map(({ purposeKey }) => purposeKey));
2333
+ return snapshot.routeSets.filter(({ record, effective, canceled }) => (
2334
+ effective && !canceled && requiredPurposes.has(record.purposeKey)
2335
+ ));
2336
+ }
2337
+
2056
2338
  function requireDate(value, label) {
2057
2339
  if (!parseCalendarDate(value)) throw new Error(`A valid ${label} is required.`);
2058
2340
  return value;