filegrc 0.7.1 → 0.8.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/README.md +6 -6
- package/model/index.js +21 -4
- package/model/v5.json +10233 -0
- package/package.json +4 -2
- package/src/agent.js +4 -3
- package/src/audit-preparation.js +182 -45
- package/src/audit-transition.js +3 -2
- package/src/batch-review.js +7 -6
- package/src/cli.js +74 -12
- package/src/collection-review.js +4 -3
- package/src/collection-scope.js +4 -3
- package/src/document-activation.js +145 -0
- package/src/evidence-packet.js +199 -78
- package/src/external-reviewer.js +5 -4
- package/src/files.js +141 -32
- package/src/git.js +71 -7
- package/src/index.js +4 -1
- package/src/model-migration.js +218 -7
- package/src/obligations.js +14 -11
- package/src/policy-library.js +2 -1
- package/src/program-lifecycle.js +93 -3
- package/src/program-path.js +13 -8
- package/src/program-readiness.js +235 -71
- package/src/program.js +5 -3
- package/src/reconciliation.js +3 -2
- package/src/server.js +29 -7
- package/src/setup.js +8 -5
- package/src/soc2.js +3 -2
- package/src/validate.js +148 -14
- package/src/web.js +160 -15
- package/src/workflow.js +18 -4
- package/src/workspace.js +5 -0
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 } 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,13 @@ 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
|
+
}
|
|
333
341
|
if (request.method === "POST" && url.pathname === "/api/resources") {
|
|
334
342
|
const payload = normalizeResourceMutation(await readJson(request));
|
|
335
343
|
const { record } = payload;
|
|
@@ -543,13 +551,8 @@ function browserMutation(input, options, mutationOptions, task) {
|
|
|
543
551
|
includeDetails: false,
|
|
544
552
|
validationProof: result?.[BROWSER_VALIDATION]
|
|
545
553
|
}));
|
|
546
|
-
if (result?.synchronization
|
|
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
|
-
};
|
|
554
|
+
if (result?.synchronization) {
|
|
555
|
+
result.synchronization = reconcileMutationSynchronization(result.synchronization, state.repository);
|
|
553
556
|
}
|
|
554
557
|
return {
|
|
555
558
|
...result,
|
|
@@ -564,6 +567,25 @@ function browserMutation(input, options, mutationOptions, task) {
|
|
|
564
567
|
});
|
|
565
568
|
}
|
|
566
569
|
|
|
570
|
+
export function reconcileMutationSynchronization(synchronization, repository) {
|
|
571
|
+
if (synchronization?.status !== "syncing" || repository.status === "syncing") return synchronization;
|
|
572
|
+
const backgroundFailed = repository.backgroundSynchronization?.status === "failed";
|
|
573
|
+
if (repository.status !== "synced" && !backgroundFailed) {
|
|
574
|
+
// A repository snapshot can finish just before a fast background push while
|
|
575
|
+
// its state is inspected just after the push. Keep the queued result until
|
|
576
|
+
// a verified success or failure replaces it.
|
|
577
|
+
return synchronization;
|
|
578
|
+
}
|
|
579
|
+
return {
|
|
580
|
+
...synchronization,
|
|
581
|
+
status: repository.status === "synced" ? "synced" : "not-synced",
|
|
582
|
+
synchronizedAt: repository.lastSuccessfulSynchronization ?? null,
|
|
583
|
+
pushError: repository.backgroundSyncError
|
|
584
|
+
?? repository.backgroundSynchronization?.error
|
|
585
|
+
?? null
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
567
589
|
function prefersFastMutation(request) {
|
|
568
590
|
return String(request.headers.prefer || "")
|
|
569
591
|
.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, {
|
|
83
|
+
target: setupTargetSummary(result.program || result.workspace, {
|
|
84
|
+
modelVersion: result.workspace?.dataModelVersion || (result.program ? "5" : "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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 = (
|
|
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,116 @@ 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
|
+
}
|
|
277
|
+
|
|
164
278
|
function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
165
279
|
if (record.status !== "active") return;
|
|
166
280
|
const { model } = loaded;
|
|
@@ -175,7 +289,7 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
175
289
|
));
|
|
176
290
|
return;
|
|
177
291
|
}
|
|
178
|
-
const program =
|
|
292
|
+
const program = modelSupports(model, "program-scope")
|
|
179
293
|
? (record.scopeResourceIds || []).map((id) => byId.get(id)).find(({ type } = {}) => type === "program")
|
|
180
294
|
: null;
|
|
181
295
|
const recordCount = scopedCollectionRecords(loaded, record.resourceType, program).length;
|
|
@@ -208,7 +322,7 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
208
322
|
diagnostics.push(error(
|
|
209
323
|
"inactive-authoritative-system",
|
|
210
324
|
path,
|
|
211
|
-
`${configuration.title} must name an active authoritative ${
|
|
325
|
+
`${configuration.title} must name an active authoritative ${modelSupports(model, "component-sources") ? "Component" : "System"} for an externally managed conclusion.`
|
|
212
326
|
));
|
|
213
327
|
}
|
|
214
328
|
}
|
|
@@ -436,7 +550,7 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
|
|
|
436
550
|
? model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || []
|
|
437
551
|
: [];
|
|
438
552
|
if (!expectedTypes.length) continue;
|
|
439
|
-
const completionIds =
|
|
553
|
+
const completionIds = modelSupports(model, "guided-workflow")
|
|
440
554
|
? action.completionResourceIds || []
|
|
441
555
|
: [...(action.completionResourceIds || []), ...(action.evidenceIds || [])];
|
|
442
556
|
const linked = [...new Set(completionIds)]
|
|
@@ -454,7 +568,7 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
|
|
|
454
568
|
|
|
455
569
|
function validateCompletedObligationAction(record, byId, model, path, diagnostics) {
|
|
456
570
|
if (
|
|
457
|
-
!
|
|
571
|
+
!modelSupports(model, "guided-workflow")
|
|
458
572
|
|| record.status !== "done"
|
|
459
573
|
|| !record.obligationId
|
|
460
574
|
) return;
|
|
@@ -672,6 +786,9 @@ function validateCompletionDates(record, path, diagnostics) {
|
|
|
672
786
|
]);
|
|
673
787
|
validateOrderedDates(record, path, diagnostics, ["startedAt", "endedAt"]);
|
|
674
788
|
validateOrderedDates(record, path, diagnostics, ["fieldworkStart", "fieldworkEnd", "reportDate"]);
|
|
789
|
+
if (record.type === "document") {
|
|
790
|
+
validateOrderedDates(record, path, diagnostics, ["approvedOn", "activatedOn", "effectiveOn"]);
|
|
791
|
+
}
|
|
675
792
|
if (record.acceptance) {
|
|
676
793
|
validateOrderedDates(record.acceptance, path, diagnostics, ["acceptedOn", "expiresOn"], "acceptance.");
|
|
677
794
|
}
|
|
@@ -766,8 +883,15 @@ async function validateAttestationBinding(record, model, root, byId, path, diagn
|
|
|
766
883
|
}
|
|
767
884
|
|
|
768
885
|
async function validateApprovalBinding(record, model, root, path, diagnostics) {
|
|
769
|
-
const
|
|
770
|
-
|
|
886
|
+
const bindingFields = contentBindingFields(record, model);
|
|
887
|
+
for (const binding of bindingFields) {
|
|
888
|
+
await validateContentBinding(record, model, root, path, diagnostics, binding);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function validateContentBinding(record, model, root, path, diagnostics, binding) {
|
|
893
|
+
const { field: bindingField, bound, label } = binding;
|
|
894
|
+
if (!bound(record) || !record[bindingField]) return;
|
|
771
895
|
const actual = {};
|
|
772
896
|
for (const item of markdownEntries(model, record)) {
|
|
773
897
|
try {
|
|
@@ -787,24 +911,34 @@ async function validateApprovalBinding(record, model, root, path, diagnostics) {
|
|
|
787
911
|
diagnostics.push(error(
|
|
788
912
|
"approval-content-changed",
|
|
789
913
|
path,
|
|
790
|
-
|
|
914
|
+
`${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
915
|
));
|
|
792
916
|
}
|
|
793
917
|
}
|
|
794
918
|
|
|
795
919
|
function approvalBound(record) {
|
|
796
920
|
if (record.type === "policy") return ["approved", "active", "superseded", "retired"].includes(record.status);
|
|
797
|
-
if (record.type === "document") return ["active", "superseded", "retired"].includes(record.status);
|
|
921
|
+
if (record.type === "document") return ["approved", "active", "superseded", "retired"].includes(record.status);
|
|
798
922
|
if (record.type === "training") return ["active", "retired"].includes(record.status);
|
|
799
923
|
return false;
|
|
800
924
|
}
|
|
801
925
|
|
|
802
|
-
function
|
|
803
|
-
|
|
926
|
+
function contentBindingFields(record, model) {
|
|
927
|
+
const fields = [];
|
|
928
|
+
if (["policy", "document"].includes(record.type)) {
|
|
929
|
+
fields.push({ field: "approvedContentRevisions", bound: approvalBound, label: "Approved" });
|
|
930
|
+
}
|
|
931
|
+
if (record.type === "document" && model.resources.document?.fields?.activatedContentRevisions) {
|
|
932
|
+
fields.push({
|
|
933
|
+
field: "activatedContentRevisions",
|
|
934
|
+
bound: (candidate) => ["active", "superseded", "retired"].includes(candidate?.status),
|
|
935
|
+
label: "Activated"
|
|
936
|
+
});
|
|
937
|
+
}
|
|
804
938
|
if (record.type === "training" && model.resources.training?.fields?.effectiveContentRevisions) {
|
|
805
|
-
|
|
939
|
+
fields.push({ field: "effectiveContentRevisions", bound: approvalBound, label: "Approved" });
|
|
806
940
|
}
|
|
807
|
-
return
|
|
941
|
+
return fields;
|
|
808
942
|
}
|
|
809
943
|
|
|
810
944
|
function validateCoverage(record, path, diagnostics) {
|
|
@@ -858,7 +992,7 @@ function validateCoverage(record, path, diagnostics) {
|
|
|
858
992
|
|
|
859
993
|
function validateClassification(record, loaded, path, diagnostics) {
|
|
860
994
|
if (!record.classificationId) return;
|
|
861
|
-
if (
|
|
995
|
+
if (modelSupports(loaded.model, "program-scope")) {
|
|
862
996
|
if (!loaded.resources.some(({ id, type }) => id === record.classificationId && type === "classification")) {
|
|
863
997
|
diagnostics.push(error(
|
|
864
998
|
"unknown-classification",
|