filegrc 0.1.0 → 0.3.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,6 +11,30 @@ import { markdownEntries } from "./resource-markdown.js";
11
11
  import { serializeWorkspaceMutation } from "./mutation.js";
12
12
  import { validateWorkspace } from "./validate.js";
13
13
 
14
+ const NON_EVIDENCE_RECORD_TYPES = new Set([
15
+ "audit",
16
+ "audit-population",
17
+ "audit-request",
18
+ "commitment",
19
+ "complementary-control",
20
+ "control",
21
+ "control-test",
22
+ "document",
23
+ "evidence",
24
+ "framework",
25
+ "obligation",
26
+ "organization",
27
+ "person",
28
+ "policy",
29
+ "renderer-settings",
30
+ "requirement",
31
+ "system",
32
+ "team",
33
+ "training",
34
+ "vendor",
35
+ "workspace"
36
+ ]);
37
+
14
38
  export async function prepareEvidencePacket(input, options = {}) {
15
39
  const validation = await validateWorkspace(input);
16
40
  if (!validation.ok) throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before generating evidence.`);
@@ -66,7 +90,6 @@ export async function prepareEvidencePacket(input, options = {}) {
66
90
  ...(audit.controlIds || []),
67
91
  ...(audit.controlTestIds || []),
68
92
  ...(audit.evidenceIds || []),
69
- ...(audit.findingIds || []),
70
93
  ...(audit.contactIds || []),
71
94
  ...(audit.complementaryControlIds || []),
72
95
  ...(audit.subserviceVendorIds || []),
@@ -81,6 +104,9 @@ export async function prepareEvidencePacket(input, options = {}) {
81
104
  for (const request of records.filter((record) => record.type === "audit-request" && record.auditId === audit.id)) {
82
105
  selectedIds.add(request.id);
83
106
  }
107
+ for (const record of records.filter((candidate) => ["finding", "action-item"].includes(candidate.type))) {
108
+ if (recordRelevantToAudit(record, audit, byId)) selectedIds.add(record.id);
109
+ }
84
110
  }
85
111
  for (const item of obligations) {
86
112
  selectedIds.add(item.obligationId);
@@ -132,9 +158,13 @@ export async function prepareEvidencePacket(input, options = {}) {
132
158
  const control = byId.get(controlId);
133
159
  addIds(selectedIds, control?.systemIds);
134
160
  addIds(selectedIds, control?.commitmentIds);
135
- addIds(selectedIds, control?.complementaryControlIds);
136
161
  addIds(selectedIds, control?.riskIds);
137
162
  }
163
+ for (const complementaryControl of records.filter((record) => record.type === "complementary-control")) {
164
+ if ((complementaryControl.relatedControlIds || []).some((id) => controlIds.has(id))) {
165
+ selectedIds.add(complementaryControl.id);
166
+ }
167
+ }
138
168
  for (const systemId of audit?.systemIds || []) {
139
169
  const system = byId.get(systemId);
140
170
  addIds(selectedIds, system?.commitmentIds);
@@ -215,6 +245,10 @@ export async function prepareEvidencePacket(input, options = {}) {
215
245
  end,
216
246
  timezone: loaded.workspace.timezone
217
247
  });
248
+ const filegrcRecords = datedRecords.filter((record) => (
249
+ !NON_EVIDENCE_RECORD_TYPES.has(record.type)
250
+ && controlIdsForRecord(byId.get(record.id), byId).size
251
+ ));
218
252
  const populations = (typeOne ? [] : records)
219
253
  .filter((record) => record.type === "audit-population" && (!audit || record.auditId === audit.id))
220
254
  .map((record) => populationSummary(record, byId))
@@ -285,6 +319,7 @@ export async function prepareEvidencePacket(input, options = {}) {
285
319
  },
286
320
  summary: {
287
321
  datedRecords: datedRecords.length,
322
+ filegrcRecords: filegrcRecords.length,
288
323
  records: packetRecords.length,
289
324
  policies: policyIds.size,
290
325
  controls: controlIds.size,
@@ -300,6 +335,7 @@ export async function prepareEvidencePacket(input, options = {}) {
300
335
  warnings: warningCount
301
336
  },
302
337
  datedRecords: datedRecords.sort((a, b) => a.primaryDate.localeCompare(b.primaryDate) || byTitle(a, b)),
338
+ filegrcRecords: filegrcRecords.sort((a, b) => a.primaryDate.localeCompare(b.primaryDate) || byTitle(a, b)),
303
339
  policies: [...policyIds].map((id) => recordSummary(byId.get(id))).filter(Boolean).sort(byTitle),
304
340
  controls: [...controlIds].map((id) => recordSummary(byId.get(id))).filter(Boolean).sort(byTitle),
305
341
  obligations,
@@ -337,7 +373,6 @@ function recordRelevantToAudit(record, audit, byId, seen = new Set()) {
337
373
  ...(audit.controlIds || []),
338
374
  ...(audit.controlTestIds || []),
339
375
  ...(audit.evidenceIds || []),
340
- ...(audit.findingIds || []),
341
376
  ...(audit.contactIds || []),
342
377
  ...(audit.complementaryControlIds || []),
343
378
  ...(audit.subserviceVendorIds || []),
@@ -373,6 +408,12 @@ function recordRelevantToAudit(record, audit, byId, seen = new Set()) {
373
408
 
374
409
  function expandEvidenceWorkflowContext(selectedIds, byId) {
375
410
  const queue = [...selectedIds];
411
+ const childrenBySource = new Map();
412
+ for (const record of byId.values()) {
413
+ if (!["finding", "action-item"].includes(record.type) || !record.sourceResourceId) continue;
414
+ if (!childrenBySource.has(record.sourceResourceId)) childrenBySource.set(record.sourceResourceId, []);
415
+ childrenBySource.get(record.sourceResourceId).push(record.id);
416
+ }
376
417
  const enqueue = (ids = []) => {
377
418
  for (const id of ids) {
378
419
  if (!id || selectedIds.has(id)) continue;
@@ -382,6 +423,7 @@ function expandEvidenceWorkflowContext(selectedIds, byId) {
382
423
  };
383
424
  for (let index = 0; index < queue.length; index += 1) {
384
425
  const record = byId.get(queue[index]);
426
+ enqueue(childrenBySource.get(record?.id));
385
427
  if (record?.type === "evidence") enqueue([...(record.sourceResourceIds || []), record.sourceSystemId]);
386
428
  if (record?.type === "audit-population") enqueue([record.sourceEvidenceId, ...(record.controlIds || [])]);
387
429
  if (record?.type === "control-test") enqueue([record.populationId, ...(record.sampleEvidenceIds || [])]);
@@ -393,7 +435,7 @@ function expandEvidenceWorkflowContext(selectedIds, byId) {
393
435
  ...(record.evidenceIds || [])
394
436
  ]);
395
437
  }
396
- if (record?.type === "obligation-event") enqueue([...(record.obligationIds || []), ...(record.actionItemIds || [])]);
438
+ if (record?.type === "obligation-event") enqueue(record.obligationIds || []);
397
439
  }
398
440
  }
399
441
 
@@ -515,7 +557,7 @@ async function writeChecksums(output, files) {
515
557
 
516
558
  function controlMatrixCsv(packet) {
517
559
  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"],
560
+ ["Control ID", "Code", "Control", "Control Statement", "Operating Activity", "Status", "Effective On", "Frequency", "Operation Mode", "System IDs", "Requirement IDs", "Policy IDs", "Risk IDs", "filegrc Evidence IDs", "External Evidence IDs", "Control Test IDs", "Test Outcomes", "Population IDs", "Population Counts", "Sample Sizes", "Exception Counts", "Population Evidence IDs", "Sample Evidence IDs"],
519
561
  ...packet.controlCoverage.map((control) => [
520
562
  control.id,
521
563
  control.code,
@@ -550,15 +592,15 @@ function packetHandlingMarkdown(packet) {
550
592
  "",
551
593
  `Evidence classifications: ${packet.handling.classifications.join(", ") || "none recorded"}`,
552
594
  `External references present: ${packet.handling.containsExternalReferences ? "yes" : "no"}`,
553
- "Encrypted by FileGRC: no",
595
+ "Encrypted by filegrc: no",
554
596
  "",
555
597
  "Review every included record and attachment for secrets, unnecessary personal data, customer data, and material outside the audit scope before transfer.",
556
598
  "",
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.",
599
+ "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
600
  "",
559
601
  "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
602
  "",
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.",
603
+ "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
604
  ""
563
605
  ].join("\n");
564
606
  }
@@ -810,7 +852,7 @@ function buildControlCoverage({ audit, byId, controlIds, evidenceIds, model, rec
810
852
  || (!record.auditId && record.asOfDate >= start && record.asOfDate <= end)
811
853
  ));
812
854
  const operatingRecords = records.filter((record) => (
813
- record.type !== "control"
855
+ !NON_EVIDENCE_RECORD_TYPES.has(record.type)
814
856
  && controlIdsForRecord(record, byId).has(controlId)
815
857
  && packetRecord(record, model, start, end, timezone)
816
858
  ));
@@ -859,6 +901,7 @@ function controlIdsForRecord(record, byId, seen = new Set()) {
859
901
  seen.add(record.id);
860
902
  if (record.type === "control") ids.add(record.id);
861
903
  addIds(ids, record.controlIds);
904
+ if (record.type === "complementary-control") addIds(ids, record.relatedControlIds);
862
905
  if (record.controlId) ids.add(record.controlId);
863
906
  for (const sourceId of record.sourceResourceIds || []) addIds(ids, controlIdsForRecord(byId.get(sourceId), byId, seen));
864
907
  if (record.sourceResourceId) addIds(ids, controlIdsForRecord(byId.get(record.sourceResourceId), byId, seen));
@@ -926,8 +969,10 @@ function packetGaps({
926
969
  }
927
970
  }
928
971
  }
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));
972
+ if (controlNeedsExternalEvidence(control, model) && !coverage.evidenceIds.length) {
973
+ gaps.push(gap("error", "control-missing-external-evidence", `${coverage.code || coverage.title} relies on an external system but has no linked External Evidence in the packet.`, coverage.id));
974
+ } else if (!controlNeedsExternalEvidence(control, model) && !coverage.operatingRecordIds.length) {
975
+ gaps.push(gap("error", "control-missing-filegrc-evidence", `${coverage.code || coverage.title} has no dated filegrc operating record in the packet.`, coverage.id));
931
976
  }
932
977
  if (audit?.auditKind !== "soc-2-type-1" && coverage.status === "implemented" && !coverage.operatingRecordIds.length && coverage.operationMode !== "automated") {
933
978
  gaps.push(gap("warning", "control-missing-operating-record", `${coverage.code || coverage.title} has no dated operating record in the packet period.`, coverage.id));
@@ -953,7 +998,8 @@ function packetGaps({
953
998
  if (Number.isInteger(testRecord?.exceptionCount) && testRecord.exceptionCount < 0) {
954
999
  gaps.push(gap("error", "control-test-exception-count-invalid", `${coverage.code || coverage.title} records a negative exception count.`, test.id));
955
1000
  }
956
- if ((testRecord?.exceptionCount > 0 || ["failed", "passed-with-exceptions"].includes(test.outcome)) && !(testRecord?.findingIds || []).length) {
1001
+ const testFindings = records.filter((record) => record.type === "finding" && record.sourceResourceId === test.id);
1002
+ if ((testRecord?.exceptionCount > 0 || ["failed", "passed-with-exceptions"].includes(test.outcome)) && !testFindings.length) {
957
1003
  gaps.push(gap("error", "control-test-finding-missing", `${coverage.code || coverage.title} records exceptions or failure without a linked finding.`, test.id));
958
1004
  }
959
1005
  const samplingTest = Number.isInteger(test.sampleSize) && test.sampleSize > 0;
@@ -1167,6 +1213,13 @@ function packetGaps({
1167
1213
  return deduplicateGaps(gaps);
1168
1214
  }
1169
1215
 
1216
+ function controlNeedsExternalEvidence(control, model) {
1217
+ const families = (model.evidenceSourceFamilies || []).filter((family) => (
1218
+ (family.controlCodes || []).includes(control?.code)
1219
+ ));
1220
+ return !families.length || families.some((family) => family.collectionTestRequired !== false);
1221
+ }
1222
+
1170
1223
  function auditGaps(gaps, audit, byId, records, start, end) {
1171
1224
  if (!["soc-2-type-1", "soc-2-type-2"].includes(audit.auditKind)) {
1172
1225
  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));
@@ -1470,7 +1523,7 @@ function recordSummary(record) {
1470
1523
 
1471
1524
  function packetMarkdown(packet) {
1472
1525
  const readiness = packet.readiness.status === "delivery-ready"
1473
- ? "FileGRC management checks passed. The engagement team still determines whether the evidence is sufficient and appropriate."
1526
+ ? "filegrc management checks passed. The engagement team still determines whether the evidence is sufficient and appropriate."
1474
1527
  : `${packet.readiness.errors} errors and ${packet.readiness.warnings} warnings require review. This is a draft packet.`;
1475
1528
  const periodLabel = packet.period.basis === "as-of"
1476
1529
  ? `as of ${packet.period.start}`
@@ -1490,10 +1543,10 @@ function packetMarkdown(packet) {
1490
1543
  "",
1491
1544
  "## Coverage",
1492
1545
  "",
1493
- `- ${packet.summary.datedRecords} dated records`,
1546
+ `- ${packet.summary.filegrcRecords} filegrc Evidence records`,
1494
1547
  `- ${packet.summary.obligationOccurrences} recurring obligation occurrences`,
1495
1548
  `- ${packet.summary.eventRuns} event runs`,
1496
- `- ${packet.summary.evidence} evidence records`,
1549
+ `- ${packet.summary.evidence} External Evidence records`,
1497
1550
  `- ${packet.summary.populations} reconciled or planned populations`,
1498
1551
  `- ${packet.summary.policies} policies`,
1499
1552
  `- ${packet.summary.controls} controls`,
@@ -1501,7 +1554,7 @@ function packetMarkdown(packet) {
1501
1554
  `- ${packet.summary.systems} in-scope systems`,
1502
1555
  `- ${packet.summary.sourceSystems} cataloged source systems`,
1503
1556
  "",
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.",
1557
+ "Open `index.html` for the auditor-oriented index. `control-matrix.csv` cross-references criteria, controls, filegrc Evidence, External Evidence, and tests. `source-system-index.csv` identifies the systems of record used to produce External Evidence. `external-evidence-index.csv` lists material that must be delivered or accessed outside this packet. For Type 2, `population-index.csv` records management's population reconciliation and fixed source exports. filegrc records, governed Markdown, fixed attachments, and committed historical versions are included in their respective directories.",
1505
1558
  "",
1506
1559
  "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
1560
  ""
@@ -1516,7 +1569,7 @@ function packetHtml(packet) {
1516
1569
  : "<p>None.</p>";
1517
1570
  const gaps = packet.gaps.length
1518
1571
  ? `<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>";
1572
+ : "<p>filegrc management checks passed. The engagement team still evaluates sufficiency and appropriateness.</p>";
1520
1573
  const engagementDate = packet.period.basis === "as-of"
1521
1574
  ? `As of ${escapeHtml(packet.period.start)}`
1522
1575
  : `${escapeHtml(packet.period.start)} through ${escapeHtml(packet.period.end)}`;
@@ -1527,8 +1580,8 @@ function packetHtml(packet) {
1527
1580
  ? `<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
1581
  : "<p>No recurring occurrences intersect this period.</p>";
1529
1582
  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>";
1583
+ ? `<table><thead><tr><th>External Evidence</th><th>Source and period</th><th>Controls</th><th>Files</th></tr></thead><tbody>${packet.evidence.map((item) => `<tr><td><a href="records/evidence/${encodeURIComponent(item.id)}.json">${escapeHtml(item.title)}</a><small>${escapeHtml(item.status)} · ${escapeHtml(item.evidenceKind)}</small></td><td>${escapeHtml(item.source)}<small>${escapeHtml(item.periodStart || item.collectedOn)}${item.periodEnd ? ` through ${escapeHtml(item.periodEnd)}` : ""}</small></td><td>${item.controlIds.map(escapeHtml).join("<br>") || "None"}</td><td>${item.filePaths.map((path) => `<a class="attachment" href="attachments/${path.split("/").map(encodeURIComponent).join("/")}">${escapeHtml(basename(path))}</a>`).join("") || "No fixed attachment"}</td></tr>`).join("")}</tbody></table>`
1584
+ : "<p>No External Evidence records were selected.</p>";
1532
1585
  const sourceSystems = packet.sourceSystems.length
1533
1586
  ? `<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
1587
  : "<p>No source systems were cataloged.</p>";
@@ -1539,25 +1592,25 @@ function packetHtml(packet) {
1539
1592
  ? 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
1593
  : "<p>No event workflows intersect this period.</p>";
1541
1594
  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) => {
1595
+ const filegrcRecords = packet.filegrcRecords.length
1596
+ ? `<table><thead><tr><th>Date</th><th>filegrc record</th><th>Latest committed change</th></tr></thead><tbody>${packet.filegrcRecords.map((item) => {
1544
1597
  const history = recordsById.get(item.id)?.history?.[0];
1545
1598
  const source = history
1546
1599
  ? `${history.timestamp} · ${history.author} · ${history.subject}`
1547
1600
  : "No committed file history";
1548
1601
  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
1602
  }).join("")}</tbody></table>`
1550
- : "<p>No dated operating records matched this period.</p>";
1603
+ : "<p>No filegrc Evidence records matched this period.</p>";
1551
1604
  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>`
1605
+ ? `<p><a href="control-matrix.csv">Download control matrix CSV</a></p><table><thead><tr><th>Control</th><th>Status and scope</th><th>Criteria</th><th>filegrc Evidence</th><th>External 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
1606
  : "<p>No controls were selected.</p>";
1554
- const readinessLabel = packet.readiness.status === "delivery-ready" ? "FileGRC management checks passed" : "Draft, do not deliver";
1607
+ const readinessLabel = packet.readiness.status === "delivery-ready" ? "filegrc management checks passed" : "Draft, do not deliver";
1555
1608
  const packetDate = packet.period.basis === "as-of"
1556
1609
  ? `As of ${escapeHtml(packet.period.start)}`
1557
1610
  : `${escapeHtml(packet.period.start)} through ${escapeHtml(packet.period.end)}`;
1558
1611
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Evidence packet</title><style>
1559
1612
  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>`;
1613
+ </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("filegrc Evidence", filegrcRecords)}${section("External Evidence", evidence)}${section("Integrity and history", "<p>Verify all transferred files with <code>SHA256SUMS</code>. Committed prior versions are under <code>history/</code> with an index that records their source paths and Git metadata.</p>")}</body></html>`;
1561
1614
  }
1562
1615
 
1563
1616
  async function writePacketFile(output, relativePath, source, files) {
@@ -0,0 +1,69 @@
1
+ import { createResources } from "./files.js";
2
+ import { createResourceId } from "./id.js";
3
+ import { selectedControlFamilies } from "./program-readiness.js";
4
+ import { loadWorkspace } from "./workspace.js";
5
+
6
+ const TEST_EVIDENCE_KINDS = new Set(["test-export", "test-capture"]);
7
+
8
+ export function planEvidenceTestDrafts(loaded) {
9
+ const workspace = loaded.workspace;
10
+ const selectedIds = new Set(workspace.controlIds || []);
11
+ const controls = loaded.resources.filter((record) => (
12
+ record.type === "control"
13
+ && (!selectedIds.size || selectedIds.has(record.id))
14
+ && !["not-applicable", "retired"].includes(record.status)
15
+ ));
16
+ const families = selectedControlFamilies(controls, loaded.model)
17
+ .filter((family) => family.collectionTestRequired !== false);
18
+ const evidence = loaded.resources.filter((record) => (
19
+ record.type === "evidence" && TEST_EVIDENCE_KINDS.has(record.evidenceKind)
20
+ ));
21
+ return families.map((family) => {
22
+ const familyControlIds = new Set(family.controls.map((control) => control.id));
23
+ const existing = evidence.find((record) => record.collectionTestFamilyId === family.id)
24
+ || evidence.find((record) => (
25
+ (record.controlIds || []).some((id) => familyControlIds.has(id))
26
+ ));
27
+ return {
28
+ familyId: family.id,
29
+ title: family.title,
30
+ testEvidenceKind: family.testEvidenceKind,
31
+ testPrompt: family.testPrompt,
32
+ controlIds: [...familyControlIds],
33
+ existing
34
+ };
35
+ });
36
+ }
37
+
38
+ export async function ensureEvidenceTestDrafts(input = process.cwd()) {
39
+ const loaded = await loadWorkspace(input);
40
+ const plan = planEvidenceTestDrafts(loaded);
41
+ const created = [];
42
+ const usedIds = loaded.resources.map((record) => record.id);
43
+ for (const item of plan.filter(({ existing }) => !existing)) {
44
+ const id = createResourceId(
45
+ "evidence",
46
+ `${item.title} Collection Test`,
47
+ usedIds
48
+ );
49
+ usedIds.push(id);
50
+ const record = {
51
+ schemaVersion: 1,
52
+ id,
53
+ type: "evidence",
54
+ title: `${item.title} Evidence Collection Test`,
55
+ status: "draft",
56
+ evidenceKind: item.testEvidenceKind,
57
+ collectionTestFamilyId: item.familyId,
58
+ collectionTestPrompt: item.testPrompt,
59
+ controlIds: item.controlIds
60
+ };
61
+ created.push(record);
62
+ }
63
+ if (created.length) await createResources(loaded.root, created);
64
+ return {
65
+ created,
66
+ existing: plan.filter(({ existing }) => existing).map(({ existing }) => existing),
67
+ total: plan.length
68
+ };
69
+ }
package/src/git.js CHANGED
@@ -131,6 +131,7 @@ async function commitWorkspaceUnlocked(root, message) {
131
131
  }
132
132
  const before = getGitSummary(root);
133
133
  if (!before.available) throw new Error("Git history is unavailable for this workspace.");
134
+ if (!before.branch) throw new Error("Check out a branch before creating a browser commit.");
134
135
  if (before.clean) throw new Error("The workspace has no changes to commit.");
135
136
  if (!tryGit(root, ["config", "user.name"]) || !tryGit(root, ["config", "user.email"])) {
136
137
  throw new Error("Configure git user.name and user.email before committing.");
@@ -226,7 +227,7 @@ async function pushWorkspaceUnlocked(root) {
226
227
 
227
228
  function syncReadySummary(root, action) {
228
229
  const summary = getGitSummary(root);
229
- if (!summary.available) throw new Error(`Git history is unavailable for this workspace, so FileGRC cannot ${action}.`);
230
+ if (!summary.available) throw new Error(`Git history is unavailable for this workspace, so filegrc cannot ${action}.`);
230
231
  if (!summary.branch) throw new Error(`Check out a branch before trying to ${action}.`);
231
232
  if (!summary.clean) throw new Error(`Commit or discard workspace changes before trying to ${action}.`);
232
233
  return summary;
package/src/index.js CHANGED
@@ -3,6 +3,7 @@ export { buildAgentGuide, findResourceReferences, listResourceTypes, scaffoldRes
3
3
  export { assessAuditPreparation, prepareAuditWorkspace } from "./audit-preparation.js";
4
4
  export { buildWorkspace } from "./build.js";
5
5
  export { generateEvidencePacket, prepareEvidencePacket, writeEvidencePacket } from "./evidence-packet.js";
6
+ export { ensureEvidenceTestDrafts, planEvidenceTestDrafts } from "./evidence-tests.js";
6
7
  export {
7
8
  addEvidenceAttachment,
8
9
  createResource,
@@ -32,6 +33,15 @@ export {
32
33
  createObligationEvent,
33
34
  planObligations
34
35
  } from "./obligations.js";
36
+ export { assessProgramReadiness } from "./program-readiness.js";
37
+ export {
38
+ buildAgentProgramPath,
39
+ policyEventName,
40
+ POLICY_EVENT_NAMES,
41
+ PROGRAM_PATH,
42
+ RESOURCE_INSTRUCTIONS,
43
+ resourceProgramContext
44
+ } from "./program-path.js";
35
45
  export {
36
46
  addCalendarDays,
37
47
  calendarDayDifference,
@@ -40,7 +50,8 @@ export {
40
50
  nextCalendarOccurrence
41
51
  } from "./recurrence.js";
42
52
  export { searchResources, searchableValues } from "./search.js";
43
- export { createFileGRCServer, serveWorkspace } from "./server.js";
53
+ export { createFilegrcServer, serveWorkspace } from "./server.js";
54
+ export { normalizeSetupPayload, setupWorkspace } from "./setup.js";
44
55
  export { createAppState } from "./state.js";
45
56
  export { currentCalendarDate, formatCalendarDate, formatLocalDateTime } from "./time.js";
46
57
  export { validateWorkspace } from "./validate.js";
package/src/model-docs.js CHANGED
@@ -1,4 +1,7 @@
1
+ import { buildAgentProgramPath, RESOURCE_INSTRUCTIONS } from "./program-path.js";
2
+
1
3
  export function generateModelDocumentation(model) {
4
+ const programPath = buildAgentProgramPath(model);
2
5
  const lines = [
3
6
  "# GRC Data Model",
4
7
  "",
@@ -10,6 +13,28 @@ export function generateModelDocumentation(model) {
10
13
  "",
11
14
  "Each structured resource is one UTF-8 JSON file. Long-form work is an implicit Markdown companion beside that JSON file. Git supplies file authors, timestamps, diffs, commit messages, and revisions, so records do not duplicate those fields or file paths.",
12
15
  "",
16
+ "## Program path",
17
+ "",
18
+ "The renderer, CLI, generated agent instructions, and this reference use the same six-step lifecycle:",
19
+ "",
20
+ ...programPath.flatMap((stage) => [
21
+ `### Step ${stage.number}. ${stage.title}`,
22
+ "",
23
+ stage.summary,
24
+ "",
25
+ ...stage.pages.map((page) => `- **${page.title}** (\`${page.type || `utility:${page.utility}`}\`): ${page.instructions}`),
26
+ "",
27
+ ...(stage.operatingRecords?.length ? [
28
+ "Operating record guides:",
29
+ "",
30
+ ...stage.operatingRecords.map((page) => `- **${page.title}** (\`${page.type}\`): ${page.instructions}`),
31
+ ""
32
+ ] : []),
33
+ "Headless commands:",
34
+ "",
35
+ ...stage.commands.map((command) => `- \`${command}\``),
36
+ ""
37
+ ]),
13
38
  "## Common fields",
14
39
  "",
15
40
  "| Field | Type | Required | Meaning |",
@@ -26,9 +51,9 @@ export function generateModelDocumentation(model) {
26
51
  "",
27
52
  `Record Markdown is shown by default for: ${model.recordContent.defaultResourceTypes.map((type) => `\`${type}\``).join(", ")}. Other resources without dedicated Markdown can add it when structured fields are not enough.`,
28
53
  "",
29
- "## Audit preparation defaults",
54
+ "## Program and audit readiness defaults",
30
55
  "",
31
- "The engine and renderer use these model-owned defaults to prepare Type 1 and Type 2 engagements. Preparation creates engagement-specific management documents from the local starter templates. Management still confirms scope, approves documents, catalogs authoritative source systems, reconciles Type 2 populations, and supplies source evidence.",
56
+ "Program Readiness checks management scope, policy adoption, control implementation, authoritative source configuration, and verified test captures without requiring an audit record. Audit Readiness starts after a CPA firm is engaged and uses the defaults below to prepare Type 1 and Type 2 fieldwork.",
32
57
  "",
33
58
  "Management documents:",
34
59
  "",
@@ -40,7 +65,11 @@ export function generateModelDocumentation(model) {
40
65
  "",
41
66
  "Authoritative systems of record:",
42
67
  "",
43
- ...model.auditReadiness.externalEvidence.map((item) => `- **${item.title}** (${item.sourceKinds.map((kind) => `\`${kind}\``).join(", ")}): ${item.description} ${item.timing}`),
68
+ ...model.evidenceSourceFamilies.map((item) => (
69
+ item.collectionTestRequired === false
70
+ ? `- **${item.title}** (${item.sourceKinds.map((kind) => `\`${kind}\``).join(", ")}): ${item.description} filegrc operating records: ${item.operationRecordTypes.map((type) => `\`${type}\``).join(", ")}. No separate collection test is required. ${item.timing}`
71
+ : `- **${item.title}** (${item.sourceKinds.map((kind) => `\`${kind}\``).join(", ")}): ${item.description} Test external collection: ${item.testPrompt} ${item.timing}`
72
+ )),
44
73
  "",
45
74
  "## Resource groups",
46
75
  ""
@@ -52,6 +81,7 @@ export function generateModelDocumentation(model) {
52
81
  lines.push(`### ${group.title}`, "");
53
82
  for (const [type, resource] of resources) {
54
83
  lines.push(`#### \`${type}\``, "", resource.description, "");
84
+ lines.push(`Instructions: ${RESOURCE_INSTRUCTIONS[type] || resource.description}`, "");
55
85
  lines.push(`Policy basis: ${resource.guidance.policyBasis}`, "");
56
86
  lines.push(`Timing: ${resource.guidance.cadence}`, "");
57
87
  if (resource.guidance.sourceResourceIds?.length) {
@@ -68,7 +98,12 @@ export function generateModelDocumentation(model) {
68
98
  lines.push("Markdown companions:", "");
69
99
  for (const [name, markdown] of Object.entries(resource.markdown)) {
70
100
  const suffix = markdown.primary ? ".md" : `-${name}.md`;
71
- lines.push(`- **${markdown.label}**: \`${suffix}\` beside the JSON record${markdown.required ? " (required)" : " (optional)"}.`);
101
+ const requirement = markdown.required
102
+ ? "required"
103
+ : markdown.requiredWhen
104
+ ? `required when ${conditionText(markdown.requiredWhen)}`
105
+ : "optional";
106
+ lines.push(`- **${markdown.label}**: \`${suffix}\` beside the JSON record (${requirement}).`);
72
107
  }
73
108
  lines.push("");
74
109
  }
@@ -78,8 +113,10 @@ export function generateModelDocumentation(model) {
78
113
  const requiredLabel = required.has(name) || field.required ? "Yes" : field.requiredWhen ? "Conditional" : "No";
79
114
  lines.push(`| \`${name}\` | ${fieldType(field)} | ${requiredLabel} | ${escapeCell(fieldNotes(field))} |`);
80
115
  }
81
- for (const choices of resource.oneOf ?? []) {
82
- lines.push("", `At least one of ${choices.map(choiceLabel).join(", ")} is required.`);
116
+ for (const group of resource.oneOf ?? []) {
117
+ const choices = Array.isArray(group) ? group : group.fields || [];
118
+ const condition = Array.isArray(group) ? "" : ` when ${conditionText(group.when)}`;
119
+ lines.push("", `At least one of ${choices.map(choiceLabel).join(", ")} is required${condition}.`);
83
120
  }
84
121
  lines.push("");
85
122
  }
@@ -87,6 +124,14 @@ export function generateModelDocumentation(model) {
87
124
  return lines.join("\n");
88
125
  }
89
126
 
127
+ function conditionText(condition) {
128
+ return Object.entries(condition).map(([name, value]) => (
129
+ Array.isArray(value)
130
+ ? `\`${name}\` is one of ${value.map((item) => `\`${item}\``).join(", ")}`
131
+ : `\`${name}\` is \`${value}\``
132
+ )).join(" and ");
133
+ }
134
+
90
135
  function recordContentMode(model, type, resource) {
91
136
  if (!model.recordContent?.slot || resource.markdown) return null;
92
137
  return model.recordContent.defaultResourceTypes.includes(type) ? "default" : "optional";