filegrc 0.7.0 → 0.7.1

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.
@@ -9,11 +9,22 @@ import {
9
9
  coverageStart
10
10
  } from "./coverage.js";
11
11
  import { createResource, createResources, deleteResource, updateResource } from "./files.js";
12
+ import { getChangedDataPathsSinceRevision, getFileAtRevision, hasGitRevision } from "./git.js";
12
13
  import { createResourceId } from "./id.js";
13
14
  import { currentPartyPeople, partiesIndependent } from "./parties.js";
14
15
  import { resolveDataPath } from "./paths.js";
15
16
  import { assessProgramReadiness } from "./program-readiness.js";
16
17
  import { markdownEntries } from "./resource-markdown.js";
18
+ import {
19
+ auditorWasEngaged,
20
+ missingSoc2References,
21
+ personWasActiveOn,
22
+ recordWasInUseDuringAudit,
23
+ REQUIRED_SOC2_DESCRIPTION_REFERENCES,
24
+ REQUIRED_SOC2_SECURITY_REFERENCES,
25
+ signatoryAppointmentIssue,
26
+ subsequentEventsReviewIssue
27
+ } from "./soc2.js";
17
28
  import { loadWorkspace } from "./workspace.js";
18
29
 
19
30
  const NON_EVIDENCE_RECORD_TYPES = new Set([
@@ -39,7 +50,6 @@ const NON_EVIDENCE_RECORD_TYPES = new Set([
39
50
  "vendor",
40
51
  "workspace"
41
52
  ]);
42
-
43
53
  export async function assessAuditPreparation(input, options = {}) {
44
54
  const loaded = input?.resources && input?.model && input?.entries
45
55
  ? input
@@ -61,7 +71,7 @@ export async function assessAuditPreparation(input, options = {}) {
61
71
  const stages = [
62
72
  programFoundationStage(programReadiness, loaded.workspace),
63
73
  engagementStage(audit, byId, programReadiness),
64
- scopeStage(audit, records, byId, programReadiness)
74
+ scopeStage(loaded, audit, records, byId, programReadiness)
65
75
  ];
66
76
  const fieldworkSections = audit
67
77
  ? [
@@ -71,7 +81,7 @@ export async function assessAuditPreparation(input, options = {}) {
71
81
  ]
72
82
  : [];
73
83
  stages.push(fieldworkStage(audit, fieldworkSections));
74
- stages.push(auditorStage());
84
+ stages.push(auditorStage(audit, byId, loaded.model.modelVersion));
75
85
 
76
86
  for (const stage of stages) {
77
87
  stage.counts = countStatuses(stage.items);
@@ -212,7 +222,7 @@ export async function prepareAuditWorkspace(input, options = {}) {
212
222
  };
213
223
  }
214
224
 
215
- function scopeStage(audit, records, byId, programReadiness) {
225
+ function scopeStage(loaded, audit, records, byId, programReadiness) {
216
226
  const items = [];
217
227
  if (!audit) {
218
228
  items.push(item(
@@ -259,11 +269,24 @@ function scopeStage(audit, records, byId, programReadiness) {
259
269
  ));
260
270
  }
261
271
 
272
+ const v4 = String(programReadiness.dataModelVersion) === "4";
273
+ const engagementStart = coverageStart(audit.coverage);
274
+ const engagementEnd = coverageEnd(audit.coverage);
275
+ const scopeRevision = assessScopeRevision(loaded, audit, v4);
276
+ items.push(item(
277
+ "scope-revision",
278
+ scopeRevision.status,
279
+ "Bind the reviewed engagement scope",
280
+ scopeRevision.message,
281
+ audit
282
+ ));
262
283
  const systems = (audit.systemIds || []).map((id) => byId.get(id)).filter(Boolean);
263
284
  const completeSystems = systems.filter((system) => (
264
- system.status === "active"
265
- && system.description
266
- && system.classificationId
285
+ recordWasInUseDuringAudit(system, engagementStart, engagementEnd)
286
+ && (v4
287
+ ? system.purpose && system.boundary && (system.servicesProvided || []).length
288
+ : system.description)
289
+ && (v4 || system.classificationId)
267
290
  && (system.ownerIds || []).length
268
291
  ));
269
292
  items.push(item(
@@ -271,12 +294,11 @@ function scopeStage(audit, records, byId, programReadiness) {
271
294
  systems.length && completeSystems.length === systems.length ? "complete" : "action",
272
295
  "Define the service boundary",
273
296
  systems.length
274
- ? `${completeSystems.length} of ${systems.length} selected systems are active, explicitly in scope, owned, classified, and described.`
297
+ ? `${completeSystems.length} of ${systems.length} selected systems were in use for the engagement, explicitly in scope, owned, and described${v4 ? "" : ", with a classification"}.`
275
298
  : "Select every in-scope service and supporting system, then describe its owner, environment, data, vendors, and boundary.",
276
299
  systems[0] || { type: "system" }
277
300
  ));
278
301
 
279
- const engagementStart = coverageStart(audit.coverage);
280
302
  const commitments = records.filter((record) => record.type === "commitment"
281
303
  && record.status === "active"
282
304
  && systems.some((system) => (record.systemIds || []).includes(system.id)));
@@ -304,48 +326,121 @@ function scopeStage(audit, records, byId, programReadiness) {
304
326
  const frameworkRequirementIds = records
305
327
  .filter((record) => record.type === "requirement" && (audit.frameworkIds || []).includes(record.frameworkId))
306
328
  .map((record) => record.id);
329
+ const program = audit.programId ? byId.get(audit.programId) : null;
330
+ const v4Decisions = new Map((program?.requirementApplicability || []).map((decision) => [decision.requirementId, decision]));
307
331
  const unresolvedRequirements = frameworkRequirementIds
308
332
  .map((id) => byId.get(id))
309
- .filter((requirement) => (
310
- requirement.applicability === "undetermined"
311
- || (requirement.applicability === "not-applicable" && !requirement.applicabilityRationale)
312
- ));
313
- const applicableRequirementIds = frameworkRequirementIds.filter((id) => byId.get(id)?.applicability === "applicable");
333
+ .filter((requirement) => {
334
+ if (!v4) {
335
+ return requirement.applicability === "undetermined"
336
+ || (requirement.applicability === "not-applicable" && !requirement.applicabilityRationale);
337
+ }
338
+ const decision = v4Decisions.get(requirement.id);
339
+ return !decision
340
+ || decision.decision === "undetermined"
341
+ || (decision.decision === "not-applicable" && !decision.rationale);
342
+ });
343
+ const applicableRequirementIds = frameworkRequirementIds.filter((id) => (
344
+ v4 ? v4Decisions.get(id)?.decision === "applicable" : byId.get(id)?.applicability === "applicable"
345
+ ));
314
346
  const missingApplicableRequirements = applicableRequirementIds.filter((id) => !(audit.requirementIds || []).includes(id));
315
347
  const selectedRequirements = (audit.requirementIds || []).map((id) => byId.get(id)).filter(Boolean);
316
348
  const unexpectedRequirements = selectedRequirements.filter((requirement) => (
317
349
  !(audit.frameworkIds || []).includes(requirement.frameworkId)
318
- || requirement.applicability !== "applicable"
350
+ || (v4
351
+ ? v4Decisions.get(requirement.id)?.decision !== "applicable"
352
+ : requirement.applicability !== "applicable")
319
353
  ));
320
- const descriptionCriteriaSelected = selectedRequirements.some((requirement) => (
321
- (requirement.tags || []).includes("description-criteria")
322
- || /^DC\d+/i.test(requirement.reference || "")
354
+ const selectedControls = (audit.controlIds || []).map((id) => byId.get(id)).filter(Boolean);
355
+ const uncoveredRequirements = selectedRequirements.filter((requirement) => (
356
+ !isDescriptionRequirement(requirement)
357
+ && !selectedControls.some((control) => (control.requirementIds || []).includes(requirement.id))
323
358
  ));
359
+ const descriptionRequirements = frameworkRequirementIds
360
+ .map((id) => byId.get(id))
361
+ .filter(isDescriptionRequirement);
362
+ const missingDescriptionRequirements = descriptionRequirements.filter((requirement) => (
363
+ !(audit.requirementIds || []).includes(requirement.id)
364
+ ));
365
+ const missingRequiredDescriptionReferences = v4
366
+ ? missingSoc2References(descriptionRequirements, REQUIRED_SOC2_DESCRIPTION_REFERENCES)
367
+ : [];
368
+ const invalidMandatoryDescriptionDecisions = v4
369
+ ? descriptionRequirements.filter((requirement) => (
370
+ REQUIRED_SOC2_DESCRIPTION_REFERENCES.includes(String(requirement.reference || "").trim().toUpperCase())
371
+ && v4Decisions.get(requirement.id)?.decision !== "applicable"
372
+ ))
373
+ : [];
374
+ const securityRequirements = frameworkRequirementIds
375
+ .map((id) => byId.get(id))
376
+ .filter(isSecurityRequirement);
377
+ const missingRequiredSecurityReferences = v4
378
+ ? missingSoc2References(securityRequirements, REQUIRED_SOC2_SECURITY_REFERENCES)
379
+ : [];
380
+ const missingSelectedSecurityReferences = v4
381
+ ? missingSoc2References(selectedRequirements.filter(isSecurityRequirement), REQUIRED_SOC2_SECURITY_REFERENCES)
382
+ : [];
383
+ const invalidMandatorySecurityDecisions = v4
384
+ ? securityRequirements.filter((requirement) => (
385
+ REQUIRED_SOC2_SECURITY_REFERENCES.includes(String(requirement.reference || "").trim().toUpperCase())
386
+ && v4Decisions.get(requirement.id)?.decision !== "applicable"
387
+ ))
388
+ : [];
324
389
  const criteriaComplete = (audit.frameworkIds || []).length
325
390
  && (audit.requirementIds || []).length
326
391
  && (audit.controlIds || []).length
327
392
  && !unresolvedRequirements.length
328
393
  && !missingApplicableRequirements.length
329
394
  && !unexpectedRequirements.length
330
- && descriptionCriteriaSelected;
395
+ && !uncoveredRequirements.length
396
+ && descriptionRequirements.length
397
+ && !missingDescriptionRequirements.length
398
+ && !missingRequiredDescriptionReferences.length
399
+ && !missingRequiredSecurityReferences.length
400
+ && !missingSelectedSecurityReferences.length
401
+ && !invalidMandatorySecurityDecisions.length
402
+ && !invalidMandatoryDescriptionDecisions.length;
331
403
  items.push(item(
332
404
  "criteria",
333
405
  criteriaComplete ? "complete" : "action",
334
- "Confirm criteria and controls in scope",
406
+ "Confirm Trust Services criteria, Description Criteria, and Controls",
335
407
  criteriaComplete
336
- ? `${audit.requirementIds.length} applicable criteria and ${audit.controlIds.length} controls are selected, with applicability resolved for the selected frameworks.`
408
+ ? `${selectedRequirements.filter((requirement) => !isDescriptionRequirement(requirement)).length} applicable Trust Services criteria, ${selectedRequirements.filter(isDescriptionRequirement).length} SOC 2 Description Criteria, and ${audit.controlIds.length} Controls are selected. Every applicable Trust Services criterion maps to a selected Control; Description Criteria govern the system description and do not map to Controls.`
337
409
  : unresolvedRequirements.length
338
410
  ? `Resolve applicability and record a rationale for ${unresolvedRequirements.length} selected-framework criteria.`
339
- : missingApplicableRequirements.length
340
- ? `Add ${missingApplicableRequirements.length} applicable selected-framework criteria to the engagement.`
341
- : unexpectedRequirements.length
342
- ? `Remove ${unexpectedRequirements.length} criteria that are not applicable members of the selected frameworks.`
343
- : !descriptionCriteriaSelected
344
- ? "Select the applicable SOC 2 description criteria as well as the Trust Services Criteria."
345
- : "Select the Security criteria, any optional Trust Services Categories, and the controls included in this report.",
346
- audit
411
+ : !descriptionRequirements.length
412
+ ? "Select the SOC 2 Description Criteria framework and all nine criteria. These govern the system description and do not map to Controls."
413
+ : missingRequiredDescriptionReferences.length
414
+ ? `Use the complete SOC 2 Description Criteria set; ${missingRequiredDescriptionReferences.join(", ")} ${missingRequiredDescriptionReferences.length === 1 ? "is" : "are"} missing from the selected framework.`
415
+ : missingRequiredSecurityReferences.length
416
+ ? `Use the complete SOC 2 Security Common Criteria set; ${missingRequiredSecurityReferences.join(", ")} ${missingRequiredSecurityReferences.length === 1 ? "is" : "are"} missing from the selected framework.`
417
+ : invalidMandatoryDescriptionDecisions.length
418
+ ? `Mark all nine Description Criteria applicable in the selected Program; ${invalidMandatoryDescriptionDecisions.map(({ reference }) => reference).join(", ")} ${invalidMandatoryDescriptionDecisions.length === 1 ? "is" : "are"} missing, undetermined, or not applicable.`
419
+ : invalidMandatorySecurityDecisions.length
420
+ ? `Mark every Security Common Criterion applicable in the selected Program; ${invalidMandatorySecurityDecisions.map(({ reference }) => reference).join(", ")} ${invalidMandatorySecurityDecisions.length === 1 ? "is" : "are"} missing, undetermined, or not applicable.`
421
+ : missingSelectedSecurityReferences.length
422
+ ? `Add every Security Common Criterion to the engagement; ${missingSelectedSecurityReferences.join(", ")} ${missingSelectedSecurityReferences.length === 1 ? "is" : "are"} missing.`
423
+ : missingDescriptionRequirements.length
424
+ ? `Add all nine SOC 2 Description Criteria to the engagement; ${missingDescriptionRequirements.length} ${missingDescriptionRequirements.length === 1 ? "is" : "are"} missing.`
425
+ : missingApplicableRequirements.length
426
+ ? `Add ${missingApplicableRequirements.length} applicable selected-framework Trust Services criteria to the engagement.`
427
+ : unexpectedRequirements.length
428
+ ? `Remove ${unexpectedRequirements.length} criteria that are not applicable members of the selected frameworks.`
429
+ : uncoveredRequirements.length
430
+ ? `Map ${uncoveredRequirements.length} selected Trust Services criteria to Controls included in the engagement.`
431
+ : "Select the Security criteria, any optional Trust Services Categories, and the Controls included in this report.",
432
+ audit,
433
+ {
434
+ commands: unresolvedRequirements.length
435
+ ? [
436
+ "npx filegrc review-applicability --scaffold --type requirement > decisions.json",
437
+ "npx filegrc review-applicability decisions.json --preview --json"
438
+ ]
439
+ : [`npx filegrc get ${audit.id} --mutation`]
440
+ }
347
441
  ));
348
442
 
443
+ const treatments = audit.subserviceTreatments || [];
349
444
  const expectedSubserviceVendorIds = new Set(systems.flatMap((system) => system.subserviceVendorIds || []));
350
445
  const missingSubserviceVendorIds = [...expectedSubserviceVendorIds].filter((id) => !(audit.subserviceVendorIds || []).includes(id));
351
446
  const inclusiveSystemIds = records
@@ -355,16 +450,54 @@ function scopeStage(audit, records, byId, programReadiness) {
355
450
  .map((id) => byId.get(id))
356
451
  .filter((control) => (control?.systemIds || []).some((id) => inclusiveSystemIds.includes(id)))
357
452
  .length;
358
- const subserviceComplete = Boolean(audit.subserviceMethod)
359
- && !((audit.subserviceVendorIds || []).length && audit.subserviceMethod === "not-applicable")
360
- && !missingSubserviceVendorIds.length
361
- && !(audit.subserviceMethod === "inclusive" && (!inclusiveSystemIds.length || !inclusiveControlCount));
453
+ const v4InclusiveTreatments = treatments.filter(({ method }) => method === "inclusive");
454
+ const treatmentComponentCounts = new Map();
455
+ for (const treatment of treatments) {
456
+ for (const componentId of treatment.componentIds || []) {
457
+ treatmentComponentCounts.set(componentId, (treatmentComponentCounts.get(componentId) || 0) + 1);
458
+ }
459
+ }
460
+ const v4InvalidTreatments = treatments.filter((treatment) => (
461
+ byId.get(treatment.vendorId)?.type !== "vendor"
462
+ || !recordWasInUseDuringAudit(byId.get(treatment.vendorId), engagementStart, engagementEnd)
463
+ || !(treatment.componentIds || []).length
464
+ || (treatment.componentIds || []).some((componentId) => {
465
+ const component = byId.get(componentId);
466
+ return component?.type !== "component"
467
+ || !recordWasInUseDuringAudit(component, engagementStart, engagementEnd)
468
+ || component.vendorId !== treatment.vendorId
469
+ || (treatmentComponentCounts.get(componentId) || 0) > 1
470
+ || !(component.systemUses || []).some(({ systemId }) => (audit.systemIds || []).includes(systemId));
471
+ })
472
+ ));
473
+ const v4InclusiveWithoutControls = v4InclusiveTreatments.filter((treatment) => !selectedControls.some((control) => (
474
+ (control.componentIds || []).some((id) => (treatment.componentIds || []).includes(id))
475
+ )));
476
+ const subserviceComplete = v4
477
+ ? Boolean(audit.subserviceConclusion && audit.subserviceConclusionRationale)
478
+ && (audit.subserviceConclusion === "identified" ? treatments.length > 0 : treatments.length === 0)
479
+ && v4InvalidTreatments.length === 0
480
+ && v4InclusiveWithoutControls.length === 0
481
+ : Boolean(audit.subserviceMethod)
482
+ && !((audit.subserviceVendorIds || []).length && audit.subserviceMethod === "not-applicable")
483
+ && !missingSubserviceVendorIds.length
484
+ && !(audit.subserviceMethod === "inclusive" && (!inclusiveSystemIds.length || !inclusiveControlCount));
362
485
  items.push(item(
363
486
  "subservices",
364
487
  subserviceComplete ? "complete" : "action",
365
488
  "Decide how subservice organizations are presented",
366
489
  subserviceComplete
367
- ? `${displayValue(audit.subserviceMethod)} method selected for ${(audit.subserviceVendorIds || []).length} subservice organizations${audit.subserviceMethod === "inclusive" ? `, with ${inclusiveControlCount} included controls` : ""}.`
490
+ ? v4
491
+ ? audit.subserviceConclusion === "identified"
492
+ ? `${treatments.length} Vendor and Component subservice treatments are recorded with a rationale.`
493
+ : "Management recorded and explained that no subservice organizations are included in the report scope."
494
+ : `${displayValue(audit.subserviceMethod)} method selected for ${(audit.subserviceVendorIds || []).length} subservice organizations${audit.subserviceMethod === "inclusive" ? `, with ${inclusiveControlCount} included controls` : ""}.`
495
+ : v4
496
+ ? v4InvalidTreatments.length
497
+ ? `${v4InvalidTreatments.length} subservice ${v4InvalidTreatments.length === 1 ? "treatment does" : "treatments do"} not identify one Vendor's supplied Components in use within the selected System boundary during the engagement, or ${v4InvalidTreatments.length === 1 ? "repeats" : "repeat"} a Component across treatments.`
498
+ : v4InclusiveWithoutControls.length
499
+ ? `${v4InclusiveWithoutControls.length} inclusive subservice treatments have no selected Controls linked to their Components.`
500
+ : "Record whether subservice organizations are identified, explain the conclusion, and record each Vendor, Component, carve-out or inclusive method, and rationale when applicable."
368
501
  : missingSubserviceVendorIds.length
369
502
  ? `Add ${missingSubserviceVendorIds.length} subservice organizations already identified by the in-scope systems.`
370
503
  : audit.subserviceMethod === "inclusive"
@@ -404,6 +537,175 @@ function scopeStage(audit, records, byId, programReadiness) {
404
537
  return stage("period", "Confirm the Formal Period", "Record the auditor-agreed report type, date or period, scope, criteria, systems, and dependency treatment.", items);
405
538
  }
406
539
 
540
+ function isDescriptionRequirement(requirement) {
541
+ return (requirement.tags || []).includes("description-criteria") || /^DC\d+/i.test(requirement.reference || "");
542
+ }
543
+
544
+ function isSecurityRequirement(requirement) {
545
+ const tags = requirement?.tags || [];
546
+ return tags.includes("security") || tags.includes("common-criteria") || /^CC\d+(?:\.|$)/i.test(requirement?.reference || "");
547
+ }
548
+
549
+ const AUDIT_SCOPE_REVISION_FIELDS = [
550
+ "auditKind",
551
+ "programId",
552
+ "frameworkIds",
553
+ "scope",
554
+ "coverage",
555
+ "systemIds",
556
+ "requirementIds",
557
+ "controlIds",
558
+ "commitmentIds",
559
+ "complementaryControlIds",
560
+ "complementaryControlsConclusion",
561
+ "subserviceConclusion",
562
+ "subserviceConclusionRationale",
563
+ "subserviceTreatments",
564
+ "subserviceVendorIds",
565
+ "subserviceMethod",
566
+ "signatoryAppointmentIds"
567
+ ];
568
+
569
+ function assessScopeRevision(loaded, audit, v4) {
570
+ if (!v4) {
571
+ return {
572
+ status: "complete",
573
+ message: "The legacy workspace stores the engagement selections directly on the Audit."
574
+ };
575
+ }
576
+ if (!audit.scopeRevision) {
577
+ return {
578
+ status: "action",
579
+ message: "Review the engagement's Program, Systems, criteria, Controls, commitments, subservices, complementary controls, and signatories, then record the reviewed Git revision in scopeRevision."
580
+ };
581
+ }
582
+ if (!hasGitRevision(loaded.root, audit.scopeRevision)) {
583
+ return {
584
+ status: "action",
585
+ message: `${audit.scopeRevision} is not an available Git commit. Commit the reviewed scope and record that exact commit in scopeRevision.`
586
+ };
587
+ }
588
+ const entry = loaded.entries.find(({ record }) => record.id === audit.id);
589
+ const source = entry
590
+ ? getFileAtRevision(loaded.root, audit.scopeRevision, `data/${entry.relativePath}`)
591
+ : null;
592
+ let historicalAudit = null;
593
+ try {
594
+ historicalAudit = source ? JSON.parse(source) : null;
595
+ } catch {
596
+ historicalAudit = null;
597
+ }
598
+ if (historicalAudit?.id !== audit.id || historicalAudit.type !== "audit") {
599
+ return {
600
+ status: "action",
601
+ message: `${audit.scopeRevision} does not contain this Audit record. Commit the reviewed scope and update scopeRevision.`
602
+ };
603
+ }
604
+ if (auditScopeFingerprint(historicalAudit) !== auditScopeFingerprint(audit)) {
605
+ return {
606
+ status: "action",
607
+ message: "The current engagement scope differs from the scope stored at scopeRevision. Review the diff, commit the accepted scope, and update scopeRevision."
608
+ };
609
+ }
610
+ const changedScopeRecords = scopeRecordsChangedSinceRevision(loaded, audit, historicalAudit);
611
+ if (changedScopeRecords === null) {
612
+ return {
613
+ status: "action",
614
+ message: "FileGRC could not compare the current engagement scope with scopeRevision. Confirm Git history is available, then review and record the exact scope commit."
615
+ };
616
+ }
617
+ if (changedScopeRecords.length) {
618
+ return {
619
+ status: "action",
620
+ message: `${changedScopeRecords.length} record${changedScopeRecords.length === 1 ? "" : "s"} within the reviewed engagement scope changed after scopeRevision. Review the scope diff, commit the accepted records, and update scopeRevision.`
621
+ };
622
+ }
623
+ return {
624
+ status: "complete",
625
+ message: `Management reviewed the current engagement scope at Git revision ${audit.scopeRevision}.`
626
+ };
627
+ }
628
+
629
+ function scopeRecordsChangedSinceRevision(loaded, audit, historicalAudit) {
630
+ const currentByPath = new Map(loaded.entries.map((entry) => [`data/${entry.relativePath}`, entry.record]));
631
+ const changedPaths = getChangedDataPathsSinceRevision(loaded.root, audit.scopeRevision);
632
+ if (!changedPaths) return null;
633
+ const candidatePaths = changedPaths.filter((path) => path.endsWith(".json"));
634
+ const changed = [];
635
+ for (const path of candidatePaths) {
636
+ const current = currentByPath.get(path) || null;
637
+ let historical = null;
638
+ const historicalSource = getFileAtRevision(loaded.root, audit.scopeRevision, path);
639
+ try {
640
+ historical = historicalSource ? JSON.parse(historicalSource) : null;
641
+ } catch {
642
+ historical = null;
643
+ }
644
+ if (
645
+ !scopeRecordIsRelevant(current, audit)
646
+ && !scopeRecordIsRelevant(historical, historicalAudit)
647
+ ) continue;
648
+ if (JSON.stringify(canonicalScopeValue(current)) !== JSON.stringify(canonicalScopeValue(historical))) changed.push(path);
649
+ }
650
+ return changed;
651
+ }
652
+
653
+ function scopeRecordIsRelevant(record, audit) {
654
+ if (!record || !audit) return false;
655
+ const selectedSystemIds = new Set(audit.systemIds || []);
656
+ const selectedFrameworkIds = new Set(audit.frameworkIds || []);
657
+ const selectedIds = new Set([
658
+ audit.programId,
659
+ ...(audit.frameworkIds || []),
660
+ ...(audit.systemIds || []),
661
+ ...(audit.requirementIds || []),
662
+ ...(audit.controlIds || []),
663
+ ...(audit.commitmentIds || []),
664
+ ...(audit.complementaryControlIds || []),
665
+ ...(audit.signatoryAppointmentIds || []),
666
+ ...auditSubserviceVendorIds(audit),
667
+ ...auditSubserviceComponentIds(audit)
668
+ ].filter(Boolean));
669
+ if (selectedIds.has(record.id)) return true;
670
+ if (record.type === "requirement") return selectedFrameworkIds.has(record.frameworkId);
671
+ if (["commitment", "complementary-control"].includes(record.type)) {
672
+ return (record.systemIds || []).some((id) => selectedSystemIds.has(id));
673
+ }
674
+ if (record.type === "component") {
675
+ return (record.systemUses || []).some(({ systemId }) => selectedSystemIds.has(systemId));
676
+ }
677
+ return false;
678
+ }
679
+
680
+ function auditSubserviceVendorIds(audit) {
681
+ return [
682
+ ...(audit.subserviceVendorIds || []),
683
+ ...(audit.subserviceTreatments || []).map(({ vendorId }) => vendorId)
684
+ ];
685
+ }
686
+
687
+ function auditSubserviceComponentIds(audit) {
688
+ return (audit.subserviceTreatments || []).flatMap(({ componentIds }) => componentIds || []);
689
+ }
690
+
691
+ function auditScopeFingerprint(audit) {
692
+ return JSON.stringify(Object.fromEntries(AUDIT_SCOPE_REVISION_FIELDS.map((field) => (
693
+ [field, canonicalScopeValue(audit[field] ?? null)]
694
+ ))));
695
+ }
696
+
697
+ function canonicalScopeValue(value) {
698
+ if (Array.isArray(value)) {
699
+ return value
700
+ .map(canonicalScopeValue)
701
+ .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
702
+ }
703
+ if (value && typeof value === "object") {
704
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalScopeValue(value[key])]));
705
+ }
706
+ return value;
707
+ }
708
+
407
709
  function programFoundationStage(programReadiness, workspace) {
408
710
  const ready = programReadiness.evidenceReady;
409
711
  return stage("program", "Program Readiness", "The management program can be prepared and operated without an audit record or CPA firm.", [
@@ -434,11 +736,32 @@ function engagementStage(audit, byId, programReadiness) {
434
736
  ]);
435
737
  }
436
738
  const auditor = audit.auditorVendorId ? byId.get(audit.auditorVendorId) : null;
437
- const named = Boolean(auditor);
739
+ const named = auditorWasEngaged(auditor, audit);
438
740
  const currentOwners = [...currentPartyPeople(audit.ownerIds, byId)]
439
741
  .map((id) => byId.get(id))
440
742
  .filter(Boolean);
441
- return stage("engagement", "Engage the Auditor", "Record the independent CPA firm and the current management owner who authorizes and coordinates the engagement.", [
743
+ const engagementTerms = audit.engagementTermsDocumentId ? byId.get(audit.engagementTermsDocumentId) : null;
744
+ const engagementTermsComplete = Boolean(
745
+ engagementTerms?.type === "document"
746
+ && engagementTerms.documentKind === "soc2-engagement-terms"
747
+ && engagementTerms.status === "active"
748
+ && engagementTerms.approvedOn
749
+ && engagementTerms.effectiveOn
750
+ );
751
+ const acknowledgementPeople = (audit.managementAcknowledgedByIds || [])
752
+ .map((id) => byId.get(id))
753
+ .filter((record) => personWasActiveOn(record, audit.managementAcknowledgedOn));
754
+ const acknowledged = Boolean(
755
+ engagementTermsComplete
756
+ && acknowledgementPeople.length === (audit.managementAcknowledgedByIds || []).length
757
+ && acknowledgementPeople.length
758
+ && audit.managementAcknowledgedOn
759
+ && audit.managementAcknowledgedOn >= engagementTerms.approvedOn
760
+ && (!audit.fieldworkStart || audit.managementAcknowledgedOn <= audit.fieldworkStart)
761
+ );
762
+ const preliminary = audit.status === "planned";
763
+ const programGoalAligned = programReadiness.target.goal === audit.auditKind;
764
+ const items = [
442
765
  item(
443
766
  "engagement-record",
444
767
  "complete",
@@ -446,13 +769,24 @@ function engagementStage(audit, byId, programReadiness) {
446
769
  `${audit.title} tracks the formal scope, dates, requests, fieldwork, and report.`,
447
770
  audit
448
771
  ),
772
+ item(
773
+ "program-goal-alignment",
774
+ programGoalAligned ? "complete" : "action",
775
+ "Align the Program goal with the engagement",
776
+ programGoalAligned
777
+ ? `The Program goal and formal engagement are both ${audit.auditKind === "soc-2-type-2" ? "SOC 2 Type 2" : "SOC 2 Type 1"}.`
778
+ : `Change the Program goal from ${programReadiness.target.label} to ${audit.auditKind === "soc-2-type-2" ? "SOC 2 Type 2" : "SOC 2 Type 1"}, or correct the Audit type if the engagement record is wrong.`,
779
+ audit
780
+ ),
449
781
  item(
450
782
  "auditor",
451
783
  named ? "complete" : "action",
452
784
  "Record the independent CPA firm",
453
785
  named
454
786
  ? `${auditor.title} is recorded for the engagement.`
455
- : "Select the CPA firm and record it here. The independent management policy reviewer is a different role.",
787
+ : auditor
788
+ ? `${auditor.title} was not active for the engagement period. Confirm the CPA firm and its engagement dates.`
789
+ : "Select the CPA firm and record it here. The independent management policy reviewer is a different role.",
456
790
  audit
457
791
  ),
458
792
  item(
@@ -470,7 +804,34 @@ function engagementStage(audit, byId, programReadiness) {
470
804
  ]
471
805
  }
472
806
  )
473
- ]);
807
+ ];
808
+ if (["3", "4"].includes(String(programReadiness.dataModelVersion))) {
809
+ items.push(
810
+ item(
811
+ "engagement-terms",
812
+ engagementTermsComplete ? "complete" : preliminary ? "later" : "action",
813
+ "Link the engagement terms",
814
+ engagementTermsComplete
815
+ ? `${engagementTerms.title} records the accepted CPA engagement terms.`
816
+ : preliminary
817
+ ? "Link the accepted CPA engagement terms before moving this Audit to in progress."
818
+ : "Link an active, approved Document with documentKind soc2-engagement-terms that contains the accepted CPA engagement terms.",
819
+ engagementTerms || { type: "document" }
820
+ ),
821
+ item(
822
+ "management-acknowledgement",
823
+ acknowledged ? "complete" : preliminary ? "later" : "action",
824
+ "Record management acknowledgement",
825
+ acknowledged
826
+ ? `Management acknowledged the engagement terms on ${audit.managementAcknowledgedOn}.`
827
+ : preliminary
828
+ ? "Name the responsible management people and the acknowledgement date before moving this Audit to in progress."
829
+ : "Name the management people who acknowledged the approved engagement terms and record the actual acknowledgement date on or after approval and no later than fieldwork start. Each person must have been active on that date.",
830
+ audit
831
+ )
832
+ );
833
+ }
834
+ return stage("engagement", "Engage the Auditor", "Record the independent CPA firm and the current management owner who authorizes and coordinates the engagement.", items);
474
835
  }
475
836
 
476
837
  function fieldworkStage(audit, sections) {
@@ -520,11 +881,14 @@ async function documentsStage(loaded, audit, byId) {
520
881
  .find((record) => (
521
882
  record?.type === "evidence"
522
883
  && record.status === "verified"
884
+ && record.artifactKind === "signed-record"
885
+ && record.artifactSubtype === "signed-management-representation"
523
886
  && (record.filePaths || []).length
524
887
  ));
525
- if (!signedEvidence) contentIssues.push("Link a verified fixed-format copy of the signed representation letter as evidence.");
526
- else if (!signedEvidence.collectedOn || (engagementEnd && signedEvidence.collectedOn < engagementEnd)) {
527
- contentIssues.push(`The signed representation must be dated on or after the engagement ${audit.auditKind === "soc-2-type-1" ? "date" : "period end"}.`);
888
+ if (!signedEvidence) contentIssues.push("Link verified signed-record Evidence with subtype signed-management-representation and a fixed-format copy of the signed letter.");
889
+ else {
890
+ const dateIssue = signedRepresentationDateIssue(signedEvidence, audit, loaded.model.modelVersion);
891
+ if (dateIssue) contentIssues.push(dateIssue);
528
892
  }
529
893
  }
530
894
  const complete = Boolean(
@@ -542,7 +906,7 @@ async function documentsStage(loaded, audit, byId) {
542
906
  );
543
907
  const representationLater = definition.kind === "soc2-management-representation"
544
908
  && audit
545
- && !["fieldwork", "complete"].includes(audit.status);
909
+ && ["planned", "in-progress", "fieldwork"].includes(audit.status);
546
910
  items.push(item(
547
911
  definition.kind,
548
912
  complete ? "complete" : representationLater ? "later" : "action",
@@ -558,6 +922,24 @@ async function documentsStage(loaded, audit, byId) {
558
922
  return stage("documents", "Management Documents", "Prepare management's description, assertions, completeness work, and closing representations.", items);
559
923
  }
560
924
 
925
+ export function signedRepresentationDateIssue(evidence, audit, modelVersion) {
926
+ const engagementEnd = coverageEnd(audit?.coverage);
927
+ const timingMessage = `The signed representation must be dated on or after the engagement ${audit?.auditKind === "soc-2-type-1" ? "date" : "period end"}.`;
928
+ if (String(modelVersion) !== "4") {
929
+ return !evidence?.collectedOn || (engagementEnd && evidence.collectedOn < engagementEnd)
930
+ ? timingMessage
931
+ : null;
932
+ }
933
+ const signedOn = evidence?.businessEventAt?.slice(0, 10);
934
+ if (!signedOn) {
935
+ return "Record the letter's actual signing timestamp in the signed Evidence businessEventAt field. Collection time does not prove when management made the representations.";
936
+ }
937
+ if (audit?.reportDate && signedOn !== audit.reportDate) {
938
+ return `The written representations are dated ${signedOn}; AT-C 205 requires them to be dated as of the practitioner's report date, ${audit.reportDate}.`;
939
+ }
940
+ return engagementEnd && signedOn < engagementEnd ? timingMessage : null;
941
+ }
942
+
561
943
  function evidenceStage(audit, records, byId, model) {
562
944
  const controls = (audit?.controlIds || []).map((id) => byId.get(id)).filter(Boolean);
563
945
  const evidence = records.filter((record) => record.type === "evidence");
@@ -710,8 +1092,35 @@ function populationsStage(audit, records, byId, model) {
710
1092
  );
711
1093
  }
712
1094
 
713
- function auditorStage() {
1095
+ function auditorStage(audit, byId, modelVersion) {
1096
+ const subsequentEventsIssue = audit ? subsequentEventsReviewIssue(audit) : null;
1097
+ const subsequentEventsStatus = !audit || ["planned", "in-progress", "fieldwork"].includes(audit.status)
1098
+ ? "later"
1099
+ : subsequentEventsIssue ? "action" : "complete";
1100
+ const signatoryIssue = audit && ["report-draft", "issued", "delivered", "complete"].includes(audit.status)
1101
+ ? signatoryAppointmentIssue(audit, byId)
1102
+ : null;
1103
+ const signatoryStatus = !audit || ["planned", "in-progress", "fieldwork"].includes(audit.status)
1104
+ ? "later"
1105
+ : signatoryIssue ? "action" : "complete";
1106
+ const managementItems = String(modelVersion) === "4" ? [
1107
+ item(
1108
+ "subsequent-events",
1109
+ subsequentEventsStatus,
1110
+ "Review subsequent events through the report date",
1111
+ subsequentEventsIssue?.message || "Management recorded the subsequent-events review through the CPA report date.",
1112
+ audit || { type: "audit" }
1113
+ ),
1114
+ item(
1115
+ "signatory-authority",
1116
+ signatoryStatus,
1117
+ "Confirm signatory authority",
1118
+ signatoryIssue?.message || "The linked Appointments establish the signers' authority on the CPA report date.",
1119
+ audit || { type: "audit" }
1120
+ )
1121
+ ] : [];
714
1122
  return stage("auditor", "Fieldwork and Report", "filegrc prepares the record set but does not make the CPA firm's independent judgments.", [
1123
+ ...managementItems,
715
1124
  item("firm-eligibility", "external", "Firm eligibility and independence", "Confirm directly with the engagement partner that the firm and signing practitioner meet applicable licensing, peer-review, ethics, and independence requirements. Keep the signed engagement terms with the audit record if management needs a copy."),
716
1125
  item("sampling", "external", "Sample selection and independent testing", "The auditor chooses samples, performs tests, evaluates exceptions, and decides whether more work is needed."),
717
1126
  item("report", "external", "Report and opinion", "Management reviews and signs its representations. The auditor issues the final report and opinion."),
@@ -804,8 +1213,9 @@ async function primaryMarkdown(loaded, record) {
804
1213
  if (!item) return "";
805
1214
  try {
806
1215
  return await readFile(resolveDataPath(loaded.root, item.path), "utf8");
807
- } catch {
808
- return "";
1216
+ } catch (error) {
1217
+ if (error.code === "ENOENT") return "";
1218
+ throw error;
809
1219
  }
810
1220
  }
811
1221
 
@@ -988,7 +1398,13 @@ function materializeManagementMarkdown(source, audit, records) {
988
1398
  .replaceAll("[selected categories]", categories || "[selected categories]")
989
1399
  .replaceAll(
990
1400
  "[Carve-out, inclusive, or not applicable]",
991
- audit.subserviceMethod ? displayValue(audit.subserviceMethod) : "[Carve-out, inclusive, or not applicable]"
1401
+ audit.subserviceConclusion
1402
+ ? audit.subserviceConclusion === "not-applicable"
1403
+ ? `Not applicable: ${audit.subserviceConclusionRationale || "[explain the conclusion]"}`
1404
+ : `${[...new Set((audit.subserviceTreatments || []).map(({ method }) => displayValue(method)))].join(" and ") || "[record subservice treatments]"}: ${audit.subserviceConclusionRationale || "[explain the conclusion]"}`
1405
+ : audit.subserviceMethod
1406
+ ? displayValue(audit.subserviceMethod)
1407
+ : "[Carve-out, inclusive, or not applicable]"
992
1408
  );
993
1409
  }
994
1410
 
@@ -1010,14 +1426,15 @@ function stage(id, title, description, items) {
1010
1426
  return { id, title, description, items };
1011
1427
  }
1012
1428
 
1013
- function item(id, status, title, message, resource = {}) {
1429
+ function item(id, status, title, message, resource = {}, options = {}) {
1014
1430
  return {
1015
1431
  id,
1016
1432
  status,
1017
1433
  title,
1018
1434
  message,
1019
1435
  ...(resource.type ? { resourceType: resource.type } : {}),
1020
- ...(resource.id ? { resourceId: resource.id } : {})
1436
+ ...(resource.id ? { resourceId: resource.id } : {}),
1437
+ ...(options.commands?.length ? { commands: options.commands } : {})
1021
1438
  };
1022
1439
  }
1023
1440