filegrc 0.3.3 → 0.4.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,24 +1,42 @@
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";
5
+ import { isSafeGitName } from "./git-name.js";
3
6
  import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
4
7
  import { parseCalendarDate, validCalendarRecurrence } from "./recurrence.js";
5
8
  import { obligationIsRunning } from "./program-lifecycle.js";
6
9
  import { partyPeople } from "./parties.js";
7
10
  import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
8
11
  import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
12
+ import { recordTiming } from "./timing.js";
9
13
  import { indexResources, loadWorkspace } from "./workspace.js";
10
14
 
11
15
  const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
16
+ const NAMESPACE_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
12
17
  const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
13
18
  const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
14
19
  const MAX_OBLIGATION_OFFSET_DAYS = 36_600;
15
20
  const MAX_OBLIGATION_OFFSET_HOURS = MAX_OBLIGATION_OFFSET_DAYS * 24;
16
21
 
17
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) {
18
32
  const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input);
19
33
  const diagnostics = [...loaded.diagnostics];
20
34
  const { byId } = indexResources(loaded.resources);
21
35
  const seen = new Map();
36
+ const pathById = new Map(loaded.entries.map((entry) => [
37
+ entry.record?.id,
38
+ `data/${entry.relativePath}`
39
+ ]));
22
40
  const asOf = currentCalendarDate(loaded.workspace?.timezone || "UTC");
23
41
  const obligationsByControl = new Map();
24
42
  for (const obligation of loaded.resources.filter((record) => record.type === "obligation" && record.status !== "retired")) {
@@ -54,8 +72,14 @@ export async function validateWorkspace(input = process.cwd()) {
54
72
  validateLocation(record, definition, entry.relativePath, diagnostics);
55
73
  validateRecord(record, definition, loaded.model, displayPath, diagnostics);
56
74
  validateDateRanges(record, displayPath, diagnostics);
57
- if (record.type === "obligation") validateObligation(record, displayPath, diagnostics);
75
+ if (record.type === "appointment") validateAppointment(record, byId, displayPath, diagnostics);
76
+ if (record.type === "obligation") validateObligation(record, loaded.model, byId, displayPath, diagnostics);
77
+ if (record.type === "obligation-event") validatePolicyEvent(record, loaded.model, byId, displayPath, diagnostics);
58
78
  if (record.type === "evidence") validateEvidencePaths(record, displayPath, diagnostics);
79
+ validateCoverage(record, displayPath, diagnostics);
80
+ validateClassification(record, loaded.workspace, displayPath, diagnostics);
81
+ validateCompletionDates(record, displayPath, diagnostics);
82
+ await validateAttestationBinding(record, loaded.model, loaded.root, byId, displayPath, diagnostics);
59
83
 
60
84
  const fields = { ...loaded.model.commonFields, ...definition.fields };
61
85
  for (const [fieldName, field] of Object.entries(fields)) {
@@ -95,12 +119,21 @@ export async function validateWorkspace(input = process.cwd()) {
95
119
  }
96
120
  }
97
121
  }
122
+ validateNestedRelations(fieldName, value, field, loaded.model, byId, displayPath, diagnostics);
98
123
  }
99
124
  validateIndependentApproval(record, byId, displayPath, diagnostics);
100
- validateCompletedObligationEvent(record, byId, displayPath, diagnostics);
125
+ validateCompletedObligationEvent(record, byId, loaded.model, displayPath, diagnostics);
101
126
  validateImplementedControlSchedules(record, obligationsByControl, byId, asOf, displayPath, diagnostics);
102
127
  await validateMarkdown(record, definition, loaded.model, loaded.root, displayPath, diagnostics);
128
+ await validateApprovalBinding(record, loaded.model, loaded.root, displayPath, diagnostics);
103
129
  }
130
+ validateRelationshipConstraints(
131
+ loaded.resources,
132
+ loaded.model,
133
+ byId,
134
+ pathById,
135
+ diagnostics
136
+ );
104
137
 
105
138
  diagnostics.sort((a, b) => `${a.severity}:${a.path}:${a.code}`.localeCompare(`${b.severity}:${b.path}:${b.code}`));
106
139
  return {
@@ -115,10 +148,53 @@ export async function validateWorkspace(input = process.cwd()) {
115
148
  };
116
149
  }
117
150
 
