filegrc 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/setup.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { applyResourceBatch } from "./files.js";
2
2
  import { createResourceId } from "./id.js";
3
+ import { resolveProgram, selectedRequirementIds } from "./program.js";
3
4
  import { loadWorkspace } from "./workspace.js";
4
5
 
5
6
  const PROGRAM_GOALS = new Set(["none", "readiness", "type-1", "type-2"]);
@@ -12,7 +13,8 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
12
13
  const plan = buildSetupRecords(loaded, setup);
13
14
  const updates = [
14
15
  ...(plan.existingSystem ? [plan.system] : []),
15
- plan.workspace,
16
+ plan.target,
17
+ ...(plan.component ? [plan.component] : []),
16
18
  ...(plan.renderer ? [plan.renderer] : [])
17
19
  ];
18
20
  const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, entry.revision]));
@@ -30,7 +32,8 @@ export async function setupWorkspace(input = process.cwd(), payload = {}) {
30
32
  return {
31
33
  draft: setup.draft,
32
34
  system: plan.system,
33
- workspace: plan.workspace,
35
+ workspace: plan.target.type === "workspace" ? plan.target : plan.workspace,
36
+ program: plan.target.type === "program" ? plan.target : null,
34
37
  renderer: plan.renderer,
35
38
  commitment: plan.commitment,
36
39
  linkedControlIds: [],
@@ -49,13 +52,13 @@ export async function planWorkspaceSetup(input = process.cwd(), payload = {}) {
49
52
  draft: setup.draft,
50
53
  changes: {
51
54
  system: plan.existingSystem ? "update" : "create",
52
- workspace: "update",
55
+ [plan.target.type]: "update",
53
56
  renderer: plan.renderer ? "update" : "unchanged",
54
57
  controls: 0,
55
58
  commitment: plan.commitment ? "create" : "unchanged"
56
59
  },
57
60
  system: setupSystemSummary(plan.system),
58
- target: setupTargetSummary(plan.workspace),
61
+ target: setupTargetSummary(plan.target, loaded.model),
59
62
  renderer: plan.renderer ? setupRendererSummary(plan.renderer) : null,
60
63
  commitment: plan.commitment || null,
61
64
  onboardingComplete: !setup.draft
@@ -69,12 +72,12 @@ export function summarizeSetupResult(result) {
69
72
  draft: result.draft,
70
73
  changes: {
71
74
  system: "saved",
72
- workspace: "updated",
75
+ [result.program ? "program" : "workspace"]: "updated",
73
76
  controls: result.linkedControlIds?.length || 0,
74
77
  commitment: result.commitment ? "saved" : "unchanged"
75
78
  },
76
79
  system: setupSystemSummary(result.system),
77
- target: setupTargetSummary(result.workspace),
80
+ target: setupTargetSummary(result.program || result.workspace, { modelVersion: result.program ? "4" : "3" }),
78
81
  renderer: result.renderer ? setupRendererSummary(result.renderer) : null,
79
82
  commitment: result.commitment || null,
80
83
  onboardingComplete: result.onboardingComplete
@@ -126,14 +129,16 @@ function validateSetup(loaded, setup) {
126
129
  throw new Error(`System "${setup.systemId}" cannot be used for initial scope because it is ${system.status}.`);
127
130
  }
128
131
  }
129
- const classifications = Object.keys(loaded.workspace.classificationDefinitions || {});
132
+ const classifications = String(loaded.model.modelVersion) === "4"
133
+ ? loaded.resources.filter(({ type, status }) => type === "classification" && status === "active").map(({ id }) => id)
134
+ : Object.keys(loaded.workspace.classificationDefinitions || {});
130
135
  if (classifications.length && !classifications.includes(setup.classificationId)) {
131
136
  throw new Error(`classificationId must be one of ${classifications.join(", ")}.`);
132
137
  }
133
138
  }
134
139
 
135
- function findSetupSystem(resources, workspace, setup) {
136
- const scopedSystemIds = new Set(workspace.systemIds || []);
140
+ function findSetupSystem(resources, target, setup) {
141
+ const scopedSystemIds = new Set(target.systemIds || []);
137
142
  return (setup.systemId && resources.find(({ type, id }) => type === "system" && id === setup.systemId))
138
143
  || resources.find(({ type, id, title, status }) => (
139
144
  type === "system"
@@ -144,7 +149,9 @@ function findSetupSystem(resources, workspace, setup) {
144
149
  }
145
150
 
146
151
  function buildSetupRecords(loaded, setup) {
147
- const existingSystem = findSetupSystem(loaded.resources, loaded.workspace, setup);
152
+ const target = resolveProgram(loaded);
153
+ const v4 = String(loaded.model.modelVersion) === "4";
154
+ const existingSystem = findSetupSystem(loaded.resources, target, setup);
148
155
  const systemId = existingSystem?.id || createResourceId(
149
156
  "system",
150
157
  setup.serviceName,
@@ -162,18 +169,46 @@ function buildSetupRecords(loaded, setup) {
162
169
  : existingSystem?.status || "active",
163
170
  criticality: setup.criticality,
164
171
  ownerIds: [setup.ownerId],
165
- description: setup.boundary,
166
- systemKind: existingSystem?.systemKind || "service",
172
+ ...(v4 ? {
173
+ purpose: existingSystem?.purpose || setup.boundary,
174
+ servicesProvided: existingSystem?.servicesProvided || [setup.serviceName],
175
+ boundary: setup.boundary,
176
+ exclusions: existingSystem?.exclusions || []
177
+ } : {
178
+ description: setup.boundary,
179
+ systemKind: existingSystem?.systemKind || "service"
180
+ }),
167
181
  classificationId: setup.classificationId,
168
182
  internetExposed: setup.internetExposed
169
183
  };
170
184
  const existingWorkspace = loaded.resources.find(({ type }) => type === "workspace");
171
185
  if (!existingWorkspace) throw new Error("The workspace settings record was not found.");
172
- const workspace = {
173
- ...existingWorkspace,
186
+ const nextTarget = {
187
+ ...target,
188
+ ...(!setup.draft && v4 ? { status: "active" } : {}),
174
189
  assuranceGoal: assuranceGoalFromSetup(setup.programGoal),
175
- systemIds: [...new Set([...(existingWorkspace.systemIds || []), systemId])]
190
+ systemIds: [...new Set([...(target.systemIds || []), systemId])]
176
191
  };
192
+ const componentEntry = v4
193
+ ? loaded.resources.find(({ id, type }) => id === "component-filegrc-program-repository" && type === "component")
194
+ : null;
195
+ const existingSystemUses = componentEntry?.systemUses || [];
196
+ const component = componentEntry ? {
197
+ ...componentEntry,
198
+ status: setup.draft
199
+ ? componentEntry.status || "planned"
200
+ : componentEntry.status === "planned"
201
+ ? "active"
202
+ : componentEntry.status || "active",
203
+ systemUses: [
204
+ ...existingSystemUses.filter((use) => use.systemId !== systemId),
205
+ {
206
+ systemId,
207
+ roles: ["control-support", "evidence-source", "supporting-operations"],
208
+ rationale: "FileGRC stores the Program records, retained evidence index, and Git revision history used to operate and support this System's Controls."
209
+ }
210
+ ]
211
+ } : null;
177
212
  const existingRenderer = loaded.resources.find(({ type }) => type === "renderer-settings");
178
213
  const renderer = existingRenderer ? { ...existingRenderer, showOnboarding: setup.draft } : null;
179
214
  const existingCommitment = loaded.resources.find((record) => (
@@ -181,7 +216,7 @@ function buildSetupRecords(loaded, setup) {
181
216
  && !["superseded", "retired"].includes(record.status)
182
217
  && (record.systemIds || []).includes(systemId)
183
218
  ));
184
- const commitment = String(loaded.model.modelVersion) === "3" && !existingCommitment
219
+ const commitment = ["3", "4"].includes(String(loaded.model.modelVersion)) && !existingCommitment
185
220
  ? {
186
221
  id: createResourceId(
187
222
  "commitment",
@@ -196,11 +231,11 @@ function buildSetupRecords(loaded, setup) {
196
231
  systemIds: [systemId],
197
232
  ownerIds: [setup.ownerId],
198
233
  customerFacing: true,
199
- ...(workspace.requirementIds?.length ? { requirementIds: [...workspace.requirementIds] } : {}),
200
- ...(workspace.controlIds?.length ? { controlIds: [...workspace.controlIds] } : {})
234
+ ...(selectedRequirementIds(nextTarget, loaded.model).length ? { requirementIds: selectedRequirementIds(nextTarget, loaded.model) } : {}),
235
+ ...(nextTarget.controlIds?.length ? { controlIds: [...nextTarget.controlIds] } : {})
201
236
  }
202
237
  : null;
203
- return { existingSystem, system, workspace, renderer, commitment };
238
+ return { existingSystem, system, workspace: existingWorkspace, target: nextTarget, component, renderer, commitment };
204
239
  }
205
240
 
206
241
  function assuranceGoalFromSetup(goal) {
@@ -214,14 +249,14 @@ function setupSystemSummary(system) {
214
249
  return { ...system };
215
250
  }
216
251
 
217
- function setupTargetSummary(workspace) {
252
+ function setupTargetSummary(workspace, model) {
218
253
  return {
219
254
  assuranceGoal: workspace.assuranceGoal,
220
255
  systemIds: [...(workspace.systemIds || [])],
221
256
  scopeCounts: {
222
257
  systems: workspace.systemIds?.length || 0,
223
258
  frameworks: workspace.frameworkIds?.length || 0,
224
- requirements: workspace.requirementIds?.length || 0,
259
+ requirements: selectedRequirementIds(workspace, model).length,
225
260
  controls: workspace.controlIds?.length || 0
226
261
  }
227
262
  };
@@ -1,17 +1,19 @@
1
- export function sourceCoverageComplete(record, loaded) {
1
+ export function sourceCoverageComplete(record, loaded, program = loaded.workspace) {
2
2
  if (!record?.validFrom || !record.collectionCadence || !record.retention || !record.reconciliationMethod) {
3
3
  return false;
4
4
  }
5
- if (record.coverageKind === "external-system" && (!record.systemId || !(record.retrieverIds || []).length)) {
5
+ const external = record.coverageKind === "external-system" || record.coverageKind === "external-component";
6
+ const sourceId = record.componentId || record.systemId;
7
+ if (external && (!sourceId || !(record.retrieverIds || []).length)) {
6
8
  return false;
7
9
  }
8
10
  if (["not-applicable", "zero-population"].includes(record.coverageKind) && !record.applicabilityReview) {
9
11
  return false;
10
12
  }
11
- if (loaded.workspace?.candidateCoverage && !(record.readinessTestEvidenceIds || []).length) {
13
+ if (program?.candidateCoverage && !(record.readinessTestEvidenceIds || []).length) {
12
14
  return false;
13
15
  }
14
- if (loaded.workspace?.candidateCoverage) {
16
+ if (program?.candidateCoverage) {
15
17
  const tests = (record.readinessTestEvidenceIds || []).map((id) => (
16
18
  loaded.resources.find((resource) => resource.id === id && resource.type === "evidence")
17
19
  ));
@@ -21,16 +23,18 @@ export function sourceCoverageComplete(record, loaded) {
21
23
  || test.retrievalResult !== "passed"
22
24
  || test.accessConfirmed !== true
23
25
  || !(test.coveredSourceFamilyIds || []).includes(record.sourceFamilyId)
24
- || (record.coverageKind === "external-system" && !(
25
- test.sourceSystemId === record.systemId
26
- || (test.systemIds || []).includes(record.systemId)
26
+ || (external && !(
27
+ test.sourceComponentId === sourceId
28
+ || test.sourceSystemId === sourceId
29
+ || (test.componentIds || []).includes(sourceId)
30
+ || (test.systemIds || []).includes(sourceId)
27
31
  ))
28
32
  ))) return false;
29
33
  }
30
34
  return true;
31
35
  }
32
36
 
33
- export function assessSourceCoverageReadiness(loaded, selectedControlIds = []) {
37
+ export function assessSourceCoverageReadiness(loaded, selectedControlIds = [], program = loaded.workspace) {
34
38
  if (!loaded.model.resources["source-coverage"]) return [];
35
39
  const selected = new Set(selectedControlIds);
36
40
  const selectedControlCodes = new Set(loaded.resources
@@ -55,7 +59,7 @@ export function assessSourceCoverageReadiness(loaded, selectedControlIds = []) {
55
59
  return {
56
60
  family,
57
61
  record,
58
- complete: Boolean(record?.status === "active" && sourceCoverageComplete(record, loaded))
62
+ complete: Boolean(record?.status === "active" && sourceCoverageComplete(record, loaded, program))
59
63
  };
60
64
  });
61
65
  }
package/src/state.js CHANGED
@@ -12,6 +12,7 @@ import { currentCalendarDate } from "./time.js";
12
12
  import { serializeWorkspaceMutation } from "./mutation.js";
13
13
  import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
14
14
  import { assessWorkflow } from "./workflow.js";
15
+ import { measureTiming } from "./timing.js";
15
16
 
16
17
  const renderedMarkdownCache = new Map();
17
18
  const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000;
@@ -37,19 +38,21 @@ async function createAppStateUnlocked(input, options) {
37
38
  ? getWorkspaceHistories(loaded.root, loaded.entries.map((entry) => `data/${entry.relativePath}`), 12)
38
39
  : new Map();
39
40
 
40
- for (const entry of loaded.entries) {
41
- entries.push(await createStateEntry(loaded, entry, {
42
- includeDetails,
43
- history: histories.get(`data/${entry.relativePath}`) ?? []
44
- }));
45
- }
41
+ await measureTiming("state-entries", async () => {
42
+ for (const entry of loaded.entries) {
43
+ entries.push(await createStateEntry(loaded, entry, {
44
+ includeDetails,
45
+ history: histories.get(`data/${entry.relativePath}`) ?? []
46
+ }));
47
+ }
48
+ });
46
49
 
47
50
  const git = getGitSummary(loaded.root);
48
51
  delete git.root;
49
- const repository = await getBrowserRepositoryState(loaded.root, {
52
+ const repository = await measureTiming("state-repository", () => getBrowserRepositoryState(loaded.root, {
50
53
  readOnly: options.readOnly,
51
54
  allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
52
- });
55
+ }));
53
56
  const workspace = loaded.workspace ?? {
54
57
  dataModelVersion: loaded.model.modelVersion,
55
58
  id: "workspace",
@@ -60,12 +63,12 @@ async function createAppStateUnlocked(input, options) {
60
63
  };
61
64
  const asOf = options.asOf ?? currentCalendarDate(workspace.timezone);
62
65
  const generatedAt = new Date().toISOString();
63
- const programReadiness = await assessProgramReadiness(loaded, {
66
+ const programReadiness = await measureTiming("state-program-readiness", () => assessProgramReadiness(loaded, {
64
67
  asOf,
65
68
  generatedAt
66
- });
69
+ }));
67
70
  const audits = loaded.resources.filter((record) => record.type === "audit");
68
- const auditPreparations = Object.fromEntries(await Promise.all(
71
+ const auditPreparations = await measureTiming("state-audit-preparation", async () => Object.fromEntries(await Promise.all(
69
72
  (audits.length ? audits : [null]).map(async (audit) => {
70
73
  const preparation = await assessAuditPreparation(loaded, {
71
74
  auditId: audit?.id,
@@ -74,13 +77,13 @@ async function createAppStateUnlocked(input, options) {
74
77
  });
75
78
  return [audit?.id || "none", preparation];
76
79
  })
77
- ));
80
+ )));
78
81
  const obligations = planObligations(entries, {
79
82
  asOf,
80
83
  now: options.now ?? generatedAt,
81
84
  model: loaded.model
82
85
  });
83
- const workflow = await assessWorkflow(loaded, {
86
+ const workflow = await measureTiming("state-workflow", () => assessWorkflow(loaded, {
84
87
  asOf,
85
88
  evaluatedAt: generatedAt,
86
89
  programReadiness,
@@ -90,7 +93,7 @@ async function createAppStateUnlocked(input, options) {
90
93
  obligations,
91
94
  git,
92
95
  validation
93
- });
96
+ }));
94
97
  const collectionReviews = Object.fromEntries(
95
98
  assessCollectionReviews(loaded).map((assessment) => [
96
99
  assessment.resourceType,
package/src/timing.js CHANGED
@@ -5,7 +5,9 @@ const timingContext = new AsyncLocalStorage();
5
5
 
6
6
  export async function collectTimings(task) {
7
7
  const timings = new Map();
8
+ const started = performance.now();
8
9
  const result = await timingContext.run(timings, task);
10
+ recordCollectedTiming(timings, "total", performance.now() - started);
9
11
  return { result, timings: Object.fromEntries(timings) };
10
12
  }
11
13
 
@@ -30,6 +32,10 @@ export function measureTimingSync(name, task) {
30
32
  export function recordTiming(name, durationMs) {
31
33
  const timings = timingContext.getStore();
32
34
  if (!timings) return;
35
+ recordCollectedTiming(timings, name, durationMs);
36
+ }
37
+
38
+ function recordCollectedTiming(timings, name, durationMs) {
33
39
  const current = timings.get(name) ?? { count: 0, durationMs: 0 };
34
40
  current.count += 1;
35
41
  current.durationMs += durationMs;
package/src/validate.js CHANGED
@@ -73,6 +73,10 @@ async function validateWorkspaceUnmeasured(input) {
73
73
  validateRecord(record, definition, loaded.model, displayPath, diagnostics);
74
74
  validateDateRanges(record, displayPath, diagnostics);
75
75
  if (record.type === "appointment") validateAppointment(record, byId, displayPath, diagnostics);
76
+ if (record.type === "program") validateProgram(record, displayPath, diagnostics);
77
+ if (record.type === "component") validateComponent(record, displayPath, diagnostics);
78
+ if (record.type === "control") validateControlComponents(record, byId, displayPath, diagnostics);
79
+ if (record.type === "audit") validateAuditSubservices(record, byId, displayPath, diagnostics);
76
80
  if (record.type === "collection-review") {
77
81
  validateCollectionReview(record, loaded.model, loaded.resources, byId, displayPath, diagnostics);
78
82
  }
@@ -83,7 +87,7 @@ async function validateWorkspaceUnmeasured(input) {
83
87
  if (record.type === "obligation-event") validatePolicyEvent(record, loaded.model, byId, displayPath, diagnostics);
84
88
  if (record.type === "evidence") validateEvidencePaths(record, displayPath, diagnostics);
85
89
  validateCoverage(record, displayPath, diagnostics);
86
- validateClassification(record, loaded.workspace, displayPath, diagnostics);
90
+ validateClassification(record, loaded, displayPath, diagnostics);
87
91
  validateCompletionDates(record, displayPath, diagnostics);
88
92
  await validateAttestationBinding(record, loaded.model, loaded.root, byId, displayPath, diagnostics);
89
93
 
@@ -167,7 +171,24 @@ function validateCollectionReview(record, model, resources, byId, path, diagnost
167
171
  ));
168
172
  return;
169
173
  }
170
- const recordCount = resources.filter(({ type }) => type === record.resourceType).length;
174
+ const program = String(model.modelVersion) === "4"
175
+ ? (record.scopeResourceIds || []).map((id) => byId.get(id)).find(({ type } = {}) => type === "program")
176
+ : null;
177
+ const programSystemIds = new Set(program?.systemIds || []);
178
+ const componentIds = new Set(resources.filter((candidate) => (
179
+ candidate.type === "component"
180
+ && (candidate.systemUses || []).some(({ systemId }) => programSystemIds.has(systemId))
181
+ )).map(({ id }) => id));
182
+ const selectedIds = {
183
+ system: programSystemIds,
184
+ component: componentIds,
185
+ framework: new Set(program?.frameworkIds || []),
186
+ vendor: new Set(resources.filter(({ id }) => componentIds.has(id)).map(({ vendorId }) => vendorId).filter(Boolean)),
187
+ asset: new Set(resources.filter(({ type, componentIds: ids }) => type === "asset" && (ids || []).some((id) => componentIds.has(id))).map(({ id }) => id))
188
+ }[record.resourceType];
189
+ const recordCount = resources.filter(({ type, id }) => (
190
+ type === record.resourceType && (!program || !selectedIds || selectedIds.has(id))
191
+ )).length;
171
192
  if (!recordCount && record.decision === "complete") {
172
193
  diagnostics.push(error(
173
194
  "invalid-collection-review-decision",
@@ -184,12 +205,12 @@ function validateCollectionReview(record, model, resources, byId, path, diagnost
184
205
  }
185
206
  if (
186
207
  record.decision === "externally-managed"
187
- && byId.get(record.authoritativeSystemId)?.status !== "active"
208
+ && byId.get(record.authoritativeComponentId || record.authoritativeSystemId)?.status !== "active"
188
209
  ) {
189
210
  diagnostics.push(error(
190
211
  "inactive-authoritative-system",
191
212
  path,
192
- `${configuration.title} must name an active authoritative System for an externally managed conclusion.`
213
+ `${configuration.title} must name an active authoritative ${String(model.modelVersion) === "4" ? "Component" : "System"} for an externally managed conclusion.`
193
214
  ));
194
215
  }
195
216
  }
@@ -302,6 +323,64 @@ function validateAppointment(record, byId, path, diagnostics) {
302
323
  ));
303
324
  }
304
325
 
326
+ function validateProgram(record, path, diagnostics) {
327
+ const requirementIds = (record.requirementApplicability || [])
328
+ .map(({ requirementId }) => requirementId)
329
+ .filter(Boolean);
330
+ if (new Set(requirementIds).size !== requirementIds.length) {
331
+ diagnostics.push(error(
332
+ "duplicate-program-applicability",
333
+ path,
334
+ "A Program may record each Requirement only once in requirementApplicability."
335
+ ));
336
+ }
337
+ }
338
+
339
+ function validateComponent(record, path, diagnostics) {
340
+ const systemIds = (record.systemUses || []).map(({ systemId }) => systemId).filter(Boolean);
341
+ if (new Set(systemIds).size !== systemIds.length) {
342
+ diagnostics.push(error("duplicate-system-use", path, "A Component may name each System only once in systemUses."));
343
+ }
344
+ for (const [index, use] of (record.systemUses || []).entries()) {
345
+ if (!(use.roles || []).length || !String(use.rationale || "").trim()) {
346
+ diagnostics.push(error("incomplete-system-use", path, `systemUses[${index}] needs at least one role and a rationale.`));
347
+ }
348
+ }
349
+ }
350
+
351
+ function validateControlComponents(record, byId, path, diagnostics) {
352
+ if (record.status !== "implemented") return;
353
+ const systemIds = new Set(record.systemIds || []);
354
+ for (const field of ["componentIds", "evidenceSourceComponentIds"]) {
355
+ for (const id of record[field] || []) {
356
+ const component = byId.get(id);
357
+ if (component?.type !== "component") continue;
358
+ const uses = (component.systemUses || []).filter(({ systemId }) => systemIds.has(systemId));
359
+ if (!uses.length) {
360
+ diagnostics.push(error("component-outside-control-scope", path, `${field} Component "${id}" has no use in a System where this Control applies.`));
361
+ }
362
+ if (field === "evidenceSourceComponentIds" && !uses.some(({ roles }) => (roles || []).includes("evidence-source"))) {
363
+ diagnostics.push(error("invalid-evidence-source-component", path, `Component "${id}" must have the evidence-source role in a System where this Control applies.`));
364
+ }
365
+ }
366
+ }
367
+ }
368
+
369
+ function validateAuditSubservices(record, byId, path, diagnostics) {
370
+ for (const [index, treatment] of (record.subserviceTreatments || []).entries()) {
371
+ for (const componentId of treatment.componentIds || []) {
372
+ const component = byId.get(componentId);
373
+ if (component?.type === "component" && component.vendorId !== treatment.vendorId) {
374
+ diagnostics.push(error(
375
+ "subservice-vendor-mismatch",
376
+ path,
377
+ `subserviceTreatments[${index}] Component "${componentId}" is not supplied by Vendor "${treatment.vendorId}".`
378
+ ));
379
+ }
380
+ }
381
+ }
382
+ }
383
+
305
384
  function validateCompletedObligationEvent(record, byId, model, path, diagnostics) {
306
385
  if (record.type !== "obligation-event" || record.status !== "complete") return;
307
386
  if (record.completedOn && record.occurredOn && record.completedOn < record.occurredOn) {
@@ -334,7 +413,7 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
334
413
  ? model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || []
335
414
  : [];
336
415
  if (!expectedTypes.length) continue;
337
- const completionIds = String(model.modelVersion) === "3"
416
+ const completionIds = ["3", "4"].includes(String(model.modelVersion))
338
417
  ? action.completionResourceIds || []
339
418
  : [...(action.completionResourceIds || []), ...(action.evidenceIds || [])];
340
419
  const linked = [...new Set(completionIds)]
@@ -352,7 +431,7 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
352
431
 
353
432
  function validateCompletedObligationAction(record, byId, model, path, diagnostics) {
354
433
  if (
355
- String(model.modelVersion) !== "3"
434
+ !["3", "4"].includes(String(model.modelVersion))
356
435
  || record.status !== "done"
357
436
  || !record.obligationId
358
437
  ) return;
@@ -754,9 +833,19 @@ function validateCoverage(record, path, diagnostics) {
754
833
  }
755
834
  }
756
835
 
757
- function validateClassification(record, workspace, path, diagnostics) {
836
+ function validateClassification(record, loaded, path, diagnostics) {
758
837
  if (!record.classificationId) return;
759
- const definitions = workspace?.classificationDefinitions;
838
+ if (String(loaded.model.modelVersion) === "4") {
839
+ if (!loaded.resources.some(({ id, type }) => id === record.classificationId && type === "classification")) {
840
+ diagnostics.push(error(
841
+ "unknown-classification",
842
+ path,
843
+ `classificationId references undefined Classification "${record.classificationId}".`
844
+ ));
845
+ }
846
+ return;
847
+ }
848
+ const definitions = loaded.workspace?.classificationDefinitions;
760
849
  if (
761
850
  !definitions
762
851
  || Array.isArray(definitions)
@@ -1076,8 +1165,10 @@ function validateArrayItem(name, value, field, model, path, diagnostics, index)
1076
1165
  diagnostics.push(error("invalid-field", path, `${name} items must be objects.`));
1077
1166
  } else if (type === "object" && field.itemObjectType) {
1078
1167
  validateObjectValue(`${name}[${index}]`, value, field.itemObjectType, model, path, diagnostics);
1079
- } else if ((type === "string" || type === "data-path") && typeof value !== "string") {
1168
+ } else if ((type === "string" || type === "data-path" || type === "enum") && typeof value !== "string") {
1080
1169
  diagnostics.push(error("invalid-field", path, `${name} items must be strings.`));
1170
+ } else if (type === "enum" && field.values && !field.values.includes(value)) {
1171
+ diagnostics.push(error("invalid-field", path, `${name} items must be one of ${field.values.join(", ")}.`));
1081
1172
  } else if (type === "id" && (typeof value !== "string" || !ID_PATTERN.test(value))) {
1082
1173
  diagnostics.push(error("invalid-field", path, `${name} items must be lowercase kebab-case IDs.`));
1083
1174
  }