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.
@@ -144,7 +144,7 @@ export const PROGRAM_PATH = [
144
144
  { id: "governance", title: "Governance", description: "Record formal reviews, oversight meetings, and approved policy or control exceptions.", steps: ["Complete scheduled policy reviews and oversight meetings.", "Record decisions, attendees, follow-up work, and evidence.", "Approve time-bound exceptions before the departure begins."], types: ["policy-review", "meeting", "exception"], defaultOpen: false },
145
145
  { id: "inventories", title: "Assets and Vendors", description: "Maintain the asset inventory and recurring reviews of supplier relationships during operation.", steps: ["Keep ownership, custody, status, and lifecycle current for important assets.", "Perform vendor reviews on schedule and after material supplier changes.", "Link fixed reports and review evidence to the operating records."], types: ["asset", "vendor-review"], defaultOpen: false },
146
146
  { id: "access-training", title: "Access and Training", description: "Inventory service accounts before recording access decisions, periodic reviews, assignments, and acknowledgements.", steps: ["Catalog service accounts that need separate tracking.", "Preserve access approvals and removals as they occur, then complete periodic access reviews and resolve exceptions.", "Assign training and retain acknowledgement evidence for the exact content revision."], types: ["service-account", "access-grant", "access-review", "training", "attestation"], defaultOpen: false },
147
- { id: "security", title: "Security Operations", description: "Record vulnerability work, independent testing, and incident response activity for the period.", steps: ["Retain scan scope, results, vulnerabilities, remediation, and exceptions.", "Track penetration testing and follow-up findings.", "Start the incident workflow when a qualifying event occurs."], types: ["vulnerability-scan", "vulnerability", "penetration-test", "incident"], defaultOpen: false },
147
+ { id: "security", title: "Security Operations", description: "Record vulnerability work, applicable penetration testing, and incident response activity for the period.", steps: ["Retain scan scope, results, vulnerabilities, remediation, and exceptions.", "When the approved applicability review requires penetration testing, record the test and follow-up findings.", "Start the incident workflow when a qualifying event occurs."], types: ["vulnerability-scan", "vulnerability", "penetration-test", "incident"], defaultOpen: false },
148
148
  { id: "resilience", title: "Resilience", description: "Preserve proof that backups, restoration, continuity, and incident exercises work as designed.", steps: ["Record backup restoration tests and their results.", "Run continuity and incident exercises on schedule.", "Assign and close follow-up work from failed objectives or lessons learned."], types: ["backup-test", "exercise"], defaultOpen: false },
149
149
  { id: "issues", title: "Issues and Remediation", description: "Keep observations in the source report and track only confirmed gaps that need a separate remediation lifecycle.", steps: ["Create a Finding only when a confirmed gap needs its own owner, due date, status, or verified closure.", "Use the Finding itself for straightforward remediation; create Action Items only for separate assigned tasks.", "Work Action Items from Work Queue and close the Finding only after remediation is independently verified."], types: ["finding"], defaultOpen: false }
150
150
  ],
@@ -7,8 +7,14 @@ import { planObligations } from "./obligations.js";
7
7
  import { resolveDataPath } from "./paths.js";
8
8
  import { obligationIsEnabled, obligationIsRunning } from "./program-lifecycle.js";
9
9
  import { currentPartyPeople, partiesIndependent, partyPeople } from "./parties.js";
10
+ import { assessPolicyLibraryUpgrades } from "./policy-library.js";
10
11
  import { programComponents, resolveProgram, selectedRequirementIds } from "./program.js";
11
12
  import { markdownEntries } from "./resource-markdown.js";
13
+ import {
14
+ missingSoc2References,
15
+ REQUIRED_SOC2_DESCRIPTION_REFERENCES,
16
+ REQUIRED_SOC2_SECURITY_REFERENCES
17
+ } from "./soc2.js";
12
18
  import { assessSourceCoverageReadiness } from "./source-coverage.js";