151
+ export async function fingerprintWorkspace(input = process.cwd()) {
152
+ const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input);
153
+ const hash = createHash("sha256");
154
+ hash.update(`model\0${loaded.model.modelVersion}\0`);
155
+ for (const entry of [...loaded.entries].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
156
+ hash.update(`record\0${entry.relativePath}\0${entry.source.length}\0${entry.source}`);
157
+ const definition = loaded.model.resources[entry.record?.type];
158
+ if (!definition) continue;
159
+ for (const item of markdownEntries(loaded.model, entry.record).sort((a, b) => a.path.localeCompare(b.path))) {
160
+ try {
161
+ const source = await readFile(resolveDataPath(loaded.root, item.path), "utf8");
162
+ hash.update(`markdown\0${item.path}\0${source.length}\0${source}`);
163
+ } catch {
164
+ hash.update(`markdown-missing\0${item.path}\0`);
165
+ }
166
+ }
167
+ const fields = { ...loaded.model.commonFields, ...definition.fields };
168
+ for (const [name, field] of Object.entries(fields)) {
169
+ if (field.format !== "data-path" && field.items !== "data-path") continue;
170
+ const value = entry.record[name];
171
+ const paths = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
172
+ for (const path of [...paths].sort()) {
173
+ try {
174
+ const file = await stat(resolveDataPath(loaded.root, path));
175
+ hash.update(`data-path\0${path}\0${file.isFile() ? "file" : "other"}\0`);
176
+ } catch {
177
+ hash.update(`data-path-missing\0${path}\0`);
178
+ }
179
+ }
180
+ }
181
+ }
182
+ return { fingerprint: hash.digest("hex"), loaded };
183
+ }
184
+
118
185
  function validateImplementedControlSchedules(record, obligationsByControl, byId, asOf, path, diagnostics) {
119
186
  if (record.type !== "control" || record.status !== "implemented") return;
120
187
  const schedules = obligationsByControl.get(record.id) || [];
121
- if (!schedules.length || schedules.every((obligation) => obligationIsRunning(obligation, byId, asOf))) return;
188
+ if (!schedules.length) {
189
+ if (record.operationPattern === "continuous") return;
190
+ diagnostics.push(error(
191
+ "control-work-queue-missing",
192
+ path,
193
+ "An implemented scheduled, event-driven, or mixed Control must link at least one active Obligation."
194
+ ));
195
+ return;
196
+ }
197
+ if (schedules.every((obligation) => obligationIsRunning(obligation, byId, asOf))) return;
122
198
  const stopped = schedules.filter((obligation) => !obligationIsRunning(obligation, byId, asOf));
123
199
  const paused = stopped.filter((obligation) => obligation.status === "paused");
124
200
  const waiting = stopped.filter((obligation) => obligation.status === "active");
@@ -148,9 +224,7 @@ function validateImplementedControlSchedules(record, obligationsByControl, byId,
148
224
  function validateDateRanges(record, path, diagnostics) {
149
225
  for (const [startField, endField] of [
150
226
  ["startDate", "endDate"],
151
- ["periodStart", "periodEnd"],
152
- ["candidatePeriodStart", "candidatePeriodEnd"],
153
- ["dueWindowStart", "dueWindowEnd"]
227
+ ["startsOn", "endsOn"]
154
228
  ]) {
155
229
  const start = record[startField];
156
230
  const end = record[endField];
@@ -164,7 +238,25 @@ function validateDateRanges(record, path, diagnostics) {
164
238
  }
165
239
  }
166
240
 
167
- function validateCompletedObligationEvent(record, byId, path, diagnostics) {
241
+ function validateAppointment(record, byId, path, diagnostics) {
242
+ if ((record.scopeResourceIds || []).includes(record.id)) {
243
+ diagnostics.push(error(
244
+ "invalid-appointment-scope",
245
+ path,
246
+ "An Appointment cannot include itself in scopeResourceIds."
247
+ ));
248
+ }
249
+ if (record.status !== "active") return;
250
+ const holder = byId.get(record.holderId);
251
+ if (holder?.type === "person" && holder.status === "active") return;
252
+ diagnostics.push(error(
253
+ "inactive-appointment-holder",
254
+ path,
255
+ "An active Appointment must have an active Person as its holder."
256
+ ));
257
+ }
258
+
259
+ function validateCompletedObligationEvent(record, byId, model, path, diagnostics) {
168
260
  if (record.type !== "obligation-event" || record.status !== "complete") return;
169
261
  if (record.completedOn && record.occurredOn && record.completedOn < record.occurredOn) {
170
262
  diagnostics.push(error(
@@ -192,7 +284,9 @@ function validateCompletedObligationEvent(record, byId, path, diagnostics) {
192
284
  continue;
193
285
  }
194
286
  const obligation = byId.get(action.obligationId);
195
- const expectedTypes = obligation?.type === "obligation" ? obligation.completionResourceTypes || [] : [];
287
+ const expectedTypes = obligation?.type === "obligation"
288
+ ? model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || []
289
+ : [];
196
290
  if (!expectedTypes.length) continue;
197
291
  const linked = [...new Set([...(action.completionResourceIds || []), ...(action.evidenceIds || [])])]
198
292
  .map((id) => byId.get(id))
@@ -238,9 +332,38 @@ function validateIndependentApproval(record, byId, path, diagnostics) {
238
332
  }
239
333
  }
240
334
 
241
- function validateObligation(record, path, diagnostics) {
335
+ function validateObligation(record, model, byId, path, diagnostics) {
242
336
  const recurrence = record.recurrence;
243
337
  if (!recurrence || Array.isArray(recurrence) || typeof recurrence !== "object") return;
338
+ const activity = model.obligationActivities?.[record.activityType];
339
+ if (
340
+ activity
341
+ && Array.isArray(activity.recurrenceModes)
342
+ && !activity.recurrenceModes.includes(recurrence.mode)
343
+ ) {
344
+ diagnostics.push(error(
345
+ "invalid-obligation-activity",
346
+ path,
347
+ `${record.activityType} obligations require ${activity.recurrenceModes.join(" or ")} recurrence.`
348
+ ));
349
+ }
350
+ if (activity) {
351
+ const allowedScopeTypes = new Set(activity.scopeResourceTypes || []);
352
+ for (const [field, ids] of [
353
+ ["scopeResourceIds", record.scopeResourceIds || []],
354
+ ["templateResourceId", record.templateResourceId ? [record.templateResourceId] : []]
355
+ ]) {
356
+ for (const id of ids) {
357
+ const target = byId.get(id);
358
+ if (!target || allowedScopeTypes.has(target.type)) continue;
359
+ diagnostics.push(error(
360
+ "invalid-obligation-scope",
361
+ path,
362
+ `${field} references ${target.type} "${id}", but ${record.activityType} allows ${[...allowedScopeTypes].join(" or ")} scope.`
363
+ ));
364
+ }
365
+ }
366
+ }
244
367
  if (recurrence.mode === "calendar") {
245
368
  const normalized = { ...recurrence, anchorDate: recurrence.anchorDate || record.startsOn };
246
369
  if (!validCalendarRecurrence(normalized)) {
@@ -251,11 +374,11 @@ function validateObligation(record, path, diagnostics) {
251
374
  ));
252
375
  }
253
376
  } else if (recurrence.mode === "event") {
254
- if (typeof recurrence.eventType !== "string" || !ID_PATTERN.test(recurrence.eventType)) {
377
+ if (!model.policyEvents?.[recurrence.eventType]) {
255
378
  diagnostics.push(error(
256
379
  "invalid-obligation-recurrence",
257
380
  path,
258
- "Event recurrence requires a lowercase kebab-case eventType."
381
+ "Event recurrence must use a policy event defined by the model."
259
382
  ));
260
383
  }
261
384
  } else {
@@ -267,53 +390,287 @@ function validateObligation(record, path, diagnostics) {
267
390
  }
268
391
 
269
392
  const window = record.window;
270
- if (!window || Array.isArray(window) || typeof window !== "object") return;
271
- const dayFields = ["startOffsetDays", "endOffsetDays"].filter((name) => window[name] !== undefined);
272
- const hourFields = ["startOffsetHours", "endOffsetHours"].filter((name) => window[name] !== undefined);
273
- for (const name of [...dayFields, ...hourFields]) {
393
+ if (!window || Array.isArray(window) || typeof window !== "object") {
394
+ if (recurrence.mode === "event") {
395
+ diagnostics.push(error("invalid-obligation-window", path, "Event obligations require an explicit deadline window."));
396
+ }
397
+ return;
398
+ }
399
+ for (const name of ["startsAfter", "dueAfter"]) {
400
+ if (window[name] === undefined) continue;
274
401
  if (!Number.isInteger(window[name])) {
275
402
  diagnostics.push(error("invalid-obligation-window", path, `window.${name} must be an integer.`));
276
403
  }
277
404
  }
278
- for (const name of dayFields) {
279
- if (Number.isInteger(window[name]) && Math.abs(window[name]) > MAX_OBLIGATION_OFFSET_DAYS) {
280
- diagnostics.push(error("invalid-obligation-window", path, `window.${name} must stay within ${MAX_OBLIGATION_OFFSET_DAYS.toLocaleString("en-US")} days of the policy event.`));
405
+ const limit = window.precision === "timestamp" ? MAX_OBLIGATION_OFFSET_HOURS : MAX_OBLIGATION_OFFSET_DAYS;
406
+ const unit = window.precision === "timestamp" ? "hours" : "days";
407
+ for (const name of ["startsAfter", "dueAfter"]) {
408
+ if (Number.isInteger(window[name]) && Math.abs(window[name]) > limit) {
409
+ diagnostics.push(error("invalid-obligation-window", path, `window.${name} must stay within ${limit.toLocaleString("en-US")} ${unit} of the policy event.`));
281
410
  }
282
411
  }
283
- for (const name of hourFields) {
284
- if (Number.isInteger(window[name]) && Math.abs(window[name]) > MAX_OBLIGATION_OFFSET_HOURS) {
285
- diagnostics.push(error("invalid-obligation-window", path, `window.${name} must stay within ${MAX_OBLIGATION_OFFSET_HOURS.toLocaleString("en-US")} hours of the policy event.`));
412
+ if (recurrence.mode === "calendar" && window.precision !== "date") {
413
+ diagnostics.push(error("invalid-obligation-window", path, "Calendar obligations require a date-precision window."));
414
+ }
415
+ if (
416
+ Number.isInteger(window.dueAfter)
417
+ && window.dueAfter < (Number.isInteger(window.startsAfter) ? window.startsAfter : 0)
418
+ ) {
419
+ diagnostics.push(error("invalid-obligation-window", path, "window.dueAfter must be on or after window.startsAfter."));
420
+ }
421
+ }
422
+
423
+ function validatePolicyEvent(record, model, byId, path, diagnostics) {
424
+ const event = model.policyEvents?.[record.eventType];
425
+ if (!event) return;
426
+ const rules = event.subjectRules || [];
427
+ const allowedTypes = new Set(rules.map(({ resourceType }) => resourceType));
428
+ const counts = new Map();
429
+ for (const id of new Set(record.subjectResourceIds || [])) {
430
+ const target = byId.get(id);
431
+ if (!target) continue;
432
+ counts.set(target.type, (counts.get(target.type) || 0) + 1);
433
+ if (!allowedTypes.has(target.type)) {
434
+ diagnostics.push(error(
435
+ "invalid-policy-event-subject",
436
+ path,
437
+ `${record.eventType} cannot use ${target.type} "${id}" as a subject.`
438
+ ));
286
439
  }
287
440
  }
288
- if (dayFields.length && hourFields.length) {
289
- diagnostics.push(error("invalid-obligation-window", path, "An obligation window cannot mix day and hour offsets."));
441
+ for (const { resourceType, minimum = 0, maximum } of rules) {
442
+ const count = counts.get(resourceType) || 0;
443
+ if (count < minimum) {
444
+ diagnostics.push(error(
445
+ "invalid-policy-event-subject",
446
+ path,
447
+ `${record.eventType} requires at least ${minimum} ${resourceType} subject${minimum === 1 ? "" : "s"}.`
448
+ ));
449
+ }
450
+ if (Number.isInteger(maximum) && count > maximum) {
451
+ diagnostics.push(error(
452
+ "invalid-policy-event-subject",
453
+ path,
454
+ `${record.eventType} allows at most ${maximum} ${resourceType} subject${maximum === 1 ? "" : "s"}.`
455
+ ));
456
+ }
457
+ }
458
+ }
459
+
460
+ function validateCompletionDates(record, path, diagnostics) {
461
+ if (record.startedAt && record.completedAt && record.completedAt < record.startedAt) {
462
+ diagnostics.push(error("invalid-completion-order", path, "completedAt cannot be before startedAt."));
463
+ }
464
+ if (record.completedOn && record.reviewedOn && record.reviewedOn < record.completedOn) {
465
+ diagnostics.push(error("invalid-completion-order", path, "reviewedOn cannot be before completedOn."));
466
+ }
467
+ if (record.completedOn && record.approvedOn && record.approvedOn < record.completedOn) {
468
+ diagnostics.push(error("invalid-completion-order", path, "approvedOn cannot be before completedOn."));
290
469
  }
291
- if (recurrence.mode === "calendar" && hourFields.length) {
292
- diagnostics.push(error("invalid-obligation-window", path, "Calendar obligations use day offsets; hour offsets are only valid for event obligations."));
470
+ validateOrderedDates(record, path, diagnostics, [
471
+ "requestedOn",
472
+ "approvedOn",
473
+ "provisionedOn",
474
+ "deprovisionedOn"
475
+ ]);
476
+ validateOrderedDates(record, path, diagnostics, [
477
+ "detectedAt",
478
+ "declaredAt",
479
+ "containedAt",
480
+ "eradicatedAt",
481
+ "recoveredAt",
482
+ "closedAt"
483
+ ]);
484
+ validateOrderedDates(record, path, diagnostics, ["startedAt", "endedAt"]);
485
+ validateOrderedDates(record, path, diagnostics, ["fieldworkStart", "fieldworkEnd", "reportDate"]);
486
+ if (record.acceptance) {
487
+ validateOrderedDates(record.acceptance, path, diagnostics, ["acceptedOn", "expiresOn"], "acceptance.");
293
488
  }
489
+ if (record.approval) {
490
+ validateOrderedDates(record.approval, path, diagnostics, ["approvedOn", "expiresOn"], "approval.");
491
+ if (
492
+ record.resolution?.resolvedOn
493
+ && record.approval.approvedOn
494
+ && record.resolution.resolvedOn < record.approval.approvedOn
495
+ ) {
496
+ diagnostics.push(error(
497
+ "invalid-completion-order",
498
+ path,
499
+ "resolution.resolvedOn cannot be before approval.approvedOn."
500
+ ));
501
+ }
502
+ }
503
+ }
504
+
505
+ function validateOrderedDates(record, path, diagnostics, fields, prefix = "") {
506
+ let previous = null;
507
+ for (const field of fields) {
508
+ const value = record[field];
509
+ if (!value) continue;
510
+ if (previous && value < previous.value) {
511
+ diagnostics.push(error(
512
+ "invalid-completion-order",
513
+ path,
514
+ `${prefix}${field} cannot be before ${prefix}${previous.field}.`
515
+ ));
516
+ }
517
+ previous = { field, value };
518
+ }
519
+ }
520
+
521
+ async function validateAttestationBinding(record, model, root, byId, path, diagnostics) {
294
522
  if (
295
- recurrence.mode === "calendar"
296
- && Number.isInteger(window.startOffsetDays)
297
- && window.startOffsetDays > 0
298
- && window.endOffsetDays === undefined
299
- ) {
523
+ record.type !== "attestation"
524
+ || record.status !== "completed"
525
+ || record.attestationMethod !== "git-approval"
526
+ || !record.contentRevisions
527
+ ) return;
528
+ const expectedPaths = new Set();
529
+ const subjectPaths = new Map();
530
+ for (const id of record.subjectResourceIds || []) {
531
+ const subject = byId.get(id);
532
+ if (!subject) continue;
533
+ const paths = [];
534
+ for (const item of markdownEntries(model, subject)) {
535
+ try {
536
+ if ((await stat(resolveDataPath(root, item.path))).isFile()) paths.push(item.path);
537
+ } catch (error) {
538
+ if (error.code !== "ENOENT") throw error;
539
+ }
540
+ }
541
+ subjectPaths.set(id, paths);
542
+ for (const item of paths) expectedPaths.add(item);
543
+ }
544
+ const actualPaths = Object.keys(record.contentRevisions);
545
+ if (!expectedPaths.size) {
546
+ diagnostics.push(error(
547
+ "invalid-attestation-binding",
548
+ path,
549
+ "A git-approval Attestation must reference authored Policy, Document, or Training content."
550
+ ));
551
+ return;
552
+ }
553
+ if (!actualPaths.length) {
554
+ diagnostics.push(error(
555
+ "invalid-attestation-binding",
556
+ path,
557
+ "A git-approval Attestation must bind at least one subject Markdown file."
558
+ ));
559
+ return;
560
+ }
561
+ const invalid = actualPaths.filter((item) => (
562
+ !expectedPaths.has(item)
563
+ || !/^[a-f0-9]{64}$/.test(String(record.contentRevisions[item] || ""))
564
+ ));
565
+ const unboundSubjects = [...subjectPaths].filter(([, paths]) => (
566
+ paths.length && !paths.some((item) => actualPaths.includes(item))
567
+ )).map(([id]) => id);
568
+ if (invalid.length || unboundSubjects.length) {
569
+ diagnostics.push(error(
570
+ "invalid-attestation-binding",
571
+ path,
572
+ unboundSubjects.length
573
+ ? `Attestation contentRevisions must bind authored Markdown for every subject; missing ${unboundSubjects.join(", ")}.`
574
+ : "Attestation contentRevisions must contain valid SHA-256 hashes for subject Markdown paths and no unrelated paths."
575
+ ));
576
+ }
577
+ }
578
+
579
+ async function validateApprovalBinding(record, model, root, path, diagnostics) {
580
+ if (!approvalBound(record) || !record.approvedContentRevisions) return;
581
+ const actual = {};
582
+ for (const item of markdownEntries(model, record)) {
583
+ try {
584
+ const source = await readFile(resolveDataPath(root, item.path), "utf8");
585
+ actual[item.path] = createHash("sha256").update(source).digest("hex");
586
+ } catch (error) {
587
+ if (error.code !== "ENOENT") throw error;
588
+ }
589
+ }
590
+ const expected = record.approvedContentRevisions;
591
+ const paths = [...new Set([...Object.keys(actual), ...Object.keys(expected)])].sort();
592
+ const invalid = paths.filter((item) => (
593
+ !/^[a-f0-9]{64}$/.test(String(expected[item] || ""))
594
+ || expected[item] !== actual[item]
595
+ ));
596
+ if (invalid.length) {
300
597
  diagnostics.push(error(
301
- "invalid-obligation-window",
598
+ "approval-content-changed",
302
599
  path,
303
- "Calendar obligations with a positive window.startOffsetDays must set window.endOffsetDays."
600
+ `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.`
304
601
  ));
305
602
  }
603
+ }
604
+
605
+ function approvalBound(record) {
606
+ if (record.type === "policy") return ["approved", "active", "superseded", "retired"].includes(record.status);
607
+ if (record.type === "document") return ["active", "superseded", "retired"].includes(record.status);
608
+ return false;
609
+ }
610
+
611
+ function validateCoverage(record, path, diagnostics) {
612
+ const coverage = record.type === "workspace" ? record.candidateCoverage : record.coverage;
613
+ if (!coverage) return;
614
+ if (coverage.kind === "range" && coverage.startsOn && coverage.endsOn && coverage.endsOn < coverage.startsOn) {
615
+ diagnostics.push(error("invalid-date-range", path, "coverage.endsOn cannot be before coverage.startsOn."));
616
+ }
617
+ if (
618
+ record.type === "audit"
619
+ && record.auditKind === "soc-2-type-1"
620
+ && coverage.kind !== "as-of"
621
+ ) {
622
+ diagnostics.push(error("invalid-audit-coverage", path, "A SOC 2 Type 1 Audit requires as-of coverage."));
623
+ }
624
+ if (
625
+ record.type === "audit"
626
+ && record.auditKind === "soc-2-type-2"
627
+ && coverage.kind !== "range"
628
+ ) {
629
+ diagnostics.push(error("invalid-audit-coverage", path, "A SOC 2 Type 2 Audit requires range coverage."));
630
+ }
631
+ if (
632
+ record.type === "workspace"
633
+ && record.assuranceGoal === "soc-2-type-1"
634
+ && coverage.kind !== "as-of"
635
+ ) {
636
+ diagnostics.push(error("invalid-candidate-coverage", path, "A Type 1 management goal requires as-of candidate coverage."));
637
+ }
638
+ if (
639
+ record.type === "workspace"
640
+ && record.assuranceGoal === "soc-2-type-2"
641
+ && coverage.kind !== "range"
642
+ ) {
643
+ diagnostics.push(error("invalid-candidate-coverage", path, "A Type 2 management goal requires range candidate coverage."));
644
+ }
645
+ if (
646
+ ["audit-population", "penetration-test"].includes(record.type)
647
+ && coverage.kind !== "range"
648
+ ) {
649
+ diagnostics.push(error("invalid-coverage", path, `${record.type} requires range coverage.`));
650
+ }
306
651
  if (
307
- Number.isInteger(window.endOffsetDays)
308
- && window.endOffsetDays < (Number.isInteger(window.startOffsetDays) ? window.startOffsetDays : 0)
652
+ record.type === "evidence"
653
+ && record.artifactKind === "population-export"
654
+ && coverage.kind !== "range"
309
655
  ) {
310
- diagnostics.push(error("invalid-obligation-window", path, "window.endOffsetDays must be on or after window.startOffsetDays."));
656
+ diagnostics.push(error("invalid-coverage", path, "Population Export Evidence requires range coverage."));
311
657
  }
658
+ }
659
+
660
+ function validateClassification(record, workspace, path, diagnostics) {
661
+ if (!record.classificationId) return;
662
+ const definitions = workspace?.classificationDefinitions;
312
663
  if (
313
- Number.isInteger(window.endOffsetHours)
314
- && window.endOffsetHours < (Number.isInteger(window.startOffsetHours) ? window.startOffsetHours : 0)
664
+ !definitions
665
+ || Array.isArray(definitions)
666
+ || typeof definitions !== "object"
667
+ || !Object.hasOwn(definitions, record.classificationId)
315
668
  ) {
316
- diagnostics.push(error("invalid-obligation-window", path, "window.endOffsetHours must be on or after window.startOffsetHours."));
669
+ diagnostics.push(error(
670
+ "unknown-classification",
671
+ path,
672
+ `classificationId references undefined Workspace classification "${record.classificationId}".`
673
+ ));
317
674
  }
318
675
  }
319
676
 
@@ -339,6 +696,13 @@ function validateRecord(record, definition, model, path, diagnostics) {
339
696
  ]);
340
697
  for (const [name, field] of Object.entries(fields)) {
341
698
  if (field.requiredWhen && conditionMatches(record, field.requiredWhen)) required.add(name);
699
+ if (!isMissing(record[name]) && field.allowedWhen && !conditionMatches(record, field.allowedWhen)) {
700
+ diagnostics.push(error(
701
+ "invalid-field",
702
+ path,
703
+ `${name} is not allowed for the selected ${Object.keys(field.allowedWhen).join(" and ")}.`
704
+ ));
705
+ }
342
706
  if (field.disjointFrom) {
343
707
  const values = normalizedValues(record[name]);
344
708
  const otherValues = new Set(normalizedValues(record[field.disjointFrom]));
@@ -364,7 +728,11 @@ function validateRecord(record, definition, model, path, diagnostics) {
364
728
  for (const [name, value] of Object.entries(record)) {
365
729
  const field = fields[name];
366
730
  if (!field) {
367
- diagnostics.push(warning("unknown-field", path, `Field "${name}" is not defined by model v${model.modelVersion}.`));
731
+ diagnostics.push(error(
732
+ "unknown-field",
733
+ path,
734
+ `Field "${name}" is not defined by model v${model.modelVersion}. Put organization-specific data under extensions.`
735
+ ));
368
736
  continue;
369
737
  }
370
738
  validateValue(name, value, field, model, path, diagnostics);
@@ -377,6 +745,109 @@ function normalizedValues(value) {
377
745
  return typeof value === "string" ? [value] : [];
378
746
  }
379
747
 
748
+ function validateNestedRelations(name, value, field, model, byId, path, diagnostics) {
749
+ if (
750
+ field.type === "object"
751
+ && field.objectType
752
+ && value
753
+ && !Array.isArray(value)
754
+ && typeof value === "object"
755
+ ) {
756
+ validateObjectRelations(name, value, model.objectTypes?.[field.objectType], model, byId, path, diagnostics);
757
+ }
758
+ if (field.type === "array" && field.itemObjectType && Array.isArray(value)) {
759
+ for (const [index, item] of value.entries()) {
760
+ if (!item || Array.isArray(item) || typeof item !== "object") continue;
761
+ validateObjectRelations(
762
+ `${name}[${index}]`,
763
+ item,
764
+ model.objectTypes?.[field.itemObjectType],
765
+ model,
766
+ byId,
767
+ path,
768
+ diagnostics
769
+ );
770
+ }
771
+ }
772
+ }
773
+
774
+ function validateObjectRelations(name, value, schema, model, byId, path, diagnostics) {
775
+ if (!schema) return;
776
+ for (const [propertyName, property] of Object.entries(schema.properties || {})) {
777
+ const nested = value[propertyName];
778
+ if (nested === undefined || nested === null) continue;
779
+ if (property.relation) {
780
+ const ids = Array.isArray(nested) ? nested : [nested];
781
+ for (const id of ids) {
782
+ const target = byId.get(id);
783
+ if (!target) {
784
+ diagnostics.push(error(
785
+ "missing-reference",
786
+ path,
787
+ `${name}.${propertyName} references unknown ID "${id}".`
788
+ ));
789
+ } else if (!property.relation.includes("*") && !property.relation.includes(target.type)) {
790
+ diagnostics.push(error(
791
+ "wrong-reference-type",
792
+ path,
793
+ `${name}.${propertyName} references ${target.type} "${id}", expected ${property.relation.join(" or ")}.`
794
+ ));
795
+ }
796
+ }
797
+ }
798
+ validateNestedRelations(`${name}.${propertyName}`, nested, property, model, byId, path, diagnostics);
799
+ }
800
+ }
801
+
802
+ function validateRelationshipConstraints(resources, model, byId, pathById, diagnostics) {
803
+ for (const constraint of model.relationshipConstraints?.acyclic || []) {
804
+ const candidates = resources.filter(({ type }) => type === constraint.resourceType);
805
+ const visited = new Set();
806
+ for (const record of candidates) {
807
+ if (visited.has(record.id)) continue;
808
+ const chain = [];
809
+ const positions = new Map();
810
+ let current = record;
811
+ while (current?.type === constraint.resourceType && !visited.has(current.id)) {
812
+ if (positions.has(current.id)) {
813
+ const cycle = [...chain.slice(positions.get(current.id)), current.id];
814
+ const cycleRecord = chain[positions.get(current.id)];
815
+ diagnostics.push(error(
816
+ "cyclic-relationship",
817
+ pathById.get(cycleRecord) || `data/${cycleRecord}`,
818
+ `${constraint.field} forms a cycle: ${cycle.join(" -> ")}.`
819
+ ));
820
+ break;
821
+ }
822
+ positions.set(current.id, chain.length);
823
+ chain.push(current.id);
824
+ current = byId.get(current[constraint.field]);
825
+ }
826
+ for (const id of chain) visited.add(id);
827
+ }
828
+ }
829
+
830
+ for (const constraint of model.relationshipConstraints?.unique || []) {
831
+ const keys = new Map();
832
+ for (const record of resources) {
833
+ if (record.type !== constraint.resourceType) continue;
834
+ if (constraint.statuses && !constraint.statuses.includes(record.status)) continue;
835
+ const key = JSON.stringify((constraint.fields || []).map((field) => {
836
+ const value = record[field];
837
+ return Array.isArray(value) ? [...value].sort() : value ?? null;
838
+ }));
839
+ const previous = keys.get(key);
840
+ if (previous) {
841
+ diagnostics.push(error(
842
+ "duplicate-active-relationship",
843
+ pathById.get(record.id) || `data/${record.id}`,
844
+ `${record.title} duplicates ${previous.title} for ${constraint.fields.join(", ")} while both are ${record.status}.`
845
+ ));
846
+ } else keys.set(key, record);
847
+ }
848
+ }
849
+ }
850
+
380
851
  async function validateMarkdown(record, definition, model, root, path, diagnostics) {
381
852
  const present = new Set();
382
853
  for (const item of markdownEntries(model, record)) {
@@ -451,14 +922,28 @@ function validateValue(name, value, field, model, path, diagnostics) {
451
922
  if (typeof value !== "boolean") fail("must be a boolean.");
452
923
  return;
453
924
  case "object":
454
- if (!value || Array.isArray(value) || typeof value !== "object") fail("must be an object.");
925
+ if (!value || Array.isArray(value) || typeof value !== "object") {
926
+ fail("must be an object.");
927
+ return;
928
+ }
929
+ if (field.objectType) {
930
+ validateObjectValue(name, value, field.objectType, model, path, diagnostics);
931
+ }
455
932
  return;
456
933
  case "array":
457
934
  if (!Array.isArray(value)) {
458
935
  fail("must be an array.");
459
936
  return;
460
937
  }
461
- for (const item of value) validateArrayItem(name, item, field.items, path, diagnostics);
938
+ if (
939
+ ["id", "string", "data-path"].includes(field.items)
940
+ && new Set(value).size !== value.length
941
+ ) {
942
+ fail("must not contain duplicate values.");
943
+ }
944
+ for (const [index, item] of value.entries()) {
945
+ validateArrayItem(name, item, field, model, path, diagnostics, index);
946
+ }
462
947
  return;
463
948
  default:
464
949
  fail(`uses unsupported model type "${field.type}".`);
@@ -476,6 +961,7 @@ function validateValue(name, value, field, model, path, diagnostics) {
476
961
  }
477
962
  if (field.format === "email" && !EMAIL_PATTERN.test(value)) fail("must be an email address.");
478
963
  if (field.format === "timezone" && !isTimezone(value)) fail("must be an IANA time zone.");
964
+ if (field.format === "git-name" && !isSafeGitName(value)) fail("must be a safe Git name.");
479
965
  }
480
966
 
481
967
  function validateNumericRange(value, field, fail) {
@@ -487,9 +973,12 @@ function validateNumericRange(value, field, fail) {
487
973
  }
488
974
  }
489
975
 
490
- function validateArrayItem(name, value, type, path, diagnostics) {
976
+ function validateArrayItem(name, value, field, model, path, diagnostics, index) {
977
+ const type = field.items;
491
978
  if (type === "object" && (!value || Array.isArray(value) || typeof value !== "object")) {
492
979
  diagnostics.push(error("invalid-field", path, `${name} items must be objects.`));
980
+ } else if (type === "object" && field.itemObjectType) {
981
+ validateObjectValue(`${name}[${index}]`, value, field.itemObjectType, model, path, diagnostics);
493
982
  } else if ((type === "string" || type === "data-path") && typeof value !== "string") {
494
983
  diagnostics.push(error("invalid-field", path, `${name} items must be strings.`));
495
984
  } else if (type === "id" && (typeof value !== "string" || !ID_PATTERN.test(value))) {
@@ -497,6 +986,85 @@ function validateArrayItem(name, value, type, path, diagnostics) {
497
986
  }
498
987
  }
499
988
 
989
+ function validateObjectValue(name, value, objectType, model, path, diagnostics) {
990
+ const schema = model.objectTypes?.[objectType];
991
+ if (!schema) {
992
+ diagnostics.push(error("invalid-field", path, `${name}: uses unknown object type "${objectType}".`));
993
+ return;
994
+ }
995
+ const properties = schema.properties || {};
996
+ const required = new Set(schema.required || []);
997
+ for (const [propertyName, property] of Object.entries(properties)) {
998
+ if (property.requiredWhen && conditionMatches(value, property.requiredWhen)) required.add(propertyName);
999
+ if (!isMissing(value[propertyName]) && property.allowedWhen && !conditionMatches(value, property.allowedWhen)) {
1000
+ diagnostics.push(error(
1001
+ "invalid-field",
1002
+ path,
1003
+ `${name}.${propertyName} is not allowed for the selected ${Object.keys(property.allowedWhen).join(" and ")}.`
1004
+ ));
1005
+ }
1006
+ }
1007
+ for (const propertyName of required) {
1008
+ if (isMissing(value[propertyName])) {
1009
+ diagnostics.push(error("missing-field", path, `Required field "${name}.${propertyName}" is missing.`));
1010
+ }
1011
+ }
1012
+ for (const [propertyName, propertyValue] of Object.entries(value)) {
1013
+ const property = properties[propertyName];
1014
+ if (property) {
1015
+ validateValue(`${name}.${propertyName}`, propertyValue, property, model, path, diagnostics);
1016
+ continue;
1017
+ }
1018
+ if (schema.additionalProperties === true) continue;
1019
+ if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
1020
+ validateValue(`${name}.${propertyName}`, propertyValue, schema.additionalProperties, model, path, diagnostics);
1021
+ continue;
1022
+ }
1023
+ diagnostics.push(error(
1024
+ "unknown-field",
1025
+ path,
1026
+ `Field "${name}.${propertyName}" is not defined by object type "${objectType}".`
1027
+ ));
1028
+ }
1029
+ for (const propertyName of Object.keys(value)) {
1030
+ if (schema.keyFormat === "namespace" && !NAMESPACE_PATTERN.test(propertyName)) {
1031
+ diagnostics.push(error(
1032
+ "invalid-field",
1033
+ path,
1034
+ `${name}.${propertyName}: extension namespaces must use lowercase dot-separated names.`
1035
+ ));
1036
+ }
1037
+ if (schema.keyFormat === "data-path" && !isCanonicalDataPath(propertyName)) {
1038
+ diagnostics.push(error("invalid-field", path, `${name}.${propertyName}: must be a canonical data-relative path.`));
1039
+ }
1040
+ }
1041
+ validateObjectDateRanges(name, value, path, diagnostics);
1042
+ }
1043
+
1044
+ function validateObjectDateRanges(name, value, path, diagnostics) {
1045
+ for (const [startField, endField] of [
1046
+ ["startsOn", "dueOn"],
1047
+ ["dueOn", "overdueOn"],
1048
+ ["startsAt", "dueAt"],
1049
+ ["dueAt", "overdueAt"],
1050
+ ["startsOn", "endsOn"]
1051
+ ]) {
1052
+ const start = value[startField];
1053
+ const end = value[endField];
1054
+ if (!start || !end) continue;
1055
+ const invalid = startField.endsWith("At")
1056
+ ? new Date(end) < new Date(start)
1057
+ : parseCalendarDate(start) && parseCalendarDate(end) && end < start;
1058
+ if (invalid) {
1059
+ diagnostics.push(error(
1060
+ "invalid-date-range",
1061
+ path,
1062
+ `${name}.${endField} cannot be before ${name}.${startField}.`
1063
+ ));
1064
+ }
1065
+ }
1066
+ }
1067
+
500
1068
  function isDate(value) {
501
1069
  return DATE_PATTERN.test(value) && Boolean(parseCalendarDate(value));
502
1070
  }