filegrc 0.3.4 → 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,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,14 @@ 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 === "obligation") validateObligation(record, loaded.model, byId, displayPath, diagnostics);
77
+ if (record.type === "obligation-event") validatePolicyEvent(record, loaded.model, byId, displayPath, diagnostics);
59
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);
60
83
 
61
84
  const fields = { ...loaded.model.commonFields, ...definition.fields };
62
85
  for (const [fieldName, field] of Object.entries(fields)) {
@@ -96,12 +119,21 @@ export async function validateWorkspace(input = process.cwd()) {
96
119
  }
97
120
  }
98
121
  }
122
+ validateNestedRelations(fieldName, value, field, loaded.model, byId, displayPath, diagnostics);
99
123
  }
100
124
  validateIndependentApproval(record, byId, displayPath, diagnostics);
101
- validateCompletedObligationEvent(record, byId, displayPath, diagnostics);
125
+ validateCompletedObligationEvent(record, byId, loaded.model, displayPath, diagnostics);
102
126
  validateImplementedControlSchedules(record, obligationsByControl, byId, asOf, displayPath, diagnostics);
103
127
  await validateMarkdown(record, definition, loaded.model, loaded.root, displayPath, diagnostics);
128
+ await validateApprovalBinding(record, loaded.model, loaded.root, displayPath, diagnostics);
104
129
  }
130
+ validateRelationshipConstraints(
131
+ loaded.resources,
132
+ loaded.model,
133
+ byId,
134
+ pathById,
135
+ diagnostics
136
+ );
105
137
 
106
138
  diagnostics.sort((a, b) => `${a.severity}:${a.path}:${a.code}`.localeCompare(`${b.severity}:${b.path}:${b.code}`));
107
139
  return {
@@ -116,10 +148,53 @@ export async function validateWorkspace(input = process.cwd()) {
116
148
  };
117
149
  }
118
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
+
119
185
  function validateImplementedControlSchedules(record, obligationsByControl, byId, asOf, path, diagnostics) {
120
186
  if (record.type !== "control" || record.status !== "implemented") return;
121
187
  const schedules = obligationsByControl.get(record.id) || [];
122
- 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;
123
198
  const stopped = schedules.filter((obligation) => !obligationIsRunning(obligation, byId, asOf));
124
199
  const paused = stopped.filter((obligation) => obligation.status === "paused");
125
200
  const waiting = stopped.filter((obligation) => obligation.status === "active");
@@ -149,9 +224,7 @@ function validateImplementedControlSchedules(record, obligationsByControl, byId,
149
224
  function validateDateRanges(record, path, diagnostics) {
150
225
  for (const [startField, endField] of [
151
226
  ["startDate", "endDate"],
152
- ["periodStart", "periodEnd"],
153
- ["candidatePeriodStart", "candidatePeriodEnd"],
154
- ["dueWindowStart", "dueWindowEnd"]
227
+ ["startsOn", "endsOn"]
155
228
  ]) {
156
229
  const start = record[startField];
157
230
  const end = record[endField];
@@ -165,7 +238,25 @@ function validateDateRanges(record, path, diagnostics) {
165
238
  }
166
239
  }
167
240
 
168
- 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) {
169
260
  if (record.type !== "obligation-event" || record.status !== "complete") return;
170
261
  if (record.completedOn && record.occurredOn && record.completedOn < record.occurredOn) {
171
262
  diagnostics.push(error(
@@ -193,7 +284,9 @@ function validateCompletedObligationEvent(record, byId, path, diagnostics) {
193
284
  continue;
194
285
  }
195
286
  const obligation = byId.get(action.obligationId);
196
- const expectedTypes = obligation?.type === "obligation" ? obligation.completionResourceTypes || [] : [];
287
+ const expectedTypes = obligation?.type === "obligation"
288
+ ? model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || []
289
+ : [];
197
290
  if (!expectedTypes.length) continue;
198
291
  const linked = [...new Set([...(action.completionResourceIds || []), ...(action.evidenceIds || [])])]
199
292
  .map((id) => byId.get(id))
@@ -239,9 +332,38 @@ function validateIndependentApproval(record, byId, path, diagnostics) {
239
332
  }
240
333
  }
241
334
 
242
- function validateObligation(record, path, diagnostics) {
335
+ function validateObligation(record, model, byId, path, diagnostics) {
243
336
  const recurrence = record.recurrence;
244
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
+ }
245
367
  if (recurrence.mode === "calendar") {
246
368
  const normalized = { ...recurrence, anchorDate: recurrence.anchorDate || record.startsOn };
247
369
  if (!validCalendarRecurrence(normalized)) {
@@ -252,11 +374,11 @@ function validateObligation(record, path, diagnostics) {
252
374
  ));
253
375
  }
254
376
  } else if (recurrence.mode === "event") {
255
- if (typeof recurrence.eventType !== "string" || !ID_PATTERN.test(recurrence.eventType)) {
377
+ if (!model.policyEvents?.[recurrence.eventType]) {
256
378
  diagnostics.push(error(
257
379
  "invalid-obligation-recurrence",
258
380
  path,
259
- "Event recurrence requires a lowercase kebab-case eventType."
381
+ "Event recurrence must use a policy event defined by the model."
260
382
  ));
261
383
  }
262
384
  } else {
@@ -268,53 +390,287 @@ function validateObligation(record, path, diagnostics) {
268
390
  }
269
391
 
270
392
  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]) {
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;
275
401
  if (!Number.isInteger(window[name])) {
276
402
  diagnostics.push(error("invalid-obligation-window", path, `window.${name} must be an integer.`));
277
403
  }
278
404
  }
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.`));
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.`));
282
410
  }
283
411
  }
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.`));
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
+ ));
287
439
  }
288
440
  }
289
- if (dayFields.length && hourFields.length) {
290
- 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."));
291
469
  }
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."));
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.");
294
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) {
295
522
  if (
296
- recurrence.mode === "calendar"
297
- && Number.isInteger(window.startOffsetDays)
298
- && window.startOffsetDays > 0
299
- && window.endOffsetDays === undefined
300
- ) {
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) {
301
597
  diagnostics.push(error(
302
- "invalid-obligation-window",
598
+ "approval-content-changed",
303
599
  path,
304
- "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.`
305
601
  ));