13
19
  import { currentCalendarDate } from "./time.js";
14
20
  import { loadWorkspace } from "./workspace.js";
@@ -75,6 +81,7 @@ export async function assessProgramReadiness(input, options = {}) {
75
81
  && coverageStart(program.candidateCoverage) <= asOf
76
82
  );
77
83
  const obligations = planObligations(records, { asOf, through: asOf, model: loaded.model });
84
+ const policyLibrary = await assessPolicyLibraryUpgrades(loaded);
78
85
  const operating = evidenceReady && candidateStarted && stages.at(-1).counts.action === 0;
79
86
  const canStartCandidatePeriod = Boolean(
80
87
  evidenceReady
@@ -103,7 +110,10 @@ export async function assessProgramReadiness(input, options = {}) {
103
110
  canStartCandidatePeriod,
104
111
  suggestedCandidatePeriodStart: canStartCandidatePeriod ? asOf : null,
105
112
  policyActivations,
106
- policyLibraryProposals: policyLibraryProposals(records),
113
+ policyLibraryProposals: [
114
+ ...policyLibrary.proposals,
115
+ ...legacyPolicyLibraryProposals(records)
116
+ ],
107
117
  progress: {
108
118
  complete,
109
119
  total: managedItems.length,
@@ -217,7 +227,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
217
227
  const completeSystems = scope.systems.filter((system) => (
218
228
  system.status === "active"
219
229
  && (String(model.modelVersion) === "4" ? system.purpose && system.boundary && (system.servicesProvided || []).length : system.description)
220
- && system.classificationId
230
+ && (String(model.modelVersion) === "4" || system.classificationId)
221
231
  && (system.ownerIds || []).length
222
232
  ));
223
233
  items.push(item(
@@ -225,7 +235,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
225
235
  scope.systems.length && completeSystems.length === scope.systems.length ? "complete" : "action",
226
236
  "Define the service boundary",
227
237
  scope.systems.length
228
- ? `${completeSystems.length} of ${scope.systems.length} program systems are active, explicitly in scope, owned, classified, and described.`
238
+ ? `${completeSystems.length} of ${scope.systems.length} program systems are active, explicitly in scope, owned, and described${String(model.modelVersion) === "4" ? "" : ", with a classification"}.`
229
239
  : "Select and describe every service and supporting system in the program boundary.",
230
240
  scope.systems[0] || { type: "system" }
231
241
  ));
@@ -281,22 +291,69 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
281
291
  && (String(model.modelVersion) === "4" ? !v4Decisions.has(record.id) || v4Decisions.get(record.id) === "undetermined" : record.applicability === "undetermined")
282
292
  ));
283
293
  const missingRequirements = applicableRequirements.filter((record) => !selectedRequirementIds.has(record.id));
294
+ const selectedDescriptionRequirements = scope.requirements.filter(isDescriptionRequirement);
295
+ const selectedTrustServicesRequirements = scope.requirements.filter((requirement) => !isDescriptionRequirement(requirement));
296
+ const unresolvedDescriptionRequirements = unresolvedRequirements.filter(isDescriptionRequirement);
297
+ const unresolvedTrustServicesRequirements = unresolvedRequirements.filter((requirement) => !isDescriptionRequirement(requirement));
298
+ const uncoveredRequirements = scope.requirements.filter((requirement) => (
299
+ !isDescriptionRequirement(requirement)
300
+ && !scope.controls.some((control) => (control.requirementIds || []).includes(requirement.id))
301
+ ));
302
+ const enforceSoc2Baseline = String(model.modelVersion) === "4"
303
+ && ["readiness", "soc-2-type-1", "soc-2-type-2"].includes(goal);
304
+ const selectedFrameworkRequirements = records.filter((record) => (
305
+ record.type === "requirement"
306
+ && scope.frameworks.some((framework) => framework.id === record.frameworkId)
307
+ ));
308
+ const securityRequirements = selectedFrameworkRequirements.filter(isSecurityRequirement);
309
+ const descriptionRequirements = selectedFrameworkRequirements.filter(isDescriptionRequirement);
310
+ const missingRequiredSecurityReferences = enforceSoc2Baseline
311
+ ? missingSoc2References(securityRequirements, REQUIRED_SOC2_SECURITY_REFERENCES)
312
+ : [];
313
+ const missingRequiredDescriptionReferences = enforceSoc2Baseline
314
+ ? missingSoc2References(descriptionRequirements, REQUIRED_SOC2_DESCRIPTION_REFERENCES)
315
+ : [];
316
+ const mandatoryRequirements = enforceSoc2Baseline
317
+ ? [...securityRequirements, ...descriptionRequirements].filter(({ reference }) => (
318
+ REQUIRED_SOC2_SECURITY_REFERENCES.includes(String(reference || "").toUpperCase())
319
+ || REQUIRED_SOC2_DESCRIPTION_REFERENCES.includes(String(reference || "").toUpperCase())
320
+ ))
321
+ : [];
322
+ const invalidMandatoryDecisions = mandatoryRequirements.filter((requirement) => (
323
+ v4Decisions.get(requirement.id) !== "applicable"
324
+ ));
284
325
  const criteriaComplete = Boolean(
285
326
  scope.frameworks.length
286
327
  && scope.requirements.length
287
328
  && scope.controls.length
288
329
  && !unresolvedRequirements.length
289
330
  && !missingRequirements.length
331
+ && !uncoveredRequirements.length
332
+ && !missingRequiredSecurityReferences.length
333
+ && !missingRequiredDescriptionReferences.length
334
+ && !invalidMandatoryDecisions.length
290
335
  );
291
336
  items.push(item(
292
337
  "criteria",
293
338
  criteriaComplete ? "complete" : "action",
294
- "Confirm criteria and controls in scope",
339
+ "Confirm Trust Services criteria, Description Criteria, and Controls",
295
340
  criteriaComplete
296
- ? `${scope.requirements.length} applicable criteria and ${scope.controls.length} controls are in the management program scope.`
297
- : `Resolve the program criteria and controls. ${unresolvedRequirements.length} criteria remain undetermined and ${missingRequirements.length} applicable criteria are not selected.`,
341
+ ? `${selectedTrustServicesRequirements.length} applicable Trust Services criteria, ${selectedDescriptionRequirements.length} SOC 2 Description Criteria, and ${scope.controls.length} Controls are in scope. Every applicable Trust Services criterion has at least one selected Control; Description Criteria govern the system description and do not map to Controls.`
342
+ : missingRequiredSecurityReferences.length || missingRequiredDescriptionReferences.length
343
+ ? `Use the complete SOC 2 baseline. The selected Frameworks omit ${[
344
+ ...missingRequiredSecurityReferences,
345
+ ...missingRequiredDescriptionReferences
346
+ ].join(", ")}.`
347
+ : invalidMandatoryDecisions.length
348
+ ? `Mark all 33 Security Common Criteria and all nine Description Criteria applicable for this SOC 2 Program. ${invalidMandatoryDecisions.length} required ${invalidMandatoryDecisions.length === 1 ? "decision is" : "decisions are"} missing, undetermined, or not applicable.`
349
+ : `Resolve the program criteria and Controls. ${unresolvedTrustServicesRequirements.length} Trust Services applicability decisions and ${unresolvedDescriptionRequirements.length} Description Criteria decisions remain undetermined, ${missingRequirements.length} applicable criteria are not selected, and ${uncoveredRequirements.length} selected applicable Trust Services criteria have no selected Control. Description Criteria govern the system description and do not map to Controls.`,
298
350
  workspace || { type: "workspace" },
299
351
  {
352
+ unresolvedRequirementIds: unresolvedRequirements.map(({ id }) => id),
353
+ missingRequirementIds: missingRequirements.map(({ id }) => id),
354
+ uncoveredRequirementIds: uncoveredRequirements.map(({ id }) => id),
355
+ invalidMandatoryRequirementIds: invalidMandatoryDecisions.map(({ id }) => id),
356
+ missingRequiredReferences: [...missingRequiredSecurityReferences, ...missingRequiredDescriptionReferences],
300
357
  commands: [
301
358
  "npx filegrc review-applicability --scaffold --type requirement > decisions.json",
302
359
  "npx filegrc review-applicability decisions.json --preview --json",
@@ -808,7 +865,7 @@ function policyActivationItem(assessment) {
808
865
  );
809
866
  }
810
867
 
811
- function policyLibraryProposals(records) {
868
+ function legacyPolicyLibraryProposals(records) {
812
869
  const legacyIds = [
813
870
  "policy-anti-bribery-corruption",
814
871
  "policy-clear-desk-screen",
@@ -1253,6 +1310,16 @@ function assuranceGoalLabel(goal) {
1253
1310
  return "No assurance goal selected";
1254
1311
  }
1255
1312
 
1313
+ function isDescriptionRequirement(requirement) {
1314
+ return (requirement?.tags || []).includes("description-criteria")
1315
+ || /^DC\d+/i.test(requirement?.reference || "");
1316
+ }
1317
+
1318
+ function isSecurityRequirement(requirement) {
1319
+ const tags = requirement?.tags || [];
1320
+ return tags.includes("security") || tags.includes("common-criteria") || /^CC\d+(?:\.|$)/i.test(requirement?.reference || "");
1321
+ }
1322
+
1256
1323
  function stage(id, title, description, items) {
1257
1324
  return { id, title, description, items };
1258
1325
  }
package/src/setup.js CHANGED
@@ -9,6 +9,7 @@ const CRITICALITIES = new Set(["low", "medium", "high", "critical"]);
9
9
  export async function setupWorkspace(input = process.cwd(), payload = {}) {
10
10
  const loaded = await loadWorkspace(input);
11
11
  const setup = normalizeSetupPayload(payload);
12
+ setup.classificationId = resolveClassificationId(loaded, setup.classificationId);
12
13
  validateSetup(loaded, setup);
13
14
  const plan = buildSetupRecords(loaded, setup);
14
15
  const updates = [
@@ -44,6 +45,7 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
44
45
  export async function planWorkspaceSetup(input = process.cwd(), payload = {}) {
45
46
  const loaded = await loadWorkspace(input);
46
47
  const setup = normalizeSetupPayload(payload);
48
+ setup.classificationId = resolveClassificationId(loaded, setup.classificationId);
47
49
  validateSetup(loaded, setup);
48
50
  const plan = buildSetupRecords(loaded, setup);
49
51
  return {
@@ -137,6 +139,21 @@ function validateSetup(loaded, setup) {
137
139
  }
138
140
  }
139
141
 
142
+ function resolveClassificationId(loaded, value) {
143
+ const normalized = String(value || "").trim().toLowerCase();
144
+ if (!normalized) return value;
145
+ const candidates = String(loaded.model.modelVersion) === "4"
146
+ ? loaded.resources
147
+ .filter(({ type, status }) => type === "classification" && status === "active")
148
+ .map(({ id, title }) => ({ id, label: title }))
149
+ : Object.entries(loaded.workspace.classificationDefinitions || {})
150
+ .map(([id, label]) => ({ id, label }));
151
+ const matches = candidates.filter(({ id, label }) => (
152
+ id.toLowerCase() === normalized || String(label || "").trim().toLowerCase() === normalized
153
+ ));
154
+ return matches.length === 1 ? matches[0].id : value;
155
+ }
156
+
140
157
  function findSetupSystem(resources, target, setup) {
141
158
  const scopedSystemIds = new Set(target.systemIds || []);
142
159
  return (setup.systemId && resources.find(({ type, id }) => type === "system" && id === setup.systemId))
@@ -227,7 +244,7 @@ function buildSetupRecords(loaded, setup) {
227
244
  title: `${setup.serviceName} service commitment`,
228
245
  status: "planned",
229
246
  commitmentKind: "service",
230
- statement: "Replace this starter with the actual customer promise or approved service requirement before activation.",
247
+ statement: "[Complete before activation: State the actual customer promise or approved service requirement.]",
231
248
  systemIds: [systemId],
232
249
  ownerIds: [setup.ownerId],
233
250
  customerFacing: true,
package/src/soc2.js ADDED
@@ -0,0 +1,227 @@
1
+ import { coverageEnd, coverageStart } from "./coverage.js";
2
+
3
+ export const REQUIRED_SOC2_DESCRIPTION_REFERENCES = Array.from(
4
+ { length: 9 },
5
+ (_, index) => `DC${index + 1}`
6
+ );
7
+
8
+ export const REQUIRED_SOC2_SECURITY_REFERENCES = [
9
+ "CC1.1",
10
+ "CC1.2",
11
+ "CC1.3",
12
+ "CC1.4",
13
+ "CC1.5",
14
+ "CC2.1",
15
+ "CC2.2",
16
+ "CC2.3",
17
+ "CC3.1",
18
+ "CC3.2",
19
+ "CC3.3",
20
+ "CC3.4",
21
+ "CC4.1",
22
+ "CC4.2",
23
+ "CC5.1",
24
+ "CC5.2",
25
+ "CC5.3",
26
+ "CC6.1",
27
+ "CC6.2",
28
+ "CC6.3",
29
+ "CC6.4",
30
+ "CC6.5",
31
+ "CC6.6",
32
+ "CC6.7",
33
+ "CC6.8",
34
+ "CC7.1",
35
+ "CC7.2",
36
+ "CC7.3",
37
+ "CC7.4",
38
+ "CC7.5",
39
+ "CC8.1",
40
+ "CC9.1",
41
+ "CC9.2"
42
+ ];
43
+
44
+ const SOC2_PROGRAM_GOALS = new Set([
45
+ "readiness",
46
+ "soc-2-type-1",
47
+ "soc-2-type-2"
48
+ ]);
49
+
50
+ export function soc2RequirementApplicabilityConstraint(requirement, program, modelVersion = "4") {
51
+ if (
52
+ String(modelVersion) !== "4"
53
+ || requirement?.type !== "requirement"
54
+ || !SOC2_PROGRAM_GOALS.has(program?.assuranceGoal)
55
+ ) return null;
56
+ const reference = String(requirement.reference || "").trim().toUpperCase();
57
+ const security = REQUIRED_SOC2_SECURITY_REFERENCES.includes(reference);
58
+ const description = REQUIRED_SOC2_DESCRIPTION_REFERENCES.includes(reference);
59
+ if (!security && !description) return null;
60
+ const family = security ? "Security Common Criteria" : "SOC 2 Description Criteria";
61
+ return {
62
+ requiredDecision: "applicable",
63
+ allowedDecisions: ["applicable"],
64
+ message: `${reference} is required for the selected SOC 2 Security program.`,
65
+ defaultRationale: `${reference} is part of the required ${family} baseline for the selected ${soc2GoalLabel(program.assuranceGoal)} Program.`
66
+ };
67
+ }
68
+
69
+ function soc2GoalLabel(goal) {
70
+ if (goal === "soc-2-type-1") return "SOC 2 Type 1";
71
+ if (goal === "soc-2-type-2") return "SOC 2 Type 2";
72
+ return "SOC 2 readiness";
73
+ }
74
+
75
+ export function missingSoc2References(requirements, requiredReferences) {
76
+ const references = new Set(requirements.map(({ reference }) => String(reference || "").trim().toUpperCase()));
77
+ return requiredReferences.filter((reference) => !references.has(reference));
78
+ }
79
+
80
+ export function recordWasInUseDuringAudit(record, engagementStart, engagementEnd) {
81
+ if (!record) return false;
82
+ if (record.type === "vendor") {
83
+ if (!["active", "deprecated", "terminated"].includes(record.status)) return false;
84
+ const endedOn = record.endDate || (record.status === "terminated" ? record.statusTransition?.changedOn : null);
85
+ if (engagementEnd && record.startDate && record.startDate > engagementEnd) return false;
86
+ if (engagementStart && endedOn && endedOn < engagementStart) return false;
87
+ if (record.status === "terminated" && engagementStart && !endedOn) return false;
88
+ return true;
89
+ }
90
+ if (["active", "deprecated"].includes(record.status)) return true;
91
+ if (!engagementStart) return false;
92
+ if (["component", "system"].includes(record.type) && record.status === "retired") {
93
+ return Boolean(record.statusTransition?.changedOn && record.statusTransition.changedOn >= engagementStart);
94
+ }
95
+ return false;
96
+ }
97
+
98
+ export function auditorWasEngaged(auditor, audit) {
99
+ const engagementStart = audit?.managementAcknowledgedOn
100
+ || audit?.fieldworkStart
101
+ || coverageStart(audit?.coverage);
102
+ const engagementEnd = audit?.reportDate
103
+ || audit?.fieldworkEnd
104
+ || coverageEnd(audit?.coverage);
105
+ if (!recordWasInUseDuringAudit(auditor, engagementStart, engagementEnd)) return false;
106
+ const endedOn = auditor.endDate || (auditor.status === "terminated" ? auditor.statusTransition?.changedOn : null);
107
+ if (engagementStart && auditor.startDate && auditor.startDate > engagementStart) return false;
108
+ if (engagementEnd && endedOn && endedOn < engagementEnd) return false;
109
+ return true;
110
+ }
111
+
112
+ export function personWasActiveOn(person, on) {
113
+ if (person?.type !== "person" || !on) return false;
114
+ if (person.startDate && person.startDate > on) return false;
115
+ const endedOn = person.endDate || (person.status === "inactive" ? person.statusTransition?.changedOn : null);
116
+ if (endedOn && endedOn < on) return false;
117
+ return person.status === "active" || (person.status === "inactive" && Boolean(endedOn));
118
+ }
119
+
120
+ export function appointmentWasAuthorizedOn(appointment, on, byId) {
121
+ if (appointment?.type !== "appointment" || !on || !["active", "ended"].includes(appointment.status)) return false;
122
+ if (!appointment.startsOn || appointment.startsOn > on) return false;
123
+ if (appointment.endsOn && appointment.endsOn < on) return false;
124
+ return personWasActiveOn(byId.get(appointment.holderId), on);
125
+ }
126
+
127
+ export function signatoryAppointmentIssue(audit, byId) {
128
+ if (!audit?.reportDate) {
129
+ return {
130
+ code: "signatory-authority-date-missing",
131
+ message: "Record the CPA report date before confirming who had authority to sign management's assertion and written representations."
132
+ };
133
+ }
134
+ const ids = audit.signatoryAppointmentIds || [];
135
+ if (!ids.length) {
136
+ return {
137
+ code: "signatory-authority-missing",
138
+ message: "Link the dated authority Appointment for each management assertion and representation signer."
139
+ };
140
+ }
141
+ const permittedScopes = new Set([
142
+ "workspace",
143
+ audit.id,
144
+ audit.programId,
145
+ ...(audit.systemIds || []),
146
+ ...[...byId.values()].filter((record) => record.type === "organization").map(({ id }) => id)
147
+ ].filter(Boolean));
148
+ const invalid = ids.filter((id) => {
149
+ const appointment = byId.get(id);
150
+ return !appointmentWasAuthorizedOn(appointment, audit.reportDate, byId)
151
+ || !(appointment.scopeResourceIds || []).some((scopeId) => permittedScopes.has(scopeId));
152
+ });
153
+ if (invalid.length) {
154
+ return {
155
+ code: "signatory-authority-invalid",
156
+ message: `Confirm that ${invalid.join(", ")} names a dated Appointment whose holder was active and whose scope covered the workspace, Program, or Audit on ${audit.reportDate}.`
157
+ };
158
+ }
159
+ return null;
160
+ }
161
+
162
+ export function soc2ReportEvidenceIssue(evidence, audit, modelVersion = "4") {
163
+ if (
164
+ evidence?.type !== "evidence"
165
+ || evidence.status !== "verified"
166
+ || evidence.artifactKind !== "third-party-report"
167
+ || evidence.artifactSubtype !== "soc2-report"
168
+ ) {
169
+ return {
170
+ code: "invalid-audit-report-evidence",
171
+ message: `${evidence?.title || audit?.title || "The audit"} must be verified third-party-report Evidence with subtype soc2-report for the issued SOC 2 report.`
172
+ };
173
+ }
174
+ const issuedOn = (String(modelVersion) === "4"
175
+ ? evidence.sourceGeneratedAt
176
+ : evidence.sourceGeneratedAt || evidence.businessEventAt || evidence.collectedOn
177
+ )?.slice(0, 10);
178
+ if (!issuedOn) {
179
+ return {
180
+ code: "audit-report-date-missing",
181
+ message: `Record the issued SOC 2 report's actual issuance timestamp in ${evidence.title}'s sourceGeneratedAt field.`
182
+ };
183
+ }
184
+ if (audit?.reportDate && issuedOn !== audit.reportDate) {
185
+ return {
186
+ code: "audit-report-date-mismatch",
187
+ message: `${evidence.title} is dated ${issuedOn}, which does not match the Audit reportDate ${audit.reportDate}.`
188
+ };
189
+ }
190
+ if (audit?.reportDate && audit.opinionDate && audit.opinionDate !== audit.reportDate) {
191
+ return {
192
+ code: "audit-opinion-date-mismatch",
193
+ message: `The Audit opinionDate ${audit.opinionDate} does not match reportDate ${audit.reportDate}. Reconcile both dates to the issued CPA report.`
194
+ };
195
+ }
196
+ return null;
197
+ }
198
+
199
+ export function subsequentEventsReviewIssue(audit) {
200
+ const review = audit?.subsequentEventsReview;
201
+ if (!review) {
202
+ return {
203
+ code: "subsequent-events-review-missing",
204
+ message: "Review incidents, changes, findings, fraud, legal matters, subservice coverage, representations, and other relevant events through the CPA report date."
205
+ };
206
+ }
207
+ if (!(review.reviewedByIds || []).length || !String(review.conclusion || "").trim()) {
208
+ return {
209
+ code: "subsequent-events-review-incomplete",
210
+ message: "The subsequent-events review must name its reviewers and record management's conclusion."
211
+ };
212
+ }
213
+ const requiredThroughOn = audit.reportDate || audit.fieldworkEnd || audit.coverage?.endsOn || audit.coverage?.on;
214
+ if (!review.throughOn || (requiredThroughOn && review.throughOn < requiredThroughOn)) {
215
+ return {
216
+ code: "subsequent-events-period-incomplete",
217
+ message: `The subsequent-events review must cover through ${requiredThroughOn || "the latest engagement date"}${audit.reportDate ? ", the CPA report date" : ""}.`
218
+ };
219
+ }
220
+ if (!review.reviewedOn || review.reviewedOn < review.throughOn) {
221
+ return {
222
+ code: "subsequent-events-review-date-invalid",
223
+ message: "The subsequent-events review date must be on or after the date through which events were reviewed."
224
+ };
225
+ }
226
+ return null;
227
+ }
package/src/state.js CHANGED
@@ -8,11 +8,13 @@ import { planObligations } from "./obligations.js";
8
8
  import { resolveDataPath, resolveWorkspaceRoot } from "./paths.js";
9
9
  import { assessProgramReadiness } from "./program-readiness.js";
10
10
  import { markdownEntries } from "./resource-markdown.js";
11
+ import { resolveProgram } from "./program.js";
11
12
  import { currentCalendarDate } from "./time.js";
12
13
  import { serializeWorkspaceMutation } from "./mutation.js";
13
14
  import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
14
15
  import { assessWorkflow } from "./workflow.js";
15
16
  import { measureTiming } from "./timing.js";
17
+ import { soc2RequirementApplicabilityConstraint } from "./soc2.js";
16
18
 
17
19
  const renderedMarkdownCache = new Map();
18
20
  const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000;
@@ -82,6 +84,11 @@ async function createAppStateUnlocked(input, options) {
82
84
  asOf,
83
85
  generatedAt
84
86
  }));
87
+ const activeProgram = resolveProgram(loaded);
88
+ const applicabilityConstraints = Object.fromEntries(loaded.resources.flatMap((record) => {
89
+ const constraint = soc2RequirementApplicabilityConstraint(record, activeProgram, loaded.model.modelVersion);
90
+ return constraint ? [[record.id, constraint]] : [];
91
+ }));
85
92
  const audits = loaded.resources.filter((record) => record.type === "audit");
86
93
  const auditPreparations = await measureTiming("state-audit-preparation", async () => Object.fromEntries(await Promise.all(
87
94
  (audits.length ? audits : [null]).map(async (audit) => {
@@ -140,6 +147,7 @@ async function createAppStateUnlocked(input, options) {
140
147
  },
141
148
  obligations,
142
149
  collectionReviews,
150
+ applicabilityConstraints,
143
151
  programReadiness,
144
152
  auditPreparations,
145
153
  workflow,
package/src/validate.js CHANGED
@@ -370,9 +370,19 @@ function validateControlComponents(record, byId, path, diagnostics) {
370
370
  }
371
371
 
372
372
  function validateAuditSubservices(record, byId, path, diagnostics) {
373
+ const selectedSystemIds = new Set(record.systemIds || []);
374
+ const seenComponentIds = new Set();
373
375
  for (const [index, treatment] of (record.subserviceTreatments || []).entries()) {
374
376
  for (const componentId of treatment.componentIds || []) {
375
377
  const component = byId.get(componentId);
378
+ if (seenComponentIds.has(componentId)) {
379
+ diagnostics.push(error(
380
+ "duplicate-subservice-component",
381
+ path,
382
+ `subserviceTreatments[${index}] repeats Component "${componentId}" in more than one treatment.`
383
+ ));
384
+ }
385
+ seenComponentIds.add(componentId);
376
386
  if (component?.type === "component" && component.vendorId !== treatment.vendorId) {
377
387
  diagnostics.push(error(
378
388
  "subservice-vendor-mismatch",
@@ -380,6 +390,16 @@ function validateAuditSubservices(record, byId, path, diagnostics) {
380
390
  `subserviceTreatments[${index}] Component "${componentId}" is not supplied by Vendor "${treatment.vendorId}".`
381
391
  ));
382
392
  }
393
+ if (
394
+ component?.type === "component"
395
+ && !(component.systemUses || []).some(({ systemId }) => selectedSystemIds.has(systemId))
396
+ ) {
397
+ diagnostics.push(error(
398
+ "subservice-component-outside-audit-scope",
399
+ path,
400
+ `subserviceTreatments[${index}] Component "${componentId}" has no use in a System selected by this Audit.`
401
+ ));
402
+ }
383
403
  }
384
404
  }
385
405
  }