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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.3.4",
3
+ "version": "0.5.0",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,7 +21,7 @@
21
21
  "src"
22
22
  ],
23
23
  "scripts": {
24
- "test": "node --test"
24
+ "test": "node --test --test-concurrency=1"
25
25
  },
26
26
  "engines": {
27
27
  "node": ">=20"
package/src/agent.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createResourceId } from "./id.js";
2
2
  import { markdownEntries } from "./resource-markdown.js";
3
3
  import { RESOURCE_INSTRUCTIONS, resourceProgramContext } from "./program-path.js";
4
+ import { assessCollectionReview } from "./collection-review.js";
4
5
 
5
6
  const STARTING_STATUS_ORDER = [
6
7
  "draft",
@@ -28,6 +29,7 @@ export function listResourceTypes(model) {
28
29
  export function buildAgentGuide(loaded, type, options = {}) {
29
30
  const definition = loaded.model.resources[type];
30
31
  if (!definition) throw new Error(`Unknown resource type "${type}".`);
32
+ const collectionReview = assessCollectionReview(loaded, type);
31
33
  const fields = { ...loaded.model.commonFields, ...definition.fields };
32
34
  const required = new Set([
33
35
  ...Object.entries(loaded.model.commonFields)
@@ -66,6 +68,7 @@ export function buildAgentGuide(loaded, type, options = {}) {
66
68
  const requiredAtCreation = fieldList.filter(({ required: isRequired }) => isRequired);
67
69
  const conditionalRequirements = fieldList.filter(({ requiredWhen, required: isRequired }) => requiredWhen && !isRequired);
68
70
  const optionalFields = fieldList.filter(({ required, requiredWhen }) => !required && !requiredWhen);
71
+ const recommendedMarkdown = markdown.filter(({ recommended }) => recommended);
69
72
  const location = definition.singleton
70
73
  ? `data/${definition.singleton}`
71
74
  : `data/${definition.collection}/${(definition.recordPath ?? "{id}.json").replaceAll("{id}", options.id || "{id}")}`;
@@ -80,8 +83,23 @@ export function buildAgentGuide(loaded, type, options = {}) {
80
83
  programStep: resourceProgramContext(type),
81
84
  policyBasis: definition.guidance.policyBasis,
82
85
  cadence: definition.guidance.cadence,
86
+ emptyState: definition.guidance.emptyState ?? null,
83
87
  policySourceIds: definition.guidance.sourceResourceIds ?? [],
84
88
  obligationActivityTypes: definition.guidance.obligationActivityTypes ?? [],
89
+ reviewRequirements: {
90
+ recordReviewPoints: definition.guidance.reviewPoints ?? [],
91
+ collectionReview: collectionReview
92
+ ? {
93
+ title: collectionReview.configuration.title,
94
+ description: collectionReview.configuration.description,
95
+ reviewPoints: collectionReview.configuration.reviewPoints,
96
+ allowedDecisions: collectionReview.configuration.decisions,
97
+ status: collectionReview.status,
98
+ recordCount: collectionReview.recordCount,
99
+ command: `npx filegrc review-collection ${type} --scaffold`
100
+ }
101
+ : null
102
+ },
85
103
  location,
86
104
  singleton: Boolean(definition.singleton),
87
105
  requiredAtCreation,
@@ -91,15 +109,21 @@ export function buildAgentGuide(loaded, type, options = {}) {
91
109
  markdown,
92
110
  workflow: [
93
111
  "Inspect existing records and relation candidates before writing.",
94
- "Create a scaffold, then replace every null value and empty required array with facts from an authoritative source.",
95
- "Keep stable metadata in JSON and put the work performed, inputs, results, decisions, exceptions, and follow-up in the recommended Markdown companion.",
112
+ definition.singleton
113
+ ? "Open the existing singleton record, then replace every null value and empty required array with facts from an authoritative source."
114
+ : "Create a scaffold, then replace every null value and empty required array with facts from an authoritative source.",
115
+ recommendedMarkdown.length
116
+ ? "Keep model fields in JSON and use the recommended Markdown companion for the detailed work, decisions, results, exceptions, and follow-up that apply to this record."
117
+ : "Keep the current facts and lifecycle state in JSON. Add optional Record Markdown only when the model fields cannot explain the record clearly.",
96
118
  "Run npx filegrc validate, review the full Git diff, and commit the JSON, Markdown, and attachments together with a message that explains why the record changed."
97
119
  ],
98
120
  completionChecks: [
99
- "The record describes work that actually occurred; planned work is not marked complete.",
121
+ "Required and status-dependent fields are complete, and the lifecycle status matches the facts.",
100
122
  "Every relationship resolves to the intended existing record.",
101
123
  "Dates describe the business event in the workspace time zone, not the file edit time.",
102
- "Results, exceptions, decisions, evidence, and follow-up records are linked where applicable.",
124
+ ...(recommendedMarkdown.length
125
+ ? ["Required or recommended Markdown explains the work, decisions, results, exceptions, and follow-up that apply."]
126
+ : ["The structured fields state the current fact clearly; optional Record Markdown is added only when needed."]),
103
127
  "No secrets or personal data that may need erasure were added to Git."
104
128
  ]
105
129
  };
@@ -131,15 +155,24 @@ export function scaffoldResourceMutation(loaded, type, title, options = {}) {
131
155
  ...(definition.required ?? [])
132
156
  ]);
133
157
  const record = {
134
- schemaVersion: 1,
135
158
  id,
136
159
  type,
137
160
  title: normalizedTitle
138
161
  };
139
162
  for (const name of required) {
140
163
  if (record[name] !== undefined) continue;
141
- record[name] = scaffoldValue(name, fields[name]);
164
+ record[name] = scaffoldValue(name, fields[name], loaded.model);
165
+ }
166
+ for (const [name, field] of Object.entries(fields)) {
167
+ if (
168
+ record[name] === undefined
169
+ && field.requiredWhen
170
+ && conditionMatches(record, field.requiredWhen)
171
+ ) {
172
+ record[name] = scaffoldValue(name, field, loaded.model);
173
+ }
142
174
  }
175
+ applyModelScaffoldDefaults(record, loaded);
143
176
 
144
177
  const slots = markdownEntries(loaded.model, record).filter((slot) => (
145
178
  slot.required
@@ -156,6 +189,41 @@ export function scaffoldResourceMutation(loaded, type, title, options = {}) {
156
189
  };
157
190
  }
158
191
 
192
+ function applyModelScaffoldDefaults(record, loaded) {
193
+ if (record.type === "appointment") {
194
+ const normalizedTitle = record.title.toLowerCase();
195
+ const match = Object.entries(loaded.model.appointmentTemplates || {}).find(([kind, template]) => (
196
+ template.title.toLowerCase() === normalizedTitle
197
+ || kind === normalizedTitle.replace(/[^a-z0-9]+/g, "-")
198
+ ));
199
+ record.appointmentKind = match?.[0] || normalizedTitle.replace(/[^a-z0-9]+/g, "-");
200
+ if (!record.scopeResourceIds?.length && loaded.workspace?.id) {
201
+ record.scopeResourceIds = [loaded.workspace.id];
202
+ }
203
+ return;
204
+ }
205
+ if (record.type === "audit") {
206
+ const kind = {
207
+ "soc-2-type-1": "soc-2-type-1",
208
+ "soc-2-type-2": "soc-2-type-2"
209
+ }[loaded.workspace?.assuranceGoal];
210
+ if (kind) record.auditKind = kind;
211
+ for (const field of ["frameworkIds", "systemIds", "requirementIds", "controlIds"]) {
212
+ if (loaded.workspace?.[field]?.length) record[field] = [...loaded.workspace[field]];
213
+ }
214
+ const programOwner = loaded.resources.find((candidate) => (
215
+ candidate.type === "appointment"
216
+ && candidate.appointmentKind === "program-lead"
217
+ && candidate.status === "active"
218
+ )) || loaded.resources.find((candidate) => (
219
+ candidate.type === "appointment"
220
+ && candidate.appointmentKind === "policy-owner"
221
+ && candidate.status === "active"
222
+ ));
223
+ if (programOwner) record.ownerIds = [programOwner.id];
224
+ }
225
+ }
226
+
159
227
  export function findResourceReferences(loaded, id) {
160
228
  const target = loaded.resources.find((record) => record.id === id);
161
229
  if (!target) throw new Error(`Resource "${id}" was not found.`);
@@ -210,17 +278,30 @@ function allowedValues(model, field) {
210
278
  return null;
211
279
  }
212
280
 
213
- function scaffoldValue(name, field = {}) {
281
+ function scaffoldValue(name, field = {}, model) {
214
282
  if (field.const !== undefined) return field.const;
215
283
  if (name === "status" && field.values) {
216
284
  return STARTING_STATUS_ORDER.find((value) => field.values.includes(value)) ?? field.values[0] ?? null;
217
285
  }
218
286
  if (field.type === "array") return [];
219
- if (field.type === "object") return {};
287
+ if (field.type === "object") {
288
+ const schema = model?.objectTypes?.[field.objectType];
289
+ if (!schema) return {};
290
+ return Object.fromEntries((schema.required || []).map((propertyName) => [
291
+ propertyName,
292
+ scaffoldValue(propertyName, schema.properties?.[propertyName], model)
293
+ ]));
294
+ }
220
295
  if (field.type === "boolean") return false;
221
296
  return null;
222
297
  }
223
298
 
299
+ function conditionMatches(record, condition) {
300
+ return Object.entries(condition || {}).every(([name, expected]) => (
301
+ Array.isArray(expected) ? expected.includes(record[name]) : record[name] === expected
302
+ ));
303
+ }
304
+
224
305
  function markdownScaffold(title, type, slot) {
225
306
  const heading = slot.label === "Record" ? title : `${title}: ${slot.label}`;
226
307
  const sections = slot.name === "agenda"
@@ -0,0 +1,19 @@
1
+ export function assessRequiredAppointments(records, model) {
2
+ const templates = model.appointmentTemplates || {};
3
+ return Object.entries(templates).map(([kind, template]) => {
4
+ const appointments = records.filter((record) => (
5
+ record.type === "appointment"
6
+ && record.appointmentKind === kind
7
+ && record.status !== "ended"
8
+ ));
9
+ const active = appointments.find(({ status }) => status === "active");
10
+ const planned = appointments.find(({ status }) => status === "planned");
11
+ return {
12
+ kind,
13
+ template,
14
+ requiredness: template.requiredness,
15
+ record: active || planned || null,
16
+ state: active ? "complete" : "ready"
17
+ };
18
+ });
19
+ }
@@ -1,8 +1,16 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
+ import {
4
+ coverageContains,
5
+ coverageEnd,
6
+ coverageLabel,
7
+ coverageMatches,
8
+ coverageOverlaps,
9
+ coverageStart
10
+ } from "./coverage.js";
3
11
  import { createResource, createResources, deleteResource, updateResource } from "./files.js";
4
12
  import { createResourceId } from "./id.js";
5
- import { partiesIndependent } from "./parties.js";
13
+ import { currentPartyPeople, partiesIndependent } from "./parties.js";
6
14
  import { resolveDataPath } from "./paths.js";
7
15
  import { assessProgramReadiness } from "./program-readiness.js";
8
16
  import { markdownEntries } from "./resource-markdown.js";
@@ -85,9 +93,8 @@ export async function assessAuditPreparation(input, options = {}) {
85
93
  counts,
86
94
  canInitialize: Boolean(audit
87
95
  && ["soc-2-type-1", "soc-2-type-2"].includes(audit.auditKind)
88
- && (audit.auditKind === "soc-2-type-1"
89
- ? audit.typeOneAsOf
90
- : audit.periodStart && audit.periodEnd)
96
+ && coverageStart(audit.coverage)
97
+ && coverageEnd(audit.coverage)
91
98
  && initializationNeeded(audit, records, loaded.model)),
92
99
  stages
93
100
  };
@@ -100,10 +107,10 @@ export async function prepareAuditWorkspace(input, options = {}) {
100
107
  if (!["soc-2-type-1", "soc-2-type-2"].includes(audit.auditKind)) {
101
108
  throw new Error("Audit preparation requires a SOC 2 Type 1 or Type 2 engagement.");
102
109
  }
103
- if (audit.auditKind === "soc-2-type-2" && (!audit.periodStart || !audit.periodEnd)) {
110
+ if (audit.auditKind === "soc-2-type-2" && audit.coverage?.kind !== "range") {
104
111
  throw new Error("Set the Type 2 audit period before initializing audit preparation.");
105
112
  }
106
- if (audit.auditKind === "soc-2-type-1" && !audit.typeOneAsOf) {
113
+ if (audit.auditKind === "soc-2-type-1" && audit.coverage?.kind !== "as-of") {
107
114
  throw new Error("Set the Type 1 as-of date before initializing audit preparation.");
108
115
  }
109
116
 
@@ -167,15 +174,13 @@ export async function prepareAuditWorkspace(input, options = {}) {
167
174
  (system.evidenceSourceKinds || []).includes(template.sourceKind)
168
175
  ));
169
176
  return {
170
- schemaVersion: 1,
171
177
  id,
172
178
  type: "audit-population",
173
179
  title: template.title,
174
180
  status: "planned",
175
181
  auditId: audit.id,
176
182
  populationKind: template.kind,
177
- periodStart: audit.periodStart,
178
- periodEnd: audit.periodEnd,
183
+ coverage: structuredClone(audit.coverage),
179
184
  ownerIds: [...audit.ownerIds],
180
185
  ...(controlIds.length ? { controlIds } : {}),
181
186
  ...(matchingSources.length === 1 ? { sourceSystemId: matchingSources[0].id } : {}),
@@ -219,9 +224,9 @@ function scopeStage(audit, records, byId, programReadiness) {
219
224
  }
220
225
 
221
226
  const periodComplete = audit.auditKind === "soc-2-type-2"
222
- ? audit.periodStart && audit.periodEnd
227
+ ? audit.coverage?.kind === "range"
223
228
  : audit.auditKind === "soc-2-type-1"
224
- ? audit.typeOneAsOf
229
+ ? audit.coverage?.kind === "as-of"
225
230
  : false;
226
231
  items.push(item(
227
232
  "period",
@@ -229,8 +234,8 @@ function scopeStage(audit, records, byId, programReadiness) {
229
234
  "Set the auditor-agreed report type and date",
230
235
  periodComplete
231
236
  ? audit.auditKind === "soc-2-type-2"
232
- ? `Auditor-agreed Type 2 period: ${audit.periodStart} through ${audit.periodEnd}.`
233
- : `Auditor-agreed Type 1 as-of date: ${audit.typeOneAsOf}.`
237
+ ? `Auditor-agreed Type 2 period: ${coverageLabel(audit.coverage)}.`
238
+ : `Auditor-agreed Type 1 as-of date: ${coverageLabel(audit.coverage)}.`
234
239
  : audit.auditKind === "soc-2-type-1"
235
240
  ? "Set the Type 1 as-of date."
236
241
  : audit.auditKind === "soc-2-type-2"
@@ -238,11 +243,9 @@ function scopeStage(audit, records, byId, programReadiness) {
238
243
  : "Change this readiness record to a Type 1 or Type 2 engagement before planning the report.",
239
244
  audit
240
245
  ));
241
- if (audit.auditKind === "soc-2-type-2" && programReadiness.target.candidatePeriodStart) {
242
- const candidate = [programReadiness.target.candidatePeriodStart, programReadiness.target.candidatePeriodEnd]
243
- .filter(Boolean)
244
- .join(" through ");
245
- const agreed = [audit.periodStart, audit.periodEnd].filter(Boolean).join(" through ");
246
+ if (audit.auditKind === "soc-2-type-2" && programReadiness.target.candidateCoverage) {
247
+ const candidate = coverageLabel(programReadiness.target.candidateCoverage);
248
+ const agreed = coverageLabel(audit.coverage);
246
249
  items.push(item(
247
250
  "candidate-period-comparison",
248
251
  "info",
@@ -257,9 +260,8 @@ function scopeStage(audit, records, byId, programReadiness) {
257
260
  const systems = (audit.systemIds || []).map((id) => byId.get(id)).filter(Boolean);
258
261
  const completeSystems = systems.filter((system) => (
259
262
  system.status === "active"
260
- && system.inScope === true
261
263
  && system.description
262
- && system.dataClassification
264
+ && system.classificationId
263
265
  && (system.ownerIds || []).length
264
266
  ));
265
267
  items.push(item(
@@ -272,12 +274,10 @@ function scopeStage(audit, records, byId, programReadiness) {
272
274
  systems[0] || { type: "system" }
273
275
  ));
274
276
 
275
- const engagementStart = audit.auditKind === "soc-2-type-1" ? audit.typeOneAsOf : audit.periodStart;
277
+ const engagementStart = coverageStart(audit.coverage);
276
278
  const commitments = records.filter((record) => record.type === "commitment"
277
279
  && record.status === "active"
278
- && systems.some((system) => (
279
- (system.commitmentIds || []).includes(record.id) || (record.systemIds || []).includes(system.id)
280
- )));
280
+ && systems.some((system) => (record.systemIds || []).includes(system.id)));
281
281
  const completeCommitments = commitments.filter((commitment) => (
282
282
  commitment.statement
283
283
  && (commitment.ownerIds || []).length
@@ -287,7 +287,7 @@ function scopeStage(audit, records, byId, programReadiness) {
287
287
  && (commitment.controlIds || []).length
288
288
  ));
289
289
  const systemsWithoutCommitments = systems.filter((system) => !completeCommitments.some((commitment) => (
290
- (system.commitmentIds || []).includes(commitment.id) || (commitment.systemIds || []).includes(system.id)
290
+ (commitment.systemIds || []).includes(system.id)
291
291
  )));
292
292
  items.push(item(
293
293
  "commitments",
@@ -432,8 +432,11 @@ function engagementStage(audit, byId, programReadiness) {
432
432
  ]);
433
433
  }
434
434
  const auditor = audit.auditorVendorId ? byId.get(audit.auditorVendorId) : null;
435
- const named = Boolean(auditor || hasMeaningfulValue(audit.auditor));
436
- return stage("engagement", "Engage the Auditor", "Record the independent CPA firm and engagement contacts before treating the audit as active.", [
435
+ const named = Boolean(auditor);
436
+ const currentOwners = [...currentPartyPeople(audit.ownerIds, byId)]
437
+ .map((id) => byId.get(id))
438
+ .filter(Boolean);
439
+ return stage("engagement", "Engage the Auditor", "Record the independent CPA firm and the current management owner who authorizes and coordinates the engagement.", [
437
440
  item(
438
441
  "engagement-record",
439
442
  "complete",
@@ -446,9 +449,24 @@ function engagementStage(audit, byId, programReadiness) {
446
449
  named ? "complete" : "action",
447
450
  "Record the independent CPA firm",
448
451
  named
449
- ? `${auditor?.title || audit.auditor?.firm || "The independent CPA firm"} is recorded for the engagement.`
452
+ ? `${auditor.title} is recorded for the engagement.`
450
453
  : "Select the CPA firm and record it here. The independent management policy reviewer is a different role.",
451
454
  audit
455
+ ),
456
+ item(
457
+ "engagement-owner",
458
+ currentOwners.length ? "complete" : "action",
459
+ "Confirm the management engagement owner",
460
+ currentOwners.length
461
+ ? `${currentOwners.map(({ title }) => title).join(" and ")} currently owns management coordination for the engagement.`
462
+ : "Assign the audit to a current Person, Team, or Appointment. The audit owner may coordinate management and evidence work without a separate audit-specific title.",
463
+ audit,
464
+ {
465
+ commands: [
466
+ `npx filegrc get ${audit.id} --mutation`,
467
+ "npx filegrc audit-readiness AUDIT_ID --json"
468
+ ]
469
+ }
452
470
  )
453
471
  ]);
454
472
  }
@@ -479,7 +497,7 @@ async function documentsStage(loaded, audit, byId) {
479
497
  const document = audit?.[definition.field] ? byId.get(audit[definition.field]) : null;
480
498
  const source = document ? await primaryMarkdown(loaded, document) : "";
481
499
  const contentIssues = managementDocumentContentIssues(source, definition, audit);
482
- const engagementEnd = audit?.auditKind === "soc-2-type-1" ? audit.typeOneAsOf : audit?.periodEnd;
500
+ const engagementEnd = coverageEnd(audit?.coverage);
483
501
  if (document?.approvedOn && engagementEnd && document.approvedOn < engagementEnd) {
484
502
  contentIssues.push(`Approve the final document on or after the engagement ${audit.auditKind === "soc-2-type-1" ? "date" : "period end"}.`);
485
503
  }
@@ -543,11 +561,11 @@ function evidenceStage(audit, records, byId, model) {
543
561
  const evidence = records.filter((record) => record.type === "evidence");
544
562
  const externalEvidence = evidence.filter((record) => (
545
563
  record.status === "verified"
546
- && (record.evidenceKind !== "rendered-record" || record.sourceCommit)
564
+ && (record.artifactKind !== "rendered-page" || record.sourceCommit)
547
565
  && evidenceRelevantToAuditDate(record, audit)
548
566
  ));
549
- const managedFamilies = (model.evidenceSourceFamilies || []).filter((family) => family.collectionTestRequired === false);
550
- const externalFamilies = (model.evidenceSourceFamilies || []).filter((family) => family.collectionTestRequired !== false);
567
+ const managedFamilies = (model.evidenceSourceFamilies || []).filter((family) => family.filegrcManaged === true);
568
+ const externalFamilies = (model.evidenceSourceFamilies || []).filter((family) => family.filegrcManaged !== true);
551
569
  const evidenceFamiliesFor = (control) => (model.evidenceSourceFamilies || []).filter((family) => (
552
570
  (family.controlCodes || []).includes(control.code)
553
571
  ));
@@ -556,12 +574,21 @@ function evidenceStage(audit, records, byId, model) {
556
574
  && controlIdsForRecord(record, byId).size
557
575
  && recordRelevantToAuditDate(record, audit, model)
558
576
  ));
577
+ const reconciledZeroPopulationControlIds = new Set(records
578
+ .filter((record) => (
579
+ record.type === "audit-population"
580
+ && record.auditId === audit.id
581
+ && record.status === "reconciled"
582
+ && record.conclusion === "complete"
583
+ && byId.get(record.sourceEvidenceId)?.populationCount === 0
584
+ ))
585
+ .flatMap((record) => record.controlIds || []));
559
586
  const managedControls = controls.filter((control) => managedFamilies.some((family) => (
560
587
  (family.controlCodes || []).includes(control.code)
561
588
  )));
562
589
  const controlsWithFilegrcRecords = managedControls.filter((control) => filegrcRecords.some((record) => (
563
590
  controlIdsForRecord(record, byId).has(control.id)
564
- )));
591
+ )) || reconciledZeroPopulationControlIds.has(control.id));
565
592
  const externalControls = controls.filter((control) => externalFamilies.some((family) => (
566
593
  (family.controlCodes || []).includes(control.code)
567
594
  )) || !evidenceFamiliesFor(control).length);
@@ -574,7 +601,7 @@ function evidenceStage(audit, records, byId, model) {
574
601
  managedControls.length && controlsWithFilegrcRecords.length === managedControls.length ? "complete" : managedControls.length ? "action" : "info",
575
602
  "Review filegrc Evidence",
576
603
  managedControls.length
577
- ? `${controlsWithFilegrcRecords.length} of ${managedControls.length} selected controls that use filegrc workflows have a dated operating record for the formal period. Complete each Step 5 record, link it to the control, and add results in its structured fields or Markdown.`
604
+ ? `${controlsWithFilegrcRecords.length} of ${managedControls.length} selected controls that use filegrc workflows have a dated operating record or reconciled zero-event population for the formal period. Complete each Step 4 record, link it to the control, and add results in its structured fields or Markdown.`
578
605
  : "No selected controls use a dedicated filegrc operating record.",
579
606
  filegrcRecords[0] || { type: managedFamilies[0]?.operationRecordTypes?.[0] || "control" }
580
607
  ),
@@ -600,20 +627,23 @@ function evidenceStage(audit, records, byId, model) {
600
627
  ));
601
628
  continue;
602
629
  }
603
- if (source.collectionTestRequired === false) {
630
+ if (source.filegrcManaged === true) {
604
631
  const sourceRecords = filegrcRecords.filter((record) => (
605
632
  relevantControls.some((control) => controlIdsForRecord(record, byId).has(control.id))
606
633
  ));
607
634
  const coveredControls = relevantControls.filter((control) => sourceRecords.some((record) => (
608
635
  controlIdsForRecord(record, byId).has(control.id)
609
- )));
636
+ )) || reconciledZeroPopulationControlIds.has(control.id));
637
+ const zeroPopulationControls = relevantControls.filter((control) => (
638
+ reconciledZeroPopulationControlIds.has(control.id)
639
+ ));
610
640
  items.push(item(
611
641
  `filegrc-${source.id}`,
612
642
  coveredControls.length === relevantControls.length ? "complete" : "action",
613
643
  source.title,
614
644
  coveredControls.length === relevantControls.length
615
- ? `${sourceRecords.length} dated filegrc ${sourceRecords.length === 1 ? "record" : "records"} cover ${relevantControls.length} mapped controls. External artifacts needed to support those results are linked from the operating records.`
616
- : `${coveredControls.length} of ${relevantControls.length} mapped controls have a dated ${source.operationRecordTypes.map(displayValue).join(" or ")} record for the formal period. Complete the Step 5 work and attach or reference any supporting external artifact on that record.`,
645
+ ? `${sourceRecords.length} dated filegrc ${sourceRecords.length === 1 ? "record" : "records"} and ${zeroPopulationControls.length} reconciled zero-population ${zeroPopulationControls.length === 1 ? "conclusion cover" : "conclusions cover"} ${relevantControls.length} mapped controls. Supporting artifacts are linked from the operating records or population export.`
646
+ : `${coveredControls.length} of ${relevantControls.length} mapped controls have a dated ${source.operationRecordTypes.map(displayValue).join(" or ")} record for the formal period. Complete the Step 4 work and attach or reference any supporting external artifact on that record.`,
617
647
  sourceRecords[0] || { type: source.operationRecordTypes[0] }
618
648
  ));
619
649
  continue;
@@ -687,7 +717,11 @@ function auditorStage() {
687
717
 
688
718
  function populationResult(population, audit, byId) {
689
719
  if (!population) return { status: "action", message: "Initialize this population for the engagement." };
690
- if (population.periodStart !== audit?.periodStart || population.periodEnd !== audit?.periodEnd) {
720
+ if (!coverageMatches(
721
+ population.coverage,
722
+ coverageStart(audit?.coverage),
723
+ coverageEnd(audit?.coverage)
724
+ )) {
691
725
  return { status: "action", message: "The population period does not match the exact audit period." };
692
726
  }
693
727
  if (population.status === "not-applicable") {
@@ -712,12 +746,15 @@ function populationResult(population, audit, byId) {
712
746
  ];
713
747
  const evidenceComplete = evidence
714
748
  && evidence.type === "evidence"
715
- && evidence.evidenceKind === "population-export"
749
+ && evidence.artifactKind === "population-export"
716
750
  && evidence.status === "verified"
717
751
  && population.sourceSystemId
718
752
  && evidence.sourceSystemId === population.sourceSystemId
719
- && evidence.periodStart === audit.periodStart
720
- && evidence.periodEnd === audit.periodEnd
753
+ && coverageMatches(
754
+ evidence.coverage,
755
+ coverageStart(audit.coverage),
756
+ coverageEnd(audit.coverage)
757
+ )
721
758
  && requiredEvidence.every((field) => evidence[field] !== undefined && evidence[field] !== null && evidence[field] !== "");
722
759
  const reconciliationComplete = (population.reconciledByIds || []).length
723
760
  && population.reconciledOn
@@ -726,14 +763,14 @@ function populationResult(population, audit, byId) {
726
763
  const generatedOn = timestampDate(evidence?.generatedAt, evidence?.timezone);
727
764
  const sequenceComplete = Number.isInteger(evidence?.populationCount)
728
765
  && evidence.populationCount >= 0
729
- && generatedOn > audit.periodEnd
766
+ && generatedOn > coverageEnd(audit.coverage)
730
767
  && population.reconciledOn >= generatedOn;
731
768
  if (!evidenceComplete || !reconciliationComplete || !sequenceComplete) {
732
769
  return { status: "action", message: "Finish the reconciliation and link a verified population export with its exact query, timezone, count, completeness check, and accuracy check." };
733
770
  }
734
771
  return {
735
772
  status: "complete",
736
- message: `${evidence.populationCount} items reconciled from ${evidence.source || "the authoritative source"}${population.conclusion === "complete-with-exceptions" ? " with documented exceptions" : ""}.`
773
+ message: `${evidence.populationCount} items reconciled from ${evidence.sourceDescription || "the authoritative source"}${population.conclusion === "complete-with-exceptions" ? " with documented exceptions" : ""}.`
737
774
  };
738
775
  }
739
776
 
@@ -792,31 +829,30 @@ function applicableManagementDocuments(audit, readiness) {
792
829
  }
793
830
 
794
831
  function evidenceOverlaps(record, start, end) {
795
- return (record.periodStart && record.periodEnd && record.periodStart <= end && record.periodEnd >= start)
832
+ return coverageOverlaps(record.coverage, start, end)
796
833
  || (record.collectedOn && record.collectedOn >= start && record.collectedOn <= end);
797
834
  }
798
835
 
799
836
  function evidenceRelevantToAuditDate(record, audit) {
800
837
  if (!audit) return false;
801
838
  if (audit.auditKind === "soc-2-type-1") {
802
- const date = audit.typeOneAsOf;
839
+ const date = coverageStart(audit.coverage);
803
840
  return Boolean(date) && (
804
- (record.periodStart && record.periodEnd && record.periodStart <= date && record.periodEnd >= date)
841
+ coverageContains(record.coverage, date)
805
842
  || record.collectedOn === date
806
843
  );
807
844
  }
808
- return Boolean(audit.periodStart && audit.periodEnd)
809
- && evidenceOverlaps(record, audit.periodStart, audit.periodEnd);
845
+ const start = coverageStart(audit.coverage);
846
+ const end = coverageEnd(audit.coverage);
847
+ return Boolean(start && end) && evidenceOverlaps(record, start, end);
810
848
  }
811
849
 
812
850
  function recordRelevantToAuditDate(record, audit, model) {
813
851
  if (!audit) return false;
814
- const start = audit.auditKind === "soc-2-type-1" ? audit.typeOneAsOf : audit.periodStart;
815
- const end = audit.auditKind === "soc-2-type-1" ? audit.typeOneAsOf : audit.periodEnd;
852
+ const start = coverageStart(audit.coverage);
853
+ const end = coverageEnd(audit.coverage);
816
854
  if (!start || !end) return false;
817
- if (record.periodStart && record.periodEnd && record.periodStart <= end && record.periodEnd >= start) {
818
- return true;
819
- }
855
+ if (coverageOverlaps(record.coverage, start, end)) return true;
820
856
  const definition = model.resources[record.type];
821
857
  const fields = { ...model.commonFields, ...(definition?.fields || {}) };
822
858
  return Object.entries(fields).some(([name, field]) => {
@@ -840,6 +876,17 @@ function controlIdsForRecord(record, byId, seen = new Set()) {
840
876
  if (record.obligationId) {
841
877
  for (const id of byId.get(record.obligationId)?.controlIds || []) ids.add(id);
842
878
  }
879
+ for (const candidate of byId.values()) {
880
+ if (
881
+ candidate.type === "obligation"
882
+ && (candidate.completionResourceIds || []).includes(record.id)
883
+ ) {
884
+ for (const id of candidate.controlIds || []) ids.add(id);
885
+ }
886
+ }
887
+ for (const subjectId of record.subjectResourceIds || []) {
888
+ for (const id of controlIdsForRecord(byId.get(subjectId), byId, seen)) ids.add(id);
889
+ }
843
890
  for (const sourceId of record.sourceResourceIds || []) {
844
891
  for (const id of controlIdsForRecord(byId.get(sourceId), byId, seen)) ids.add(id);
845
892
  }
@@ -872,9 +919,7 @@ function managementDocumentContentIssues(source, definition, audit) {
872
919
  ));
873
920
  if (missingHeadings.length) return [`Add the missing description sections: ${missingHeadings.join(", ")}.`];
874
921
  if (definition.dateBinding === "engagement" && audit) {
875
- const dates = audit.auditKind === "soc-2-type-1"
876
- ? [audit.typeOneAsOf]
877
- : [audit.periodStart, audit.periodEnd];
922
+ const dates = [coverageStart(audit.coverage), coverageEnd(audit.coverage)];
878
923
  if (dates.some((date) => date && !source.includes(date))) {
879
924
  return ["Name the exact engagement date or period in the document."];
880
925
  }
@@ -923,16 +968,16 @@ function materializeManagementMarkdown(source, audit, records) {
923
968
  .map(displayValue))]
924
969
  .join(", ");
925
970
  const period = audit.auditKind === "soc-2-type-1"
926
- ? audit.typeOneAsOf || "[as-of date]"
927
- : audit.periodStart && audit.periodEnd
928
- ? `${audit.periodStart} through ${audit.periodEnd}`
971
+ ? coverageStart(audit.coverage) || "[as-of date]"
972
+ : coverageStart(audit.coverage) && coverageEnd(audit.coverage)
973
+ ? coverageLabel(audit.coverage)
929
974
  : "[start date] through [end date]";
930
975
  return withoutDiscarded
931
976
  .replaceAll(`<!-- ${keep}:start -->`, "")
932
977
  .replaceAll(`<!-- ${keep}:end -->`, "")
933
- .replaceAll("[as-of date]", audit.typeOneAsOf || "[as-of date]")
934
- .replaceAll("[start date]", audit.periodStart || "[start date]")
935
- .replaceAll("[end date]", audit.periodEnd || "[end date]")
978
+ .replaceAll("[as-of date]", coverageStart(audit.coverage) || "[as-of date]")
979
+ .replaceAll("[start date]", coverageStart(audit.coverage) || "[start date]")
980
+ .replaceAll("[end date]", coverageEnd(audit.coverage) || "[end date]")
936
981
  .replaceAll("[engagement date or period]", period)
937
982
  .replaceAll("[engagement scope]", audit.scope || "[engagement scope]")
938
983
  .replaceAll("[in-scope systems]", systems || "[in-scope systems]")
@@ -953,8 +998,7 @@ function auditSummary(audit) {
953
998
  title: audit.title,
954
999
  status: audit.status,
955
1000
  kind: audit.auditKind,
956
- periodStart: audit.periodStart || null,
957
- periodEnd: audit.periodEnd || null
1001
+ coverage: audit.coverage || null
958
1002
  };
959
1003
  }
960
1004