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/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 { parseCalendarDate, validCalendarRecurrence } from "./recurrence.js";
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") validateEvidencePaths(record, displayPath, diagnostics);
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) {