filegrc 0.3.4 → 0.5.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
@@ -1,4 +1,6 @@
1
- import { stat } from "node:fs/promises";
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { performance } from "node:perf_hooks";
2
4
  import { getResourceDefinition } from "../model/index.js";
3
5
  import { isSafeGitName } from "./git-name.js";
4
6
  import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
@@ -7,19 +9,34 @@ import { obligationIsRunning } from "./program-lifecycle.js";
7
9
  import { partyPeople } from "./parties.js";
8
10
  import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
9
11
  import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
12
+ import { recordTiming } from "./timing.js";
10
13
  import { indexResources, loadWorkspace } from "./workspace.js";
11
14
 
12
15
  const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
16
+ const NAMESPACE_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
13
17
  const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
14
18
  const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
15
19
  const MAX_OBLIGATION_OFFSET_DAYS = 36_600;
16
20
  const MAX_OBLIGATION_OFFSET_HOURS = MAX_OBLIGATION_OFFSET_DAYS * 24;
17
21
 
18
22
  export async function validateWorkspace(input = process.cwd()) {
23
+ const timingStarted = performance.now();
24
+ try {
25
+ return await validateWorkspaceUnmeasured(input);
26
+ } finally {
27
+ recordTiming("validation", performance.now() - timingStarted);
28
+ }
29
+ }
30
+
31
+ async function validateWorkspaceUnmeasured(input) {
19
32
  const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input);
20
33
  const diagnostics = [...loaded.diagnostics];
21
34
  const { byId } = indexResources(loaded.resources);
22
35
  const seen = new Map();
36
+ const pathById = new Map(loaded.entries.map((entry) => [
37
+ entry.record?.id,
38
+ `data/${entry.relativePath}`
39
+ ]));
23
40
  const asOf = currentCalendarDate(loaded.workspace?.timezone || "UTC");
24
41
  const obligationsByControl = new Map();
25
42
  for (const obligation of loaded.resources.filter((record) => record.type === "obligation" && record.status !== "retired")) {
@@ -55,8 +72,20 @@ export async function validateWorkspace(input = process.cwd()) {
55
72
  validateLocation(record, definition, entry.relativePath, diagnostics);
56
73
  validateRecord(record, definition, loaded.model, displayPath, diagnostics);
57
74
  validateDateRanges(record, displayPath, diagnostics);
58
- if (record.type === "obligation") validateObligation(record, displayPath, diagnostics);
75
+ if (record.type === "appointment") validateAppointment(record, byId, displayPath, diagnostics);
76
+ if (record.type === "collection-review") {
77
+ validateCollectionReview(record, loaded.model, loaded.resources, byId, displayPath, diagnostics);
78
+ }
79
+ if (record.type === "obligation") validateObligation(record, loaded.model, byId, displayPath, diagnostics);
80
+ if (record.type === "action-item") {
81
+ validateCompletedObligationAction(record, byId, loaded.model, displayPath, diagnostics);
82
+ }
83
+ if (record.type === "obligation-event") validatePolicyEvent(record, loaded.model, byId, displayPath, diagnostics);
59
84
  if (record.type === "evidence") validateEvidencePaths(record, displayPath, diagnostics);
85
+ validateCoverage(record, displayPath, diagnostics);
86
+ validateClassification(record, loaded.workspace, displayPath, diagnostics);
87
+ validateCompletionDates(record, displayPath, diagnostics);
88
+ await validateAttestationBinding(record, loaded.model, loaded.root, byId, displayPath, diagnostics);
60
89
 
61
90
  const fields = { ...loaded.model.commonFields, ...definition.fields };
62
91
  for (const [fieldName, field] of Object.entries(fields)) {
@@ -96,12 +125,21 @@ export async function validateWorkspace(input = process.cwd()) {
96
125
  }
97
126
  }
98
127
  }
128
+ validateNestedRelations(fieldName, value, field, loaded.model, byId, displayPath, diagnostics);
99
129
  }
100
130
  validateIndependentApproval(record, byId, displayPath, diagnostics);
101
- validateCompletedObligationEvent(record, byId, displayPath, diagnostics);
131
+ validateCompletedObligationEvent(record, byId, loaded.model, displayPath, diagnostics);
102
132
  validateImplementedControlSchedules(record, obligationsByControl, byId, asOf, displayPath, diagnostics);
103
133
  await validateMarkdown(record, definition, loaded.model, loaded.root, displayPath, diagnostics);
134
+ await validateApprovalBinding(record, loaded.model, loaded.root, displayPath, diagnostics);
104
135
  }
136
+ validateRelationshipConstraints(
137
+ loaded.resources,
138
+ loaded.model,
139
+ byId,
140
+ pathById,
141
+ diagnostics
142
+ );
105
143
 
106
144
  diagnostics.sort((a, b) => `${a.severity}:${a.path}:${a.code}`.localeCompare(`${b.severity}:${b.path}:${b.code}`));
107
145
  return {
@@ -116,10 +154,93 @@ export async function validateWorkspace(input = process.cwd()) {
116
154
  };
117
155
  }
118
156
 
