filegrc 0.1.0 → 0.2.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,
@@ -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));
@@ -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
  ""
@@ -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,17 +1592,17 @@ 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
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"
@@ -1557,7 +1610,7 @@ function packetHtml(packet) {
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.");
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,
@@ -41,6 +51,7 @@ export {
41
51
  } from "./recurrence.js";
42
52
  export { searchResources, searchableValues } from "./search.js";
43
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";