filegrc 0.3.4 → 0.5.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,1595 @@
1
+ import { cp, mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { assessRequiredAppointments } from "./appointments.js";
5
+ import { assessSourceCoverageReadiness } from "./source-coverage.js";
6
+ import { coverageEnd, coverageStart } from "./coverage.js";
7
+ import {
8
+ applyResourceBatch,
9
+ createResource,
10
+ deleteResource,
11
+ updateResource
12
+ } from "./files.js";
13
+ import { getGitSummary, getWorkspaceHistories } from "./git.js";
14
+ import {
15
+ completeObligationAction,
16
+ completeObligationEvent,
17
+ completeObligationOccurrence,
18
+ createObligationEvent,
19
+ planObligations
20
+ } from "./obligations.js";
21
+ import { assessAuditPreparation } from "./audit-preparation.js";
22
+ import { assessProgramReadiness } from "./program-readiness.js";
23
+ import { planReconciliation } from "./reconciliation.js";
24
+ import { currentCalendarDate } from "./time.js";
25
+ import { validateWorkspace } from "./validate.js";
26
+ import { loadWorkspace } from "./workspace.js";
27
+
28
+ export const WORKFLOW_CONTRACT_VERSION = 1;
29
+
30
+ const TERMINAL_STATUSES = new Set([
31
+ "accepted",
32
+ "approved",
33
+ "closed",
34
+ "complete",
35
+ "completed",
36
+ "done",
37
+ "expired",
38
+ "inactive",
39
+ "not-applicable",
40
+ "reconciled",
41
+ "remediated",
42
+ "resolved",
43
+ "retired",
44
+ "superseded",
45
+ "terminated",
46
+ "verified"
47
+ ]);
48
+
49
+ const DATE_FIELDS = [
50
+ "dueOn",
51
+ "expiresOn",
52
+ "targetRemediationOn",
53
+ "treatmentTargetOn",
54
+ "reviewDueOn",
55
+ "scheduledFor",
56
+ "validThrough",
57
+ "fieldworkStart",
58
+ "fieldworkEnd",
59
+ "reportDate",
60
+ "endsOn",
61
+ "endDate",
62
+ "proposedEffectiveOn"
63
+ ];
64
+
65
+ const OWNER_FIELDS = [
66
+ "assigneeIds",
67
+ "ownerIds",
68
+ "responsibleIds",
69
+ "reviewerIds"
70
+ ];
71
+
72
+ /**
73
+ * Return the shared, interface-neutral workflow assessment for a workspace.
74
+ *
75
+ * Focused readiness functions use the same source facts, while this envelope is
76
+ * the complete contract for cross-interface workflow guidance.
77
+ */
78
+ export async function assessWorkflow(input, options = {}) {
79
+ const loaded = input?.resources && input?.model && input?.entries
80
+ ? input
81
+ : await loadWorkspace(input);
82
+ const workspace = loaded.workspace
83
+ || loaded.resources.find((record) => record.type === "workspace");
84
+ const timezone = options.timezone || workspace?.timezone || "UTC";
85
+ const asOf = options.asOf || currentCalendarDate(timezone);
86
+ const evaluatedAt = options.evaluatedAt || options.now || new Date().toISOString();
87
+ const program = options.programReadiness || await assessProgramReadiness(loaded, {
88
+ asOf,
89
+ generatedAt: evaluatedAt
90
+ });
91
+ const audits = selectedAudits(loaded.resources, options.auditId);
92
+ const auditPreparations = options.auditPreparations || Object.fromEntries(await Promise.all(
93
+ audits.map(async (audit) => [
94
+ audit.id,
95
+ await assessAuditPreparation(loaded, {
96
+ auditId: audit.id,
97
+ generatedAt: evaluatedAt,
98
+ programReadiness: program
99
+ })
100
+ ])
101
+ ));
102
+ const obligationPlan = options.obligations || planObligations(loaded.resources, {
103
+ asOf,
104
+ through: options.through || asOf,
105
+ now: evaluatedAt,
106
+ includeComplete: Boolean(options.includeComplete),
107
+ model: loaded.model
108
+ });
109
+ const validation = options.validation || await validateWorkspace(loaded);
110
+ const reconciliation = options.reconciliation || await planReconciliation(loaded.root);
111
+ const coverage = normalizeCoverage(
112
+ options.coverage
113
+ || (options.auditId ? audits[0]?.coverage : null)
114
+ || workspace?.candidateCoverage
115
+ );
116
+ const periodFindings = await assessPeriodHealth(loaded, {
117
+ coverage,
118
+ asOf,
119
+ evaluatedAt,
120
+ controlIds: options.auditId ? audits[0]?.controlIds : workspace?.controlIds
121
+ });
122
+ const guidedFindings = [
123
+ ...programFindings(program),
124
+ ...Object.values(auditPreparations).flatMap(auditFindings),
125
+ ...appointmentTemplateFindings(loaded),
126
+ ...sourceCoverageFindings(loaded),
127
+ ...reconciliationFindings(reconciliation),
128
+ ...periodFindings,
129
+ ...auditLifecycleFindings(loaded, audits)
130
+ ];
131
+ const findings = [
132
+ ...validationFindings(validation, loaded),
133
+ ...removeRedundantRecordFindings(recordFinalizationFindings(loaded), guidedFindings),
134
+ ...guidedFindings
135
+ ];
136
+ const workItems = buildWorkItems(loaded.resources, obligationPlan, {
137
+ asOf,
138
+ includeComplete: Boolean(options.includeComplete)
139
+ });
140
+ const git = options.git || safeGitSummary(loaded.root);
141
+ const assessments = buildAssessments({
142
+ program,
143
+ audits,
144
+ auditPreparations,
145
+ obligationPlan,
146
+ findings,
147
+ validation,
148
+ coverage
149
+ });
150
+ const recommended = recommendedAction(findings, workItems, program);
151
+
152
+ return {
153
+ contractVersion: WORKFLOW_CONTRACT_VERSION,
154
+ dataModelVersion: loaded.model.modelVersion,
155
+ evaluatedAt,
156
+ input: {
157
+ asOf,
158
+ timezone,
159
+ coverage,
160
+ auditId: options.auditId || null,
161
+ gitRevision: git.commit || null
162
+ },
163
+ assessments,
164
+ findings: findings.sort(compareFindings),
165
+ workItems: workItems.sort(compareWorkItems),
166
+ recommended: recommended
167
+ ? { ...recommended, rankingReason: rankingReason(recommended) }
168
+ : null,
169
+ counts: {
170
+ findings: countBy(findings, "state"),
171
+ workItems: countBy(workItems, "state")
172
+ },
173
+ reconciliation
174
+ };
175
+ }
176
+
177
+ export function buildWorkflowDelta(before, after) {
178
+ const beforeFindings = keyed(before?.findings);
179
+ const afterFindings = keyed(after?.findings);
180
+ const beforeWork = keyed(before?.workItems);
181
+ const afterWork = keyed(after?.workItems);
182
+ return {
183
+ contractVersion: WORKFLOW_CONTRACT_VERSION,
184
+ assessments: changedAssessments(before?.assessments || {}, after?.assessments || {}),
185
+ findings: changedItems(beforeFindings, afterFindings),
186
+ workItems: changedItems(beforeWork, afterWork),
187
+ recommendedBefore: before?.recommended?.key || null,
188
+ recommendedAfter: after?.recommended?.key || null
189
+ };
190
+ }
191
+
192
+ export function workflowForResource(workflow, type, id) {
193
+ if (!workflow) return { findings: [], workItems: [], recommended: null };
194
+ const matches = (item) => item.subject?.type === type && item.subject?.id === id;
195
+ const findings = workflow.findings.filter(matches);
196
+ const workItems = workflow.workItems.filter((item) => (
197
+ matches(item)
198
+ || item.source?.type === type && item.source?.id === id
199
+ ));
200
+ return {
201
+ contractVersion: workflow.contractVersion,
202
+ assessments: workflow.assessments,
203
+ findings,
204
+ workItems,
205
+ recommended: [...findings, ...workItems].sort(compareRecommended)[0] || null
206
+ };
207
+ }
208
+
209
+ export async function previewWorkflowMutation(input, mutation) {
210
+ const loaded = await loadWorkspace(input);
211
+ const previewRoot = await mkdtemp(join(tmpdir(), "filegrc-workflow-preview-"));
212
+ try {
213
+ await cp(join(loaded.root, "data"), join(previewRoot, "data"), {
214
+ recursive: true,
215
+ errorOnExist: true,
216
+ force: false
217
+ });
218
+ const before = await assessWorkflow(loaded.root, mutation?.assessment || {});
219
+ const record = mutation?.record;
220
+ const existing = record?.id
221
+ ? loaded.resources.find((item) => item.id === record.id && item.type === record.type)
222
+ : null;
223
+ const operation = mutation?.operation || (existing ? "update" : "create");
224
+ let result;
225
+ if (operation === "create") {
226
+ if (!record) throw new Error("A record is required for a create preview.");
227
+ result = await createResource(previewRoot, record, { content: mutation.content });
228
+ } else if (operation === "update") {
229
+ if (!record) throw new Error("A record is required for an update preview.");
230
+ result = await updateResource(previewRoot, mutation.type || record.type, mutation.id || record.id, record, {
231
+ content: mutation.content
232
+ });
233
+ } else if (operation === "delete") {
234
+ if (!mutation.type || !mutation.id) throw new Error("A type and ID are required for a delete preview.");
235
+ result = await deleteResource(previewRoot, mutation.type, mutation.id);
236
+ } else if (operation === "batch") {
237
+ result = await applyResourceBatch(previewRoot, mutation.changes);
238
+ } else if (operation === "complete-obligation") {
239
+ result = await completeObligationOccurrence(previewRoot, mutation);
240
+ } else if (operation === "complete-action") {
241
+ result = await completeObligationAction(previewRoot, mutation);
242
+ } else if (operation === "complete-event") {
243
+ result = await completeObligationEvent(previewRoot, mutation);
244
+ } else if (operation === "trigger-event") {
245
+ result = await createObligationEvent(previewRoot, mutation);
246
+ } else {
247
+ throw new Error(`Unsupported preview operation "${operation}".`);
248
+ }
249
+ const after = await assessWorkflow(previewRoot, mutation?.assessment || {});
250
+ return {
251
+ operation,
252
+ target: mutationTarget(operation, mutation, record),
253
+ result,
254
+ workflowDelta: buildWorkflowDelta(before, after),
255
+ workflow: after
256
+ };
257
+ } finally {
258
+ await rm(previewRoot, { recursive: true, force: true });
259
+ }
260
+ }
261
+
262
+ function selectedAudits(records, auditId) {
263
+ const audits = records.filter((record) => record.type === "audit");
264
+ if (!auditId) return audits;
265
+ return audits.filter((audit) => audit.id === auditId);
266
+ }
267
+
268
+ function mutationTarget(operation, mutation, record) {
269
+ if (operation === "delete") return { type: mutation.type, id: mutation.id };
270
+ if (["create", "update"].includes(operation)) return { type: record.type, id: record.id };
271
+ if (operation === "complete-obligation") return { type: "obligation", id: mutation.obligationId };
272
+ if (operation === "complete-action") return { type: "action-item", id: mutation.actionItemId };
273
+ if (operation === "complete-event") return { type: "obligation-event", id: mutation.eventId };
274
+ if (operation === "trigger-event") return { type: "policy-event", id: mutation.eventType };
275
+ return { type: "workspace", id: "batch" };
276
+ }
277
+
278
+ function programFindings(program) {
279
+ return program.stages.flatMap((stage) => stage.items
280
+ .filter(({ id }) => !["required-appointments", "independent-reviewer"].includes(id))
281
+ .map((item) => normalizeFinding(
282
+ `program.${stage.id}.${item.id}`,
283
+ item,
284
+ {
285
+ assessment: stage.id === "operation" ? "period-health" : "program-configuration",
286
+ stage: stage.id
287
+ }
288
+ )));
289
+ }
290
+
291
+ function auditFindings(preparation) {
292
+ return preparation.stages.flatMap((stage) => stage.items.map((item) => normalizeFinding(
293
+ `audit.${preparation.audit?.id || "unscoped"}.${stage.id}.${item.id}`,
294
+ item,
295
+ {
296
+ assessment: stage.id === "auditor" ? "audit-closure" : "audit-readiness",
297
+ stage: stage.id,
298
+ auditId: preparation.audit?.id || null
299
+ }
300
+ )));
301
+ }
302
+
303
+ function validationFindings(validation, loaded) {
304
+ const entriesByPath = new Map(loaded.entries.map((entry) => [
305
+ `data/${entry.relativePath}`,
306
+ entry.record
307
+ ]));
308
+ return (validation?.diagnostics || []).map((diagnostic) => {
309
+ const record = entriesByPath.get(diagnostic.path);
310
+ const state = "ready";
311
+ const code = `structural.${diagnostic.code}.${record?.id || pathKey(diagnostic.path)}.${stableSuffix(diagnostic.message)}`;
312
+ return {
313
+ key: code,
314
+ code,
315
+ assessment: "structural-validity",
316
+ stage: "structure",
317
+ state,
318
+ severity: diagnostic.severity,
319
+ requiredness: "required",
320
+ title: record?.title || "Workspace structure",
321
+ message: diagnostic.message,
322
+ fieldPath: diagnostic.path,
323
+ ...(record ? { subject: { type: record.type, id: record.id } } : {}),
324
+ dependencies: [],
325
+ actions: record
326
+ ? [mutationAction(record)]
327
+ : [{ kind: "command", command: "npx filegrc validate --json" }]
328
+ };
329
+ });
330
+ }
331
+
332
+ function recordFinalizationFindings(loaded) {
333
+ const findings = [];
334
+ for (const record of loaded.resources) {
335
+ if (["workspace", "renderer-settings"].includes(record.type)) continue;
336
+ const incomplete = recordIncompleteReason(record, loaded);
337
+ if (incomplete) {
338
+ findings.push(finalizationFinding(record, incomplete));
339
+ }
340
+ for (const missing of finalizationFields(record, loaded.model)) {
341
+ findings.push(fieldFinding(record, missing));
342
+ }
343
+ }
344
+ return findings;
345
+ }
346
+
347
+ function removeRedundantRecordFindings(recordFindings, guidedFindings) {
348
+ const guidedSubjects = new Set(guidedFindings
349
+ .filter(({ subject }) => subject?.type && subject?.id)
350
+ .map(({ subject }) => subjectKey(subject)));
351
+ const guidedApplicabilitySubjects = new Set(guidedFindings
352
+ .filter(({ code, subject }) => (
353
+ subject?.type
354
+ && subject?.id
355
+ && (
356
+ (subject.type === "control" && code.startsWith("program.controls.control-"))
357
+ || (subject.type === "commitment" && code === "program.scope.commitments")
358
+ )
359
+ ))
360
+ .map(({ subject }) => subjectKey(subject)));
361
+ const guidedSubjectIds = new Set(guidedFindings
362
+ .filter(({ subject }) => subject?.id)
363
+ .map(({ subject }) => subject.id));
364
+ const applicabilityFieldSubjects = new Set(recordFindings
365
+ .filter(({ fieldPath, subject }) => (
366
+ fieldPath === "applicabilityReview"
367
+ && subject?.type
368
+ && subject?.id
369
+ ))
370
+ .map(({ subject }) => subjectKey(subject)));
371
+
372
+ return recordFindings.filter((finding) => {
373
+ const key = finding.subject ? subjectKey(finding.subject) : null;
374
+ if (!key) return true;
375
+ if (finding.code.endsWith(".finalize")) {
376
+ if (finding.subject?.type === "collection-review" && guidedSubjectIds.has(finding.subject.id)) {
377
+ return false;
378
+ }
379
+ return !guidedSubjects.has(key) && !applicabilityFieldSubjects.has(key);
380
+ }
381
+ if (finding.fieldPath === "applicabilityReview") {
382
+ return !guidedApplicabilitySubjects.has(key);
383
+ }
384
+ return true;
385
+ });
386
+ }
387
+
388
+ function subjectKey(subject) {
389
+ return `${subject.type}:${subject.id}`;
390
+ }
391
+
392
+ function recordIncompleteReason(record, loaded) {
393
+ if (record.type === "requirement" && record.applicability === "undetermined") {
394
+ return {
395
+ state: "ready",
396
+ requiredness: "required",
397
+ message: "Review this criterion against the current service scope and record the applicability decision."
398
+ };
399
+ }
400
+ if (record.type === "appointment" && record.status === "planned") {
401
+ const assessed = assessRequiredAppointments(loaded.resources, loaded.model)
402
+ .find(({ kind }) => kind === record.appointmentKind);
403
+ if (assessed) return null;
404
+ return {
405
+ state: "ready",
406
+ requiredness: appointmentRequiredness(record, loaded.model),
407
+ message: "Assign a holder, confirm the authority scope and independence needs, then activate this Appointment on its real start date."
408
+ };
409
+ }
410
+ const messages = {
411
+ draft: "Complete the record, its relationships, and required Markdown before moving it to review.",
412
+ planned: "Review this planned record against the actual program and complete its finalization checks.",
413
+ proposed: "Review the proposed work, owner, schedule, and completion profile before activating it.",
414
+ "in-review": "Complete independent review and bind the approval to the exact content revision.",
415
+ open: "Complete or formally dispose of this open work with the required proof.",
416
+ "in-progress": "Finish the work, record its result, and link its completion proof.",
417
+ blocked: "Resolve the recorded blockers before completing this work.",
418
+ "partially-implemented": "Finish the remaining control design, operation, source, and scheduling work."
419
+ };
420
+ if (!messages[record.status]) return null;
421
+ const requiredness = recordFinalizationRequiredness(record, loaded);
422
+ if (record.status === "blocked") {
423
+ const byId = new Map(loaded.resources.map((resource) => [resource.id, resource]));
424
+ return {
425
+ state: "blocked",
426
+ requiredness,
427
+ message: actionBlockingReason(record, byId),
428
+ dependencies: blockingDependencies(record.blockingResourceIds, byId)
429
+ };
430
+ }
431
+ return {
432
+ state: requiredness === "conditional" ? "scheduled" : "ready",
433
+ requiredness,
434
+ message: requiredness === "conditional"
435
+ ? "Keep this starter in draft until its recorded audience, program role, or audit stage applies. Finalize or retire it when that decision is made."
436
+ : messages[record.status]
437
+ };
438
+ }
439
+
440
+ function recordFinalizationRequiredness(record, loaded) {
441
+ if (!["policy", "document", "training"].includes(record.type)) return "required";
442
+ const selectedControlIds = new Set(
443
+ loaded.workspace?.controlIds?.length
444
+ ? loaded.workspace.controlIds
445
+ : loaded.resources
446
+ .filter((resource) => resource.type === "control" && !["not-applicable", "retired"].includes(resource.status))
447
+ .map(({ id }) => id)
448
+ );
449
+ const requiredByRunningObligation = loaded.resources.some((resource) => (
450
+ resource.type === "obligation"
451
+ && resource.status === "active"
452
+ && (
453
+ resource.templateResourceId === record.id
454
+ || (resource.scopeResourceIds || []).includes(record.id)
455
+ )
456
+ ));
457
+ if (requiredByRunningObligation) return "required";
458
+ if (
459
+ ["policy", "document"].includes(record.type)
460
+ && record.programRole === "required"
461
+ && (record.controlIds || []).some((id) => selectedControlIds.has(id))
462
+ ) return "required";
463
+ if (
464
+ record.type === "policy"
465
+ && loaded.resources.some((control) => (
466
+ control.type === "control"
467
+ && selectedControlIds.has(control.id)
468
+ && (control.policyIds || []).includes(record.id)
469
+ ))
470
+ ) return "required";
471
+ return "conditional";
472
+ }
473
+
474
+ function finalizationFields(record, model) {
475
+ const fields = [];
476
+ const needsReview = ["requirement", "commitment", "complementary-control", "control"].includes(record.type)
477
+ && model.resources[record.type]?.fields?.applicabilityReview
478
+ && !record.applicabilityReview;
479
+ if (needsReview) {
480
+ fields.push({
481
+ field: "applicabilityReview",
482
+ requiredness: "required",
483
+ message: "Record the reviewed applicability decision, rationale, reviewer, and date. FileGRC records the current scope automatically."
484
+ });
485
+ }
486
+ if (record.type === "policy" && model.resources.policy?.fields?.programRole && !record.programRole) {
487
+ fields.push({
488
+ field: "programRole",
489
+ requiredness: "required",
490
+ message: "Classify this starter as required, conditional, alternative, or supporting for this program."
491
+ });
492
+ }
493
+ if (record.type === "policy" && ["draft", "in-review"].includes(record.status) && record.effectiveOn) {
494
+ fields.push({
495
+ field: "effectiveOn",
496
+ requiredness: "required",
497
+ message: "A draft cannot assert a factual effective date. Move the date to proposedEffectiveOn or activate the approved revision."
498
+ });
499
+ }
500
+ const requested = {
501
+ risk: [
502
+ ["treatmentTargetOn", ["open", "monitoring"].includes(record.status)],
503
+ ["reviewDueOn", ["open", "monitoring"].includes(record.status)]
504
+ ],
505
+ vulnerability: [
506
+ ["confirmedOn", record.status !== "false-positive"],
507
+ ["severityAssignedOn", record.severity !== "unknown"],
508
+ ["targetRemediationOn", ["open", "in-progress"].includes(record.status)]
509
+ ],
510
+ "access-grant": [["businessNeed", !["revoked", "expired"].includes(record.status)]],
511
+ "service-account": [
512
+ ["authenticationMethod", record.status === "active"],
513
+ ["reviewDueOn", record.status === "active"],
514
+ ["nonExpiringRationale", record.status === "active" && !record.expiresOn]
515
+ ],
516
+ training: [
517
+ ["effectiveContentRevisions", record.status === "active"],
518
+ ["effectiveOn", record.status === "active"]
519
+ ],
520
+ control: [
521
+ ["procedureRevision", record.status === "implemented"],
522
+ ["procedureEffectiveOn", record.status === "implemented"],
523
+ ["implementationReviewedByIds", record.status === "implemented"],
524
+ ["implementationReviewedOn", record.status === "implemented"]
525
+ ]
526
+ }[record.type] || [];
527
+ for (const [field, applies] of requested) {
528
+ if (!model.resources[record.type]?.fields?.[field] || !applies || present(record[field])) continue;
529
+ fields.push({
530
+ field,
531
+ requiredness: "conditional",
532
+ message: `Complete ${fieldLabel(field)} before treating this ${fieldLabel(record.type).toLowerCase()} as finalized.`
533
+ });
534
+ }
535
+ return fields;
536
+ }
537
+
538
+ function finalizationFinding(record, details) {
539
+ const code = `record.${record.type}.${record.id}.finalize`;
540
+ return {
541
+ key: code,
542
+ code,
543
+ assessment: recordAssessment(record.type),
544
+ stage: recordStage(record.type),
545
+ state: details.state,
546
+ severity: details.state === "blocked" ? "error" : "warning",
547
+ requiredness: details.requiredness,
548
+ title: `Finalize ${record.title}`,
549
+ message: details.message,
550
+ subject: { type: record.type, id: record.id },
551
+ dependencies: details.dependencies || [],
552
+ actions: [mutationAction(record)]
553
+ };
554
+ }
555
+
556
+ function fieldFinding(record, missing) {
557
+ const code = `record.${record.type}.${record.id}.field.${missing.field}`;
558
+ return {
559
+ key: code,
560
+ code,
561
+ assessment: recordAssessment(record.type),
562
+ stage: recordStage(record.type),
563
+ state: "ready",
564
+ severity: "warning",
565
+ requiredness: missing.requiredness,
566
+ title: `${record.title}: ${fieldLabel(missing.field)}`,
567
+ message: missing.message,
568
+ subject: { type: record.type, id: record.id },
569
+ fieldPath: missing.field,
570
+ dependencies: [],
571
+ actions: [mutationAction(record)]
572
+ };
573
+ }
574
+
575
+ function appointmentTemplateFindings(loaded) {
576
+ return assessRequiredAppointments(loaded.resources, loaded.model)
577
+ .map(({ kind, template, record, state, requiredness }) => (
578
+ appointmentFinding(kind, template, record, state, requiredness)
579
+ ));
580
+ }
581
+
582
+ function appointmentFinding(kind, template, record, state, requiredness) {
583
+ const code = `governance.appointment.${kind}`;
584
+ const messages = {
585
+ complete: `${template.title} is assigned through an active dated Appointment.`,
586
+ ready: record
587
+ ? `Assign and activate the planned ${template.title} Appointment.`
588
+ : `Create, assign, and activate the required ${template.title} Appointment.`,
589
+ "not-applicable": `${template.title} is conditional and no current scope fact requires it.`
590
+ };
591
+ return {
592
+ key: code,
593
+ code,
594
+ assessment: "program-configuration",
595
+ stage: kind === "independent-policy-reviewer" ? "policies" : "scope",
596
+ state,
597
+ severity: state === "ready" ? "warning" : "info",
598
+ requiredness,
599
+ title: template.title,
600
+ message: messages[state],
601
+ subject: { type: "appointment", ...(record ? { id: record.id } : {}) },
602
+ dependencies: [],
603
+ actions: record
604
+ ? [mutationAction(record)]
605
+ : [{ kind: "command", command: `npx filegrc scaffold appointment --title ${shellArgument(template.title)}` }]
606
+ };
607
+ }
608
+
609
+ function sourceCoverageFindings(loaded) {
610
+ const selectedControlIds = loaded.resources
611
+ .filter((record) => record.type === "control" && record.status !== "not-applicable" && record.status !== "retired")
612
+ .map(({ id }) => id);
613
+ return assessSourceCoverageReadiness(loaded, selectedControlIds)
614
+ .map(({ family, record, complete }) => {
615
+ const state = complete ? "complete" : "ready";
616
+ const code = `evidence-source.${family.id}.coverage`;
617
+ return {
618
+ key: code,
619
+ code,
620
+ assessment: "program-configuration",
621
+ stage: "controls",
622
+ state,
623
+ severity: state === "complete" ? "info" : "warning",
624
+ requiredness: "required",
625
+ title: `${family.title} source coverage`,
626
+ message: complete
627
+ ? "The source family has a reviewed authoritative path, valid coverage dates, retrieval ownership, and reconciliation method."
628
+ : record
629
+ ? record.status === "active"
630
+ ? "Complete any missing source details and link a passed retrieval test after a candidate period is set."
631
+ : "Finish the planned source-family decision, retrieval method, retention, validity dates, and pre-period dry run when a candidate period is set."
632
+ : "Create a source-family coverage record and choose FileGRC, an external authoritative System, reviewed zero population, or reviewed not applicable.",
633
+ subject: { type: "source-coverage", ...(record ? { id: record.id } : {}) },
634
+ dependencies: [],
635
+ actions: record
636
+ ? [mutationAction(record)]
637
+ : [{ kind: "command", command: `npx filegrc scaffold source-coverage --title ${shellArgument(`${family.title} coverage`)}` }]
638
+ };
639
+ });
640
+ }
641
+
642
+ function reconciliationFindings(reconciliation) {
643
+ return (reconciliation?.candidates || []).map((candidate) => ({
644
+ key: `reconciliation.${candidate.transitionFingerprint}`,
645
+ code: `reconciliation.${candidate.eventType}`,
646
+ assessment: "period-health",
647
+ stage: "operate",
648
+ state: "ready",
649
+ severity: "warning",
650
+ requiredness: "conditional",
651
+ title: `Confirm ${fieldLabel(candidate.eventType)}`,
652
+ message: candidate.message,
653
+ subject: candidate.subject,
654
+ fieldPath: candidate.sourcePath,
655
+ dependencies: [],
656
+ actions: [candidate.action]
657
+ }));
658
+ }
659
+
660
+ async function assessPeriodHealth(loaded, options) {
661
+ const coverage = options.coverage;
662
+ if (!coverage?.start || !coverage?.end) {
663
+ if (loaded.workspace?.assuranceGoal !== "soc-2-type-2") return [];
664
+ return [periodFinding(
665
+ "period.coverage.select",
666
+ "Select the candidate operating period",
667
+ "Set the candidate Type 2 start and end dates before FileGRC can calculate continuous policy, control, source, role, and obligation coverage.",
668
+ "ready",
669
+ { type: "workspace", id: loaded.workspace.id },
670
+ [{ kind: "command", command: "npx filegrc get workspace workspace --mutation" }]
671
+ )];
672
+ }
673
+
674
+ const findings = [];
675
+ const recordById = new Map(loaded.resources.map((record) => [record.id, record]));
676
+ const relevantEntries = loaded.entries.filter(({ record }) => [
677
+ "appointment",
678
+ "control",
679
+ "evidence",
680
+ "obligation",
681
+ "policy",
682
+ "source-coverage",
683
+ "system"
684
+ ].includes(record.type));
685
+ const paths = relevantEntries.map(({ relativePath }) => `data/${relativePath}`);
686
+ const histories = getWorkspaceHistories(loaded.root, paths, 50);
687
+ const allHistory = [...histories.values()].flat().filter(Boolean);
688
+ const earliestCommitDate = allHistory
689
+ .map(({ timestamp }) => timestamp?.slice(0, 10))
690
+ .filter(Boolean)
691
+ .sort()[0];
692
+ if (!earliestCommitDate || earliestCommitDate > coverage.start) {
693
+ findings.push(periodFinding(
694
+ "period.git-history.span",
695
+ "Confirm history before the period start",
696
+ earliestCommitDate
697
+ ? `The available FileGRC history starts on ${earliestCommitDate}, after the proposed period start ${coverage.start}. Confirm that authoritative external history covers the earlier interval or change the period.`
698
+ : "No committed FileGRC history covers the proposed period. Commit current facts and identify authoritative external history before relying on this period.",
699
+ "ready",
700
+ { type: "workspace", id: loaded.workspace.id },
701
+ [{ kind: "command", command: "git log --reverse -- data" }]
702
+ ));
703
+ }
704
+
705
+ const coreKinds = assessRequiredAppointments(loaded.resources, loaded.model)
706
+ .filter(({ requiredness }) => ["required", "core"].includes(requiredness))
707
+ .map(({ kind }) => kind);
708
+ for (const kind of coreKinds) {
709
+ const covering = loaded.resources.some((record) => (
710
+ record.type === "appointment"
711
+ && record.appointmentKind === kind
712
+ && record.status === "active"
713
+ && record.startsOn
714
+ && record.startsOn <= coverage.start
715
+ && (!record.endsOn || record.endsOn >= coverage.end)
716
+ ));
717
+ if (!covering) {
718
+ findings.push(periodFinding(
719
+ `period.appointment.${kind}.gap`,
720
+ `${fieldLabel(kind)} period coverage`,
721
+ `No active dated ${fieldLabel(kind)} Appointment covers ${coverage.start} through ${coverage.end}.`,
722
+ "ready",
723
+ { type: "appointment" },
724
+ [{ kind: "command", command: `npx filegrc list appointment --workflow --json` }]
725
+ ));
726
+ }
727
+ }
728
+
729
+ const selectedControlIds = new Set(options.controlIds || []);
730
+ const selectedControlCodes = new Set(loaded.resources
731
+ .filter((record) => record.type === "control" && selectedControlIds.has(record.id))
732
+ .map(({ code }) => code)
733
+ .filter(Boolean));
734
+ const selectedSourceFamilies = new Set((loaded.model.evidenceSourceFamilies || [])
735
+ .filter((family) => family.controlCodes.some((code) => selectedControlCodes.has(code)))
736
+ .map(({ id }) => id));
737
+ for (const record of loaded.resources) {
738
+ if (record.type === "policy" && ["required", "alternative"].includes(record.programRole)) {
739
+ if (record.status !== "active" || !record.effectiveOn || record.effectiveOn > coverage.start) {
740
+ findings.push(periodGap(record, "policy", record.effectiveOn, coverage));
741
+ }
742
+ }
743
+ if (
744
+ record.type === "control"
745
+ && selectedControlIds.has(record.id)
746
+ && !["not-applicable", "retired"].includes(record.status)
747
+ ) {
748
+ const startsOn = record.procedureEffectiveOn || record.effectiveOn;
749
+ if (record.status !== "implemented" || !startsOn || startsOn > coverage.start || (
750
+ record.retiredOn && record.retiredOn < coverage.end
751
+ )) {
752
+ findings.push(periodGap(record, "control procedure", startsOn, coverage));
753
+ }
754
+ }
755
+ if (
756
+ record.type === "source-coverage"
757
+ && selectedSourceFamilies.has(record.sourceFamilyId)
758
+ && record.status !== "retired"
759
+ ) {
760
+ if (
761
+ record.status !== "active"
762
+ || !record.validFrom
763
+ || record.validFrom > coverage.start
764
+ || (record.validThrough && record.validThrough < coverage.end)
765
+ ) {
766
+ findings.push(periodGap(record, "evidence source", record.validFrom, coverage));
767
+ }
768
+ }
769
+ }
770
+
771
+ const periodThrough = [coverage.end, options.asOf].sort()[0];
772
+ const periodResources = loaded.resources.filter((record) => (
773
+ record.type !== "obligation"
774
+ || !(record.controlIds || []).length
775
+ || record.controlIds.some((id) => selectedControlIds.has(id))
776
+ ));
777
+ const occurrences = planObligations(periodResources, {
778
+ from: coverage.start,
779
+ asOf: periodThrough,
780
+ through: periodThrough,
781
+ now: options.evaluatedAt,
782
+ includeComplete: true,
783
+ model: loaded.model
784
+ });
785
+ for (const item of occurrences.items.filter((item) => ["overdue", "blocked", "due", "proposed"].includes(item.status))) {
786
+ findings.push(periodFinding(
787
+ `period.obligation.${item.key || stableSuffix(JSON.stringify(item))}`,
788
+ item.title,
789
+ item.status === "proposed"
790
+ ? "This occurrence is still a proposal because its governing policy, control, owner, or completion profile is incomplete."
791
+ : item.status === "blocked"
792
+ ? item.blockingReason || "This work is blocked by an unresolved source record."
793
+ : item.status === "due"
794
+ ? `This occurrence is open within its allowed window and must be completed by ${item.dueWindowEnd}.`
795
+ : `The expected occurrence for ${item.dueWindowStart} through ${item.dueWindowEnd} has no accepted completion.`,
796
+ item.status === "overdue"
797
+ ? "overdue"
798
+ : item.status === "blocked"
799
+ ? "blocked"
800
+ : item.status === "due" ? "scheduled" : "ready",
801
+ item.actionItemId
802
+ ? { type: "action-item", id: item.actionItemId }
803
+ : { type: "obligation", id: item.obligationId },
804
+ [obligationNextAction(item)],
805
+ item.status === "blocked"
806
+ ? blockingDependencies(item.blockingResourceIds, recordById)
807
+ : []
808
+ ));
809
+ }
810
+
811
+ for (const entry of relevantEntries) {
812
+ const record = entry.record;
813
+ const history = histories.get(`data/${entry.relativePath}`) || [];
814
+ const changesInsidePeriod = history.filter(({ timestamp }) => (
815
+ timestamp?.slice(0, 10) > coverage.start
816
+ && timestamp?.slice(0, 10) <= coverage.end
817
+ ));
818
+ if (changesInsidePeriod.length > 1 && ["appointment", "control", "policy", "source-coverage", "system"].includes(record.type)) {
819
+ findings.push(periodFinding(
820
+ `period.change.${record.type}.${record.id}`,
821
+ `Review period impact for ${record.title}`,
822
+ `${changesInsidePeriod.length} committed revisions fall inside the period. Confirm the effective date, review, source continuity, and any needed Policy Event or Exception.`,
823
+ "ready",
824
+ { type: record.type, id: record.id },
825
+ [mutationAction(record)]
826
+ ));
827
+ }
828
+ if (record.type === "evidence") {
829
+ const businessDate = (record.businessEventAt || record.sourceGeneratedAt || record.generatedAt || record.collectedOn)?.slice(0, 10);
830
+ const firstCommit = history.map(({ timestamp }) => timestamp?.slice(0, 10)).filter(Boolean).sort()[0];
831
+ if (businessDate && firstCommit && calendarDaysBetween(businessDate, firstCommit) > 7) {
832
+ findings.push(periodFinding(
833
+ `period.contemporaneity.evidence.${record.id}`,
834
+ `Explain late entry for ${record.title}`,
835
+ `The recorded source date is ${businessDate}, but the first available Git entry is ${firstCommit}. Preserve the reason for the delay and verify the original source.`,
836
+ "ready",
837
+ { type: "evidence", id: record.id },
838
+ [mutationAction(record)]
839
+ ));
840
+ }
841
+ }
842
+ }
843
+ return findings;
844
+ }
845
+
846
+ function auditLifecycleFindings(loaded, audits) {
847
+ if (String(loaded.model.modelVersion) !== "3") return [];
848
+ const findings = [];
849
+ for (const audit of audits) {
850
+ if (!(audit.controlIds || []).length) {
851
+ findings.push(auditLifecycleFinding(
852
+ audit,
853
+ "control-scope",
854
+ "audit-readiness",
855
+ "Select the audit control scope",
856
+ "An audit with no selected controls cannot produce a meaningful evidence or occurrence assessment."
857
+ ));
858
+ }
859
+ if (!audit.scopeRevision) {
860
+ const workspaceControls = new Set(loaded.workspace?.controlIds || []);
861
+ const omittedControls = [...workspaceControls].filter((id) => !(audit.controlIds || []).includes(id));
862
+ findings.push(auditLifecycleFinding(
863
+ audit,
864
+ "scope-revision",
865
+ "audit-readiness",
866
+ "Review the engagement scope diff",
867
+ `Confirm the carried-forward services, systems, categories, requirements, controls, commitments, subservices, and signatories. ${omittedControls.length} workspace controls are outside the current audit selection.`
868
+ ));
869
+ }
870
+ if (audit.auditKind === "soc-2-type-2") {
871
+ const start = coverageStart(audit.coverage);
872
+ const end = coverageEnd(audit.coverage);
873
+ if (!start || !end) {
874
+ findings.push(auditLifecycleFinding(
875
+ audit,
876
+ "formal-period",
877
+ "audit-readiness",
878
+ "Record the firm-agreed Type 2 period",
879
+ "Set the exact start and end dates before evaluating expected occurrences and source continuity."
880
+ ));
881
+ }
882
+ const candidateStart = coverageStart(loaded.workspace?.candidateCoverage);
883
+ if (start && candidateStart && start < candidateStart) {
884
+ findings.push(auditLifecycleFinding(
885
+ audit,
886
+ "period-feasibility",
887
+ "audit-readiness",
888
+ "Resolve the retrospective period gap",
889
+ `The formal period starts on ${start}, before the candidate-ready date ${candidateStart}. Identify authoritative earlier history or change the period.`
890
+ ));
891
+ }
892
+ }
893
+ if (!["planning", "draft"].includes(audit.status)) {
894
+ if (!audit.engagementTermsDocumentId) {
895
+ findings.push(auditLifecycleFinding(
896
+ audit,
897
+ "engagement-terms",
898
+ "audit-readiness",
899
+ "Link the engagement terms",
900
+ "Record the CPA firm's engagement terms without representing its professional judgments as management facts."
901
+ ));
902
+ }
903
+ if (!(audit.managementAcknowledgedByIds || []).length || !audit.managementAcknowledgedOn) {
904
+ findings.push(auditLifecycleFinding(
905
+ audit,
906
+ "management-acknowledgement",
907
+ "audit-readiness",
908
+ "Record management acknowledgement",
909
+ "Name who acknowledged the engagement terms and the actual acknowledgement date."
910
+ ));
911
+ }
912
+ }
913
+ const lateStage = ["report-draft", "issued", "delivered", "complete"].includes(audit.status);
914
+ if (lateStage && !audit.subsequentEventsReview) {
915
+ findings.push(auditLifecycleFinding(
916
+ audit,
917
+ "subsequent-events",
918
+ "audit-readiness",
919
+ "Complete the subsequent-events review",
920
+ "Review incidents, changes, findings, subservice coverage, representations, and system-description disclosures through the report date."
921
+ ));
922
+ }
923
+ if (["fieldwork", "report-draft", "issued", "delivered", "complete"].includes(audit.status) && !audit.packetDelivery) {
924
+ findings.push(auditLifecycleFinding(
925
+ audit,
926
+ "packet-delivery",
927
+ "delivery-readiness",
928
+ "Approve and record packet delivery",
929
+ "Record the least-disclosure review, redaction decision, recipient, delivery system, exact packet revision and manifest, management approval, delivery date, and receipt."
930
+ ));
931
+ }
932
+ if (audit.status !== "complete") {
933
+ const nextStep = auditClosureNextStep(audit.status);
934
+ findings.push(auditLifecycleFinding(
935
+ audit,
936
+ "advance",
937
+ "audit-closure",
938
+ nextStep.title,
939
+ nextStep.message
940
+ ));
941
+ }
942
+ if (audit.status !== "complete") continue;
943
+ const openRequests = loaded.resources.filter((record) => (
944
+ record.type === "audit-request"
945
+ && (record.auditId === audit.id || record.auditIds?.includes(audit.id))
946
+ && !["complete", "closed", "accepted", "canceled"].includes(record.status)
947
+ ));
948
+ const openFindings = loaded.resources.filter((record) => (
949
+ record.type === "finding"
950
+ && (record.auditId === audit.id || record.auditIds?.includes(audit.id))
951
+ && !["closed", "accepted", "remediated"].includes(record.status)
952
+ ));
953
+ for (const [suffix, records, noun] of [
954
+ ["requests", openRequests, "Audit Requests"],
955
+ ["findings", openFindings, "Findings"]
956
+ ]) {
957
+ if (!records.length) continue;
958
+ findings.push(auditLifecycleFinding(
959
+ audit,
960
+ suffix,
961
+ "audit-closure",
962
+ `Resolve or accept open ${noun}`,
963
+ `${records.length} ${noun} remain open, so this engagement cannot be treated as closed.`
964
+ ));
965
+ }
966
+ for (const [field, title, message] of [
967
+ ["reportEvidenceId", "Link the issued report", "Link the exact issued report evidence and its coverage."],
968
+ ["retentionDecision", "Record the retention decision", "Record the approved retention and authorized distribution decision."],
969
+ ["carryForwardActionIds", "Review carry-forward work", "Record the next-period actions, including an explicit empty list when no carry-forward work remains."],
970
+ ["signatoryAppointmentIds", "Confirm authorized signatories", "Link active authority Appointments for management assertion and representation signers."]
971
+ ]) {
972
+ if (field === "carryForwardActionIds" ? Array.isArray(audit[field]) : present(audit[field])) continue;
973
+ findings.push(auditLifecycleFinding(audit, field, "audit-closure", title, message));
974
+ }
975
+ }
976
+ return findings;
977
+ }
978
+
979
+ function normalizeFinding(code, item, context) {
980
+ const state = findingState(item.status);
981
+ const subject = item.resourceType
982
+ ? { type: item.resourceType, ...(item.resourceId ? { id: item.resourceId } : {}) }
983
+ : null;
984
+ const dependencies = (item.unresolvedAssignments || []).map((assignment) => ({
985
+ type: assignment.resourceType,
986
+ id: assignment.resourceId,
987
+ reasons: assignment.reasons || []
988
+ }));
989
+ return {
990
+ key: code,
991
+ code,
992
+ assessment: context.assessment,
993
+ stage: context.stage,
994
+ ...(context.auditId ? { auditId: context.auditId } : {}),
995
+ state,
996
+ severity: findingSeverity(state, context.assessment),
997
+ title: item.title,
998
+ message: item.message,
999
+ ...(subject ? { subject } : {}),
1000
+ dependencies,
1001
+ actions: (item.commands || []).map((command) => ({
1002
+ kind: "command",
1003
+ command
1004
+ }))
1005
+ };
1006
+ }
1007
+
1008
+ function mutationAction(record) {
1009
+ return {
1010
+ kind: "command",
1011
+ command: `npx filegrc get ${shellArgument(record.id)} --mutation`
1012
+ };
1013
+ }
1014
+
1015
+ function findingState(status) {
1016
+ if (status === "action") return "ready";
1017
+ if (status === "later") return "scheduled";
1018
+ if (status === "external") return "waiting-external";
1019
+ if (status === "info") return "not-applicable";
1020
+ return status || "ready";
1021
+ }
1022
+
1023
+ function findingSeverity(state, assessment) {
1024
+ if (state === "overdue") return "error";
1025
+ if (["ready", "blocked"].includes(state)) {
1026
+ return ["period-health", "audit-readiness", "delivery-readiness"].includes(assessment)
1027
+ ? "error"
1028
+ : "warning";
1029
+ }
1030
+ return "info";
1031
+ }
1032
+
1033
+ function buildWorkItems(records, obligationPlan, options) {
1034
+ const byId = new Map(records.map((record) => [record.id, record]));
1035
+ const items = obligationPlan.items.map((item) => obligationWorkItem(item, byId, options.asOf));
1036
+ const obligationSources = new Set(items
1037
+ .map((item) => item.source?.id)
1038
+ .filter(Boolean));
1039
+ for (const record of records) {
1040
+ if (record.type === "obligation" || obligationSources.has(record.id)) continue;
1041
+ const due = firstDate(record);
1042
+ if (!due) continue;
1043
+ const state = sourceWorkState(record, due, options.asOf);
1044
+ if (state === "complete" && !options.includeComplete) continue;
1045
+ items.push({
1046
+ key: `source:${record.type}:${record.id}:${due}`,
1047
+ kind: "source-deadline",
1048
+ source: { type: record.type, id: record.id },
1049
+ subject: { type: record.type, id: record.id },
1050
+ title: record.title,
1051
+ ownerIds: firstOwners(record),
1052
+ dueOn: due,
1053
+ state,
1054
+ priority: workPriority(state, due, options.asOf),
1055
+ scope: workScope(record),
1056
+ blockingReason: record.status === "blocked"
1057
+ ? actionBlockingReason(record, byId)
1058
+ : null,
1059
+ dependencies: record.status === "blocked"
1060
+ ? blockingDependencies(record.blockingResourceIds, byId)
1061
+ : [],
1062
+ requiredCompletionProfile: sourceCompletionProfile(record),
1063
+ nextAction: sourceNextAction(record, due, options.asOf, records)
1064
+ });
1065
+ }
1066
+ return uniqueByKey(items);
1067
+ }
1068
+
1069
+ function obligationWorkItem(item, byId, asOf) {
1070
+ const subjectId = item.subjectResourceId
1071
+ || (item.scopeResourceIds?.length === 1 ? item.scopeResourceIds[0] : null);
1072
+ const subjectRecord = subjectId ? byId.get(subjectId) : null;
1073
+ const due = item.dueWindowEndAt || item.dueWindowEnd;
1074
+ return {
1075
+ key: `obligation:${item.key || item.actionItemId || item.obligationId}${subjectId ? `:${subjectId}` : ""}`,
1076
+ kind: item.kind === "calendar" ? "obligation-occurrence" : "assigned-work",
1077
+ source: {
1078
+ type: item.actionItemId ? "action-item" : "obligation",
1079
+ id: item.actionItemId || item.obligationId
1080
+ },
1081
+ ...(subjectId ? { subject: { type: subjectRecord?.type || "unknown", id: subjectId } } : {}),
1082
+ title: item.title,
1083
+ ownerIds: item.ownerIds || item.assigneeIds || [],
1084
+ ...(item.dueWindowStart ? { availableOn: item.dueWindowStart } : {}),
1085
+ ...(due ? { dueOn: due } : {}),
1086
+ state: item.status,
1087
+ priority: workPriority(item.status, due?.slice(0, 10), asOf),
1088
+ scope: item.scopeResourceIds || (subjectId ? [subjectId] : []),
1089
+ blockingReason: item.blockingReason || item.reason || null,
1090
+ dependencies: blockingDependencies(item.blockingResourceIds, byId),
1091
+ requiredCompletionProfile: item.completionProfile || item.activityType || null,
1092
+ completionProfile: {
1093
+ activityType: item.activityType || null,
1094
+ profileId: item.completionProfile || null,
1095
+ resourceTypes: item.completionResourceTypes || []
1096
+ },
1097
+ nextAction: obligationNextAction(item)
1098
+ };
1099
+ }
1100
+
1101
+ function obligationNextAction(item) {
1102
+ if (item.actionItemId) {
1103
+ if (item.status === "blocked") {
1104
+ return {
1105
+ kind: "command",
1106
+ command: `npx filegrc get ${shellArgument(item.actionItemId)} --mutation`
1107
+ };
1108
+ }
1109
+ return {
1110
+ kind: "command",
1111
+ command: `npx filegrc complete-action ${shellArgument(item.actionItemId)} --scaffold --completed-on YYYY-MM-DD`
1112
+ };
1113
+ }
1114
+ return {
1115
+ kind: "command",
1116
+ command: `npx filegrc complete ${shellArgument(item.obligationId)} --scaffold --window-start ${shellArgument(item.dueWindowStart)} --completed-on YYYY-MM-DD`
1117
+ };
1118
+ }
1119
+
1120
+ function actionBlockingReason(record, byId) {
1121
+ if (record.status !== "blocked") return null;
1122
+ const blockers = (record.blockingResourceIds || [])
1123
+ .map((id) => byId.get(id)?.title || id)
1124
+ .filter(Boolean);
1125
+ return blockers.length
1126
+ ? `Blocked by ${blockers.join(", ")}.`
1127
+ : "The source record is blocked.";
1128
+ }
1129
+
1130
+ function blockingDependencies(ids = [], byId) {
1131
+ return ids.map((id) => {
1132
+ const record = byId.get(id);
1133
+ return {
1134
+ type: record?.type || "unknown",
1135
+ id,
1136
+ reasons: ["Resolve this prerequisite before continuing the blocked work."]
1137
+ };
1138
+ });
1139
+ }
1140
+
1141
+ function buildAssessments({ program, audits, auditPreparations, obligationPlan, findings, validation, coverage }) {
1142
+ const scopeStage = program.stages.find((stage) => stage.id === "scope");
1143
+ const configurationReady = stageComplete(scopeStage);
1144
+ const evidenceReady = program.evidenceReady;
1145
+ const periodFindings = findings.filter((finding) => finding.assessment === "period-health");
1146
+ const periodStarted = evidenceReady && Boolean(coverage?.start && coverage?.end);
1147
+ const periodHealthy = periodStarted
1148
+ && obligationPlan.counts.overdue === 0
1149
+ && obligationPlan.counts.blocked === 0
1150
+ && !periodFindings.some(blockingFinding);
1151
+ const auditValues = Object.values(auditPreparations);
1152
+ const auditReady = audits.length > 0
1153
+ && auditValues.every((preparation) => preparation.status === "management-ready");
1154
+ const deliveryReady = auditReady && audits.every((audit) => (
1155
+ ["fieldwork", "report-draft", "issued", "delivered", "complete"].includes(audit.status)
1156
+ )) && !findings.some((finding) => finding.assessment === "delivery-readiness" && blockingFinding(finding));
1157
+ const auditClosed = audits.length > 0
1158
+ && audits.every((audit) => audit.status === "complete")
1159
+ && !findings.some((finding) => finding.assessment === "audit-closure" && blockingFinding(finding));
1160
+ const deliveryFindingKeys = findingKeys(findings, "delivery-readiness");
1161
+ return {
1162
+ structuralValidity: assessment(
1163
+ validation.ok ? "complete" : "needs-work",
1164
+ validation.ok ? "Workspace records pass structural validation." : "Workspace structure or relationships need work.",
1165
+ findingKeys(findings, "structural-validity")
1166
+ ),
1167
+ programConfiguration: assessment(
1168
+ configurationReady ? "complete" : "needs-work",
1169
+ configurationReady ? "Program scope and ownership are configured." : "Program scope or ownership still needs work.",
1170
+ findingKeys(findings, "program-configuration", ({ key, stage }) => (
1171
+ key.startsWith("program.") && stage === "scope"
1172
+ ))
1173
+ ),
1174
+ evidenceReadiness: assessment(
1175
+ evidenceReady ? "complete" : "needs-work",
1176
+ evidenceReady ? "Evidence collection can begin." : "Evidence collection prerequisites remain.",
1177
+ findingKeys(findings, "program-configuration", ({ key }) => key.startsWith("program."))
1178
+ ),
1179
+ periodHealth: assessment(
1180
+ !periodStarted ? "not-started" : periodHealthy ? "complete" : "at-risk",
1181
+ !evidenceReady
1182
+ ? "Period health starts after the Evidence Ready gate."
1183
+ : !coverage?.start || !coverage?.end
1184
+ ? "Select the candidate or formal period before checking period health."
1185
+ : periodHealthy
1186
+ ? "No current period-health blockers were found."
1187
+ : "The candidate or operating period has blockers.",
1188
+ periodStarted ? findingKeys(findings, "period-health") : []
1189
+ ),
1190
+ auditReadiness: assessment(
1191
+ auditReady ? "complete" : audits.length ? "needs-work" : "not-started",
1192
+ auditReady ? "Management audit preparation is complete." : audits.length ? "Audit preparation remains." : "No audit engagement is selected.",
1193
+ findingKeys(findings, "audit-readiness")
1194
+ ),
1195
+ deliveryReadiness: assessment(
1196
+ deliveryReady
1197
+ ? "complete"
1198
+ : auditReady || deliveryFindingKeys.length ? "needs-work" : "not-started",
1199
+ deliveryReady
1200
+ ? "The engagement is ready for packet delivery review."
1201
+ : auditReady || deliveryFindingKeys.length
1202
+ ? "Complete the delivery checks before sending the packet."
1203
+ : "Packet delivery starts after management audit preparation is complete.",
1204
+ auditReady || deliveryFindingKeys.length ? deliveryFindingKeys : []
1205
+ ),
1206
+ auditClosure: assessment(
1207
+ auditClosed ? "complete" : audits.length ? "needs-work" : "not-started",
1208
+ auditClosed ? "All selected audits are closed." : "Audit closure remains.",
1209
+ findingKeys(findings, "audit-closure")
1210
+ )
1211
+ };
1212
+ }
1213
+
1214
+ function assessment(status, message, findingKeysValue) {
1215
+ return { status, message, findingKeys: findingKeysValue };
1216
+ }
1217
+
1218
+ function stageComplete(stage) {
1219
+ return Boolean(stage) && !stage.items.some((item) => item.status === "action");
1220
+ }
1221
+
1222
+ function findingKeys(findings, assessmentName, predicate = () => true) {
1223
+ return findings
1224
+ .filter((finding) => (
1225
+ finding.assessment === assessmentName
1226
+ && blockingFinding(finding)
1227
+ && predicate(finding)
1228
+ ))
1229
+ .map((finding) => finding.key);
1230
+ }
1231
+
1232
+ function blockingFinding(finding) {
1233
+ return ["blocked", "overdue", "ready"].includes(finding.state);
1234
+ }
1235
+
1236
+ function recommendedAction(findings, workItems, program) {
1237
+ const firstProgramAction = program?.firstAction;
1238
+ if (firstProgramAction) {
1239
+ const preferred = findings.find((finding) => (
1240
+ finding.key.startsWith("program.")
1241
+ && finding.key.endsWith(`.${firstProgramAction.id}`)
1242
+ && blockingFinding(finding)
1243
+ ));
1244
+ if (preferred) return preferred;
1245
+ }
1246
+ return [...findings.filter(blockingFinding), ...workItems.filter(activeWork)]
1247
+ .sort(compareRecommended)[0] || null;
1248
+ }
1249
+
1250
+ function compareRecommended(left, right) {
1251
+ const leftPriority = left.priority ?? findingPriority(left);
1252
+ const rightPriority = right.priority ?? findingPriority(right);
1253
+ return leftPriority - rightPriority || left.key.localeCompare(right.key);
1254
+ }
1255
+
1256
+ function compareFindings(left, right) {
1257
+ return compareRecommended(left, right);
1258
+ }
1259
+
1260
+ function compareWorkItems(left, right) {
1261
+ return compareRecommended(left, right);
1262
+ }
1263
+
1264
+ function findingPriority(finding) {
1265
+ if (finding.state === "overdue") return 0;
1266
+ if (finding.state === "ready") return finding.severity === "error" ? 10 : 20;
1267
+ if (finding.state === "blocked") return 30;
1268
+ if (finding.severity === "error") return 10;
1269
+ if (finding.state === "waiting-external") return 40;
1270
+ if (finding.state === "scheduled") return 50;
1271
+ return 90;
1272
+ }
1273
+
1274
+ function workPriority(state, dueOn, asOf) {
1275
+ if (state === "overdue") return 0;
1276
+ if (dueOn && asOf && dueOn < asOf) return 5;
1277
+ if (state === "due") return 10;
1278
+ if (state === "ready" || state === "open") return 20;
1279
+ if (state === "blocked") return 30;
1280
+ if (state === "upcoming" || state === "scheduled") return 50;
1281
+ return 90;
1282
+ }
1283
+
1284
+ function activeWork(item) {
1285
+ return !["canceled", "complete", "not-applicable", "superseded"].includes(item.state);
1286
+ }
1287
+
1288
+ function firstDate(record) {
1289
+ if (record.completionWindow?.dueAt) return record.completionWindow.dueAt;
1290
+ if (record.completionWindow?.dueOn) return record.completionWindow.dueOn;
1291
+ for (const field of DATE_FIELDS) {
1292
+ if (typeof record[field] === "string" && record[field]) return record[field];
1293
+ }
1294
+ return null;
1295
+ }
1296
+
1297
+ function workScope(record) {
1298
+ for (const field of [
1299
+ "scopeResourceIds",
1300
+ "systemIds",
1301
+ "controlIds",
1302
+ "vendorIds",
1303
+ "requirementIds"
1304
+ ]) {
1305
+ if (Array.isArray(record[field]) && record[field].length) return record[field];
1306
+ }
1307
+ return [];
1308
+ }
1309
+
1310
+ function sourceCompletionProfile(record) {
1311
+ if (record.type === "action-item") return record.obligationId ? "assigned-obligation-work" : "assigned-action";
1312
+ if (record.type === "audit-request") return "audit-request-response";
1313
+ if (record.type === "risk") return "risk-treatment-and-review";
1314
+ if (record.type === "vulnerability") return "vulnerability-remediation";
1315
+ if (record.type === "finding") return "finding-remediation-and-verification";
1316
+ if (record.type === "exception") return "exception-expiry-and-compensating-control-review";
1317
+ if (record.type === "service-account") return "service-account-review-or-disablement";
1318
+ if (record.type === "vendor") return "vendor-assurance-renewal-or-termination";
1319
+ if (record.type === "access-grant") return "access-renewal-or-revocation";
1320
+ if (record.type === "audit") return "audit-lifecycle-transition";
1321
+ return `${record.type}-lifecycle`;
1322
+ }
1323
+
1324
+ function sourceNextAction(record, due, asOf, records) {
1325
+ const eventType = due <= asOf ? {
1326
+ exception: "exception-expired",
1327
+ "service-account": "service-account-expired",
1328
+ vulnerability: "vulnerability-overdue"
1329
+ }[record.type] : null;
1330
+ const alreadyRecorded = eventType && records.some((item) => (
1331
+ item.type === "obligation-event"
1332
+ && item.eventType === eventType
1333
+ && (item.subjectResourceIds || []).includes(record.id)
1334
+ && item.occurredOn >= due.slice(0, 10)
1335
+ ));
1336
+ if (eventType && !alreadyRecorded) {
1337
+ return {
1338
+ kind: "command",
1339
+ command: `npx filegrc trigger ${eventType} --occurred-on YYYY-MM-DD --subject ${shellArgument(record.id)} --json`
1340
+ };
1341
+ }
1342
+ return {
1343
+ kind: "command",
1344
+ command: `npx filegrc get ${shellArgument(record.id)} --mutation`
1345
+ };
1346
+ }
1347
+
1348
+ function firstOwners(record) {
1349
+ for (const field of OWNER_FIELDS) {
1350
+ if (Array.isArray(record[field]) && record[field].length) return record[field];
1351
+ }
1352
+ return [];
1353
+ }
1354
+
1355
+ function sourceWorkState(record, due, asOf) {
1356
+ if (TERMINAL_STATUSES.has(record.status)) return "complete";
1357
+ if (["canceled", "superseded"].includes(record.status)) return record.status;
1358
+ if (["draft", "in-review", "partially-implemented", "planned", "proposed"].includes(record.status)) {
1359
+ return "proposed";
1360
+ }
1361
+ if (due < asOf) return "overdue";
1362
+ if (due === asOf) return "due";
1363
+ return "scheduled";
1364
+ }
1365
+
1366
+ function candidateCoverage(workspace) {
1367
+ const coverage = workspace?.candidateCoverage;
1368
+ if (!coverage) return null;
1369
+ return {
1370
+ kind: coverage.kind,
1371
+ start: coverageStart(coverage),
1372
+ end: coverageEnd(coverage)
1373
+ };
1374
+ }
1375
+
1376
+ function normalizeCoverage(coverage) {
1377
+ if (!coverage) return null;
1378
+ const start = coverage.start || coverage.startsOn || coverage.on || null;
1379
+ const end = coverage.end || coverage.endsOn || coverage.on || null;
1380
+ return {
1381
+ kind: coverage.kind || (start === end ? "as-of" : "range"),
1382
+ start,
1383
+ end
1384
+ };
1385
+ }
1386
+
1387
+ function periodFinding(key, title, message, state, subject, actions, dependencies = []) {
1388
+ return {
1389
+ key,
1390
+ code: key,
1391
+ assessment: "period-health",
1392
+ stage: "operation",
1393
+ state,
1394
+ severity: findingSeverity(state, "period-health"),
1395
+ requiredness: "required",
1396
+ title,
1397
+ message,
1398
+ subject,
1399
+ dependencies,
1400
+ actions
1401
+ };
1402
+ }
1403
+
1404
+ function auditClosureNextStep(status) {
1405
+ const steps = {
1406
+ planning: {
1407
+ title: "Confirm the engagement and start audit preparation",
1408
+ message: "Link the agreed engagement terms, confirm scope and management acknowledgement, then move the audit to in progress."
1409
+ },
1410
+ draft: {
1411
+ title: "Finalize the draft engagement",
1412
+ message: "Resolve the draft scope and ownership, link the agreed engagement terms, then move the audit to in progress."
1413
+ },
1414
+ "in-progress": {
1415
+ title: "Begin fieldwork",
1416
+ message: "Finish management preparation, confirm the fieldwork dates, and move the audit to fieldwork when the CPA firm begins testing."
1417
+ },
1418
+ fieldwork: {
1419
+ title: "Finish fieldwork and prepare the report",
1420
+ message: "Resolve management audit requests, record the approved packet delivery, and move the audit to report draft when fieldwork is complete."
1421
+ },
1422
+ "report-draft": {
1423
+ title: "Complete the report-date review",
1424
+ message: "Complete the subsequent-events review and final management representations, then wait for the CPA firm to issue its report."
1425
+ },
1426
+ issued: {
1427
+ title: "Record final report delivery",
1428
+ message: "Confirm the issued report evidence, opinion, report date, authorized recipient, and delivery receipt, then move the audit to delivered."
1429
+ },
1430
+ delivered: {
1431
+ title: "Close the audit",
1432
+ message: "Resolve or accept open requests and findings, record retention and carry-forward decisions, then move the audit to complete."
1433
+ }
1434
+ };
1435
+ return steps[status] || {
1436
+ title: "Advance the audit",
1437
+ message: "Review the audit record, complete the current lifecycle requirements, and record the next status."
1438
+ };
1439
+ }
1440
+
1441
+ function periodGap(record, noun, startsOn, coverage) {
1442
+ const timing = startsOn
1443
+ ? `Its recorded start is ${startsOn}, after the period begins on ${coverage.start}, or it does not remain effective through ${coverage.end}.`
1444
+ : `It has no effective start that covers ${coverage.start} through ${coverage.end}.`;
1445
+ return periodFinding(
1446
+ `period.coverage.${record.type}.${record.id}`,
1447
+ `${record.title}: continuous ${noun} coverage`,
1448
+ timing,
1449
+ "ready",
1450
+ { type: record.type, id: record.id },
1451
+ [mutationAction(record)]
1452
+ );
1453
+ }
1454
+
1455
+ function auditLifecycleFinding(audit, suffix, assessmentName, title, message) {
1456
+ const key = `audit.${audit.id}.lifecycle.${suffix}`;
1457
+ return {
1458
+ key,
1459
+ code: key,
1460
+ assessment: assessmentName,
1461
+ stage: assessmentName === "audit-closure" ? "auditor" : "deliver",
1462
+ auditId: audit.id,
1463
+ state: "ready",
1464
+ severity: "warning",
1465
+ requiredness: "required",
1466
+ title,
1467
+ message,
1468
+ subject: { type: "audit", id: audit.id },
1469
+ dependencies: [],
1470
+ actions: [mutationAction(audit)]
1471
+ };
1472
+ }
1473
+
1474
+ function calendarDaysBetween(start, end) {
1475
+ return Math.floor((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86_400_000);
1476
+ }
1477
+
1478
+ function safeGitSummary(root) {
1479
+ try {
1480
+ return getGitSummary(root);
1481
+ } catch {
1482
+ return {};
1483
+ }
1484
+ }
1485
+
1486
+ function countBy(items, field) {
1487
+ return items.reduce((counts, item) => {
1488
+ const key = item[field] || "unknown";
1489
+ counts[key] = (counts[key] || 0) + 1;
1490
+ return counts;
1491
+ }, {});
1492
+ }
1493
+
1494
+ function keyed(items = []) {
1495
+ return new Map(items.map((item) => [item.key, item]));
1496
+ }
1497
+
1498
+ function changedAssessments(before, after) {
1499
+ const changes = [];
1500
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
1501
+ if (before[key]?.status === after[key]?.status) continue;
1502
+ changes.push({
1503
+ assessment: key,
1504
+ before: before[key]?.status || null,
1505
+ after: after[key]?.status || null
1506
+ });
1507
+ }
1508
+ return changes;
1509
+ }
1510
+
1511
+ function changedItems(before, after) {
1512
+ const added = [];
1513
+ const removed = [];
1514
+ const changed = [];
1515
+ for (const [key, item] of after) {
1516
+ if (!before.has(key)) added.push(item);
1517
+ else if (before.get(key).state !== item.state) {
1518
+ changed.push({ key, before: before.get(key).state, after: item.state });
1519
+ }
1520
+ }
1521
+ for (const [key, item] of before) {
1522
+ if (!after.has(key)) removed.push(item);
1523
+ }
1524
+ return { added, removed, changed };
1525
+ }
1526
+
1527
+ function uniqueByKey(items) {
1528
+ return [...new Map(items.map((item) => [item.key, item])).values()];
1529
+ }
1530
+
1531
+ function shellArgument(value) {
1532
+ const text = String(value);
1533
+ return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(text)
1534
+ ? text
1535
+ : `'${text.replaceAll("'", "'\\''")}'`;
1536
+ }
1537
+
1538
+ function recordAssessment(type) {
1539
+ if (["audit", "audit-request", "audit-population"].includes(type)) return "audit-readiness";
1540
+ if (["action-item", "obligation", "obligation-event", "control-activity"].includes(type)) return "period-health";
1541
+ return "program-configuration";
1542
+ }
1543
+
1544
+ function recordStage(type) {
1545
+ if (["person", "appointment", "team", "framework", "requirement", "commitment", "system", "vendor"].includes(type)) {
1546
+ return "scope";
1547
+ }
1548
+ if (["policy", "document", "training"].includes(type)) return "policies";
1549
+ if (["control", "complementary-control", "source-coverage"].includes(type)) return "controls";
1550
+ if (["audit", "audit-request", "audit-population"].includes(type)) return "audit";
1551
+ return "operate";
1552
+ }
1553
+
1554
+ function appointmentRequiredness(record, model) {
1555
+ return model.appointmentTemplates?.[record.appointmentKind]?.requiredness || "conditional";
1556
+ }
1557
+
1558
+ function present(value) {
1559
+ if (value === undefined || value === null || value === "") return false;
1560
+ if (Array.isArray(value)) return value.length > 0;
1561
+ if (typeof value === "object") return Object.keys(value).length > 0;
1562
+ return true;
1563
+ }
1564
+
1565
+ function fieldLabel(value) {
1566
+ return String(value)
1567
+ .replace(/[-_]+/g, " ")
1568
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
1569
+ .replace(/^./, (letter) => letter.toUpperCase());
1570
+ }
1571
+
1572
+ function pathKey(value) {
1573
+ return String(value || "workspace")
1574
+ .toLowerCase()
1575
+ .replace(/[^a-z0-9]+/g, ".")
1576
+ .replace(/^\.|\.$/g, "");
1577
+ }
1578
+
1579
+ function stableSuffix(value) {
1580
+ let hash = 2166136261;
1581
+ for (const character of String(value || "")) {
1582
+ hash ^= character.codePointAt(0);
1583
+ hash = Math.imul(hash, 16777619);
1584
+ }
1585
+ return (hash >>> 0).toString(36);
1586
+ }
1587
+
1588
+ function rankingReason(item) {
1589
+ if (item.state === "overdue") return "Ranked first because it is overdue.";
1590
+ if (item.state === "expired") return "Ranked first because it is expired.";
1591
+ if (item.state === "ready" || item.state === "due") return "Ranked before blocked work because it can be acted on now.";
1592
+ if (item.state === "blocked") return "Ranked after ready work because its named prerequisite must be resolved first.";
1593
+ if (item.severity === "error") return "Ranked before warning-level work because it blocks a named assessment.";
1594
+ return "Ranked by due date, workflow state, and stable item key.";
1595
+ }