306
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
+ }
307
651
  if (
308
- Number.isInteger(window.endOffsetDays)
309
- && window.endOffsetDays < (Number.isInteger(window.startOffsetDays) ? window.startOffsetDays : 0)
652
+ record.type === "evidence"
653
+ && record.artifactKind === "population-export"
654
+ && coverage.kind !== "range"
310
655
  ) {
311
- 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."));
312
657
  }
658
+ }
659
+
660
+ function validateClassification(record, workspace, path, diagnostics) {
661
+ if (!record.classificationId) return;
662
+ const definitions = workspace?.classificationDefinitions;
313
663
  if (
314
- Number.isInteger(window.endOffsetHours)
315
- && 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)
316
668
  ) {
317
- 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
+ ));
318
674
  }
319
675
  }
320
676
 
@@ -340,6 +696,13 @@ function validateRecord(record, definition, model, path, diagnostics) {
340
696
  ]);
341
697
  for (const [name, field] of Object.entries(fields)) {
342
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
+ }
343
706
  if (field.disjointFrom) {
344
707
  const values = normalizedValues(record[name]);
345
708
  const otherValues = new Set(normalizedValues(record[field.disjointFrom]));
@@ -365,7 +728,11 @@ function validateRecord(record, definition, model, path, diagnostics) {
365
728
  for (const [name, value] of Object.entries(record)) {
366
729
  const field = fields[name];
367
730
  if (!field) {
368
- 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
+ ));
369
736
  continue;
370
737
  }
371
738
  validateValue(name, value, field, model, path, diagnostics);
@@ -378,6 +745,109 @@ function normalizedValues(value) {
378
745
  return typeof value === "string" ? [value] : [];
379
746
  }
380
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
+
381
851
  async function validateMarkdown(record, definition, model, root, path, diagnostics) {
382
852
  const present = new Set();
383
853
  for (const item of markdownEntries(model, record)) {
@@ -452,14 +922,28 @@ function validateValue(name, value, field, model, path, diagnostics) {
452
922
  if (typeof value !== "boolean") fail("must be a boolean.");
453
923
  return;
454
924
  case "object":
455
- 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
+ }
456
932
  return;
457
933
  case "array":
458
934
  if (!Array.isArray(value)) {
459
935
  fail("must be an array.");
460
936
  return;
461
937
  }
462
- 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
+ }
463
947
  return;
464
948
  default:
465
949
  fail(`uses unsupported model type "${field.type}".`);
@@ -489,9 +973,12 @@ function validateNumericRange(value, field, fail) {
489
973
  }
490
974
  }
491
975
 
492
- function validateArrayItem(name, value, type, path, diagnostics) {
976
+ function validateArrayItem(name, value, field, model, path, diagnostics, index) {
977
+ const type = field.items;
493
978
  if (type === "object" && (!value || Array.isArray(value) || typeof value !== "object")) {
494
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);
495
982
  } else if ((type === "string" || type === "data-path") && typeof value !== "string") {
496
983
  diagnostics.push(error("invalid-field", path, `${name} items must be strings.`));
497
984
  } else if (type === "id" && (typeof value !== "string" || !ID_PATTERN.test(value))) {
@@ -499,6 +986,85 @@ function validateArrayItem(name, value, type, path, diagnostics) {
499
986
  }
500
987
  }
501
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
+
502
1068
  function isDate(value) {
503
1069
  return DATE_PATTERN.test(value) && Boolean(parseCalendarDate(value));
504
1070
  }