filegrc 0.11.0 → 0.12.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/model/index.js +9 -5
- package/model/v10.json +12122 -0
- package/model/v9.json +11647 -0
- package/package.json +1 -1
- package/src/agent.js +3 -0
- package/src/audit-populations.js +88 -0
- package/src/audit-preparation.js +7 -2
- package/src/cli.js +186 -13
- package/src/collection-review-integrity.js +118 -0
- package/src/collection-review.js +71 -7
- package/src/collection-scope.js +8 -0
- package/src/evidence-packet.js +328 -46
- package/src/files.js +364 -6
- package/src/git.js +1064 -149
- package/src/index.js +17 -1
- package/src/model-migration.js +221 -5
- package/src/obligations.js +834 -66
- package/src/policy-library/information-security-policy-v2.md +1 -1
- package/src/policy-library.js +85 -8
- package/src/program-path.js +11 -5
- package/src/program-readiness.js +84 -6
- package/src/reconciliation.js +332 -81
- package/src/reporting-route-integrity.js +542 -0
- package/src/reporting-route-sets.js +745 -0
- package/src/server.js +160 -47
- package/src/state.js +30 -8
- package/src/time.js +55 -0
- package/src/validate.js +978 -4
- package/src/web.js +491 -49
- package/src/workflow-history-integrity.js +872 -0
- package/src/workflow.js +36 -10
package/src/validate.js
CHANGED
|
@@ -2,22 +2,39 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { readFile, stat } from "node:fs/promises";
|
|
3
3
|
import { performance } from "node:perf_hooks";
|
|
4
4
|
import { getResourceDefinition, modelSupports } from "../model/index.js";
|
|
5
|
-
import { scopedCollectionRecords } from "./collection-scope.js";
|
|
5
|
+
import { scopedCollectionRecords, selectScopedCollectionRecords } from "./collection-scope.js";
|
|
6
6
|
import {
|
|
7
7
|
collectionRevision,
|
|
8
8
|
collectionRevisionMatches
|
|
9
9
|
} from "./collection-revision.js";
|
|
10
10
|
import { isSafeGitName } from "./git-name.js";
|
|
11
|
+
import { getFileBufferAtRevision, getFileObjectIdAtRevision, getFilePathAtRevision, getGitSummary, getWorkingFileObjectId, hasGitRevision, isDataHistoryAncestor } from "./git.js";
|
|
11
12
|
import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
|
|
12
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
addCalendarDays,
|
|
15
|
+
calendarOccurrence,
|
|
16
|
+
calendarOccurrenceIndex,
|
|
17
|
+
parseCalendarDate,
|
|
18
|
+
validCalendarRecurrence
|
|
19
|
+
} from "./recurrence.js";
|
|
13
20
|
import { resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
|
|
14
21
|
import { obligationIsEnabled } from "./program-lifecycle.js";
|
|
15
22
|
import { partyPeople } from "./parties.js";
|
|
16
23
|
import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
|
|
17
|
-
import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
|
|
24
|
+
import { currentCalendarDate, isRfc3339Timestamp, localDateTimeValue, timestampFromLocalDateTime } from "./time.js";
|
|
18
25
|
import { recordTiming } from "./timing.js";
|
|
19
26
|
import { personWasActiveOn } from "./soc2.js";
|
|
20
27
|
import { indexResources, loadWorkspace } from "./workspace.js";
|
|
28
|
+
import { collectionReviewRevision, historicalCollectionReviewSnapshot } from "./collection-review-integrity.js";
|
|
29
|
+
import {
|
|
30
|
+
reportingRouteRevision,
|
|
31
|
+
reportingRouteBindingExpectationForValidation,
|
|
32
|
+
reportingRouteEventCommit,
|
|
33
|
+
reportingRouteEventAuthorityIssueAtCommit,
|
|
34
|
+
reportingRouteFixedEvidence,
|
|
35
|
+
reportingRouteRecordAtRevision
|
|
36
|
+
} from "./reporting-route-integrity.js";
|
|
37
|
+
import { validateWorkflowHistoryIntegrity } from "./workflow-history-integrity.js";
|
|
21
38
|
|
|
22
39
|
const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
23
40
|
const NAMESPACE_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
|
|
@@ -25,6 +42,13 @@ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
25
42
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
26
43
|
const MAX_OBLIGATION_OFFSET_DAYS = 36_600;
|
|
27
44
|
const MAX_OBLIGATION_OFFSET_HOURS = MAX_OBLIGATION_OFFSET_DAYS * 24;
|
|
45
|
+
const COMPLETION_DATE_FIELDS = [
|
|
46
|
+
"completedOn", "performedOn", "reviewedOn", "occurredOn", "collectedOn",
|
|
47
|
+
"verifiedOn", "approvedOn", "submittedOn", "closedOn", "reportDate"
|
|
48
|
+
];
|
|
49
|
+
const COMPLETION_TIMESTAMP_FIELDS = [
|
|
50
|
+
"completedAt", "endedAt", "closedAt", "provisionedOn", "deprovisionedOn"
|
|
51
|
+
];
|
|
28
52
|
|
|
29
53
|
export async function validateWorkspace(input = process.cwd()) {
|
|
30
54
|
const timingStarted = performance.now();
|
|
@@ -55,6 +79,9 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
55
79
|
: [...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])]
|
|
56
80
|
));
|
|
57
81
|
const currentReviewRevisions = await resourceReviewRevisions(loaded, reviewDependencyIds);
|
|
82
|
+
if (modelSupports(loaded.model, "rolled-up-obligations")) {
|
|
83
|
+
await validateWorkflowHistoryIntegrity(loaded, diagnostics);
|
|
84
|
+
}
|
|
58
85
|
for (const obligation of loaded.resources.filter((record) => record.type === "obligation" && record.status !== "retired")) {
|
|
59
86
|
for (const controlId of obligation.controlIds || []) {
|
|
60
87
|
if (!obligationsByControl.has(controlId)) obligationsByControl.set(controlId, []);
|
|
@@ -98,16 +125,24 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
98
125
|
validateCollectionReview(record, loaded, byId, displayPath, diagnostics);
|
|
99
126
|
}
|
|
100
127
|
if (record.type === "obligation") validateObligation(record, loaded.model, byId, displayPath, diagnostics);
|
|
128
|
+
if (record.type === "obligation-rule") validateObligationRule(record, loaded.model, byId, displayPath, diagnostics);
|
|
129
|
+
if (record.type === "obligation-occurrence") {
|
|
130
|
+
validateObligationOccurrence(record, loaded.model, loaded.resources, loaded.entries, loaded.root, loaded.workspace.timezone, byId, asOf, displayPath, diagnostics);
|
|
131
|
+
}
|
|
101
132
|
if (record.type === "retention-schedule-item") validateRetentionScheduleItem(record, loaded, byId, currentReviewRevisions, displayPath, diagnostics);
|
|
102
133
|
if (record.type === "requirement-mapping") validateRequirementMapping(record, currentReviewRevisions, displayPath, diagnostics);
|
|
103
134
|
if (record.type === "action-item") {
|
|
104
135
|
validateCompletedObligationAction(record, byId, loaded.model, displayPath, diagnostics);
|
|
105
136
|
}
|
|
106
137
|
if (record.type === "obligation-event") validatePolicyEvent(record, loaded.model, byId, displayPath, diagnostics);
|
|
107
|
-
if (record.type === "evidence")
|
|
138
|
+
if (record.type === "evidence") {
|
|
139
|
+
validateEvidencePaths(record, displayPath, diagnostics);
|
|
140
|
+
validateEvidenceSourceRevision(record, loaded.root, displayPath, diagnostics);
|
|
141
|
+
}
|
|
108
142
|
validateCoverage(record, displayPath, diagnostics);
|
|
109
143
|
validateClassification(record, loaded, displayPath, diagnostics);
|
|
110
144
|
validateCompletionDates(record, displayPath, diagnostics);
|
|
145
|
+
validateReportingRouteBinding(record, loaded, displayPath, diagnostics);
|
|
111
146
|
await validateAttestationBinding(record, loaded.model, loaded.root, byId, displayPath, diagnostics);
|
|
112
147
|
|
|
113
148
|
const fields = { ...loaded.model.commonFields, ...definition.fields };
|
|
@@ -152,6 +187,7 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
152
187
|
}
|
|
153
188
|
validateIndependentApproval(record, byId, displayPath, diagnostics);
|
|
154
189
|
validateCompletedObligationEvent(record, byId, loaded.model, displayPath, diagnostics);
|
|
190
|
+
validateActionObligationRule(record, byId, displayPath, diagnostics);
|
|
155
191
|
validateImplementedControlSchedules(record, obligationsByControl, displayPath, diagnostics);
|
|
156
192
|
await validateMarkdown(record, definition, loaded.model, loaded.root, displayPath, diagnostics);
|
|
157
193
|
await validateApprovalBinding(record, loaded.model, loaded.root, displayPath, diagnostics);
|
|
@@ -166,6 +202,30 @@ async function validateWorkspaceUnmeasured(input) {
|
|
|
166
202
|
if (modelSupports(loaded.model, "document-workflow-scope")) {
|
|
167
203
|
validateDocumentWorkflowScopes(loaded.resources, loaded.model, byId, pathById, diagnostics);
|
|
168
204
|
}
|
|
205
|
+
if (modelSupports(loaded.model, "rolled-up-obligations")) {
|
|
206
|
+
validateObligationRuleSet(loaded.resources, pathById, diagnostics);
|
|
207
|
+
validateAuditPopulationSet(loaded.resources, byId, pathById, diagnostics);
|
|
208
|
+
}
|
|
209
|
+
if (modelSupports(loaded.model, "reporting-routes") && !modelSupports(loaded.model, "reporting-route-sets")) {
|
|
210
|
+
validateReportingRouteSet(loaded.resources, pathById, diagnostics, loaded.workspace?.timezone || "UTC");
|
|
211
|
+
}
|
|
212
|
+
if (modelSupports(loaded.model, "reporting-route-sets")) {
|
|
213
|
+
validateReportingRouteSets(loaded, byId, pathById, diagnostics);
|
|
214
|
+
}
|
|
215
|
+
if (modelSupports(loaded.model, "temporal-collection-reviews")) {
|
|
216
|
+
validateCollectionReviewSet(loaded.resources, pathById, diagnostics);
|
|
217
|
+
}
|
|
218
|
+
if (modelSupports(loaded.model, "guided-workflow")) {
|
|
219
|
+
const { planReconciliation } = await import("./reconciliation.js");
|
|
220
|
+
const reconciliation = await planReconciliation(loaded);
|
|
221
|
+
for (const candidate of reconciliation.candidates.filter(({ committedRevision }) => committedRevision)) {
|
|
222
|
+
diagnostics.push(warning(
|
|
223
|
+
"unreconciled-committed-transition",
|
|
224
|
+
candidate.sourcePath,
|
|
225
|
+
`${candidate.eventType} transition in Git commit ${candidate.committedRevision.slice(0, 8)} still needs confirmation or dismissal.`
|
|
226
|
+
));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
169
229
|
|
|
170
230
|
diagnostics.sort((a, b) => `${a.severity}:${a.path}:${a.code}`.localeCompare(`${b.severity}:${b.path}:${b.code}`));
|
|
171
231
|
const result = {
|
|
@@ -308,6 +368,716 @@ function validateDocumentWorkflowScopes(resources, model, byId, pathById, diagno
|
|
|
308
368
|
}
|
|
309
369
|
}
|
|
310
370
|
|
|
371
|
+
function validateObligationRule(record, model, byId, path, diagnostics) {
|
|
372
|
+
const obligation = byId.get(record.obligationId);
|
|
373
|
+
if (obligation?.type !== "obligation") return;
|
|
374
|
+
validateObligation({
|
|
375
|
+
...obligation,
|
|
376
|
+
recurrence: record.recurrence,
|
|
377
|
+
window: record.window
|
|
378
|
+
}, model, byId, path, diagnostics);
|
|
379
|
+
if (record.selector?.resourceType) {
|
|
380
|
+
const activity = obligation.activityType === "custom"
|
|
381
|
+
? { ...model.obligationActivities?.custom, ...obligation.customActivity }
|
|
382
|
+
: model.obligationActivities?.[obligation.activityType];
|
|
383
|
+
if (!activity?.aggregate?.completionMemberField) {
|
|
384
|
+
diagnostics.push(error(
|
|
385
|
+
"unbound-obligation-selector",
|
|
386
|
+
path,
|
|
387
|
+
`Selector-based ${obligation.activityType} rules need a model-defined completion member field and passing states.`
|
|
388
|
+
));
|
|
389
|
+
}
|
|
390
|
+
const selectedDefinition = model.resources[record.selector.resourceType];
|
|
391
|
+
if (!selectedDefinition) return;
|
|
392
|
+
const selectedFields = { ...model.commonFields, ...selectedDefinition.fields };
|
|
393
|
+
const allowedStatuses = selectedFields.status?.values || [];
|
|
394
|
+
const invalidStatuses = (record.selector.statuses || []).filter((status) => !allowedStatuses.includes(status));
|
|
395
|
+
if (invalidStatuses.length) {
|
|
396
|
+
diagnostics.push(error(
|
|
397
|
+
"invalid-obligation-selector-status",
|
|
398
|
+
path,
|
|
399
|
+
`Selector statuses are not valid for ${record.selector.resourceType}: ${invalidStatuses.join(", ")}.`
|
|
400
|
+
));
|
|
401
|
+
}
|
|
402
|
+
const criticalityField = selectedFields.criticality;
|
|
403
|
+
if ((record.selector.criticalities || []).length && !criticalityField) {
|
|
404
|
+
diagnostics.push(error(
|
|
405
|
+
"invalid-obligation-selector-criticality",
|
|
406
|
+
path,
|
|
407
|
+
`${record.selector.resourceType} records do not have a criticality field.`
|
|
408
|
+
));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (record.status === "active" && obligation.activeRuleId !== record.id) {
|
|
412
|
+
diagnostics.push(error(
|
|
413
|
+
"inactive-obligation-rule-binding",
|
|
414
|
+
path,
|
|
415
|
+
`Active rule "${record.id}" must be the activeRuleId on Obligation "${obligation.id}".`
|
|
416
|
+
));
|
|
417
|
+
}
|
|
418
|
+
if (
|
|
419
|
+
record.approvedOn
|
|
420
|
+
&& record.effectiveAt
|
|
421
|
+
&& currentCalendarDate(record.timezone || "UTC", new Date(record.effectiveAt)) < record.approvedOn
|
|
422
|
+
) {
|
|
423
|
+
diagnostics.push(error("backdated-obligation-rule", path, "effectiveAt cannot be before the management approval date."));
|
|
424
|
+
}
|
|
425
|
+
if (record.supersedesId) {
|
|
426
|
+
const prior = byId.get(record.supersedesId);
|
|
427
|
+
if (prior?.type === "obligation-rule" && prior.obligationId !== record.obligationId) {
|
|
428
|
+
diagnostics.push(error("wrong-obligation-rule", path, "A rule may supersede only a rule for the same Obligation."));
|
|
429
|
+
}
|
|
430
|
+
if (!record.cutoverDecision) {
|
|
431
|
+
diagnostics.push(error("missing-rule-cutover", path, "A superseding rule needs an explicit cutover decision for any open occurrence."));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function validateObligationOccurrence(record, model, resources, entries, root, workspaceTimezone, byId, asOf, path, diagnostics) {
|
|
437
|
+
const members = record.members || [];
|
|
438
|
+
const memberIds = members.map(({ resourceId }) => resourceId);
|
|
439
|
+
if (new Set(memberIds).size !== memberIds.length) {
|
|
440
|
+
diagnostics.push(error("duplicate-obligation-member", path, "An occurrence may contain each population member only once."));
|
|
441
|
+
}
|
|
442
|
+
const expected = members.filter(({ disposition }) => disposition === "expected");
|
|
443
|
+
const completed = expected.filter(({ result }) => result === "passed");
|
|
444
|
+
if (record.expectedCount !== expected.length || record.completedCount !== completed.length) {
|
|
445
|
+
diagnostics.push(error(
|
|
446
|
+
"invalid-obligation-occurrence-count",
|
|
447
|
+
path,
|
|
448
|
+
`expectedCount and completedCount must equal the reconciled member facts (${expected.length} expected, ${completed.length} passed).`
|
|
449
|
+
));
|
|
450
|
+
}
|
|
451
|
+
const rule = byId.get(record.ruleId);
|
|
452
|
+
const obligation = byId.get(record.obligationId);
|
|
453
|
+
if (record.collectionReviewId) {
|
|
454
|
+
const review = byId.get(record.collectionReviewId);
|
|
455
|
+
const reviewEntry = entries.find(({ record: candidate }) => candidate.id === record.collectionReviewId);
|
|
456
|
+
const historicalSnapshot = historicalCollectionReviewSnapshot(
|
|
457
|
+
root,
|
|
458
|
+
review,
|
|
459
|
+
model,
|
|
460
|
+
workspaceTimezone,
|
|
461
|
+
rule?.selector?.resourceType,
|
|
462
|
+
record.membershipCutoffAt,
|
|
463
|
+
rule?.selector,
|
|
464
|
+
reviewEntry?.relativePath,
|
|
465
|
+
record.collectionReviewCommit
|
|
466
|
+
);
|
|
467
|
+
const memberIds = [...(record.members || []).map(({ resourceId }) => resourceId)].sort();
|
|
468
|
+
const reviewedIds = [...(historicalSnapshot?.selectedIds || [])].sort();
|
|
469
|
+
const reviewCoversCutoff = review?.coverage?.kind === "as-of"
|
|
470
|
+
? review.coverage.on === record.membershipCutoffAt
|
|
471
|
+
: review?.coverage?.kind === "range"
|
|
472
|
+
&& review.coverage.startsOn <= record.membershipCutoffAt
|
|
473
|
+
&& review.coverage.endsOn >= record.membershipCutoffAt;
|
|
474
|
+
if (
|
|
475
|
+
review?.type !== "collection-review"
|
|
476
|
+
|| !(review.scopeResourceIds || []).includes(record.programId)
|
|
477
|
+
|| !historicalSnapshot
|
|
478
|
+
|| record.collectionReviewCommit !== historicalSnapshot.reviewCommit
|
|
479
|
+
|| record.collectionReviewRevision !== collectionReviewRevision(review)
|
|
480
|
+
|| record.collectionRevision !== review.collectionRevision
|
|
481
|
+
|| record.scopeRevision !== review.scopeRevision
|
|
482
|
+
|| !reviewCoversCutoff
|
|
483
|
+
|| JSON.stringify(memberIds) !== JSON.stringify(reviewedIds)
|
|
484
|
+
) {
|
|
485
|
+
diagnostics.push(error(
|
|
486
|
+
"invalid-obligation-collection-review-binding",
|
|
487
|
+
path,
|
|
488
|
+
"A historical occurrence must bind the exact immutable Collection Review revision, scope, cutoff coverage, and reviewed population."
|
|
489
|
+
));
|
|
490
|
+
}
|
|
491
|
+
} else if (rule?.selector && record.membershipCutoffAt < asOf) {
|
|
492
|
+
diagnostics.push(error(
|
|
493
|
+
"missing-obligation-population-provenance",
|
|
494
|
+
path,
|
|
495
|
+
"A historical occurrence must bind an immutable Collection Review for its exact population cutoff."
|
|
496
|
+
));
|
|
497
|
+
} else if (rule?.selector) {
|
|
498
|
+
const selectedIds = selectScopedCollectionRecords(
|
|
499
|
+
{ resources, model },
|
|
500
|
+
rule.selector,
|
|
501
|
+
byId.get(record.programId)
|
|
502
|
+
).map(({ id }) => id).sort();
|
|
503
|
+
if (JSON.stringify([...memberIds].sort()) !== JSON.stringify(selectedIds)) {
|
|
504
|
+
diagnostics.push(error(
|
|
505
|
+
"invalid-obligation-occurrence-population",
|
|
506
|
+
path,
|
|
507
|
+
"An occurrence without a historical Collection Review must contain the exact current selector population."
|
|
508
|
+
));
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (record.supersedesId) {
|
|
512
|
+
const prior = byId.get(record.supersedesId);
|
|
513
|
+
if (
|
|
514
|
+
prior?.type === "obligation-occurrence"
|
|
515
|
+
&& (
|
|
516
|
+
prior.programId !== record.programId
|
|
517
|
+
|| prior.obligationId !== record.obligationId
|
|
518
|
+
|| prior.ruleId !== record.ruleId
|
|
519
|
+
|| prior.occurrenceKey !== record.occurrenceKey
|
|
520
|
+
|| JSON.stringify(prior.coverage) !== JSON.stringify(record.coverage)
|
|
521
|
+
|| prior.membershipCutoffAt !== record.membershipCutoffAt
|
|
522
|
+
|| prior.collectionReviewId !== record.collectionReviewId
|
|
523
|
+
|| prior.collectionReviewCommit !== record.collectionReviewCommit
|
|
524
|
+
|| prior.collectionReviewRevision !== record.collectionReviewRevision
|
|
525
|
+
|| prior.collectionRevision !== record.collectionRevision
|
|
526
|
+
|| prior.scopeRevision !== record.scopeRevision
|
|
527
|
+
)
|
|
528
|
+
) {
|
|
529
|
+
diagnostics.push(error(
|
|
530
|
+
"wrong-obligation-occurrence-supersession",
|
|
531
|
+
path,
|
|
532
|
+
"An occurrence correction must preserve its predecessor's Program, Obligation, rule, key, coverage, and membership cutoff."
|
|
533
|
+
));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
const activity = obligation?.type === "obligation"
|
|
537
|
+
? obligation.activityType === "custom"
|
|
538
|
+
? { ...model.obligationActivities?.custom, ...(obligation.customActivity || {}) }
|
|
539
|
+
: model.obligationActivities?.[obligation.activityType]
|
|
540
|
+
: null;
|
|
541
|
+
if (rule?.type === "obligation-rule" && rule.obligationId !== record.obligationId) {
|
|
542
|
+
diagnostics.push(error("wrong-obligation-rule", path, "The occurrence rule must belong to the same Obligation."));
|
|
543
|
+
}
|
|
544
|
+
validateOccurrenceScheduleBinding(record, obligation, rule, resources, path, diagnostics);
|
|
545
|
+
for (const member of members) {
|
|
546
|
+
if (!byId.has(member.resourceId)) continue;
|
|
547
|
+
const validCompletions = [];
|
|
548
|
+
for (const completionId of member.completionResourceIds || []) {
|
|
549
|
+
const completion = byId.get(completionId);
|
|
550
|
+
if (!completion) {
|
|
551
|
+
diagnostics.push(error("missing-member-completion-record", path, `Completion "${completionId}" does not exist.`));
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (!activity?.completionResourceTypes.includes(completion.type)) {
|
|
555
|
+
diagnostics.push(error(
|
|
556
|
+
"wrong-member-completion-type",
|
|
557
|
+
path,
|
|
558
|
+
`Completion "${completionId}" has type "${completion.type}", which cannot satisfy ${obligation?.activityType || "this activity"}.`
|
|
559
|
+
));
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
const memberField = activity.aggregate?.completionMemberField;
|
|
563
|
+
const memberValue = memberField ? completion[memberField] : null;
|
|
564
|
+
if (!memberField || (Array.isArray(memberValue) ? !memberValue.includes(member.resourceId) : memberValue !== member.resourceId)) {
|
|
565
|
+
diagnostics.push(error(
|
|
566
|
+
"wrong-member-completion",
|
|
567
|
+
path,
|
|
568
|
+
`Completion "${completionId}" does not belong to population member "${member.resourceId}".`
|
|
569
|
+
));
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (!completionFallsInOccurrence(completion, record.coverage, rule?.timezone || "UTC")) {
|
|
573
|
+
diagnostics.push(error(
|
|
574
|
+
"completion-outside-occurrence",
|
|
575
|
+
path,
|
|
576
|
+
`Completion "${completionId}" does not fall inside this occurrence's coverage.`
|
|
577
|
+
));
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
if (completionPassesObligationActivity(completion, activity)) validCompletions.push(completion);
|
|
581
|
+
}
|
|
582
|
+
if (member.result === "passed" && validCompletions.length === 0) {
|
|
583
|
+
diagnostics.push(error(
|
|
584
|
+
"missing-passing-member-completion",
|
|
585
|
+
path,
|
|
586
|
+
`Passed member "${member.resourceId}" needs a matching completion inside the occurrence window with an allowed passing status and result.`
|
|
587
|
+
));
|
|
588
|
+
}
|
|
589
|
+
if (member.disposition === "exception") {
|
|
590
|
+
const exception = byId.get(member.exceptionId);
|
|
591
|
+
const occurrenceStart = record.coverage?.kind === "as-of" ? record.coverage.on : record.coverage?.startsOn;
|
|
592
|
+
const occurrenceEnd = record.coverage?.kind === "as-of" ? record.coverage.on : record.coverage?.endsOn;
|
|
593
|
+
const exceptionCoversMember = exception?.scopeResourceIds?.includes(member.resourceId)
|
|
594
|
+
|| exception?.scopeResourceIds?.includes(record.obligationId);
|
|
595
|
+
if (
|
|
596
|
+
exception?.type !== "exception"
|
|
597
|
+
|| exception.status !== "approved"
|
|
598
|
+
|| !exceptionCoversMember
|
|
599
|
+
|| exception.approval?.approvedOn > occurrenceStart
|
|
600
|
+
|| exception.approval?.expiresOn < occurrenceEnd
|
|
601
|
+
) {
|
|
602
|
+
diagnostics.push(error(
|
|
603
|
+
"invalid-member-exception",
|
|
604
|
+
path,
|
|
605
|
+
`Exception member "${member.resourceId}" needs an approved Exception that covers the member or Obligation for the full occurrence.`
|
|
606
|
+
));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (member.disposition === "not-applicable" && !String(member.rationale || "").trim()) {
|
|
610
|
+
diagnostics.push(error(
|
|
611
|
+
"missing-member-non-applicability-rationale",
|
|
612
|
+
path,
|
|
613
|
+
`Non-applicable member "${member.resourceId}" needs the reviewed rationale for excluding it.`
|
|
614
|
+
));
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (record.status !== "reconciled") {
|
|
618
|
+
if (record.status === "open" && (record.conclusion || record.reconciledAt || (record.reviewedByIds || []).length)) {
|
|
619
|
+
diagnostics.push(error("premature-obligation-conclusion", path, "Only a reconciled occurrence may record a conclusion, reconciliation time, or reviewers."));
|
|
620
|
+
}
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const reconciledOn = record.reconciledAt
|
|
624
|
+
? currentCalendarDate(rule?.timezone || "UTC", new Date(record.reconciledAt))
|
|
625
|
+
: null;
|
|
626
|
+
if (record.reconciledAt && new Date(record.reconciledAt) > new Date()) {
|
|
627
|
+
diagnostics.push(error(
|
|
628
|
+
"future-obligation-reconciliation",
|
|
629
|
+
path,
|
|
630
|
+
"An occurrence reconciliation time cannot be in the future."
|
|
631
|
+
));
|
|
632
|
+
}
|
|
633
|
+
const populationStillOpen = reconciledOn <= record.membershipCutoffAt;
|
|
634
|
+
if (reconciledOn && populationStillOpen) {
|
|
635
|
+
diagnostics.push(error(
|
|
636
|
+
"obligation-population-still-open",
|
|
637
|
+
path,
|
|
638
|
+
`This occurrence cannot be reconciled before its ${record.membershipCutoffAt} population cutoff.`
|
|
639
|
+
));
|
|
640
|
+
}
|
|
641
|
+
if (record.conclusion === "zero-population" && members.length !== 0) {
|
|
642
|
+
diagnostics.push(error("invalid-zero-population", path, "A zero-population conclusion requires an empty frozen population."));
|
|
643
|
+
}
|
|
644
|
+
if (record.conclusion === "complete" && record.completedCount !== record.expectedCount) {
|
|
645
|
+
diagnostics.push(error("incomplete-obligation-occurrence", path, "A complete conclusion requires every expected member to pass."));
|
|
646
|
+
}
|
|
647
|
+
if (
|
|
648
|
+
record.conclusion === "complete-with-exceptions"
|
|
649
|
+
&& members.some((member) => (
|
|
650
|
+
(member.disposition === "expected" && member.result !== "passed")
|
|
651
|
+
|| (member.disposition === "exception" && !member.exceptionId)
|
|
652
|
+
))
|
|
653
|
+
) {
|
|
654
|
+
diagnostics.push(error("incomplete-obligation-occurrence", path, "A complete-with-exceptions conclusion requires every expected member to pass and every exception to name its approved Exception."));
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function validateOccurrenceScheduleBinding(record, obligation, rule, resources, path, diagnostics) {
|
|
659
|
+
if (obligation?.type !== "obligation" || rule?.type !== "obligation-rule") return;
|
|
660
|
+
const start = record.coverage?.kind === "range" ? record.coverage.startsOn : null;
|
|
661
|
+
if (!start || !validCalendarRecurrence(rule.recurrence)) return;
|
|
662
|
+
const index = calendarOccurrenceIndex(rule.recurrence, start);
|
|
663
|
+
const occurrence = calendarOccurrence(rule.recurrence, index);
|
|
664
|
+
const next = calendarOccurrence(rule.recurrence, index + 1);
|
|
665
|
+
const startOffset = rule.window?.precision === "date" && Number.isInteger(rule.window.startsAfter)
|
|
666
|
+
? rule.window.startsAfter
|
|
667
|
+
: 0;
|
|
668
|
+
const expectedStart = occurrence ? addCalendarDays(occurrence, startOffset) : null;
|
|
669
|
+
const expectedEnd = rule.window?.precision === "date" && Number.isInteger(rule.window.dueAfter)
|
|
670
|
+
? addCalendarDays(occurrence, rule.window.dueAfter)
|
|
671
|
+
: next ? addCalendarDays(next, -1) : null;
|
|
672
|
+
const expectedCutoff = rule.selector?.cutoff === "window-end" ? expectedEnd : expectedStart;
|
|
673
|
+
const program = resources.find(({ type, id }) => type === "program" && id === record.programId);
|
|
674
|
+
const expectedKey = `${program?.id || "program"}:${obligation.id}:${expectedStart}`;
|
|
675
|
+
const governingRule = resources
|
|
676
|
+
.filter((candidate) => (
|
|
677
|
+
candidate.type === "obligation-rule"
|
|
678
|
+
&& candidate.obligationId === obligation.id
|
|
679
|
+
&& ["active", "retired"].includes(candidate.status)
|
|
680
|
+
&& candidate.effectiveAt
|
|
681
|
+
&& expectedStart
|
|
682
|
+
&& currentCalendarDate(candidate.timezone || "UTC", new Date(candidate.effectiveAt)) <= expectedStart
|
|
683
|
+
))
|
|
684
|
+
.sort((left, right) => right.effectiveAt.localeCompare(left.effectiveAt))[0];
|
|
685
|
+
if (
|
|
686
|
+
expectedStart !== start
|
|
687
|
+
|| record.coverage.endsOn !== expectedEnd
|
|
688
|
+
|| record.membershipCutoffAt !== expectedCutoff
|
|
689
|
+
|| record.occurrenceKey !== expectedKey
|
|
690
|
+
|| record.programId !== program?.id
|
|
691
|
+
|| governingRule?.id !== rule.id
|
|
692
|
+
|| JSON.stringify([...(record.ownerIds || [])].sort()) !== JSON.stringify([...(obligation.ownerIds || [])].sort())
|
|
693
|
+
) {
|
|
694
|
+
diagnostics.push(error(
|
|
695
|
+
"invalid-obligation-occurrence-schedule-binding",
|
|
696
|
+
path,
|
|
697
|
+
"The occurrence must bind the exact governing rule, Program, schedule window, population cutoff, key, and owners."
|
|
698
|
+
));
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function completionPassesObligationActivity(record, activity) {
|
|
703
|
+
const aggregate = activity?.aggregate;
|
|
704
|
+
if (!aggregate) return true;
|
|
705
|
+
if (aggregate.passingStatuses?.length && !aggregate.passingStatuses.includes(record.status)) return false;
|
|
706
|
+
if (aggregate.passingResults?.length) {
|
|
707
|
+
const result = record.decision ?? record.outcome ?? record.result;
|
|
708
|
+
if (!aggregate.passingResults.includes(result)) return false;
|
|
709
|
+
}
|
|
710
|
+
return true;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function completionFallsInOccurrence(record, coverage, timezone = "UTC") {
|
|
714
|
+
const date = completionDateForOccurrence(record, timezone);
|
|
715
|
+
if (!date || !coverage) return false;
|
|
716
|
+
if (coverage.kind === "as-of") return date === coverage.on;
|
|
717
|
+
return coverage.kind === "range" && date >= coverage.startsOn && date <= coverage.endsOn;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function completionDateForOccurrence(record, timezone = "UTC") {
|
|
721
|
+
for (const field of COMPLETION_TIMESTAMP_FIELDS) {
|
|
722
|
+
if (isRfc3339Timestamp(record[field])) return currentCalendarDate(timezone, new Date(record[field]));
|
|
723
|
+
}
|
|
724
|
+
for (const field of COMPLETION_DATE_FIELDS) {
|
|
725
|
+
if (parseCalendarDate(record[field])) return record[field];
|
|
726
|
+
}
|
|
727
|
+
const coverage = record.coverage;
|
|
728
|
+
const coverageDate = coverage?.kind === "as-of" ? coverage.on : coverage?.endsOn;
|
|
729
|
+
if (parseCalendarDate(coverageDate)) return coverageDate;
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function validateObligationRuleSet(resources, pathById, diagnostics) {
|
|
734
|
+
const activeByObligation = new Map();
|
|
735
|
+
const currentOccurrences = new Map();
|
|
736
|
+
const rulesById = new Map(resources.filter(({ type }) => type === "obligation-rule").map((record) => [record.id, record]));
|
|
737
|
+
const supersedingRulesByPriorId = new Map();
|
|
738
|
+
for (const rule of rulesById.values()) {
|
|
739
|
+
if (!rule.supersedesId) continue;
|
|
740
|
+
if (!supersedingRulesByPriorId.has(rule.supersedesId)) supersedingRulesByPriorId.set(rule.supersedesId, []);
|
|
741
|
+
supersedingRulesByPriorId.get(rule.supersedesId).push(rule);
|
|
742
|
+
}
|
|
743
|
+
for (const record of resources) {
|
|
744
|
+
if (record.type === "obligation-rule" && record.status === "active") {
|
|
745
|
+
const prior = activeByObligation.get(record.obligationId);
|
|
746
|
+
if (prior) diagnostics.push(error(
|
|
747
|
+
"multiple-active-obligation-rules",
|
|
748
|
+
pathById.get(record.id),
|
|
749
|
+
`Obligation "${record.obligationId}" has multiple active rules: ${prior.id}, ${record.id}.`
|
|
750
|
+
));
|
|
751
|
+
else activeByObligation.set(record.obligationId, record);
|
|
752
|
+
}
|
|
753
|
+
if (record.type === "obligation-occurrence" && record.status !== "superseded") {
|
|
754
|
+
const prior = currentOccurrences.get(record.occurrenceKey);
|
|
755
|
+
if (prior) diagnostics.push(error(
|
|
756
|
+
"multiple-current-obligation-occurrences",
|
|
757
|
+
pathById.get(record.id),
|
|
758
|
+
`Occurrence key "${record.occurrenceKey}" has multiple current reconciliations: ${prior.id}, ${record.id}.`
|
|
759
|
+
));
|
|
760
|
+
else currentOccurrences.set(record.occurrenceKey, record);
|
|
761
|
+
if (
|
|
762
|
+
record.status === "open"
|
|
763
|
+
&& (supersedingRulesByPriorId.get(record.ruleId) || []).some((rule) => (
|
|
764
|
+
rule.obligationId === record.obligationId && rule.cutoverDecision === "supersede-open-window"
|
|
765
|
+
))
|
|
766
|
+
) {
|
|
767
|
+
diagnostics.push(error(
|
|
768
|
+
"open-occurrence-after-rule-cutover",
|
|
769
|
+
pathById.get(record.id),
|
|
770
|
+
`Occurrence "${record.id}" remains open even though its rule cutover selected supersede open occurrences.`
|
|
771
|
+
));
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function validateAuditPopulationSet(resources, byId, pathById, diagnostics) {
|
|
778
|
+
const current = new Map();
|
|
779
|
+
for (const record of resources.filter(({ type }) => type === "audit-population")) {
|
|
780
|
+
if (record.supersedesId) {
|
|
781
|
+
const prior = byId.get(record.supersedesId);
|
|
782
|
+
if (
|
|
783
|
+
prior?.type === "audit-population"
|
|
784
|
+
&& (prior.auditId !== record.auditId || prior.populationKind !== record.populationKind)
|
|
785
|
+
) {
|
|
786
|
+
diagnostics.push(error(
|
|
787
|
+
"wrong-audit-population-supersession",
|
|
788
|
+
pathById.get(record.id),
|
|
789
|
+
"An Audit population correction must keep the same Audit and population kind as its predecessor."
|
|
790
|
+
));
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
if (record.status === "superseded") continue;
|
|
794
|
+
const key = `${record.auditId}:${record.populationKind}`;
|
|
795
|
+
const prior = current.get(key);
|
|
796
|
+
if (prior) {
|
|
797
|
+
diagnostics.push(error(
|
|
798
|
+
"multiple-current-audit-populations",
|
|
799
|
+
pathById.get(record.id),
|
|
800
|
+
`Audit "${record.auditId}" has multiple current ${record.populationKind} populations: ${prior.id}, ${record.id}.`
|
|
801
|
+
));
|
|
802
|
+
} else current.set(key, record);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function validateReportingRouteSet(resources, pathById, diagnostics, timezone) {
|
|
807
|
+
const routes = resources.filter((record) => (
|
|
808
|
+
record.type === "reporting-route" && ["active", "retired"].includes(record.status)
|
|
809
|
+
));
|
|
810
|
+
for (const route of routes) {
|
|
811
|
+
const effectiveAt = new Date(route.effectiveAt).getTime();
|
|
812
|
+
const approvedAt = route.approvedOn
|
|
813
|
+
? new Date(timestampFromLocalDateTime(`${route.approvedOn}T00:00:00`, timezone)).getTime()
|
|
814
|
+
: null;
|
|
815
|
+
const endsAt = route.endsAt ? new Date(route.endsAt).getTime() : null;
|
|
816
|
+
if (approvedAt !== null && effectiveAt < approvedAt) {
|
|
817
|
+
diagnostics.push(error("invalid-reporting-route-order", pathById.get(route.id), "A Reporting Route cannot become effective before its approval date."));
|
|
818
|
+
}
|
|
819
|
+
if (endsAt !== null && endsAt <= effectiveAt) {
|
|
820
|
+
diagnostics.push(error("invalid-reporting-route-order", pathById.get(route.id), "A Reporting Route must end after it becomes effective."));
|
|
821
|
+
}
|
|
822
|
+
if (
|
|
823
|
+
!localDateTimeValue(new Date(route.effectiveAt), timezone).endsWith("T00:00:00")
|
|
824
|
+
|| (route.endsAt && !localDateTimeValue(new Date(route.endsAt), timezone).endsWith("T00:00:00"))
|
|
825
|
+
) {
|
|
826
|
+
diagnostics.push(error(
|
|
827
|
+
"ambiguous-reporting-route-cutover",
|
|
828
|
+
pathById.get(route.id),
|
|
829
|
+
`Reporting Route cutovers must use midnight in ${timezone} so date-bound assignments resolve one route for the full day.`
|
|
830
|
+
));
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
for (let index = 0; index < routes.length; index += 1) {
|
|
834
|
+
for (let next = index + 1; next < routes.length; next += 1) {
|
|
835
|
+
const left = routes[index];
|
|
836
|
+
const right = routes[next];
|
|
837
|
+
if (left.purpose !== right.purpose || left.priority !== right.priority) continue;
|
|
838
|
+
const leftEnd = left.endsAt ? new Date(left.endsAt).getTime() : Number.POSITIVE_INFINITY;
|
|
839
|
+
const rightEnd = right.endsAt ? new Date(right.endsAt).getTime() : Number.POSITIVE_INFINITY;
|
|
840
|
+
if (new Date(left.effectiveAt).getTime() < rightEnd && new Date(right.effectiveAt).getTime() < leftEnd) {
|
|
841
|
+
diagnostics.push(error(
|
|
842
|
+
"overlapping-reporting-routes",
|
|
843
|
+
pathById.get(right.id),
|
|
844
|
+
`Reporting routes "${left.id}" and "${right.id}" overlap for ${left.purpose}/${left.priority}.`
|
|
845
|
+
));
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function validateReportingRouteSets(loaded, byId, pathById, diagnostics) {
|
|
852
|
+
const routeSets = loaded.resources.filter(({ type }) => type === "reporting-route-set");
|
|
853
|
+
const successors = new Map();
|
|
854
|
+
const repository = getGitSummary(loaded.root);
|
|
855
|
+
const currentByPurpose = new Map();
|
|
856
|
+
for (const source of loaded.resources.filter(({ type }) => ["policy", "document", "commitment", "risk"].includes(type))) {
|
|
857
|
+
const path = pathById.get(source.id);
|
|
858
|
+
for (const requirement of Array.isArray(source.reportingRouteRequirements) ? source.reportingRouteRequirements : []) {
|
|
859
|
+
if (!requirement || typeof requirement !== "object" || Array.isArray(requirement)) continue;
|
|
860
|
+
validateTimestampTimezone(requirement.effectiveAt, requirement.timezone, path, diagnostics, "requirement start");
|
|
861
|
+
if (requirement.endsAt) {
|
|
862
|
+
validateTimestampTimezone(requirement.endsAt, requirement.timezone, path, diagnostics, "requirement end");
|
|
863
|
+
if (new Date(requirement.endsAt) <= new Date(requirement.effectiveAt)) {
|
|
864
|
+
diagnostics.push(error("invalid-reporting-route-requirement-interval", path, "A Reporting Route requirement must end after it starts."));
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
for (const route of routeSets) {
|
|
870
|
+
const path = pathById.get(route.id);
|
|
871
|
+
if (["draft", "proposed", "approved"].includes(route.status)) {
|
|
872
|
+
const key = `${route.programId}\0${route.purposeKey}`;
|
|
873
|
+
const current = currentByPurpose.get(key) || { approved: [], pending: [] };
|
|
874
|
+
current[route.status === "approved" ? "approved" : "pending"].push(route);
|
|
875
|
+
currentByPurpose.set(key, current);
|
|
876
|
+
}
|
|
877
|
+
if (route.predecessorId) {
|
|
878
|
+
const predecessor = byId.get(route.predecessorId);
|
|
879
|
+
if (route.predecessorId === route.id) {
|
|
880
|
+
diagnostics.push(error("invalid-reporting-route-lineage", path, "A Reporting Route Set cannot name itself as its predecessor."));
|
|
881
|
+
}
|
|
882
|
+
if (
|
|
883
|
+
predecessor?.type === "reporting-route-set"
|
|
884
|
+
&& (predecessor.programId !== route.programId || predecessor.purposeKey !== route.purposeKey)
|
|
885
|
+
) {
|
|
886
|
+
diagnostics.push(error("invalid-reporting-route-lineage", path, "A Reporting Route Set successor must keep the same Program and immutable purpose key."));
|
|
887
|
+
}
|
|
888
|
+
if (predecessor?.type === "reporting-route-set" && !FINAL_ROUTE_SET_STATUSES.has(predecessor.status)) {
|
|
889
|
+
diagnostics.push(error("invalid-reporting-route-lineage", path, `Predecessor Reporting Route Set "${predecessor.id}" must already be finalized.`));
|
|
890
|
+
}
|
|
891
|
+
const current = successors.get(route.predecessorId);
|
|
892
|
+
if (current) {
|
|
893
|
+
diagnostics.push(error("branched-reporting-route-lineage", path, `Reporting Route Set "${route.predecessorId}" has more than one successor: ${current}, ${route.id}.`));
|
|
894
|
+
} else successors.set(route.predecessorId, route.id);
|
|
895
|
+
}
|
|
896
|
+
if (!FINAL_ROUTE_SET_STATUSES.has(route.status)) continue;
|
|
897
|
+
if (route.status === "historical") continue;
|
|
898
|
+
if (!/^[a-f0-9]{40}$/i.test(String(route.proposalCommit || "")) || !hasGitRevision(loaded.root, route.proposalCommit)) {
|
|
899
|
+
diagnostics.push(error("invalid-reporting-route-proposal", path, "An approved Reporting Route Set must bind a full, available proposal commit."));
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
const entry = loaded.entries.find(({ record }) => record.id === route.id);
|
|
903
|
+
const proposal = entry ? reportingRouteRecordAtRevision(loaded, entry, route.proposalCommit) : null;
|
|
904
|
+
if (!proposal || proposal.status !== "proposed" || !sameRouteProposal(proposal, route)) {
|
|
905
|
+
diagnostics.push(error("changed-reporting-route-proposal", path, "The approved Route Set facts must exactly match the committed proposal; only managed approval fields may differ."));
|
|
906
|
+
}
|
|
907
|
+
for (const markdown of markdownEntries(loaded.model, route)) {
|
|
908
|
+
const currentPath = `data/${markdown.path}`;
|
|
909
|
+
const proposedPath = getFilePathAtRevision(loaded.root, currentPath, route.proposalCommit);
|
|
910
|
+
const proposedObject = proposedPath
|
|
911
|
+
? getFileObjectIdAtRevision(loaded.root, route.proposalCommit, proposedPath)
|
|
912
|
+
: null;
|
|
913
|
+
const currentObject = getWorkingFileObjectId(loaded.root, currentPath);
|
|
914
|
+
if (!proposedObject || proposedObject !== currentObject) {
|
|
915
|
+
diagnostics.push(error("changed-reporting-route-instructions", path, "Reporting Route Set instructions must exactly match the committed proposal."));
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
const approval = route.approval;
|
|
919
|
+
if (!approval) continue;
|
|
920
|
+
if (approval.proposalCommit !== route.proposalCommit) {
|
|
921
|
+
diagnostics.push(error("invalid-reporting-route-proposal", path, "Approval proposalCommit must match the Route Set proposalCommit exactly."));
|
|
922
|
+
}
|
|
923
|
+
validateTimestampTimezone(approval.approvedAt, approval.timezone, path, diagnostics, "approval");
|
|
924
|
+
validateTimestampTimezone(approval.effectiveAt, approval.timezone, path, diagnostics, "effective");
|
|
925
|
+
if (new Date(approval.approvedAt) > new Date()) {
|
|
926
|
+
diagnostics.push(error("future-reporting-route-approval", path, "Approval time must be an actual nonfuture event time."));
|
|
927
|
+
}
|
|
928
|
+
if (new Date(approval.effectiveAt) < new Date(approval.approvedAt)) {
|
|
929
|
+
diagnostics.push(error("invalid-reporting-route-order", path, "Effective time cannot precede approval time."));
|
|
930
|
+
}
|
|
931
|
+
if (
|
|
932
|
+
!reportingRouteFixedEvidence(
|
|
933
|
+
loaded.resources,
|
|
934
|
+
route.id,
|
|
935
|
+
approval.evidenceIds,
|
|
936
|
+
approval.approvedAt,
|
|
937
|
+
approval.timezone,
|
|
938
|
+
{ root: loaded.root }
|
|
939
|
+
).length
|
|
940
|
+
) {
|
|
941
|
+
diagnostics.push(error("missing-reporting-route-event-evidence", path, "Reporting Route approval needs linked verified, fixed Evidence covering the approval event."));
|
|
942
|
+
}
|
|
943
|
+
const approvalCommit = reportingRouteEventCommit(loaded, route, "approval");
|
|
944
|
+
if (approvalCommit && (
|
|
945
|
+
approvalCommit === route.proposalCommit
|
|
946
|
+
|| !isDataHistoryAncestor(loaded, route.proposalCommit, approvalCommit)
|
|
947
|
+
)) {
|
|
948
|
+
diagnostics.push(error("invalid-reporting-route-order", path, "The approval commit must descend from the exact proposal commit."));
|
|
949
|
+
}
|
|
950
|
+
if (repository.commit && !isDataHistoryAncestor(loaded, route.proposalCommit, repository.commit)) {
|
|
951
|
+
diagnostics.push(error("invalid-reporting-route-lineage", path, "The current revision must descend from its proposal commit."));
|
|
952
|
+
}
|
|
953
|
+
const approvalAuthorityIssue = reportingRouteEventAuthorityIssueAtCommit(loaded, route, "approval");
|
|
954
|
+
if (approvalAuthorityIssue) {
|
|
955
|
+
diagnostics.push(error(approvalAuthorityIssue.code, path, approvalAuthorityIssue.message));
|
|
956
|
+
}
|
|
957
|
+
if (route.status === "canceled") {
|
|
958
|
+
const cancellationCommit = reportingRouteEventCommit(loaded, route, "cancellation");
|
|
959
|
+
if (approvalCommit && cancellationCommit && (
|
|
960
|
+
cancellationCommit === approvalCommit
|
|
961
|
+
|| !isDataHistoryAncestor(loaded, approvalCommit, cancellationCommit)
|
|
962
|
+
)) {
|
|
963
|
+
diagnostics.push(error("invalid-reporting-route-order", path, "The cancellation commit must descend from the approval commit."));
|
|
964
|
+
}
|
|
965
|
+
validateTimestampTimezone(route.cancellation?.canceledAt, approval.timezone, path, diagnostics, "cancellation");
|
|
966
|
+
if (new Date(route.cancellation?.canceledAt) > new Date()) {
|
|
967
|
+
diagnostics.push(error("future-reporting-route-cancellation", path, "Cancellation time must be an actual nonfuture event time."));
|
|
968
|
+
}
|
|
969
|
+
if (
|
|
970
|
+
!reportingRouteFixedEvidence(
|
|
971
|
+
loaded.resources,
|
|
972
|
+
route.id,
|
|
973
|
+
route.cancellation?.evidenceIds,
|
|
974
|
+
route.cancellation?.canceledAt,
|
|
975
|
+
approval.timezone,
|
|
976
|
+
{ root: loaded.root }
|
|
977
|
+
).length
|
|
978
|
+
) {
|
|
979
|
+
diagnostics.push(error("missing-reporting-route-event-evidence", path, "Reporting Route cancellation needs linked verified, fixed Evidence covering the cancellation event."));
|
|
980
|
+
}
|
|
981
|
+
const cancellationAuthorityIssue = reportingRouteEventAuthorityIssueAtCommit(loaded, route, "cancellation");
|
|
982
|
+
if (cancellationAuthorityIssue) {
|
|
983
|
+
diagnostics.push(error(cancellationAuthorityIssue.code, path, cancellationAuthorityIssue.message));
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
for (const route of routeSets) {
|
|
988
|
+
const visited = new Set([route.id]);
|
|
989
|
+
let predecessorId = route.predecessorId;
|
|
990
|
+
while (predecessorId) {
|
|
991
|
+
if (visited.has(predecessorId)) {
|
|
992
|
+
diagnostics.push(error("invalid-reporting-route-lineage", pathById.get(route.id), `Reporting Route Set "${route.id}" belongs to a predecessor cycle.`));
|
|
993
|
+
break;
|
|
994
|
+
}
|
|
995
|
+
visited.add(predecessorId);
|
|
996
|
+
predecessorId = byId.get(predecessorId)?.predecessorId;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
for (const { approved, pending } of currentByPurpose.values()) {
|
|
1000
|
+
const candidate = pending[1] || approved[1];
|
|
1001
|
+
if (pending.length > 1 || approved.length > 1) {
|
|
1002
|
+
diagnostics.push(error(
|
|
1003
|
+
"ambiguous-reporting-route-set",
|
|
1004
|
+
pathById.get(candidate.id),
|
|
1005
|
+
"A Program and purpose may have at most one approved channel set and one pending successor."
|
|
1006
|
+
));
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
if (approved.length === 1 && pending.length === 1 && pending[0].predecessorId !== approved[0].id) {
|
|
1010
|
+
diagnostics.push(error(
|
|
1011
|
+
"invalid-reporting-route-lineage",
|
|
1012
|
+
pathById.get(pending[0].id),
|
|
1013
|
+
`Pending successor "${pending[0].id}" must name approved Route Set "${approved[0].id}" as its predecessor.`
|
|
1014
|
+
));
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
const finalizedByPurpose = new Map();
|
|
1018
|
+
for (const route of routeSets.filter(({ status, approval }) => (
|
|
1019
|
+
["approved", "canceled"].includes(status) && typeof approval?.effectiveAt === "string"
|
|
1020
|
+
))) {
|
|
1021
|
+
const key = `${route.programId}\0${route.purposeKey}`;
|
|
1022
|
+
const routes = finalizedByPurpose.get(key) || [];
|
|
1023
|
+
routes.push(route);
|
|
1024
|
+
finalizedByPurpose.set(key, routes);
|
|
1025
|
+
if (
|
|
1026
|
+
route.status === "canceled"
|
|
1027
|
+
&& new Date(route.cancellation?.canceledAt) < new Date(route.approval?.effectiveAt)
|
|
1028
|
+
) {
|
|
1029
|
+
diagnostics.push(error(
|
|
1030
|
+
"invalid-reporting-route-order",
|
|
1031
|
+
pathById.get(route.id),
|
|
1032
|
+
"A Reporting Channel Set cannot be canceled before it becomes effective."
|
|
1033
|
+
));
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
for (const routes of finalizedByPurpose.values()) {
|
|
1037
|
+
routes.sort((left, right) => left.approval.effectiveAt.localeCompare(right.approval.effectiveAt));
|
|
1038
|
+
for (let index = 1; index < routes.length; index += 1) {
|
|
1039
|
+
const previous = routes[index - 1];
|
|
1040
|
+
const current = routes[index];
|
|
1041
|
+
if (current.predecessorId !== previous.id) {
|
|
1042
|
+
diagnostics.push(error(
|
|
1043
|
+
"invalid-reporting-route-lineage",
|
|
1044
|
+
pathById.get(current.id),
|
|
1045
|
+
`Reporting Channel Set "${current.id}" must name "${previous.id}" as its predecessor.`
|
|
1046
|
+
));
|
|
1047
|
+
}
|
|
1048
|
+
const previousEnd = previous.status === "canceled"
|
|
1049
|
+
? new Date(previous.cancellation?.canceledAt)
|
|
1050
|
+
: null;
|
|
1051
|
+
if (!previousEnd || new Date(current.approval.effectiveAt) < previousEnd) {
|
|
1052
|
+
diagnostics.push(error(
|
|
1053
|
+
"overlapping-reporting-route-sets",
|
|
1054
|
+
pathById.get(current.id),
|
|
1055
|
+
`Reporting Channel Sets "${previous.id}" and "${current.id}" overlap for the same Program and purpose.`
|
|
1056
|
+
));
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const FINAL_ROUTE_SET_STATUSES = new Set(["approved", "canceled", "historical"]);
|
|
1063
|
+
|
|
1064
|
+
function sameRouteProposal(proposal, finalized) {
|
|
1065
|
+
const allowed = new Set(["status", "proposalCommit", "approval", "cancellation"]);
|
|
1066
|
+
const keys = new Set([...Object.keys(proposal), ...Object.keys(finalized)]);
|
|
1067
|
+
return [...keys].every((key) => allowed.has(key) || JSON.stringify(proposal[key]) === JSON.stringify(finalized[key]));
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function validateTimestampTimezone(value, timezone, path, diagnostics, label) {
|
|
1071
|
+
if (!value || !timezone) return;
|
|
1072
|
+
const source = String(value);
|
|
1073
|
+
const local = source.replace(/(?:Z|[+-]\d\d:\d\d)$/, "").replace(/\.\d+$/, "");
|
|
1074
|
+
let zoned;
|
|
1075
|
+
try { zoned = localDateTimeValue(new Date(source), timezone); } catch { return; }
|
|
1076
|
+
if (zoned !== local) {
|
|
1077
|
+
diagnostics.push(error("reporting-route-timezone-offset-mismatch", path, `The ${label} timestamp offset does not match ${timezone}.`));
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
311
1081
|
function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
312
1082
|
if (record.status !== "active") return;
|
|
313
1083
|
const { model } = loaded;
|
|
@@ -326,6 +1096,9 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
326
1096
|
? (record.scopeResourceIds || []).map((id) => byId.get(id)).find(({ type } = {}) => type === "program")
|
|
327
1097
|
: null;
|
|
328
1098
|
const recordCount = scopedCollectionRecords(loaded, record.resourceType, program).length;
|
|
1099
|
+
const currentPopulationIds = scopedCollectionRecords(loaded, record.resourceType, program)
|
|
1100
|
+
.map(({ id }) => id)
|
|
1101
|
+
.sort();
|
|
329
1102
|
const currentRevision = collectionRevision(loaded, record.resourceType, {
|
|
330
1103
|
program,
|
|
331
1104
|
authoritativeSourceId: record.decision === "externally-managed"
|
|
@@ -344,6 +1117,47 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
344
1117
|
currentRevision
|
|
345
1118
|
}
|
|
346
1119
|
);
|
|
1120
|
+
if (modelSupports(model, "temporal-collection-reviews")) {
|
|
1121
|
+
const temporalValues = [record.coverage, record.knowledgeCutoffAt, record.populationResourceIds];
|
|
1122
|
+
const hasTemporalBinding = temporalValues.every((value) => value !== undefined && value !== null);
|
|
1123
|
+
if (temporalValues.some((value) => value !== undefined && value !== null) && !hasTemporalBinding) {
|
|
1124
|
+
diagnostics.push(error(
|
|
1125
|
+
"incomplete-collection-review-binding",
|
|
1126
|
+
path,
|
|
1127
|
+
"A temporal Collection Review must record coverage, a knowledge cutoff, and the reviewed population together."
|
|
1128
|
+
));
|
|
1129
|
+
} else if (hasTemporalBinding && (
|
|
1130
|
+
!hasGitRevision(loaded.root, record.scopeRevision)
|
|
1131
|
+
|| record.coverage.kind !== "as-of"
|
|
1132
|
+
|| record.coverage.on !== record.reviewedOn
|
|
1133
|
+
|| !isRfc3339Timestamp(record.knowledgeCutoffAt)
|
|
1134
|
+
|| currentCalendarDate(loaded.workspace.timezone, new Date(record.knowledgeCutoffAt)) !== record.reviewedOn
|
|
1135
|
+
|| (current && JSON.stringify([...record.populationResourceIds].sort()) !== JSON.stringify(currentPopulationIds))
|
|
1136
|
+
)) {
|
|
1137
|
+
diagnostics.push(error(
|
|
1138
|
+
"invalid-current-collection-review",
|
|
1139
|
+
path,
|
|
1140
|
+
"A temporal Collection Review must bind its review date to a retrievable Git scope revision and the exact reviewed population."
|
|
1141
|
+
));
|
|
1142
|
+
}
|
|
1143
|
+
if (hasTemporalBinding) {
|
|
1144
|
+
const head = getGitSummary(loaded.root).commit;
|
|
1145
|
+
const committed = head
|
|
1146
|
+
&& getFileObjectIdAtRevision(loaded.root, head, path) === getWorkingFileObjectId(loaded.root, path);
|
|
1147
|
+
const cutoff = new Date(record.knowledgeCutoffAt);
|
|
1148
|
+
const now = new Date();
|
|
1149
|
+
if (!committed && (
|
|
1150
|
+
currentCalendarDate(loaded.workspace.timezone, now) !== record.reviewedOn
|
|
1151
|
+
|| now.getTime() - cutoff.getTime() > 86_400_000
|
|
1152
|
+
)) {
|
|
1153
|
+
diagnostics.push(error(
|
|
1154
|
+
"stale-uncommitted-collection-review",
|
|
1155
|
+
path,
|
|
1156
|
+
"Commit a temporal Collection Review on its review date and within 24 hours of its knowledge cutoff, or create a new current review."
|
|
1157
|
+
));
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
347
1161
|
if (current && !recordCount && record.decision === "complete") {
|
|
348
1162
|
diagnostics.push(error(
|
|
349
1163
|
"invalid-collection-review-decision",
|
|
@@ -371,6 +1185,24 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
371
1185
|
}
|
|
372
1186
|
}
|
|
373
1187
|
|
|
1188
|
+
function validateCollectionReviewSet(resources, pathById, diagnostics) {
|
|
1189
|
+
const current = new Map();
|
|
1190
|
+
for (const review of resources.filter((record) => record.type === "collection-review" && record.status !== "retired")) {
|
|
1191
|
+
const programId = (review.scopeResourceIds || []).find((id) => (
|
|
1192
|
+
resources.find((record) => record.id === id)?.type === "program"
|
|
1193
|
+
)) || "workspace";
|
|
1194
|
+
const key = `${programId}:${review.resourceType}`;
|
|
1195
|
+
const prior = current.get(key);
|
|
1196
|
+
if (prior) {
|
|
1197
|
+
diagnostics.push(error(
|
|
1198
|
+
"multiple-current-collection-reviews",
|
|
1199
|
+
pathById.get(review.id),
|
|
1200
|
+
`Program "${programId}" has multiple current ${review.resourceType} Collection Reviews: ${prior.id}, ${review.id}. Retire or supersede the duplicate.`
|
|
1201
|
+
));
|
|
1202
|
+
} else current.set(key, review);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
374
1206
|
export async function fingerprintWorkspace(input = process.cwd()) {
|
|
375
1207
|
const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input);
|
|
376
1208
|
const hash = createHash("sha256");
|
|
@@ -610,6 +1442,33 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
|
|
|
610
1442
|
}
|
|
611
1443
|
}
|
|
612
1444
|
|
|
1445
|
+
function validateActionObligationRule(record, byId, path, diagnostics) {
|
|
1446
|
+
if (record.type !== "action-item" || !record.obligationId) return;
|
|
1447
|
+
const event = byId.get(record.sourceResourceId);
|
|
1448
|
+
const obligation = byId.get(record.obligationId);
|
|
1449
|
+
if (event?.type !== "obligation-event" || obligation?.type !== "obligation" || obligation.scheduleMode !== "rule") return;
|
|
1450
|
+
const rule = byId.get(record.obligationRuleId);
|
|
1451
|
+
const eventInstant = event.occurredAt
|
|
1452
|
+
? new Date(event.occurredAt)
|
|
1453
|
+
: new Date(timestampFromLocalDateTime(`${event.occurredOn}T23:59:59`, rule?.timezone || "UTC"));
|
|
1454
|
+
const governingRule = [...byId.values()]
|
|
1455
|
+
.filter((candidate) => (
|
|
1456
|
+
candidate.type === "obligation-rule"
|
|
1457
|
+
&& candidate.obligationId === obligation.id
|
|
1458
|
+
&& ["active", "retired"].includes(candidate.status)
|
|
1459
|
+
&& candidate.effectiveAt
|
|
1460
|
+
&& new Date(candidate.effectiveAt) <= eventInstant
|
|
1461
|
+
))
|
|
1462
|
+
.sort((left, right) => right.effectiveAt.localeCompare(left.effectiveAt))[0];
|
|
1463
|
+
if (rule?.type !== "obligation-rule" || rule.obligationId !== obligation.id || governingRule?.id !== rule.id) {
|
|
1464
|
+
diagnostics.push(error(
|
|
1465
|
+
"invalid-action-obligation-rule",
|
|
1466
|
+
path,
|
|
1467
|
+
"An event Action Item must bind the exact Obligation Rule that governed its event time."
|
|
1468
|
+
));
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
613
1472
|
function validateCompletedObligationAction(record, byId, model, path, diagnostics) {
|
|
614
1473
|
if (
|
|
615
1474
|
!modelSupports(model, "guided-workflow")
|
|
@@ -647,6 +1506,45 @@ function validateEvidencePaths(record, path, diagnostics) {
|
|
|
647
1506
|
}
|
|
648
1507
|
}
|
|
649
1508
|
|
|
1509
|
+
function validateEvidenceSourceRevision(record, root, path, diagnostics) {
|
|
1510
|
+
if (
|
|
1511
|
+
record.sourceKind === "rendered-page"
|
|
1512
|
+
&& record.artifactKind !== "rendered-page"
|
|
1513
|
+
) {
|
|
1514
|
+
diagnostics.push(error(
|
|
1515
|
+
"invalid-rendered-evidence-kind",
|
|
1516
|
+
path,
|
|
1517
|
+
"Evidence with sourceKind rendered-page must also use artifactKind rendered-page."
|
|
1518
|
+
));
|
|
1519
|
+
}
|
|
1520
|
+
const revisionAvailable = record.sourceCommit && hasGitRevision(root, record.sourceCommit);
|
|
1521
|
+
if (
|
|
1522
|
+
record.sourceKind === "rendered-page"
|
|
1523
|
+
&& record.sourceCommit
|
|
1524
|
+
&& !revisionAvailable
|
|
1525
|
+
) {
|
|
1526
|
+
diagnostics.push(error(
|
|
1527
|
+
"invalid-evidence-source-revision",
|
|
1528
|
+
path,
|
|
1529
|
+
`Rendered-page Evidence sourceCommit "${record.sourceCommit}" must name an available Git commit.`
|
|
1530
|
+
));
|
|
1531
|
+
}
|
|
1532
|
+
if (
|
|
1533
|
+
record.sourceKind === "rendered-page"
|
|
1534
|
+
&& revisionAvailable
|
|
1535
|
+
&& (() => {
|
|
1536
|
+
const repositoryCommit = getGitSummary(root).commit;
|
|
1537
|
+
return !repositoryCommit || !isDataHistoryAncestor(root, record.sourceCommit, repositoryCommit);
|
|
1538
|
+
})()
|
|
1539
|
+
) {
|
|
1540
|
+
diagnostics.push(error(
|
|
1541
|
+
"non-authoritative-evidence-source-revision",
|
|
1542
|
+
path,
|
|
1543
|
+
`Rendered-page Evidence sourceCommit "${record.sourceCommit}" must belong to the current authoritative Git history.`
|
|
1544
|
+
));
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
650
1548
|
function validateIndependentApproval(record, byId, path, diagnostics) {
|
|
651
1549
|
if (!["policy", "document"].includes(record.type)) return;
|
|
652
1550
|
if (!(record.approverIds || []).length) return;
|
|
@@ -991,6 +1889,82 @@ async function validateAttestationBinding(record, model, root, byId, path, diagn
|
|
|
991
1889
|
}
|
|
992
1890
|
}
|
|
993
1891
|
|
|
1892
|
+
function validateReportingRouteBinding(record, loaded, path, diagnostics) {
|
|
1893
|
+
if (
|
|
1894
|
+
record.type !== "attestation"
|
|
1895
|
+
|| record.status !== "completed"
|
|
1896
|
+
|| (!loaded.model.resources.attestation?.fields?.reportingRouteId
|
|
1897
|
+
&& !loaded.model.resources.attestation?.fields?.reportingRouteSetId)
|
|
1898
|
+
) return;
|
|
1899
|
+
const date = record.assignedOn || record.completedOn;
|
|
1900
|
+
if (!date) return;
|
|
1901
|
+
if (loaded.model.resources.attestation.fields.reportingRouteSetId) {
|
|
1902
|
+
const expectation = reportingRouteBindingExpectationForValidation(loaded, record);
|
|
1903
|
+
if (!expectation.required) {
|
|
1904
|
+
if (record.reportingRouteSetId || record.reportingRouteSetCommit) {
|
|
1905
|
+
diagnostics.push(error("invalid-reporting-route-binding", path, `The Attestation claims Reporting Channel Set delivery when no structured security-reporting requirement applied on ${date}.`));
|
|
1906
|
+
}
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
if (expectation.error) {
|
|
1910
|
+
diagnostics.push(error("invalid-reporting-route-binding", path, expectation.error));
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
if (!record.reportingRouteSetId || !/^[a-f0-9]{40}$/i.test(String(record.reportingRouteSetCommit || ""))) {
|
|
1914
|
+
diagnostics.push(error("invalid-reporting-route-binding", path, "A Route Set delivery binding must name both the Route Set and its full Git commit."));
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
const repository = getGitSummary(loaded.root);
|
|
1918
|
+
if (
|
|
1919
|
+
!repository.commit
|
|
1920
|
+
|| !isDataHistoryAncestor(loaded, record.reportingRouteSetCommit, repository.commit)
|
|
1921
|
+
|| record.reportingRouteSetId !== expectation.routeSet.id
|
|
1922
|
+
|| record.reportingRouteSetCommit !== expectation.commit
|
|
1923
|
+
) {
|
|
1924
|
+
diagnostics.push(error(
|
|
1925
|
+
"invalid-reporting-route-binding",
|
|
1926
|
+
path,
|
|
1927
|
+
`The Attestation must bind the authoritative approved revision of Reporting Channel Set "${expectation.routeSet.id}" that governed security reporting on ${date}.`
|
|
1928
|
+
));
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
const routeEntry = loaded.entries.find(({ record: candidate }) => candidate.id === record.reportingRouteSetId);
|
|
1932
|
+
const route = routeEntry
|
|
1933
|
+
? reportingRouteRecordAtRevision(loaded, routeEntry, record.reportingRouteSetCommit)
|
|
1934
|
+
: null;
|
|
1935
|
+
if (
|
|
1936
|
+
route?.type !== "reporting-route-set"
|
|
1937
|
+
|| route.status !== "approved"
|
|
1938
|
+
|| route.purposeKey !== "security-reporting"
|
|
1939
|
+
) {
|
|
1940
|
+
diagnostics.push(error("invalid-reporting-route-binding", path, `The Attestation must bind an approved security Reporting Route Set effective on ${date} at the named Git commit.`));
|
|
1941
|
+
}
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
const cutoff = timestampFromLocalDateTime(`${date}T23:59:59`, loaded.workspace.timezone);
|
|
1945
|
+
const route = loaded.entries
|
|
1946
|
+
.filter(({ record: candidate }) => (
|
|
1947
|
+
candidate.type === "reporting-route"
|
|
1948
|
+
&& ["active", "retired"].includes(candidate.status)
|
|
1949
|
+
&& candidate.purpose === "security-reporting"
|
|
1950
|
+
&& candidate.priority === "primary"
|
|
1951
|
+
&& new Date(candidate.effectiveAt) <= new Date(cutoff)
|
|
1952
|
+
&& (!candidate.endsAt || new Date(candidate.endsAt) > new Date(cutoff))
|
|
1953
|
+
))
|
|
1954
|
+
.sort((left, right) => right.record.effectiveAt.localeCompare(left.record.effectiveAt))[0] || null;
|
|
1955
|
+
const expectedId = route?.record.id;
|
|
1956
|
+
const expectedRevision = route ? reportingRouteRevision(route.record) : undefined;
|
|
1957
|
+
if (record.reportingRouteId !== expectedId || record.reportingRouteRevision !== expectedRevision) {
|
|
1958
|
+
diagnostics.push(error(
|
|
1959
|
+
"invalid-reporting-route-binding",
|
|
1960
|
+
path,
|
|
1961
|
+
route
|
|
1962
|
+
? `A completed Attestation must bind the primary security reporting route effective on ${date} and its exact revision.`
|
|
1963
|
+
: `A completed Attestation cannot claim a reporting route when none was effective on ${date}.`
|
|
1964
|
+
));
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
|
|
994
1968
|
async function validateApprovalBinding(record, model, root, path, diagnostics) {
|
|
995
1969
|
const bindingFields = contentBindingFields(record, model);
|
|
996
1970
|
for (const binding of bindingFields) {
|