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.
@@ -0,0 +1,635 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { planObligations } from "./obligations.js";
3
+ import { resolveDataPath } from "./paths.js";
4
+ import { obligationIsRunning } from "./program-lifecycle.js";
5
+ import { markdownEntries } from "./resource-markdown.js";
6
+ import { currentCalendarDate } from "./time.js";
7
+ import { loadWorkspace } from "./workspace.js";
8
+
9
+ const TEST_EVIDENCE_KINDS = new Set(["test-capture", "test-export"]);
10
+
11
+ export async function assessProgramReadiness(input, options = {}) {
12
+ const loaded = input?.resources && input?.model && input?.entries
13
+ ? input
14
+ : await loadWorkspace(input);
15
+ const records = loaded.resources;
16
+ const byId = new Map(records.map((record) => [record.id, record]));
17
+ const workspace = loaded.workspace || records.find((record) => record.type === "workspace");
18
+ const asOf = options.asOf || currentCalendarDate(workspace?.timezone || "UTC");
19
+ const scope = programScope(workspace, records, byId);
20
+ const markdown = new Map();
21
+ const readMarkdown = async (record) => {
22
+ if (!record) return "";
23
+ if (!markdown.has(record.id)) markdown.set(record.id, await primaryMarkdown(loaded, record));
24
+ return markdown.get(record.id);
25
+ };
26
+
27
+ const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
28
+ const collectionStage = evidenceCollectionStage(scope, records, byId, loaded.model);
29
+ const evidenceStage = stage(
30
+ "evidence",
31
+ "Test Evidence Collection",
32
+ "For external evidence without a dedicated Step 5 record, catalog the authoritative Systems, document repeatable extraction, and verify one test capture before operation begins.",
33
+ [...sourceStage.items, ...collectionStage.items]
34
+ );
35
+ const evidenceGateStages = [
36
+ scopeStage(workspace, scope, records),
37
+ await policiesStage(scope, byId, readMarkdown, asOf),
38
+ await controlsStage(scope, byId, readMarkdown, asOf),
39
+ evidenceStage
40
+ ];
41
+ for (const current of evidenceGateStages) finalizeStage(current);
42
+ const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0);
43
+ const stages = [
44
+ ...evidenceGateStages,
45
+ operationStage(workspace, scope, records, byId, asOf, evidenceReady)
46
+ ];
47
+ finalizeStage(stages.at(-1));
48
+ const candidateStarted = Boolean(
49
+ workspace?.assuranceGoal === "soc-2-type-2"
50
+ && workspace.candidatePeriodStart
51
+ && workspace.candidatePeriodStart <= asOf
52
+ );
53
+ const obligations = planObligations(records, { asOf, through: asOf });
54
+ const operating = evidenceReady && candidateStarted && obligations.counts.overdue === 0;
55
+ const canStartCandidatePeriod = Boolean(
56
+ evidenceReady
57
+ && workspace?.assuranceGoal === "soc-2-type-2"
58
+ && !workspace.candidatePeriodStart
59
+ );
60
+ const items = stages.flatMap((current) => current.items);
61
+ const managedItems = items.filter((current) => !["info", "later"].includes(current.status));
62
+ const complete = managedItems.filter((current) => current.status === "complete").length;
63
+ const firstAction = items.find((current) => current.status === "action") || null;
64
+
65
+ return {
66
+ schemaVersion: 1,
67
+ generatedAt: options.generatedAt || new Date().toISOString(),
68
+ asOf,
69
+ target: {
70
+ goal: workspace?.assuranceGoal || "none",
71
+ label: assuranceGoalLabel(workspace?.assuranceGoal),
72
+ candidateTypeOneAsOf: workspace?.candidateTypeOneAsOf || null,
73
+ candidatePeriodStart: workspace?.candidatePeriodStart || null,
74
+ candidatePeriodEnd: workspace?.candidatePeriodEnd || null
75
+ },
76
+ status: operating ? "operating" : evidenceReady ? "evidence-ready" : "needs-work",
77
+ evidenceReady,
78
+ operating,
79
+ canStartCandidatePeriod,
80
+ suggestedCandidatePeriodStart: canStartCandidatePeriod ? asOf : null,
81
+ progress: {
82
+ complete,
83
+ total: managedItems.length,
84
+ percent: managedItems.length ? Math.round((complete / managedItems.length) * 100) : 0
85
+ },
86
+ counts: countStatuses(items),
87
+ firstAction,
88
+ scope: {
89
+ systemIds: scope.systems.map((record) => record.id),
90
+ frameworkIds: scope.frameworks.map((record) => record.id),
91
+ requirementIds: scope.requirements.map((record) => record.id),
92
+ controlIds: scope.controls.map((record) => record.id)
93
+ },
94
+ stages
95
+ };
96
+ }
97
+
98
+ function programScope(workspace, records, byId) {
99
+ const select = (ids, type, fallback) => {
100
+ if (ids?.length) return ids.map((id) => byId.get(id)).filter((record) => record?.type === type);
101
+ return records.filter(fallback);
102
+ };
103
+ return {
104
+ systems: select(workspace?.systemIds, "system", (record) => (
105
+ record.type === "system" && record.inScope === true && record.status !== "retired"
106
+ )),
107
+ frameworks: select(workspace?.frameworkIds, "framework", (record) => (
108
+ record.type === "framework" && record.status === "active"
109
+ )),
110
+ requirements: select(workspace?.requirementIds, "requirement", (record) => (
111
+ record.type === "requirement" && record.applicability === "applicable"
112
+ )),
113
+ controls: select(workspace?.controlIds, "control", (record) => (
114
+ record.type === "control" && !["not-applicable", "retired"].includes(record.status)
115
+ ))
116
+ };
117
+ }
118
+
119
+ function scopeStage(workspace, scope, records) {
120
+ const items = [];
121
+ const goal = workspace?.assuranceGoal || "none";
122
+ items.push(item(
123
+ "program-goal",
124
+ goal !== "none" ? "complete" : "action",
125
+ "Choose the program goal",
126
+ goal !== "none"
127
+ ? `Target: ${assuranceGoalLabel(goal)}. This is a management objective, not an active CPA engagement.`
128
+ : "Choose readiness, SOC 2 Type 1, or SOC 2 Type 2 as the management objective.",
129
+ workspace || { type: "workspace" }
130
+ ));
131
+
132
+ const completeSystems = scope.systems.filter((system) => (
133
+ system.status === "active"
134
+ && system.inScope === true
135
+ && system.description
136
+ && system.dataClassification
137
+ && (system.ownerIds || []).length
138
+ ));
139
+ items.push(item(
140
+ "service-boundary",
141
+ scope.systems.length && completeSystems.length === scope.systems.length ? "complete" : "action",
142
+ "Define the service boundary",
143
+ scope.systems.length
144
+ ? `${completeSystems.length} of ${scope.systems.length} program systems are active, explicitly in scope, owned, classified, and described.`
145
+ : "Select and describe every service and supporting system in the program boundary.",
146
+ scope.systems[0] || { type: "system" }
147
+ ));
148
+
149
+ const selectedRequirementIds = new Set(scope.requirements.map((record) => record.id));
150
+ const applicableRequirements = records.filter((record) => (
151
+ record.type === "requirement"
152
+ && scope.frameworks.some((framework) => framework.id === record.frameworkId)
153
+ && record.applicability === "applicable"
154
+ ));
155
+ const unresolvedRequirements = records.filter((record) => (
156
+ record.type === "requirement"
157
+ && scope.frameworks.some((framework) => framework.id === record.frameworkId)
158
+ && record.applicability === "undetermined"
159
+ ));
160
+ const missingRequirements = applicableRequirements.filter((record) => !selectedRequirementIds.has(record.id));
161
+ const criteriaComplete = Boolean(
162
+ scope.frameworks.length
163
+ && scope.requirements.length
164
+ && scope.controls.length
165
+ && !unresolvedRequirements.length
166
+ && !missingRequirements.length
167
+ );
168
+ items.push(item(
169
+ "criteria",
170
+ criteriaComplete ? "complete" : "action",
171
+ "Confirm criteria and controls in scope",
172
+ criteriaComplete
173
+ ? `${scope.requirements.length} applicable criteria and ${scope.controls.length} controls are in the management program scope.`
174
+ : `Resolve the program criteria and controls. ${unresolvedRequirements.length} criteria remain undetermined and ${missingRequirements.length} applicable criteria are not selected.`,
175
+ workspace || { type: "workspace" }
176
+ ));
177
+
178
+ return stage("scope", "Define Scope", "Set the management objective, service boundary, criteria, controls, and dependencies.", items);
179
+ }
180
+
181
+ async function policiesStage(scope, byId, readMarkdown, asOf) {
182
+ const linkedPolicyIds = new Set(scope.controls.flatMap((control) => control.policyIds || []));
183
+ const policies = [...linkedPolicyIds].map((id) => byId.get(id)).filter((record) => (
184
+ record?.type === "policy" && !["superseded", "retired"].includes(record.status)
185
+ ));
186
+ const approvers = policies.flatMap((policy) => partyPeople(policy.approverIds || [], byId))
187
+ .map((id) => byId.get(id))
188
+ .filter(Boolean);
189
+ const appointedReviewer = approvers.find((person) => (
190
+ ["active", "external"].includes(person.status)
191
+ && person.email
192
+ && !(person.id === "person-independent-approver" && ["Independent Approver", "Independent Reviewer"].includes(person.title))
193
+ ));
194
+ const items = [
195
+ item(
196
+ "independent-reviewer",
197
+ appointedReviewer ? "complete" : "action",
198
+ "Appoint the independent policy reviewer",
199
+ appointedReviewer
200
+ ? `${appointedReviewer.title} is recorded as a reviewer separate from policy ownership.`
201
+ : "Appoint a reviewer who is separate from the policy owner. The reviewer may be another person in the organization or an external person, and is separate from the CPA firm that may later perform the audit.",
202
+ appointedReviewer || { type: "person", id: "person-independent-approver" }
203
+ )
204
+ ];
205
+ if (!policies.length) {
206
+ items.push(item(
207
+ "policy-scope",
208
+ "action",
209
+ "Link policies to the selected controls",
210
+ "No applicable policies are linked from the controls in program scope.",
211
+ { type: "policy" }
212
+ ));
213
+ }
214
+ for (const policy of policies) {
215
+ const source = await readMarkdown(policy);
216
+ const placeholderCount = openPlaceholderCount(source);
217
+ const checks = {
218
+ reviewed: ["in-review", "approved", "active"].includes(policy.status),
219
+ independentlyApproved: ["approved", "active"].includes(policy.status)
220
+ && policy.approvedOn
221
+ && partiesIndependent(policy.ownerIds, policy.approverIds, byId),
222
+ effective: policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf,
223
+ linkedControls: (policy.controlIds || []).some((id) => scope.controls.some((control) => control.id === id)),
224
+ contentComplete: Boolean(source.trim()) && placeholderCount === 0
225
+ };
226
+ const missing = Object.entries(checks).filter(([, value]) => !value).map(([name]) => policyCheckLabel(name));
227
+ items.push(item(
228
+ `policy-${policy.id}`,
229
+ missing.length ? "action" : "complete",
230
+ policy.title,
231
+ missing.length
232
+ ? `Remaining adoption work: ${missing.join(", ")}${placeholderCount ? ` (${placeholderCount} open placeholders)` : ""}.`
233
+ : `Reviewed, independently approved, effective ${policy.effectiveOn}, linked to controls, with no open organization placeholders.`,
234
+ policy,
235
+ { checks, placeholderCount }
236
+ ));
237
+ }
238
+ return stage("policies", "Approve Policies", "Review the draft, obtain independent management approval, set the effective date, link controls, and clear placeholders.", items);
239
+ }
240
+
241
+ async function controlsStage(scope, byId, readMarkdown, asOf) {
242
+ const items = [];
243
+ if (!scope.controls.length) {
244
+ items.push(item("control-scope", "action", "Select the program controls", "No controls are selected for the management program.", { type: "control" }));
245
+ }
246
+ for (const control of scope.controls) {
247
+ const source = await readMarkdown(control);
248
+ const sourceSystems = (control.evidenceSourceIds || []).map((id) => byId.get(id)).filter((record) => record?.type === "system");
249
+ const queueSchedules = [...byId.values()].filter((record) => (
250
+ record.type === "obligation"
251
+ && record.status !== "retired"
252
+ && (record.controlIds || []).includes(control.id)
253
+ ));
254
+ const checks = {
255
+ implemented: control.status === "implemented",
256
+ owner: (control.ownerIds || []).length > 0,
257
+ procedure: substantiveMarkdown(source) && openPlaceholderCount(source) === 0,
258
+ scope: (control.systemIds || []).some((id) => scope.systems.some((system) => system.id === id)),
259
+ cadence: Boolean(control.frequency),
260
+ evidenceSource: sourceSystems.length > 0,
261
+ implementationDate: Boolean(control.effectiveOn && control.effectiveOn <= asOf),
262
+ policyMapping: (control.policyIds || []).length > 0,
263
+ criteriaMapping: (control.requirementIds || []).length > 0,
264
+ ...(queueSchedules.length ? {
265
+ workQueue: queueSchedules.every((obligation) => obligationIsRunning(obligation, byId, asOf))
266
+ } : {})
267
+ };
268
+ const missing = Object.entries(checks).filter(([, value]) => !value).map(([name]) => controlCheckLabel(name));
269
+ items.push(item(
270
+ `control-${control.id}`,
271
+ missing.length ? "action" : "complete",
272
+ `${control.code ? `${control.code}: ` : ""}${control.title}`,
273
+ missing.length
274
+ ? `Before implementation: ${missing.join(", ")}.`
275
+ : `Implemented ${control.effectiveOn}; owned, scoped, scheduled, documented, mapped, and tied to ${sourceSystems.length} authoritative ${sourceSystems.length === 1 ? "source" : "sources"}.`,
276
+ control,
277
+ {
278
+ checks,
279
+ workQueue: queueSchedules.length ? {
280
+ running: queueSchedules.filter((obligation) => obligationIsRunning(obligation, byId, asOf)).length,
281
+ total: queueSchedules.length
282
+ } : null
283
+ }
284
+ ));
285
+ }
286
+ return stage("controls", "Implement Controls", "Each implemented control needs an owner, actual procedure, scope, cadence, evidence source, mappings, an implementation date, and any linked Work Queue schedules running.", items);
287
+ }
288
+
289
+ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
290
+ const families = selectedControlFamilies(scope.controls, model).filter(requiresCollectionTest);
291
+ const items = [];
292
+ for (const family of families) {
293
+ const selectedSources = [...new Set(family.controls.flatMap((control) => control.evidenceSourceIds || []))]
294
+ .map((id) => byId.get(id))
295
+ .filter((record) => record?.type === "system");
296
+ const completeSources = [];
297
+ for (const source of selectedSources) {
298
+ const instructions = await readMarkdown(source);
299
+ const matchesRole = !family.sourceKinds.length
300
+ || family.sourceKinds.some((kind) => (source.evidenceSourceKinds || []).includes(kind));
301
+ if (
302
+ source.status === "active"
303
+ && matchesRole
304
+ && (source.evidenceSourceKinds || []).length
305
+ && (source.evidenceOwnerIds || []).length
306
+ && substantiveMarkdown(instructions)
307
+ && openPlaceholderCount(instructions) === 0
308
+ ) {
309
+ completeSources.push(source);
310
+ }
311
+ }
312
+ const coveredControls = family.controls.filter((control) => (
313
+ (control.evidenceSourceIds || []).some((id) => completeSources.some((source) => source.id === id))
314
+ ));
315
+ const complete = completeSources.length > 0 && coveredControls.length === family.controls.length;
316
+ items.push(item(
317
+ `source-family-${family.id}`,
318
+ complete ? "complete" : "action",
319
+ family.title,
320
+ complete
321
+ ? `${completeSources.map((source) => source.title).join(", ")} cover all ${family.controls.length} selected controls and record access owners and extraction instructions.`
322
+ : `${coveredControls.length} of ${family.controls.length} selected controls have an active authoritative system with the required source role, access owners, and extraction instructions.`,
323
+ completeSources[0] || selectedSources[0] || { type: "system" },
324
+ { controlIds: family.controls.map((control) => control.id), sourceSystemIds: selectedSources.map((source) => source.id) }
325
+ ));
326
+ }
327
+ return stage("sources", "Configure Evidence Sources", "Catalog the authoritative systems, name who can export from them, and write repeatable extraction instructions.", items);
328
+ }
329
+
330
+ function evidenceCollectionStage(scope, records, byId, model) {
331
+ const families = selectedControlFamilies(scope.controls, model).filter(requiresCollectionTest);
332
+ const captures = records.filter((record) => (
333
+ record.type === "evidence"
334
+ && TEST_EVIDENCE_KINDS.has(record.evidenceKind)
335
+ ));
336
+ const items = families.map((family) => {
337
+ const configuredSourceIds = new Set(family.controls.flatMap((control) => control.evidenceSourceIds || []));
338
+ const controlIds = new Set(family.controls.map((control) => control.id));
339
+ const capture = captures.find((record) => record.collectionTestFamilyId === family.id)
340
+ || captures.find((record) => (
341
+ [...controlIdsForRecord(record, byId)].some((id) => controlIds.has(id))
342
+ ));
343
+ const sourceIds = new Set([
344
+ ...configuredSourceIds,
345
+ ...(capture?.sourceSystemId ? [capture.sourceSystemId] : [])
346
+ ]);
347
+ const verified = capture?.status === "verified";
348
+ return item(
349
+ `test-family-${family.id}`,
350
+ verified ? "complete" : "action",
351
+ family.title,
352
+ verified
353
+ ? `${capture.title} proves that management successfully captured and verified evidence from ${byId.get(capture.sourceSystemId)?.title || "the authoritative source"}.`
354
+ : capture?.status === "draft"
355
+ ? `${capture.title} is a draft. Open it, select the authoritative source System, collect the named artifact, and have another person verify it.`
356
+ : capture
357
+ ? `${capture.title} is ${capture.status} but must be verified before this family is ready.`
358
+ : `Run and verify one test export or test capture from an authoritative source outside FileGRC, then link it to a family control.`,
359
+ capture || { type: "evidence" },
360
+ {
361
+ familyId: family.id,
362
+ controlIds: [...controlIds],
363
+ sourceSystemIds: [...sourceIds],
364
+ evidenceId: capture?.id || null,
365
+ evidenceStatus: capture?.status || null,
366
+ testEvidenceKind: family.testEvidenceKind,
367
+ testPrompt: family.testPrompt
368
+ }
369
+ );
370
+ });
371
+ return stage("collection", "Test Evidence Collection", "A verified test export or capture is required for each external evidence family that does not already have a dedicated Step 5 operating record.", items);
372
+ }
373
+
374
+ function operationStage(workspace, scope, records, byId, asOf, evidenceReady) {
375
+ const goal = workspace?.assuranceGoal || "none";
376
+ if (!evidenceReady) {
377
+ return stage("operation", "Operate the Program", "Run the controls and preserve dated evidence after the Evidence Ready gate passes.", [
378
+ item(
379
+ "operation-later",
380
+ "later",
381
+ "Begin reliable evidence collection",
382
+ "Finish scope, policy adoption, control implementation, source configuration, and test captures before recording the candidate period.",
383
+ workspace || { type: "workspace" }
384
+ )
385
+ ]);
386
+ }
387
+ if (goal !== "soc-2-type-2") {
388
+ const date = workspace?.candidateTypeOneAsOf;
389
+ return stage("operation", "Operate the Program", "Run the controls and preserve dated evidence before engaging the CPA firm.", [
390
+ item(
391
+ "candidate-type-one-date",
392
+ goal === "soc-2-type-1" && date ? "complete" : "info",
393
+ goal === "soc-2-type-1" ? "Record the management candidate Type 1 date" : "Operate controls and preserve evidence",
394
+ goal === "soc-2-type-1"
395
+ ? date ? `Management candidate Type 1 date: ${date}. The CPA firm must still agree on the formal date.` : "Set the management candidate Type 1 date after the controls and evidence mechanisms are ready."
396
+ : "The program can operate without an audit record. Keep dated evidence from every control occurrence.",
397
+ workspace || { type: "workspace" }
398
+ ),
399
+ riskAssessmentItem(scope, records, byId, asOf)
400
+ ]);
401
+ }
402
+
403
+ const obligations = planObligations(records, { asOf, through: asOf });
404
+ const start = workspace.candidatePeriodStart;
405
+ const end = workspace.candidatePeriodEnd;
406
+ const startStatus = !start ? "action" : start <= asOf ? "complete" : "later";
407
+ return stage("operation", "Operate the Program", "Start the management candidate Type 2 period only after the Evidence Ready gate, then keep collection running.", [
408
+ item(
409
+ "evidence-running",
410
+ startStatus,
411
+ "Evidence collection running",
412
+ !start
413
+ ? "Set the management candidate period start when the Evidence Ready gate passes. Do not backdate it."
414
+ : start <= asOf
415
+ ? `Management began the candidate Type 2 evidence period on ${start}. This is not the auditor-agreed report period.`
416
+ : `Evidence collection is scheduled to begin on ${start}.`,
417
+ workspace
418
+ ),
419
+ item(
420
+ "candidate-period-end",
421
+ end ? "complete" : start ? "later" : "info",
422
+ "Plan the candidate period end",
423
+ end
424
+ ? `Management candidate period: ${start || "start not set"} through ${end}. The CPA firm may agree to different dates.`
425
+ : "Add the management target end when useful. Starting reliable evidence collection is the immediate milestone.",
426
+ workspace
427
+ ),
428
+ item(
429
+ "ongoing-obligations",
430
+ obligations.counts.overdue ? "action" : "complete",
431
+ "Keep policy work current",
432
+ obligations.counts.overdue
433
+ ? `${obligations.counts.overdue} policy obligations are overdue. Complete the work and retain its dated proof.`
434
+ : `${obligations.counts.due} due and ${obligations.counts.upcoming} upcoming obligations; no overdue policy work.`,
435
+ { type: "obligation" }
436
+ ),
437
+ riskAssessmentItem(scope, records, byId, asOf)
438
+ ]);
439
+ }
440
+
441
+ function riskAssessmentItem(scope, records, byId, asOf) {
442
+ const assessment = records.find((record) => (
443
+ record.type === "risk-assessment"
444
+ && record.status === "complete"
445
+ && record.methodology
446
+ && record.approvedOn
447
+ && record.assessmentDate >= shiftYear(asOf, -1)
448
+ && partiesIndependent(record.assessorIds, record.reviewerIds, byId)
449
+ && (!scope.systems.length || !(record.systemIds || []).length || record.systemIds.some((id) => scope.systems.some((system) => system.id === id)))
450
+ ));
451
+ return item(
452
+ "risk-assessment",
453
+ assessment ? "complete" : "action",
454
+ "Maintain the current risk assessment",
455
+ assessment
456
+ ? `${assessment.title} is complete, approved, current, and independently reviewed. Update the risk register and control set when its conclusions require changes.`
457
+ : "Complete and approve a current risk assessment for the operating program with a reviewer separate from the assessor, then add or update risks and controls as needed.",
458
+ assessment || { type: "risk-assessment" }
459
+ );
460
+ }
461
+
462
+ export function selectedControlFamilies(controls, model) {
463
+ const remaining = new Set(controls.map((control) => control.id));
464
+ const families = [];
465
+ for (const definition of model.evidenceSourceFamilies || []) {
466
+ const selected = controls.filter((control) => (definition.controlCodes || []).includes(control.code));
467
+ if (!selected.length) continue;
468
+ selected.forEach((control) => remaining.delete(control.id));
469
+ families.push({
470
+ id: definition.id,
471
+ title: definition.title,
472
+ sourceKinds: definition.sourceKinds || [],
473
+ testEvidenceKind: definition.testEvidenceKind || "test-capture",
474
+ testPrompt: definition.testPrompt || `Capture usable evidence for ${definition.title.toLowerCase()}.`,
475
+ collectionTestRequired: definition.collectionTestRequired !== false,
476
+ operationRecordTypes: definition.operationRecordTypes || [],
477
+ controls: selected
478
+ });
479
+ }
480
+ const byPrefix = new Map();
481
+ for (const control of controls.filter((record) => remaining.has(record.id))) {
482
+ const prefix = String(control.code || control.id).split("-")[0].toLowerCase();
483
+ if (!byPrefix.has(prefix)) byPrefix.set(prefix, []);
484
+ byPrefix.get(prefix).push(control);
485
+ }
486
+ for (const [prefix, selected] of byPrefix) {
487
+ const title = ({
488
+ data: "Data Handling",
489
+ gov: "Governance",
490
+ net: "Network Security",
491
+ rsk: "Risk Management"
492
+ })[prefix] || `${prefix.toUpperCase()} Controls`;
493
+ families.push({
494
+ id: `control-${prefix}`,
495
+ title,
496
+ sourceKinds: [],
497
+ testEvidenceKind: "test-capture",
498
+ testPrompt: `Capture usable evidence for the selected ${title.toLowerCase()} controls.`,
499
+ collectionTestRequired: true,
500
+ operationRecordTypes: [],
501
+ controls: selected
502
+ });
503
+ }
504
+ return families;
505
+ }
506
+
507
+ function requiresCollectionTest(family) {
508
+ return family.collectionTestRequired !== false;
509
+ }
510
+
511
+ async function primaryMarkdown(loaded, record) {
512
+ const definition = loaded.model.resources[record.type];
513
+ const selected = markdownEntries(loaded.model, record).find((entry) => (
514
+ definition.markdown?.[entry.name]?.primary || entry.name === loaded.model.recordContent?.slot
515
+ ));
516
+ if (!selected) return "";
517
+ try {
518
+ return await readFile(resolveDataPath(loaded.root, selected.path), "utf8");
519
+ } catch {
520
+ return "";
521
+ }
522
+ }
523
+
524
+ function controlIdsForRecord(record, byId, seen = new Set()) {
525
+ const ids = new Set();
526
+ if (!record || seen.has(record.id)) return ids;
527
+ seen.add(record.id);
528
+ if (record.type === "control") ids.add(record.id);
529
+ for (const id of record.controlIds || []) ids.add(id);
530
+ if (record.controlId) ids.add(record.controlId);
531
+ for (const sourceId of record.sourceResourceIds || []) {
532
+ for (const id of controlIdsForRecord(byId.get(sourceId), byId, seen)) ids.add(id);
533
+ }
534
+ if (record.sourceResourceId) {
535
+ for (const id of controlIdsForRecord(byId.get(record.sourceResourceId), byId, seen)) ids.add(id);
536
+ }
537
+ return ids;
538
+ }
539
+
540
+ function openPlaceholderCount(source) {
541
+ if (!source) return 0;
542
+ const matches = source.match(
543
+ /\{\{[^}\n]+\}\}|\b(?:TODO|TBD)\b|\[(?:complete|confirm|describe|insert|name|replace|select|specify|todo|tbd)[^\]\n]*\]/giu
544
+ );
545
+ return matches?.length || 0;
546
+ }
547
+
548
+ function substantiveMarkdown(source) {
549
+ return (source.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || []).length >= 10;
550
+ }
551
+
552
+ function partiesIndependent(ownerIds = [], approverIds = [], byId) {
553
+ const owners = new Set(partyPeople(ownerIds, byId));
554
+ const approvers = partyPeople(approverIds, byId);
555
+ return owners.size > 0 && approvers.length > 0 && !approvers.some((id) => owners.has(id));
556
+ }
557
+
558
+ function partyPeople(ids = [], byId, seen = new Set()) {
559
+ const people = [];
560
+ for (const id of ids) {
561
+ if (seen.has(id)) continue;
562
+ seen.add(id);
563
+ const record = byId.get(id);
564
+ if (record?.type === "person") people.push(id);
565
+ if (record?.type === "team") {
566
+ people.push(...partyPeople([...(record.memberIds || []), ...(record.chairIds || [])], byId, seen));
567
+ }
568
+ }
569
+ return [...new Set(people)];
570
+ }
571
+
572
+ function policyCheckLabel(name) {
573
+ return ({
574
+ reviewed: "draft review",
575
+ independentlyApproved: "independent approval and approval date",
576
+ effective: "active status and effective date",
577
+ linkedControls: "linked controls",
578
+ contentComplete: "policy text and organization placeholders"
579
+ })[name] || name;
580
+ }
581
+
582
+ function controlCheckLabel(name) {
583
+ return ({
584
+ implemented: "implemented status",
585
+ owner: "owner",
586
+ procedure: "actual procedure in Record Markdown",
587
+ scope: "in-scope systems",
588
+ cadence: "cadence",
589
+ evidenceSource: "authoritative evidence source",
590
+ implementationDate: "implementation date",
591
+ policyMapping: "policy mapping",
592
+ criteriaMapping: "criteria mapping",
593
+ workQueue: "running Work Queue schedules"
594
+ })[name] || name;
595
+ }
596
+
597
+ function assuranceGoalLabel(goal) {
598
+ if (goal === "soc-2-type-1") return "SOC 2 Type 1";
599
+ if (goal === "soc-2-type-2") return "SOC 2 Type 2";
600
+ if (goal === "readiness") return "SOC 2 program readiness";
601
+ return "No assurance goal selected";
602
+ }
603
+
604
+ function stage(id, title, description, items) {
605
+ return { id, title, description, items };
606
+ }
607
+
608
+ function finalizeStage(current) {
609
+ current.counts = countStatuses(current.items);
610
+ current.status = current.counts.action ? "action" : current.counts.later ? "later" : "complete";
611
+ }
612
+
613
+ function item(id, status, title, message, resource = {}, details = {}) {
614
+ return {
615
+ id,
616
+ status,
617
+ title,
618
+ message,
619
+ ...(resource.type ? { resourceType: resource.type } : {}),
620
+ ...(resource.id ? { resourceId: resource.id } : {}),
621
+ ...details
622
+ };
623
+ }
624
+
625
+ function countStatuses(items) {
626
+ const counts = { complete: 0, action: 0, later: 0, info: 0 };
627
+ for (const current of items) counts[current.status] = (counts[current.status] || 0) + 1;
628
+ return counts;
629
+ }
630
+
631
+ function shiftYear(value, offset) {
632
+ const date = new Date(`${value}T00:00:00Z`);
633
+ date.setUTCFullYear(date.getUTCFullYear() + offset);
634
+ return date.toISOString().slice(0, 10);
635
+ }
@@ -8,13 +8,14 @@ export function resourceDataPath(model, record) {
8
8
  return join(definition.collection, recordPath).replaceAll("\\", "/");
9
9
  }