157
+ function validateCollectionReview(record, model, resources, byId, path, diagnostics) {
158
+ if (record.status !== "active") return;
159
+ const configuration = model.collectionReviews?.[record.resourceType];
160
+ if (!configuration) return;
161
+ const allowedDecisions = configuration.decisions || ["complete"];
162
+ if (!allowedDecisions.includes(record.decision)) {
163
+ diagnostics.push(error(
164
+ "invalid-collection-review-decision",
165
+ path,
166
+ `${configuration.title} review must use one of: ${allowedDecisions.join(", ")}.`
167
+ ));
168
+ return;
169
+ }
170
+ const recordCount = resources.filter(({ type }) => type === record.resourceType).length;
171
+ if (!recordCount && record.decision === "complete") {
172
+ diagnostics.push(error(
173
+ "invalid-collection-review-decision",
174
+ path,
175
+ `${configuration.title} has no records and cannot use the complete conclusion.`
176
+ ));
177
+ }
178
+ if (recordCount && record.decision === "zero-population") {
179
+ diagnostics.push(error(
180
+ "invalid-collection-review-decision",
181
+ path,
182
+ `${configuration.title} has ${recordCount} records and cannot use the zero-population conclusion.`
183
+ ));
184
+ }
185
+ if (
186
+ record.decision === "externally-managed"
187
+ && byId.get(record.authoritativeSystemId)?.status !== "active"
188
+ ) {
189
+ diagnostics.push(error(
190
+ "inactive-authoritative-system",
191
+ path,
192
+ `${configuration.title} must name an active authoritative System for an externally managed conclusion.`
193
+ ));
194
+ }
195
+ }
196
+
197
+ export async function fingerprintWorkspace(input = process.cwd()) {
198
+ const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input);
199
+ const hash = createHash("sha256");
200
+ hash.update(`model\0${loaded.model.modelVersion}\0`);
201
+ for (const entry of [...loaded.entries].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
202
+ hash.update(`record\0${entry.relativePath}\0${entry.source.length}\0${entry.source}`);
203
+ const definition = loaded.model.resources[entry.record?.type];
204
+ if (!definition) continue;
205
+ for (const item of markdownEntries(loaded.model, entry.record).sort((a, b) => a.path.localeCompare(b.path))) {
206
+ try {
207
+ const source = await readFile(resolveDataPath(loaded.root, item.path), "utf8");
208
+ hash.update(`markdown\0${item.path}\0${source.length}\0${source}`);
209
+ } catch {
210
+ hash.update(`markdown-missing\0${item.path}\0`);
211
+ }
212
+ }
213
+ const fields = { ...loaded.model.commonFields, ...definition.fields };
214
+ for (const [name, field] of Object.entries(fields)) {
215
+ if (field.format !== "data-path" && field.items !== "data-path") continue;
216
+ const value = entry.record[name];
217
+ const paths = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
218
+ for (const path of [...paths].sort()) {
219
+ try {
220
+ const file = await stat(resolveDataPath(loaded.root, path));
221
+ hash.update(`data-path\0${path}\0${file.isFile() ? "file" : "other"}\0`);
222
+ } catch {
223
+ hash.update(`data-path-missing\0${path}\0`);
224
+ }
225
+ }
226
+ }
227
+ }
228
+ return { fingerprint: hash.digest("hex"), loaded };
229
+ }
230
+
119
231
  function validateImplementedControlSchedules(record, obligationsByControl, byId, asOf, path, diagnostics) {
120
232
  if (record.type !== "control" || record.status !== "implemented") return;
121
233
  const schedules = obligationsByControl.get(record.id) || [];
122
- if (!schedules.length || schedules.every((obligation) => obligationIsRunning(obligation, byId, asOf))) return;
234
+ if (!schedules.length) {
235
+ if (record.operationPattern === "continuous") return;
236
+ diagnostics.push(error(
237
+ "control-work-queue-missing",
238
+ path,
239
+ "An implemented scheduled, event-driven, or mixed Control must link at least one active Obligation."
240
+ ));
241
+ return;
242
+ }
243
+ if (schedules.every((obligation) => obligationIsRunning(obligation, byId, asOf))) return;
123
244
  const stopped = schedules.filter((obligation) => !obligationIsRunning(obligation, byId, asOf));
124
245
  const paused = stopped.filter((obligation) => obligation.status === "paused");
125
246
  const waiting = stopped.filter((obligation) => obligation.status === "active");
@@ -149,9 +270,7 @@ function validateImplementedControlSchedules(record, obligationsByControl, byId,
149
270
  function validateDateRanges(record, path, diagnostics) {
150
271
  for (const [startField, endField] of [
151
272
  ["startDate", "endDate"],
152
- ["periodStart", "periodEnd"],
153
- ["candidatePeriodStart", "candidatePeriodEnd"],
154
- ["dueWindowStart", "dueWindowEnd"]
273
+ ["startsOn", "endsOn"]
155
274
  ]) {
156
275
  const start = record[startField];
157
276
  const end = record[endField];
@@ -165,7 +284,25 @@ function validateDateRanges(record, path, diagnostics) {
165
284
  }
166
285
  }
167
286
 
168
- function validateCompletedObligationEvent(record, byId, path, diagnostics) {
287
+ function validateAppointment(record, byId, path, diagnostics) {
288
+ if ((record.scopeResourceIds || []).includes(record.id)) {
289
+ diagnostics.push(error(
290
+ "invalid-appointment-scope",
291
+ path,
292
+ "An Appointment cannot include itself in scopeResourceIds."
293
+ ));
294
+ }
295
+ if (record.status !== "active") return;
296
+ const holder = byId.get(record.holderId);
297
+ if (holder?.type === "person" && holder.status === "active") return;
298
+ diagnostics.push(error(
299
+ "inactive-appointment-holder",
300
+ path,
301
+ "An active Appointment must have an active Person as its holder."
302
+ ));
303
+ }
304
+
305
+ function validateCompletedObligationEvent(record, byId, model, path, diagnostics) {
169
306
  if (record.type !== "obligation-event" || record.status !== "complete") return;
170
307
  if (record.completedOn && record.occurredOn && record.completedOn < record.occurredOn) {
171
308
  diagnostics.push(error(
@@ -193,9 +330,14 @@ function validateCompletedObligationEvent(record, byId, path, diagnostics) {
193
330
  continue;
194
331
  }
195
332
  const obligation = byId.get(action.obligationId);
196
- const expectedTypes = obligation?.type === "obligation" ? obligation.completionResourceTypes || [] : [];
333
+ const expectedTypes = obligation?.type === "obligation"
334
+ ? model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || []
335
+ : [];
197
336
  if (!expectedTypes.length) continue;
198
- const linked = [...new Set([...(action.completionResourceIds || []), ...(action.evidenceIds || [])])]
337
+ const completionIds = String(model.modelVersion) === "3"
338
+ ? action.completionResourceIds || []
339
+ : [...(action.completionResourceIds || []), ...(action.evidenceIds || [])];
340
+ const linked = [...new Set(completionIds)]
199
341
  .map((id) => byId.get(id))
200
342
  .filter(Boolean);
201
343
  if (!linked.some((item) => expectedTypes.includes(item.type))) {
@@ -208,6 +350,27 @@ function validateCompletedObligationEvent(record, byId, path, diagnostics) {
208
350
  }
209
351
  }
210
352
 
353
+ function validateCompletedObligationAction(record, byId, model, path, diagnostics) {
354
+ if (
355
+ String(model.modelVersion) !== "3"
356
+ || record.status !== "done"
357
+ || !record.obligationId
358
+ ) return;
359
+ const obligation = byId.get(record.obligationId);
360
+ if (obligation?.type !== "obligation") return;
361
+ const expectedTypes = model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || [];
362
+ if (!expectedTypes.length) return;
363
+ const linked = [...new Set(record.completionResourceIds || [])]
364
+ .map((id) => byId.get(id))
365
+ .filter(Boolean);
366
+ if (linked.some((item) => expectedTypes.includes(item.type))) return;
367
+ diagnostics.push(error(
368
+ "missing-obligation-completion",
369
+ path,
370
+ `A done Action Item linked to "${obligation.id}" needs completionResourceIds containing ${expectedTypes.join(" or ")}.`
371
+ ));
372
+ }
373
+
211
374
  function validateEvidencePaths(record, path, diagnostics) {
212
375
  const expectedPrefix = `evidence/${record.id}/`;
213
376
  for (const filePath of record.filePaths || []) {
@@ -239,9 +402,38 @@ function validateIndependentApproval(record, byId, path, diagnostics) {
239
402
  }
240
403
  }
241
404
 
242
- function validateObligation(record, path, diagnostics) {
405
+ function validateObligation(record, model, byId, path, diagnostics) {
243
406
  const recurrence = record.recurrence;
244
407
  if (!recurrence || Array.isArray(recurrence) || typeof recurrence !== "object") return;
408
+ const activity = model.obligationActivities?.[record.activityType];
409
+ if (
410
+ activity
411
+ && Array.isArray(activity.recurrenceModes)
412
+ && !activity.recurrenceModes.includes(recurrence.mode)
413
+ ) {
414
+ diagnostics.push(error(
415
+ "invalid-obligation-activity",
416
+ path,
417
+ `${record.activityType} obligations require ${activity.recurrenceModes.join(" or ")} recurrence.`
418
+ ));
419
+ }
420
+ if (activity) {
421
+ const allowedScopeTypes = new Set(activity.scopeResourceTypes || []);
422
+ for (const [field, ids] of [
423
+ ["scopeResourceIds", record.scopeResourceIds || []],
424
+ ["templateResourceId", record.templateResourceId ? [record.templateResourceId] : []]
425
+ ]) {
426
+ for (const id of ids) {
427
+ const target = byId.get(id);
428
+ if (!target || allowedScopeTypes.has(target.type)) continue;
429
+ diagnostics.push(error(
430
+ "invalid-obligation-scope",
431
+ path,
432
+ `${field} references ${target.type} "${id}", but ${record.activityType} allows ${[...allowedScopeTypes].join(" or ")} scope.`
433
+ ));
434
+ }
435
+ }
436
+ }
245
437
  if (recurrence.mode === "calendar") {
246
438
  const normalized = { ...recurrence, anchorDate: recurrence.anchorDate || record.startsOn };
247
439
  if (!validCalendarRecurrence(normalized)) {
@@ -252,13 +444,30 @@ function validateObligation(record, path, diagnostics) {
252
444
  ));
253
445
  }
254
446
  } else if (recurrence.mode === "event") {
255
- if (typeof recurrence.eventType !== "string" || !ID_PATTERN.test(recurrence.eventType)) {
447
+ if (!model.policyEvents?.[recurrence.eventType]) {
256
448
  diagnostics.push(error(
257
449
  "invalid-obligation-recurrence",
258
450
  path,
259
- "Event recurrence requires a lowercase kebab-case eventType."
451
+ "Event recurrence must use a policy event defined by the model."
260
452
  ));
261
453
  }
454
+ if (record.eventRiskLevels?.length) {
455
+ if (recurrence.eventType !== "person-ended") {
456
+ diagnostics.push(error(
457
+ "invalid-obligation-event-filter",
458
+ path,
459
+ "eventRiskLevels may be used only with the person-ended Policy Event."
460
+ ));
461
+ }
462
+ const invalid = record.eventRiskLevels.filter((value) => !["normal", "high"].includes(value));
463
+ if (invalid.length) {
464
+ diagnostics.push(error(
465
+ "invalid-obligation-event-filter",
466
+ path,
467
+ `Departure risk filters must be normal or high, not ${invalid.join(", ")}.`
468
+ ));
469
+ }
470
+ }
262
471
  } else {
263
472
  diagnostics.push(error(
264
473
  "invalid-obligation-recurrence",
@@ -268,53 +477,297 @@ function validateObligation(record, path, diagnostics) {
268
477
  }
269
478
 
270
479
  const window = record.window;
271
- if (!window || Array.isArray(window) || typeof window !== "object") return;
272
- const dayFields = ["startOffsetDays", "endOffsetDays"].filter((name) => window[name] !== undefined);
273
- const hourFields = ["startOffsetHours", "endOffsetHours"].filter((name) => window[name] !== undefined);
274
- for (const name of [...dayFields, ...hourFields]) {
480
+ if (!window || Array.isArray(window) || typeof window !== "object") {
481
+ if (recurrence.mode === "event") {
482
+ diagnostics.push(error("invalid-obligation-window", path, "Event obligations require an explicit deadline window."));
483
+ }
484
+ return;
485
+ }
486
+ for (const name of ["startsAfter", "dueAfter"]) {
487
+ if (window[name] === undefined) continue;
275
488
  if (!Number.isInteger(window[name])) {
276
489
  diagnostics.push(error("invalid-obligation-window", path, `window.${name} must be an integer.`));
277
490
  }
278
491
  }
279
- for (const name of dayFields) {
280
- if (Number.isInteger(window[name]) && Math.abs(window[name]) > MAX_OBLIGATION_OFFSET_DAYS) {
281
- diagnostics.push(error("invalid-obligation-window", path, `window.${name} must stay within ${MAX_OBLIGATION_OFFSET_DAYS.toLocaleString("en-US")} days of the policy event.`));
492
+ const limit = window.precision === "timestamp" ? MAX_OBLIGATION_OFFSET_HOURS : MAX_OBLIGATION_OFFSET_DAYS;
493
+ const unit = window.precision === "timestamp" ? "hours" : "days";
494
+ for (const name of ["startsAfter", "dueAfter"]) {
495
+ if (Number.isInteger(window[name]) && Math.abs(window[name]) > limit) {
496
+ diagnostics.push(error("invalid-obligation-window", path, `window.${name} must stay within ${limit.toLocaleString("en-US")} ${unit} of the policy event.`));
282
497
  }
283
498
  }
284
- for (const name of hourFields) {
285
- if (Number.isInteger(window[name]) && Math.abs(window[name]) > MAX_OBLIGATION_OFFSET_HOURS) {
286
- diagnostics.push(error("invalid-obligation-window", path, `window.${name} must stay within ${MAX_OBLIGATION_OFFSET_HOURS.toLocaleString("en-US")} hours of the policy event.`));
499
+ if (recurrence.mode === "calendar" && window.precision !== "date") {
500
+ diagnostics.push(error("invalid-obligation-window", path, "Calendar obligations require a date-precision window."));
501
+ }
502
+ if (
503
+ Number.isInteger(window.dueAfter)
504
+ && window.dueAfter < (Number.isInteger(window.startsAfter) ? window.startsAfter : 0)
505
+ ) {
506
+ diagnostics.push(error("invalid-obligation-window", path, "window.dueAfter must be on or after window.startsAfter."));
507
+ }
508
+ }
509
+
510
+ function validatePolicyEvent(record, model, byId, path, diagnostics) {
511
+ const event = model.policyEvents?.[record.eventType];
512
+ if (!event) return;
513
+ const rules = event.subjectRules || [];
514
+ const allowedTypes = new Set(rules.map(({ resourceType }) => resourceType));
515
+ const counts = new Map();
516
+ for (const id of new Set(record.subjectResourceIds || [])) {
517
+ const target = byId.get(id);
518
+ if (!target) continue;
519
+ counts.set(target.type, (counts.get(target.type) || 0) + 1);
520
+ if (!allowedTypes.has(target.type)) {
521
+ diagnostics.push(error(
522
+ "invalid-policy-event-subject",
523
+ path,
524
+ `${record.eventType} cannot use ${target.type} "${id}" as a subject.`
525
+ ));
526
+ }
527
+ }
528
+ for (const { resourceType, minimum = 0, maximum } of rules) {
529
+ const count = counts.get(resourceType) || 0;
530
+ if (count < minimum) {
531
+ diagnostics.push(error(
532
+ "invalid-policy-event-subject",
533
+ path,
534
+ `${record.eventType} requires at least ${minimum} ${resourceType} subject${minimum === 1 ? "" : "s"}.`
535
+ ));
536
+ }
537
+ if (Number.isInteger(maximum) && count > maximum) {
538
+ diagnostics.push(error(
539
+ "invalid-policy-event-subject",
540
+ path,
541
+ `${record.eventType} allows at most ${maximum} ${resourceType} subject${maximum === 1 ? "" : "s"}.`
542
+ ));
287
543
  }
288
544
  }
289
- if (dayFields.length && hourFields.length) {
290
- diagnostics.push(error("invalid-obligation-window", path, "An obligation window cannot mix day and hour offsets."));
545
+ }
546
+
547
+ function validateCompletionDates(record, path, diagnostics) {
548
+ if (record.startedAt && record.completedAt && record.completedAt < record.startedAt) {
549
+ diagnostics.push(error("invalid-completion-order", path, "completedAt cannot be before startedAt."));
550
+ }
551
+ if (record.completedOn && record.reviewedOn && record.reviewedOn < record.completedOn) {
552
+ diagnostics.push(error("invalid-completion-order", path, "reviewedOn cannot be before completedOn."));
553
+ }
554
+ if (record.completedOn && record.approvedOn && record.approvedOn < record.completedOn) {
555
+ diagnostics.push(error("invalid-completion-order", path, "approvedOn cannot be before completedOn."));
291
556
  }
292
- if (recurrence.mode === "calendar" && hourFields.length) {
293
- diagnostics.push(error("invalid-obligation-window", path, "Calendar obligations use day offsets; hour offsets are only valid for event obligations."));
557
+ validateOrderedDates(record, path, diagnostics, [
558
+ "requestedOn",
559
+ "approvedOn",
560
+ "provisionedOn",
561
+ "deprovisionedOn"
562
+ ]);
563
+ validateOrderedDates(record, path, diagnostics, [
564
+ "detectedAt",
565
+ "declaredAt",
566
+ "containedAt",
567
+ "eradicatedAt",
568
+ "recoveredAt",
569
+ "closedAt"
570
+ ]);
571
+ validateOrderedDates(record, path, diagnostics, ["startedAt", "endedAt"]);
572
+ validateOrderedDates(record, path, diagnostics, ["fieldworkStart", "fieldworkEnd", "reportDate"]);
573
+ if (record.acceptance) {
574
+ validateOrderedDates(record.acceptance, path, diagnostics, ["acceptedOn", "expiresOn"], "acceptance.");
294
575
  }
576
+ if (record.approval) {
577
+ validateOrderedDates(record.approval, path, diagnostics, ["approvedOn", "expiresOn"], "approval.");
578
+ if (
579
+ record.resolution?.resolvedOn
580
+ && record.approval.approvedOn
581
+ && record.resolution.resolvedOn < record.approval.approvedOn
582
+ ) {
583
+ diagnostics.push(error(
584
+ "invalid-completion-order",
585
+ path,
586
+ "resolution.resolvedOn cannot be before approval.approvedOn."
587
+ ));
588
+ }
589
+ }
590
+ }
591
+
592
+ function validateOrderedDates(record, path, diagnostics, fields, prefix = "") {
593
+ let previous = null;
594
+ for (const field of fields) {
595
+ const value = record[field];
596
+ if (!value) continue;
597
+ if (previous && value < previous.value) {
598
+ diagnostics.push(error(
599
+ "invalid-completion-order",
600
+ path,
601
+ `${prefix}${field} cannot be before ${prefix}${previous.field}.`
602
+ ));
603
+ }
604
+ previous = { field, value };
605
+ }
606
+ }
607
+
608
+ async function validateAttestationBinding(record, model, root, byId, path, diagnostics) {
295
609
  if (
296
- recurrence.mode === "calendar"
297
- && Number.isInteger(window.startOffsetDays)
298
- && window.startOffsetDays > 0
299
- && window.endOffsetDays === undefined
300
- ) {
610
+ record.type !== "attestation"
611
+ || record.status !== "completed"
612
+ || record.attestationMethod !== "git-approval"
613
+ || !record.contentRevisions
614
+ ) return;
615
+ const expectedPaths = new Set();
616
+ const subjectPaths = new Map();
617
+ for (const id of record.subjectResourceIds || []) {
618
+ const subject = byId.get(id);
619
+ if (!subject) continue;
620
+ const paths = [];
621
+ for (const item of markdownEntries(model, subject)) {
622
+ try {
623
+ if ((await stat(resolveDataPath(root, item.path))).isFile()) paths.push(item.path);
624
+ } catch (error) {
625
+ if (error.code !== "ENOENT") throw error;
626
+ }
627
+ }
628
+ subjectPaths.set(id, paths);
629
+ for (const item of paths) expectedPaths.add(item);
630
+ }
631
+ const actualPaths = Object.keys(record.contentRevisions);
632
+ if (!expectedPaths.size) {
633
+ diagnostics.push(error(
634
+ "invalid-attestation-binding",
635
+ path,
636
+ "A git-approval Attestation must reference authored Policy, Document, or Training content."
637
+ ));
638
+ return;
639
+ }
640
+ if (!actualPaths.length) {
641
+ diagnostics.push(error(
642
+ "invalid-attestation-binding",
643
+ path,
644
+ "A git-approval Attestation must bind at least one subject Markdown file."
645
+ ));
646
+ return;
647
+ }
648
+ const invalid = actualPaths.filter((item) => (
649
+ !expectedPaths.has(item)
650
+ || !/^[a-f0-9]{64}$/.test(String(record.contentRevisions[item] || ""))
651
+ ));
652
+ const unboundSubjects = [...subjectPaths].filter(([, paths]) => (
653
+ paths.length && !paths.some((item) => actualPaths.includes(item))
654
+ )).map(([id]) => id);
655
+ if (invalid.length || unboundSubjects.length) {
301
656
  diagnostics.push(error(
302
- "invalid-obligation-window",
657
+ "invalid-attestation-binding",
303
658
  path,
304
- "Calendar obligations with a positive window.startOffsetDays must set window.endOffsetDays."
659
+ unboundSubjects.length
660
+ ? `Attestation contentRevisions must bind authored Markdown for every subject; missing ${unboundSubjects.join(", ")}.`
661
+ : "Attestation contentRevisions must contain valid SHA-256 hashes for subject Markdown paths and no unrelated paths."
305
662
  ));
306
663
  }
664
+ }
665
+
666
+ async function validateApprovalBinding(record, model, root, path, diagnostics) {
667
+ const bindingField = approvalBindingField(record, model);
668
+ if (!bindingField || !approvalBound(record) || !record[bindingField]) return;
669
+ const actual = {};
670
+ for (const item of markdownEntries(model, record)) {
671
+ try {
672
+ const source = await readFile(resolveDataPath(root, item.path), "utf8");
673
+ actual[item.path] = createHash("sha256").update(source).digest("hex");
674
+ } catch (error) {
675
+ if (error.code !== "ENOENT") throw error;
676
+ }
677
+ }
678
+ const expected = record[bindingField];
679
+ const paths = [...new Set([...Object.keys(actual), ...Object.keys(expected)])].sort();
680
+ const invalid = paths.filter((item) => (
681
+ !/^[a-f0-9]{64}$/.test(String(expected[item] || ""))
682
+ || expected[item] !== actual[item]
683
+ ));
684
+ if (invalid.length) {
685
+ diagnostics.push(error(
686
+ "approval-content-changed",
687
+ path,
688
+ `Approved content no longer matches ${invalid.map((item) => `data/${item}`).join(", ")}. Move the record to draft or in-review, review the change, then approve it again.`
689
+ ));
690
+ }
691
+ }
692
+
693
+ function approvalBound(record) {
694
+ if (record.type === "policy") return ["approved", "active", "superseded", "retired"].includes(record.status);
695
+ if (record.type === "document") return ["active", "superseded", "retired"].includes(record.status);
696
+ if (record.type === "training") return ["active", "retired"].includes(record.status);
697
+ return false;
698
+ }
699
+
700
+ function approvalBindingField(record, model) {
701
+ if (["policy", "document"].includes(record.type)) return "approvedContentRevisions";
702
+ if (record.type === "training" && model.resources.training?.fields?.effectiveContentRevisions) {
703
+ return "effectiveContentRevisions";
704
+ }
705
+ return null;
706
+ }
707
+
708
+ function validateCoverage(record, path, diagnostics) {
709
+ const coverage = record.type === "workspace" ? record.candidateCoverage : record.coverage;
710
+ if (!coverage) return;
711
+ if (coverage.kind === "range" && coverage.startsOn && coverage.endsOn && coverage.endsOn < coverage.startsOn) {
712
+ diagnostics.push(error("invalid-date-range", path, "coverage.endsOn cannot be before coverage.startsOn."));
713
+ }
307
714
  if (
308
- Number.isInteger(window.endOffsetDays)
309
- && window.endOffsetDays < (Number.isInteger(window.startOffsetDays) ? window.startOffsetDays : 0)
715
+ record.type === "audit"
716
+ && record.auditKind === "soc-2-type-1"
717
+ && coverage.kind !== "as-of"
310
718
  ) {
311
- diagnostics.push(error("invalid-obligation-window", path, "window.endOffsetDays must be on or after window.startOffsetDays."));
719
+ diagnostics.push(error("invalid-audit-coverage", path, "A SOC 2 Type 1 Audit requires as-of coverage."));
312
720
  }
313
721
  if (
314
- Number.isInteger(window.endOffsetHours)
315
- && window.endOffsetHours < (Number.isInteger(window.startOffsetHours) ? window.startOffsetHours : 0)
722
+ record.type === "audit"
723
+ && record.auditKind === "soc-2-type-2"
724
+ && coverage.kind !== "range"
316
725
  ) {
317
- diagnostics.push(error("invalid-obligation-window", path, "window.endOffsetHours must be on or after window.startOffsetHours."));
726
+ diagnostics.push(error("invalid-audit-coverage", path, "A SOC 2 Type 2 Audit requires range coverage."));
727
+ }
728
+ if (
729
+ record.type === "workspace"
730
+ && record.assuranceGoal === "soc-2-type-1"
731
+ && coverage.kind !== "as-of"
732
+ ) {
733
+ diagnostics.push(error("invalid-candidate-coverage", path, "A Type 1 management goal requires as-of candidate coverage."));
734
+ }
735
+ if (
736
+ record.type === "workspace"
737
+ && record.assuranceGoal === "soc-2-type-2"
738
+ && coverage.kind !== "range"
739
+ ) {
740
+ diagnostics.push(error("invalid-candidate-coverage", path, "A Type 2 management goal requires range candidate coverage."));
741
+ }
742
+ if (
743
+ ["audit-population", "penetration-test"].includes(record.type)
744
+ && coverage.kind !== "range"
745
+ ) {
746
+ diagnostics.push(error("invalid-coverage", path, `${record.type} requires range coverage.`));
747
+ }
748
+ if (
749
+ record.type === "evidence"
750
+ && record.artifactKind === "population-export"
751
+ && coverage.kind !== "range"
752
+ ) {
753
+ diagnostics.push(error("invalid-coverage", path, "Population Export Evidence requires range coverage."));
754
+ }
755
+ }
756
+
757
+ function validateClassification(record, workspace, path, diagnostics) {
758
+ if (!record.classificationId) return;
759
+ const definitions = workspace?.classificationDefinitions;
760
+ if (
761
+ !definitions
762
+ || Array.isArray(definitions)
763
+ || typeof definitions !== "object"
764
+ || !Object.hasOwn(definitions, record.classificationId)
765
+ ) {
766
+ diagnostics.push(error(
767
+ "unknown-classification",
768
+ path,
769
+ `classificationId references undefined Workspace classification "${record.classificationId}".`
770
+ ));
318
771
  }
319
772
  }
320
773
 
@@ -340,6 +793,13 @@ function validateRecord(record, definition, model, path, diagnostics) {
340
793
  ]);
341
794
  for (const [name, field] of Object.entries(fields)) {
342
795
  if (field.requiredWhen && conditionMatches(record, field.requiredWhen)) required.add(name);
796
+ if (!isMissing(record[name]) && field.allowedWhen && !conditionMatches(record, field.allowedWhen)) {
797
+ diagnostics.push(error(
798
+ "invalid-field",
799
+ path,
800
+ `${name} is not allowed for the selected ${Object.keys(field.allowedWhen).join(" and ")}.`
801
+ ));
802
+ }
343
803
  if (field.disjointFrom) {
344
804
  const values = normalizedValues(record[name]);
345
805
  const otherValues = new Set(normalizedValues(record[field.disjointFrom]));
@@ -365,7 +825,11 @@ function validateRecord(record, definition, model, path, diagnostics) {
365
825
  for (const [name, value] of Object.entries(record)) {
366
826
  const field = fields[name];
367
827
  if (!field) {
368
- diagnostics.push(warning("unknown-field", path, `Field "${name}" is not defined by model v${model.modelVersion}.`));
828
+ diagnostics.push(error(
829
+ "unknown-field",
830
+ path,
831
+ `Field "${name}" is not defined by model v${model.modelVersion}. Put organization-specific data under extensions.`
832
+ ));
369
833
  continue;
370
834
  }
371
835
  validateValue(name, value, field, model, path, diagnostics);
@@ -378,6 +842,109 @@ function normalizedValues(value) {
378
842
  return typeof value === "string" ? [value] : [];
379
843
  }
380
844
 
845
+ function validateNestedRelations(name, value, field, model, byId, path, diagnostics) {
846
+ if (
847
+ field.type === "object"
848
+ && field.objectType
849
+ && value
850
+ && !Array.isArray(value)
851
+ && typeof value === "object"
852
+ ) {
853
+ validateObjectRelations(name, value, model.objectTypes?.[field.objectType], model, byId, path, diagnostics);
854
+ }
855
+ if (field.type === "array" && field.itemObjectType && Array.isArray(value)) {
856
+ for (const [index, item] of value.entries()) {
857
+ if (!item || Array.isArray(item) || typeof item !== "object") continue;
858
+ validateObjectRelations(
859
+ `${name}[${index}]`,
860
+ item,
861
+ model.objectTypes?.[field.itemObjectType],
862
+ model,
863
+ byId,
864
+ path,
865
+ diagnostics
866
+ );
867
+ }
868
+ }
869
+ }
870
+
871
+ function validateObjectRelations(name, value, schema, model, byId, path, diagnostics) {
872
+ if (!schema) return;
873
+ for (const [propertyName, property] of Object.entries(schema.properties || {})) {
874
+ const nested = value[propertyName];
875
+ if (nested === undefined || nested === null) continue;
876
+ if (property.relation) {
877
+ const ids = Array.isArray(nested) ? nested : [nested];
878
+ for (const id of ids) {
879
+ const target = byId.get(id);
880
+ if (!target) {
881
+ diagnostics.push(error(
882
+ "missing-reference",
883
+ path,
884
+ `${name}.${propertyName} references unknown ID "${id}".`
885
+ ));
886
+ } else if (!property.relation.includes("*") && !property.relation.includes(target.type)) {
887
+ diagnostics.push(error(
888
+ "wrong-reference-type",
889
+ path,
890
+ `${name}.${propertyName} references ${target.type} "${id}", expected ${property.relation.join(" or ")}.`
891
+ ));
892
+ }
893
+ }
894
+ }
895
+ validateNestedRelations(`${name}.${propertyName}`, nested, property, model, byId, path, diagnostics);
896
+ }
897
+ }
898
+
899
+ function validateRelationshipConstraints(resources, model, byId, pathById, diagnostics) {
900
+ for (const constraint of model.relationshipConstraints?.acyclic || []) {
901
+ const candidates = resources.filter(({ type }) => type === constraint.resourceType);
902
+ const visited = new Set();
903
+ for (const record of candidates) {
904
+ if (visited.has(record.id)) continue;
905
+ const chain = [];
906
+ const positions = new Map();
907
+ let current = record;
908
+ while (current?.type === constraint.resourceType && !visited.has(current.id)) {
909
+ if (positions.has(current.id)) {
910
+ const cycle = [...chain.slice(positions.get(current.id)), current.id];
911
+ const cycleRecord = chain[positions.get(current.id)];
912
+ diagnostics.push(error(
913
+ "cyclic-relationship",
914
+ pathById.get(cycleRecord) || `data/${cycleRecord}`,
915
+ `${constraint.field} forms a cycle: ${cycle.join(" -> ")}.`
916
+ ));
917
+ break;
918
+ }
919
+ positions.set(current.id, chain.length);
920
+ chain.push(current.id);
921
+ current = byId.get(current[constraint.field]);
922
+ }
923
+ for (const id of chain) visited.add(id);
924
+ }
925
+ }
926
+
927
+ for (const constraint of model.relationshipConstraints?.unique || []) {
928
+ const keys = new Map();
929
+ for (const record of resources) {
930
+ if (record.type !== constraint.resourceType) continue;
931
+ if (constraint.statuses && !constraint.statuses.includes(record.status)) continue;
932
+ const key = JSON.stringify((constraint.fields || []).map((field) => {
933
+ const value = record[field];
934
+ return Array.isArray(value) ? [...value].sort() : value ?? null;
935
+ }));
936
+ const previous = keys.get(key);
937
+ if (previous) {
938
+ diagnostics.push(error(
939
+ "duplicate-active-relationship",
940
+ pathById.get(record.id) || `data/${record.id}`,
941
+ `${record.title} duplicates ${previous.title} for ${constraint.fields.join(", ")} while both are ${record.status}.`
942
+ ));
943
+ } else keys.set(key, record);
944
+ }
945
+ }
946
+ }
947
+
381
948
  async function validateMarkdown(record, definition, model, root, path, diagnostics) {
382
949
  const present = new Set();
383
950
  for (const item of markdownEntries(model, record)) {
@@ -452,14 +1019,28 @@ function validateValue(name, value, field, model, path, diagnostics) {
452
1019
  if (typeof value !== "boolean") fail("must be a boolean.");
453
1020
  return;
454
1021
  case "object":
455
- if (!value || Array.isArray(value) || typeof value !== "object") fail("must be an object.");
1022
+ if (!value || Array.isArray(value) || typeof value !== "object") {
1023
+ fail("must be an object.");
1024
+ return;
1025
+ }
1026
+ if (field.objectType) {
1027
+ validateObjectValue(name, value, field.objectType, model, path, diagnostics);
1028
+ }
456
1029
  return;
457
1030
  case "array":
458
1031
  if (!Array.isArray(value)) {
459
1032
  fail("must be an array.");
460
1033
  return;
461
1034
  }
462
- for (const item of value) validateArrayItem(name, item, field.items, path, diagnostics);
1035
+ if (
1036
+ ["id", "string", "data-path"].includes(field.items)
1037
+ && new Set(value).size !== value.length
1038
+ ) {
1039
+ fail("must not contain duplicate values.");
1040
+ }
1041
+ for (const [index, item] of value.entries()) {
1042
+ validateArrayItem(name, item, field, model, path, diagnostics, index);
1043
+ }
463
1044
  return;
464
1045
  default:
465
1046
  fail(`uses unsupported model type "${field.type}".`);
@@ -489,9 +1070,12 @@ function validateNumericRange(value, field, fail) {
489
1070
  }
490
1071
  }
491
1072
 
492
- function validateArrayItem(name, value, type, path, diagnostics) {
1073
+ function validateArrayItem(name, value, field, model, path, diagnostics, index) {
1074
+ const type = field.items;
493
1075
  if (type === "object" && (!value || Array.isArray(value) || typeof value !== "object")) {
494
1076
  diagnostics.push(error("invalid-field", path, `${name} items must be objects.`));
1077
+ } else if (type === "object" && field.itemObjectType) {
1078
+ validateObjectValue(`${name}[${index}]`, value, field.itemObjectType, model, path, diagnostics);
495
1079
  } else if ((type === "string" || type === "data-path") && typeof value !== "string") {
496
1080
  diagnostics.push(error("invalid-field", path, `${name} items must be strings.`));
497
1081
  } else if (type === "id" && (typeof value !== "string" || !ID_PATTERN.test(value))) {
@@ -499,6 +1083,85 @@ function validateArrayItem(name, value, type, path, diagnostics) {
499
1083
  }
500
1084
  }
501
1085
 
1086
+ function validateObjectValue(name, value, objectType, model, path, diagnostics) {
1087
+ const schema = model.objectTypes?.[objectType];
1088
+ if (!schema) {
1089
+ diagnostics.push(error("invalid-field", path, `${name}: uses unknown object type "${objectType}".`));
1090
+ return;
1091
+ }
1092
+ const properties = schema.properties || {};
1093
+ const required = new Set(schema.required || []);
1094
+ for (const [propertyName, property] of Object.entries(properties)) {
1095
+ if (property.requiredWhen && conditionMatches(value, property.requiredWhen)) required.add(propertyName);
1096
+ if (!isMissing(value[propertyName]) && property.allowedWhen && !conditionMatches(value, property.allowedWhen)) {
1097
+ diagnostics.push(error(
1098
+ "invalid-field",
1099
+ path,
1100
+ `${name}.${propertyName} is not allowed for the selected ${Object.keys(property.allowedWhen).join(" and ")}.`
1101
+ ));
1102
+ }
1103
+ }
1104
+ for (const propertyName of required) {
1105
+ if (isMissing(value[propertyName])) {
1106
+ diagnostics.push(error("missing-field", path, `Required field "${name}.${propertyName}" is missing.`));
1107
+ }
1108
+ }
1109
+ for (const [propertyName, propertyValue] of Object.entries(value)) {
1110
+ const property = properties[propertyName];
1111
+ if (property) {
1112
+ validateValue(`${name}.${propertyName}`, propertyValue, property, model, path, diagnostics);
1113
+ continue;
1114
+ }
1115
+ if (schema.additionalProperties === true) continue;
1116
+ if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
1117
+ validateValue(`${name}.${propertyName}`, propertyValue, schema.additionalProperties, model, path, diagnostics);
1118
+ continue;
1119
+ }
1120
+ diagnostics.push(error(
1121
+ "unknown-field",
1122
+ path,
1123
+ `Field "${name}.${propertyName}" is not defined by object type "${objectType}".`
1124
+ ));
1125
+ }
1126
+ for (const propertyName of Object.keys(value)) {
1127
+ if (schema.keyFormat === "namespace" && !NAMESPACE_PATTERN.test(propertyName)) {
1128
+ diagnostics.push(error(
1129
+ "invalid-field",
1130
+ path,
1131
+ `${name}.${propertyName}: extension namespaces must use lowercase dot-separated names.`
1132
+ ));
1133
+ }
1134
+ if (schema.keyFormat === "data-path" && !isCanonicalDataPath(propertyName)) {
1135
+ diagnostics.push(error("invalid-field", path, `${name}.${propertyName}: must be a canonical data-relative path.`));
1136
+ }
1137
+ }
1138
+ validateObjectDateRanges(name, value, path, diagnostics);
1139
+ }
1140
+
1141
+ function validateObjectDateRanges(name, value, path, diagnostics) {
1142
+ for (const [startField, endField] of [
1143
+ ["startsOn", "dueOn"],
1144
+ ["dueOn", "overdueOn"],
1145
+ ["startsAt", "dueAt"],
1146
+ ["dueAt", "overdueAt"],
1147
+ ["startsOn", "endsOn"]
1148
+ ]) {
1149
+ const start = value[startField];
1150
+ const end = value[endField];
1151
+ if (!start || !end) continue;
1152
+ const invalid = startField.endsWith("At")
1153
+ ? new Date(end) < new Date(start)
1154
+ : parseCalendarDate(start) && parseCalendarDate(end) && end < start;
1155
+ if (invalid) {
1156
+ diagnostics.push(error(
1157
+ "invalid-date-range",
1158
+ path,
1159
+ `${name}.${endField} cannot be before ${name}.${startField}.`
1160
+ ));
1161
+ }
1162
+ }
1163
+ }
1164
+
502
1165
  function isDate(value) {
503
1166
  return DATE_PATTERN.test(value) && Boolean(parseCalendarDate(value));
504
1167
  }