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/README.md +7 -7
- package/model/index.js +22 -4
- package/model/v5.json +10233 -0
- package/model/v6.json +10358 -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 +143 -12
- package/src/collection-review.js +4 -3
- package/src/collection-scope.js +4 -3
- package/src/document-activation.js +181 -0
- package/src/evidence-packet.js +199 -78
- package/src/external-reviewer.js +5 -4
- package/src/files.js +173 -35
- package/src/git.js +71 -7
- package/src/index.js +11 -1
- package/src/model-migration.js +363 -7
- package/src/obligations.js +14 -11
- package/src/policy-library.js +7 -3
- package/src/program-lifecycle.js +131 -3
- package/src/program-path.js +19 -13
- package/src/program-readiness.js +338 -71
- package/src/program.js +5 -3
- package/src/reconciliation.js +3 -2
- package/src/server.js +36 -7
- package/src/setup.js +8 -5
- package/src/soc2.js +3 -2
- package/src/validate.js +178 -16
- package/src/web.js +264 -17
- package/src/workflow.js +38 -7
- package/src/workspace.js +5 -0
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 (
|
|
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 (
|
|
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 (
|
|
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"
|
package/src/reconciliation.js
CHANGED
|
@@ -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 (!
|
|
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
|
|
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
|
|
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, {
|
|
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 =
|
|
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,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 =
|
|
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 ${
|
|
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 =
|
|
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
|
-
!
|
|
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
|
|
770
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
|
803
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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",
|