10
10
 
11
- export function markdownSlots(model, type) {
11
+ export function markdownSlots(model, type, record = null) {
12
12
  const definition = getResourceDefinition(model, type);
13
13
  const dedicated = Object.entries(definition.markdown ?? {}).map(([name, slot]) => ({
14
14
  name,
15
15
  label: slot.label ?? humanize(name),
16
16
  primary: Boolean(slot.primary),
17
- required: Boolean(slot.required)
17
+ required: Boolean(slot.required || (record && conditionMatches(record, slot.requiredWhen))),
18
+ requiredWhen: slot.requiredWhen ?? null
18
19
  }));
19
20
  if (dedicated.length) return dedicated;
20
21
  return [{
@@ -26,7 +27,7 @@ export function markdownSlots(model, type) {
26
27
  }
27
28
 
28
29
  export function markdownDataPath(model, record, slotName) {
29
- const slot = markdownSlots(model, record.type).find(({ name }) => name === slotName);
30
+ const slot = markdownSlots(model, record.type, record).find(({ name }) => name === slotName);
30
31
  if (!slot) throw new Error(`Unknown Markdown slot "${slotName}" for ${record.type}.`);
31
32
  const recordPath = resourceDataPath(model, record);
32
33
  const extension = extname(recordPath);
@@ -36,11 +37,17 @@ export function markdownDataPath(model, record, slotName) {
36
37
  }
37
38
 
38
39
  export function markdownEntries(model, record) {
39
- return markdownSlots(model, record.type)
40
+ return markdownSlots(model, record.type, record)
40
41
  .map((slot) => ({ ...slot, path: markdownDataPath(model, record, slot.name) }))
41
42
  .filter(({ path }) => path);
42
43
  }
43
44
 
45
+ function conditionMatches(record, condition) {
46
+ return condition && Object.entries(condition).every(([name, expected]) => (
47
+ Array.isArray(expected) ? expected.includes(record[name]) : record[name] === expected
48
+ ));
49
+ }
50
+
44
51
  export function isMarkdownChoice(value) {
45
52
  return typeof value === "string" && value.startsWith("$markdown:");
46
53
  }