filegrc 0.7.1 → 0.9.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/src/program.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { modelSupports } from "../model/index.js";
2
+
1
3
  export function resolveProgram(loaded, requestedId) {
2
- if (String(loaded.model.modelVersion) !== "4") return loaded.workspace;
4
+ if (!modelSupports(loaded.model, "program-scope")) return loaded.workspace;
3
5
  const programs = loaded.resources.filter((record) => record.type === "program" && record.status !== "retired");
4
6
  if (requestedId) {
5
7
  const program = programs.find(({ id }) => id === requestedId);
@@ -33,7 +35,7 @@ export function resolveProgram(loaded, requestedId) {
33
35
  }
34
36
 
35
37
  export function selectedRequirementIds(program, model) {
36
- if (String(model.modelVersion) === "4") {
38
+ if (modelSupports(model, "program-scope")) {
37
39
  return (program.requirementApplicability || [])
38
40
  .filter(({ decision }) => decision === "applicable")
39
41
  .map(({ requirementId }) => requirementId);
@@ -42,7 +44,7 @@ export function selectedRequirementIds(program, model) {
42
44
  }
43
45
 
44
46
  export function programComponents(loaded, program) {
45
- if (String(loaded.model.modelVersion) !== "4") return [];
47
+ if (!modelSupports(loaded.model, "program-scope")) return [];
46
48
  const systemIds = new Set(program.systemIds || []);
47
49
  return loaded.resources.filter((record) => (
48
50
  record.type === "component"
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
+ import { modelSupports } from "../model/index.js";
5
6
  import { createObligationEvent } from "./obligations.js";
6
7
  import { markdownEntries } from "./resource-markdown.js";
7
8
  import { loadWorkspace } from "./workspace.js";
@@ -94,13 +95,13 @@ export async function planReconciliation(input = process.cwd()) {
94
95
  const loaded = input?.resources && input?.model && input?.entries
95
96
  ? input
96
97
  : await loadWorkspace(input);
97
- if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
98
+ if (!modelSupports(loaded.model, "guided-workflow")) {
98
99
  return {
99
100
  contractVersion: 1,
100
101
  gitRevision: gitRevision(loaded.root),
101
102
  changedPaths: [],
102
103
  candidates: [],
103
- message: "Direct-file transition reconciliation is available in model v3 and v4 workspaces."
104
+ message: "Direct-file transition reconciliation is available in model v3 and newer workspaces."
104
105
  };
105
106
  }
106
107
  const changedPaths = gitChangedPaths(loaded.root);
package/src/server.js CHANGED
@@ -5,6 +5,7 @@ import { extname, join, resolve } from "node:path";
5
5
  import { performance } from "node:perf_hooks";
6
6
  import { getResourceDefinition } from "../model/index.js";
7
7
  import { prepareAuditWorkspace } from "./audit-preparation.js";
8
+ import { activateDocuments, activateGovernedContent } from "./document-activation.js";
8
9
  import { createNextAuditCycle, planNextAuditCycle } from "./audit-transition.js";
9
10
  import { applyApplicabilityReviewWithContext, planApplicabilityReview } from "./batch-review.js";
10
11
  import { applyCollectionReview, planCollectionReview } from "./collection-review.js";
@@ -330,6 +331,20 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
330
331
  }, () => activatePolicies(input, { ...payload, confirmed: true }));
331
332
  return json(response, 200, result);
332
333
  }
334
+ if (request.method === "POST" && url.pathname === "/api/document-activations") {
335
+ const payload = await readJson(request);
336
+ const result = await browserMutation(input, options, {
337
+ message: (activation) => `Activate ${activation.documentIds.length} governed ${activation.documentIds.length === 1 ? "Document" : "Documents"}`
338
+ }, () => activateDocuments(input, { ...payload, confirmed: true }));
339
+ return json(response, 200, result);
340
+ }
341
+ if (request.method === "POST" && url.pathname === "/api/governed-content-activations") {
342
+ const payload = await readJson(request);
343
+ const result = await browserMutation(input, options, {
344
+ message: (activation) => `Activate ${activation.resourceIds.length} governed-content ${activation.resourceIds.length === 1 ? "record" : "records"}`
345
+ }, () => activateGovernedContent(input, { ...payload, confirmed: true }));
346
+ return json(response, 200, result);
347
+ }
333
348
  if (request.method === "POST" && url.pathname === "/api/resources") {
334
349
  const payload = normalizeResourceMutation(await readJson(request));
335
350
  const { record } = payload;
@@ -543,13 +558,8 @@ function browserMutation(input, options, mutationOptions, task) {
543
558
  includeDetails: false,
544
559
  validationProof: result?.[BROWSER_VALIDATION]
545
560
  }));
546
- if (result?.synchronization?.status === "syncing" && state.repository.status !== "syncing") {
547
- result.synchronization = {
548
- ...result.synchronization,
549
- status: state.repository.status === "synced" ? "synced" : "not-synced",
550
- synchronizedAt: state.repository.lastSuccessfulSynchronization ?? null,
551
- pushError: state.repository.backgroundSyncError ?? null
552
- };
561
+ if (result?.synchronization) {
562
+ result.synchronization = reconcileMutationSynchronization(result.synchronization, state.repository);
553
563
  }
554
564
  return {
555
565
  ...result,
@@ -564,6 +574,25 @@ function browserMutation(input, options, mutationOptions, task) {
564
574
  });
565
575
  }
566
576
 
577
+ export function reconcileMutationSynchronization(synchronization, repository) {
578
+ if (synchronization?.status !== "syncing" || repository.status === "syncing") return synchronization;
579
+ const backgroundFailed = repository.backgroundSynchronization?.status === "failed";
580
+ if (repository.status !== "synced" && !backgroundFailed) {
581
+ // A repository snapshot can finish just before a fast background push while
582
+ // its state is inspected just after the push. Keep the queued result until
583
+ // a verified success or failure replaces it.
584
+ return synchronization;
585
+ }
586
+ return {
587
+ ...synchronization,
588
+ status: repository.status === "synced" ? "synced" : "not-synced",
589
+ synchronizedAt: repository.lastSuccessfulSynchronization ?? null,
590
+ pushError: repository.backgroundSyncError
591
+ ?? repository.backgroundSynchronization?.error
592
+ ?? null
593
+ };
594
+ }
595
+
567
596
  function prefersFastMutation(request) {
568
597
  return String(request.headers.prefer || "")
569
598
  .split(",")
package/src/setup.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { modelSupports } from "../model/index.js";
1
2
  import { applyResourceBatch } from "./files.js";
2
3
  import { createResourceId } from "./id.js";
3
4
  import { resolveProgram, selectedRequirementIds } from "./program.js";
@@ -79,7 +80,9 @@ export function summarizeSetupResult(result) {
79
80
  commitment: result.commitment ? "saved" : "unchanged"
80
81
  },
81
82
  system: setupSystemSummary(result.system),
82
- target: setupTargetSummary(result.program || result.workspace, { modelVersion: result.program ? "4" : "3" }),
83
+ target: setupTargetSummary(result.program || result.workspace, {
84
+ modelVersion: result.workspace?.dataModelVersion || (result.program ? "6" : "3")
85
+ }),
83
86
  renderer: result.renderer ? setupRendererSummary(result.renderer) : null,
84
87
  commitment: result.commitment || null,
85
88
  onboardingComplete: result.onboardingComplete
@@ -131,7 +134,7 @@ function validateSetup(loaded, setup) {
131
134
  throw new Error(`System "${setup.systemId}" cannot be used for initial scope because it is ${system.status}.`);
132
135
  }
133
136
  }
134
- const classifications = String(loaded.model.modelVersion) === "4"
137
+ const classifications = modelSupports(loaded.model, "program-scope")
135
138
  ? loaded.resources.filter(({ type, status }) => type === "classification" && status === "active").map(({ id }) => id)
136
139
  : Object.keys(loaded.workspace.classificationDefinitions || {});
137
140
  if (classifications.length && !classifications.includes(setup.classificationId)) {
@@ -142,7 +145,7 @@ function validateSetup(loaded, setup) {
142
145
  function resolveClassificationId(loaded, value) {
143
146
  const normalized = String(value || "").trim().toLowerCase();
144
147
  if (!normalized) return value;
145
- const candidates = String(loaded.model.modelVersion) === "4"
148
+ const candidates = modelSupports(loaded.model, "program-scope")
146
149
  ? loaded.resources
147
150
  .filter(({ type, status }) => type === "classification" && status === "active")
148
151
  .map(({ id, title }) => ({ id, label: title }))
@@ -167,7 +170,7 @@ function findSetupSystem(resources, target, setup) {
167
170
 
168
171
  function buildSetupRecords(loaded, setup) {
169
172
  const target = resolveProgram(loaded);
170
- const v4 = String(loaded.model.modelVersion) === "4";
173
+ const v4 = modelSupports(loaded.model, "program-scope");
171
174
  const existingSystem = findSetupSystem(loaded.resources, target, setup);
172
175
  const systemId = existingSystem?.id || createResourceId(
173
176
  "system",
@@ -233,7 +236,7 @@ function buildSetupRecords(loaded, setup) {
233
236
  && !["superseded", "retired"].includes(record.status)
234
237
  && (record.systemIds || []).includes(systemId)
235
238
  ));
236
- const commitment = ["3", "4"].includes(String(loaded.model.modelVersion)) && !existingCommitment
239
+ const commitment = modelSupports(loaded.model, "guided-workflow") && !existingCommitment
237
240
  ? {
238
241
  id: createResourceId(
239
242
  "commitment",
package/src/soc2.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { modelSupports } from "../model/index.js";
1
2
  import { coverageEnd, coverageStart } from "./coverage.js";
2
3
 
3
4
  export const REQUIRED_SOC2_DESCRIPTION_REFERENCES = Array.from(
@@ -49,7 +50,7 @@ const SOC2_PROGRAM_GOALS = new Set([
49
50
 
50
51
  export function soc2RequirementApplicabilityConstraint(requirement, program, modelVersion = "4") {
51
52
  if (
52
- String(modelVersion) !== "4"
53
+ !modelSupports(modelVersion, "program-scope")
53
54
  || requirement?.type !== "requirement"
54
55
  || !SOC2_PROGRAM_GOALS.has(program?.assuranceGoal)
55
56
  ) return null;
@@ -171,7 +172,7 @@ export function soc2ReportEvidenceIssue(evidence, audit, modelVersion = "4") {
171
172
  message: `${evidence?.title || audit?.title || "The audit"} must be verified third-party-report Evidence with subtype soc2-report for the issued SOC 2 report.`
172
173
  };
173
174
  }
174
- const issuedOn = (String(modelVersion) === "4"
175
+ const issuedOn = (modelSupports(modelVersion, "program-scope")
175
176
  ? evidence.sourceGeneratedAt
176
177
  : evidence.sourceGeneratedAt || evidence.businessEventAt || evidence.collectedOn
177
178
  )?.slice(0, 10);
package/src/validate.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile, stat } from "node:fs/promises";
3
3
  import { performance } from "node:perf_hooks";
4
- import { getResourceDefinition } from "../model/index.js";
4
+ import { getResourceDefinition, modelSupports } from "../model/index.js";
5
5
  import { scopedCollectionRecords } from "./collection-scope.js";
6
6
  import { collectionRevision } from "./collection-revision.js";
7
7
  import { isSafeGitName } from "./git-name.js";
@@ -12,6 +12,7 @@ import { partyPeople } from "./parties.js";
12
12
  import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
13
13
  import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
14
14
  import { recordTiming } from "./timing.js";
15
+ import { personWasActiveOn } from "./soc2.js";
15
16
  import { indexResources, loadWorkspace } from "./workspace.js";
16
17
 
17
18
  const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -147,6 +148,9 @@ async function validateWorkspaceUnmeasured(input) {
147
148
  pathById,
148
149
  diagnostics
149
150
  );
151
+ if (modelSupports(loaded.model, "document-workflow-scope")) {
152
+ validateDocumentWorkflowScopes(loaded.resources, loaded.model, byId, pathById, diagnostics);
153
+ }
150
154
 
151
155
  diagnostics.sort((a, b) => `${a.severity}:${a.path}:${a.code}`.localeCompare(`${b.severity}:${b.path}:${b.code}`));
152
156
  return {
@@ -161,6 +165,130 @@ async function validateWorkspaceUnmeasured(input) {
161
165
  };
162
166
  }
163
167
 
168
+ function validateDocumentWorkflowScopes(resources, model, byId, pathById, diagnostics) {
169
+ const managementFields = [
170
+ "engagementTermsDocumentId",
171
+ ...(model.auditReadiness?.managementDocuments || []).map(({ field }) => field)
172
+ ];
173
+ const auditReferences = new Map();
174
+ const governedAuditReferences = new Map();
175
+ const addAuditReference = (map, documentId, audit, field) => {
176
+ if (!documentId) return;
177
+ if (!map.has(documentId)) map.set(documentId, []);
178
+ map.get(documentId).push({ audit, field });
179
+ };
180
+ for (const audit of resources.filter(({ type }) => type === "audit")) {
181
+ for (const field of managementFields) {
182
+ const documentId = audit[field];
183
+ if (!documentId) continue;
184
+ addAuditReference(auditReferences, documentId, audit, field);
185
+ addAuditReference(governedAuditReferences, documentId, audit, field);
186
+ const document = byId.get(documentId);
187
+ if (document?.type === "document" && document.workflowScope !== "engagement") {
188
+ diagnostics.push(error(
189
+ "invalid-document-workflow-scope",
190
+ pathById.get(audit.id) || `data/${audit.id}`,
191
+ `${field} must reference an engagement-scoped Document; "${document.title}" is scoped to the program workflow.`
192
+ ));
193
+ }
194
+ }
195
+ for (const documentId of audit.supplementalDocumentIds || []) {
196
+ addAuditReference(auditReferences, documentId, audit, "supplementalDocumentIds");
197
+ }
198
+ }
199
+
200
+ const programDocumentReferences = new Map();
201
+ const addProgramReference = (documentId, record, field) => {
202
+ if (!documentId) return;
203
+ if (!programDocumentReferences.has(documentId)) programDocumentReferences.set(documentId, []);
204
+ programDocumentReferences.get(documentId).push({ record, field });
205
+ };
206
+ for (const policy of resources.filter(({ type }) => type === "policy")) {
207
+ for (const documentId of policy.relatedDocumentIds || []) addProgramReference(documentId, policy, "relatedDocumentIds");
208
+ }
209
+ for (const obligation of resources.filter(({ type }) => type === "obligation")) {
210
+ for (const documentId of obligation.scopeResourceIds || []) {
211
+ if (byId.get(documentId)?.type === "document") addProgramReference(documentId, obligation, "scopeResourceIds");
212
+ }
213
+ if (byId.get(obligation.templateResourceId)?.type === "document") {
214
+ addProgramReference(obligation.templateResourceId, obligation, "templateResourceId");
215
+ }
216
+ }
217
+
218
+ for (const document of resources.filter(({ type }) => type === "document")) {
219
+ const path = pathById.get(document.id) || `data/${document.id}`;
220
+ const auditRefs = auditReferences.get(document.id) || [];
221
+ const auditIds = new Set(auditRefs.map(({ audit }) => audit.id));
222
+ const programRefs = programDocumentReferences.get(document.id) || [];
223
+ if (document.workflowScope === "engagement" && programRefs.length) {
224
+ const references = programRefs.map(({ record, field }) => `${record.id}.${field}`).join(", ");
225
+ diagnostics.push(error(
226
+ "engagement-document-in-program-workflow",
227
+ path,
228
+ `Engagement-scoped Document "${document.title}" cannot govern reusable program work through ${references}. Split the engagement deliverable from the program Document.`
229
+ ));
230
+ }
231
+ if (
232
+ document.workflowScope === "engagement"
233
+ && ["approved", "active"].includes(document.status)
234
+ && auditIds.size !== 1
235
+ ) {
236
+ diagnostics.push(error(
237
+ "invalid-engagement-document-audit-count",
238
+ path,
239
+ `Approved or active engagement Document "${document.title}" must belong to exactly one Audit; found ${auditIds.size}.`
240
+ ));
241
+ }
242
+ if (document.workflowScope === "program" && (governedAuditReferences.get(document.id) || []).length) {
243
+ diagnostics.push(error(
244
+ "program-document-in-engagement-workflow",
245
+ path,
246
+ `Program-scoped Document "${document.title}" cannot fill an Audit engagement or management-Document field.`
247
+ ));
248
+ }
249
+ if (document.activationBasis !== "legacy-v4") continue;
250
+ const historicalAuditIds = new Set(auditRefs
251
+ .filter(({ audit }) => ["issued", "delivered", "complete"].includes(audit.status))
252
+ .map(({ audit }) => audit.id));
253
+ if (document.workflowScope !== "engagement" || historicalAuditIds.size !== 1 || auditIds.size !== 1) {
254
+ diagnostics.push(error(
255
+ "invalid-legacy-document-activation",
256
+ path,
257
+ `activationBasis legacy-v4 is reserved for an engagement Document tied to exactly one issued, delivered, or completed Audit.`
258
+ ));
259
+ }
260
+ }
261
+
262
+ for (const document of resources.filter(({ type, activationBasis }) => (
263
+ type === "document" && activationBasis === "recorded"
264
+ ))) {
265
+ const invalidActorIds = (document.activatedByIds || []).filter((id) => (
266
+ !personWasActiveOn(byId.get(id), document.activatedOn)
267
+ ));
268
+ if (invalidActorIds.length) {
269
+ diagnostics.push(error(
270
+ "invalid-document-activation-actor",
271
+ pathById.get(document.id) || `data/${document.id}`,
272
+ `Document activation actors must have been active on ${document.activatedOn}: ${invalidActorIds.join(", ")}.`
273
+ ));
274
+ }
275
+ }
276
+ for (const training of resources.filter(({ type, activationBasis }) => (
277
+ type === "training" && activationBasis === "recorded"
278
+ ))) {
279
+ const invalidActorIds = (training.activatedByIds || []).filter((id) => (
280
+ !personWasActiveOn(byId.get(id), training.activatedOn)
281
+ ));
282
+ if (invalidActorIds.length) {
283
+ diagnostics.push(error(
284
+ "invalid-training-activation-actor",
285
+ pathById.get(training.id) || `data/${training.id}`,
286
+ `Training activation actors must have been active on ${training.activatedOn}: ${invalidActorIds.join(", ")}.`
287
+ ));
288
+ }
289
+ }
290
+ }
291
+
164
292
  function validateCollectionReview(record, loaded, byId, path, diagnostics) {
165
293
  if (record.status !== "active") return;
166
294
  const { model } = loaded;
@@ -175,7 +303,7 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
175
303
  ));
176
304
  return;
177
305
  }
178
- const program = String(model.modelVersion) === "4"
306
+ const program = modelSupports(model, "program-scope")
179
307
  ? (record.scopeResourceIds || []).map((id) => byId.get(id)).find(({ type } = {}) => type === "program")
180
308
  : null;
181
309
  const recordCount = scopedCollectionRecords(loaded, record.resourceType, program).length;
@@ -208,7 +336,7 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
208
336
  diagnostics.push(error(
209
337
  "inactive-authoritative-system",
210
338
  path,
211
- `${configuration.title} must name an active authoritative ${String(model.modelVersion) === "4" ? "Component" : "System"} for an externally managed conclusion.`
339
+ `${configuration.title} must name an active authoritative ${modelSupports(model, "component-sources") ? "Component" : "System"} for an externally managed conclusion.`
212
340
  ));
213
341
  }
214
342
  }
@@ -436,7 +564,7 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
436
564
  ? model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || []
437
565
  : [];
438
566
  if (!expectedTypes.length) continue;
439
- const completionIds = ["3", "4"].includes(String(model.modelVersion))
567
+ const completionIds = modelSupports(model, "guided-workflow")
440
568
  ? action.completionResourceIds || []
441
569
  : [...(action.completionResourceIds || []), ...(action.evidenceIds || [])];
442
570
  const linked = [...new Set(completionIds)]
@@ -454,7 +582,7 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
454
582
 
455
583
  function validateCompletedObligationAction(record, byId, model, path, diagnostics) {
456
584
  if (
457
- !["3", "4"].includes(String(model.modelVersion))
585
+ !modelSupports(model, "guided-workflow")
458
586
  || record.status !== "done"
459
587
  || !record.obligationId
460
588
  ) return;
@@ -672,6 +800,9 @@ function validateCompletionDates(record, path, diagnostics) {
672
800
  ]);
673
801
  validateOrderedDates(record, path, diagnostics, ["startedAt", "endedAt"]);
674
802
  validateOrderedDates(record, path, diagnostics, ["fieldworkStart", "fieldworkEnd", "reportDate"]);
803
+ if (["document", "training"].includes(record.type) && record.activatedOn) {
804
+ validateOrderedDates(record, path, diagnostics, ["approvedOn", "activatedOn", "effectiveOn"]);
805
+ }
675
806
  if (record.acceptance) {
676
807
  validateOrderedDates(record.acceptance, path, diagnostics, ["acceptedOn", "expiresOn"], "acceptance.");
677
808
  }
@@ -766,8 +897,15 @@ async function validateAttestationBinding(record, model, root, byId, path, diagn
766
897
  }
767
898
 
768
899
  async function validateApprovalBinding(record, model, root, path, diagnostics) {
769
- const bindingField = approvalBindingField(record, model);
770
- if (!bindingField || !approvalBound(record) || !record[bindingField]) return;
900
+ const bindingFields = contentBindingFields(record, model);
901
+ for (const binding of bindingFields) {
902
+ await validateContentBinding(record, model, root, path, diagnostics, binding);
903
+ }
904
+ }
905
+
906
+ async function validateContentBinding(record, model, root, path, diagnostics, binding) {
907
+ const { field: bindingField, bound, label } = binding;
908
+ if (!bound(record) || !record[bindingField]) return;
771
909
  const actual = {};
772
910
  for (const item of markdownEntries(model, record)) {
773
911
  try {
@@ -787,24 +925,48 @@ async function validateApprovalBinding(record, model, root, path, diagnostics) {
787
925
  diagnostics.push(error(
788
926
  "approval-content-changed",
789
927
  path,
790
- `Approved content no longer matches ${invalid.map((item) => `data/${item}`).join(", ")}. Move the record to draft or in-review, review the change, then approve it again.`
928
+ `${label} content no longer matches ${invalid.map((item) => `data/${item}`).join(", ")}. Move the record to draft or in-review, review the change, then approve and activate it again.`
791
929
  ));
792
930
  }
793
931
  }
794
932
 
795
- function approvalBound(record) {
933
+ function approvalBound(record, model) {
796
934
  if (record.type === "policy") return ["approved", "active", "superseded", "retired"].includes(record.status);
797
- if (record.type === "document") return ["active", "superseded", "retired"].includes(record.status);
798
- if (record.type === "training") return ["active", "retired"].includes(record.status);
935
+ if (record.type === "document") return ["approved", "active", "superseded", "retired"].includes(record.status);
936
+ if (record.type === "training") {
937
+ return (modelSupports(model, "governed-training-activation")
938
+ ? ["approved", "active", "superseded", "retired"]
939
+ : ["active", "retired"]).includes(record.status);
940
+ }
799
941
  return false;
800
942
  }
801
943
 
802
- function approvalBindingField(record, model) {
803
- if (["policy", "document"].includes(record.type)) return "approvedContentRevisions";
944
+ function contentBindingFields(record, model) {
945
+ const fields = [];
946
+ if (["policy", "document"].includes(record.type)) {
947
+ fields.push({ field: "approvedContentRevisions", bound: (candidate) => approvalBound(candidate, model), label: "Approved" });
948
+ }
949
+ if (record.type === "document" && model.resources.document?.fields?.activatedContentRevisions) {
950
+ fields.push({
951
+ field: "activatedContentRevisions",
952
+ bound: (candidate) => ["active", "superseded", "retired"].includes(candidate?.status),
953
+ label: "Activated"
954
+ });
955
+ }
804
956
  if (record.type === "training" && model.resources.training?.fields?.effectiveContentRevisions) {
805
- return "effectiveContentRevisions";
957
+ fields.push({ field: "effectiveContentRevisions", bound: (candidate) => approvalBound(candidate, model), label: "Approved" });
958
+ }
959
+ if (record.type === "training" && model.resources.training?.fields?.approvedContentRevisions) {
960
+ fields.push({ field: "approvedContentRevisions", bound: (candidate) => approvalBound(candidate, model), label: "Approved" });
961
+ }
962
+ if (record.type === "training" && model.resources.training?.fields?.activatedContentRevisions) {
963
+ fields.push({
964
+ field: "activatedContentRevisions",
965
+ bound: (candidate) => ["active", "superseded", "retired"].includes(candidate?.status),
966
+ label: "Activated"
967
+ });
806
968
  }
807
- return null;
969
+ return fields;
808
970
  }
809
971
 
810
972
  function validateCoverage(record, path, diagnostics) {
@@ -858,7 +1020,7 @@ function validateCoverage(record, path, diagnostics) {
858
1020
 
859
1021
  function validateClassification(record, loaded, path, diagnostics) {
860
1022
  if (!record.classificationId) return;
861
- if (String(loaded.model.modelVersion) === "4") {
1023
+ if (modelSupports(loaded.model, "program-scope")) {
862
1024
  if (!loaded.resources.some(({ id, type }) => id === record.classificationId && type === "classification")) {
863
1025
  diagnostics.push(error(
864
1026
  "unknown-classification",