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.
@@ -0,0 +1,165 @@
1
+ import { applyResourceBatch } from "./files.js";
2
+ import { createResourceId } from "./id.js";
3
+ import { currentCalendarDate } from "./time.js";
4
+ import { loadWorkspace } from "./workspace.js";
5
+
6
+ export async function scaffoldExternalReviewerGovernance(input = process.cwd()) {
7
+ const loaded = await loadWorkspace(input);
8
+ if (String(loaded.model.modelVersion) !== "3") {
9
+ throw new Error("External reviewer setup requires a model v3 workspace.");
10
+ }
11
+ return {
12
+ reviewerName: null,
13
+ jobTitle: null,
14
+ email: null,
15
+ organization: null,
16
+ startsOn: currentCalendarDate(loaded.workspace.timezone),
17
+ independenceRationale: null,
18
+ appointedByIds: [],
19
+ instructions: "Replace every null required value with current facts. The reviewer must be independent from policy ownership and operating work. Preview the completed file before applying it."
20
+ };
21
+ }
22
+
23
+ export async function planExternalReviewerGovernance(input = process.cwd(), options = {}) {
24
+ const loaded = await loadWorkspace(input);
25
+ if (String(loaded.model.modelVersion) !== "3") {
26
+ throw new Error("External reviewer setup requires a model v3 workspace.");
27
+ }
28
+ const name = required(options.reviewerName, "External reviewer name");
29
+ const startsOn = required(options.startsOn, "Appointment start date");
30
+ const existingPerson = loaded.resources.find((record) => (
31
+ record.type === "person"
32
+ && record.affiliation === "external"
33
+ && (
34
+ record.id === options.reviewerId
35
+ || record.email && options.email && record.email.toLowerCase() === String(options.email).toLowerCase()
36
+ )
37
+ ));
38
+ const jobTitle = required(
39
+ options.jobTitle || existingPerson?.jobTitle,
40
+ "External reviewer organizational job title"
41
+ );
42
+ const person = {
43
+ ...(existingPerson || {
44
+ id: options.reviewerId || createResourceId("person", name, loaded.resources.map(({ id }) => id)),
45
+ type: "person"
46
+ }),
47
+ title: name,
48
+ status: "active",
49
+ affiliation: "external",
50
+ jobTitle,
51
+ ...(options.email ? { email: String(options.email) } : {}),
52
+ ...(options.organization ? { organization: String(options.organization) } : {})
53
+ };
54
+ const workspaceId = loaded.workspace.id;
55
+ const creates = existingPerson ? [] : [person];
56
+ const updates = existingPerson ? [person] : [];
57
+ const appointmentIds = [];
58
+ const appointmentSpecs = [
59
+ {
60
+ kind: "independent-policy-reviewer",
61
+ title: "Independent Policy Reviewer",
62
+ responsibilities: "Review and approve policies and governed documents independently from the owner, chair security and risk oversight, and challenge management decisions."
63
+ }
64
+ ];
65
+ for (const spec of appointmentSpecs) {
66
+ const existing = loaded.resources.find((record) => (
67
+ record.type === "appointment"
68
+ && record.appointmentKind === spec.kind
69
+ && record.status !== "ended"
70
+ ));
71
+ const appointment = {
72
+ ...(existing || {
73
+ id: createResourceId("appointment", spec.title, [
74
+ ...loaded.resources.map(({ id }) => id),
75
+ ...creates.map(({ id }) => id)
76
+ ]),
77
+ type: "appointment",
78
+ title: spec.title
79
+ }),
80
+ status: "active",
81
+ appointmentKind: spec.kind,
82
+ holderId: person.id,
83
+ scopeResourceIds: [workspaceId],
84
+ startsOn,
85
+ responsibilities: existing?.responsibilities || spec.responsibilities,
86
+ independenceRationale: required(
87
+ options.independenceRationale,
88
+ "Independence rationale"
89
+ ),
90
+ ...(options.appointedByIds?.length
91
+ ? { appointedByIds: [...new Set(options.appointedByIds.map(String))] }
92
+ : {})
93
+ };
94
+ appointmentIds.push(appointment.id);
95
+ if (existing) updates.push(appointment);
96
+ else creates.push(appointment);
97
+ }
98
+
99
+ const team = loaded.resources.find((record) => (
100
+ record.type === "team" && record.id === "team-security-risk-oversight"
101
+ )) || loaded.resources.find((record) => record.type === "team" && /oversight/i.test(record.title));
102
+ if (team) {
103
+ updates.push({
104
+ ...team,
105
+ status: "active",
106
+ memberIds: [...new Set([...(team.memberIds || []), person.id])],
107
+ chairIds: [...new Set([...(team.chairIds || []), appointmentIds[0]])]
108
+ });
109
+ } else {
110
+ creates.push({
111
+ id: createResourceId("team", "Security and Risk Oversight", [
112
+ ...loaded.resources.map(({ id }) => id),
113
+ ...creates.map(({ id }) => id)
114
+ ]),
115
+ type: "team",
116
+ title: "Security and Risk Oversight",
117
+ status: "active",
118
+ purpose: "Provide independent review of security, risk, policies, incidents, findings, and overdue work.",
119
+ memberIds: [person.id],
120
+ chairIds: [appointmentIds[0]]
121
+ });
122
+ }
123
+
124
+ for (const policy of loaded.resources.filter((record) => (
125
+ record.type === "policy"
126
+ && ["draft", "in-review"].includes(record.status)
127
+ && !(record.approverIds || []).includes(person.id)
128
+ ))) {
129
+ updates.push({
130
+ ...policy,
131
+ approverIds: [...new Set([...(policy.approverIds || []), person.id])]
132
+ });
133
+ }
134
+ return {
135
+ operation: "external-reviewer-governance",
136
+ reviewerId: person.id,
137
+ appointmentIds,
138
+ changes: {
139
+ create: creates,
140
+ update: deduplicateUpdates(updates),
141
+ validateWholeWorkspace: true
142
+ }
143
+ };
144
+ }
145
+
146
+ export async function setupExternalReviewerGovernance(input = process.cwd(), options = {}) {
147
+ if (options.confirmed !== true) {
148
+ throw new Error("Preview the external reviewer governance bundle and confirm the write.");
149
+ }
150
+ const plan = await planExternalReviewerGovernance(input, options);
151
+ const result = await applyResourceBatch(input, plan.changes);
152
+ return { ...plan, result };
153
+ }
154
+
155
+ function required(value, label) {
156
+ const normalized = String(value || "").trim();
157
+ if (!normalized) throw new Error(`${label} is required.`);
158
+ return normalized;
159
+ }
160
+
161
+ function deduplicateUpdates(records) {
162
+ const byId = new Map();
163
+ for (const record of records) byId.set(record.id, record);
164
+ return [...byId.values()];
165
+ }
package/src/files.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { constants, link, lstat, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
- import { getResourceDefinition } from "../model/index.js";
5
- import { serializeWorkspaceMutation } from "./mutation.js";
4
+ import { getResourceDefinition, loadModel } from "../model/index.js";
5
+ import { serializeWorkspaceMutation, workspaceValidationDeferred } from "./mutation.js";
6
6
  import { isCanonicalDataPath, resolveDataPath, resolveWorkspaceRoot } from "./paths.js";
7
7
  import { markdownEntries } from "./resource-markdown.js";
8
+ import { measureTiming } from "./timing.js";
8
9
  import { loadWorkspace } from "./workspace.js";
9
10
  import { validateWorkspace } from "./validate.js";
10
11
 
@@ -198,6 +199,120 @@ export async function createResources(input, records) {
198
199
  return serializeWorkspaceMutation(input, (root) => createResourcesUnlocked(root, records));
199
200
  }
200
201
 
202
+ export async function applyResourceBatch(input, changes) {
203
+ return serializeWorkspaceMutation(input, (root) => applyResourceBatchUnlocked(root, changes));
204
+ }
205
+
206
+ async function applyResourceBatchUnlocked(input, changes = {}) {
207
+ const creates = changes.create || [];
208
+ const updates = changes.update || [];
209
+ const expectedRevisions = changes.expectedRevisions || {};
210
+ if (!Array.isArray(creates) || !Array.isArray(updates) || (!creates.length && !updates.length)) {
211
+ throw new Error("A resource batch needs at least one create or update.");
212
+ }
213
+ if (Array.isArray(expectedRevisions) || typeof expectedRevisions !== "object") {
214
+ throw new Error("Batch expected revisions must be keyed by resource ID.");
215
+ }
216
+ const loaded = await loadWorkspace(input);
217
+ const workspaceUpdate = updates.find((record) => (
218
+ record.type === "workspace" && record.id === loaded.workspace?.id
219
+ ));
220
+ const changesModelVersion = workspaceUpdate
221
+ && String(workspaceUpdate.dataModelVersion || "") !== String(loaded.workspace?.dataModelVersion || "");
222
+ const targetModelVersion = changes.targetModelVersion
223
+ ? String(changes.targetModelVersion)
224
+ : null;
225
+ if (changesModelVersion && !targetModelVersion) {
226
+ throw new Error(
227
+ "A resource batch that changes dataModelVersion must declare targetModelVersion."
228
+ );
229
+ }
230
+ if (
231
+ targetModelVersion
232
+ && (
233
+ changes.validateWholeWorkspace !== true
234
+ || String(workspaceUpdate?.dataModelVersion || "") !== targetModelVersion
235
+ )
236
+ ) {
237
+ throw new Error(
238
+ "A cross-model resource batch must validate the whole workspace and update its dataModelVersion to the target model."
239
+ );
240
+ }
241
+ const writeModel = targetModelVersion ? loadModel(targetModelVersion) : loaded.model;
242
+ const deferValidation = workspaceValidationDeferred();
243
+ const before = deferValidation || changes.validateWholeWorkspace
244
+ ? null
245
+ : await validateWorkspace(loaded);
246
+ const existingById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
247
+ const ids = new Set();
248
+ const writes = [];
249
+ for (const record of creates) {
250
+ validateBatchRecord(record, ids);
251
+ if (existingById.has(record.id)) throw new Error(`Resource "${record.id}" already exists.`);
252
+ const path = resourcePath(loaded.root, writeModel, record);
253
+ writes.push({ operation: "create", path, record, previous: null, fileMode: 0o666 });
254
+ }
255
+ for (const record of updates) {
256
+ validateBatchRecord(record, ids);
257
+ const existing = existingById.get(record.id);
258
+ if (!existing) throw new Error(`Resource "${record.id}" was not found.`);
259
+ if (existing.record.type !== record.type) {
260
+ throw new Error(`Resource "${record.id}" cannot change type.`);
261
+ }
262
+ const path = resourcePath(loaded.root, writeModel, record);
263
+ const previous = await readFile(path, "utf8");
264
+ const mode = (await stat(path)).mode & 0o777;
265
+ assertRevision(
266
+ previous,
267
+ expectedRevisions[record.id] || existing.revision,
268
+ `Resource "${record.id}"`
269
+ );
270
+ writes.push({ operation: "update", path, record, previous, fileMode: mode });
271
+ }
272
+ const written = [];
273
+ try {
274
+ for (const item of writes) {
275
+ await writeAtomic(item.path, item.record, { exclusive: item.operation === "create" });
276
+ written.push(item);
277
+ }
278
+ let validation = null;
279
+ if (!deferValidation) {
280
+ validation = await validateWorkspace(loaded.root);
281
+ const errors = changes.validateWholeWorkspace
282
+ ? validation.diagnostics.filter(({ severity }) => severity === "error")
283
+ : newErrors(validation, before);
284
+ if (errors.length) throw new Error(formatWriteFailure(errors, "resource batch"));
285
+ }
286
+ return {
287
+ created: creates,
288
+ updated: updates,
289
+ validation
290
+ };
291
+ } catch (error) {
292
+ const rollbackErrors = [];
293
+ for (const item of written.reverse()) {
294
+ try {
295
+ if (item.operation === "create") await rm(item.path, { force: true });
296
+ else await writeTextAtomic(item.path, item.previous, { mode: item.fileMode });
297
+ } catch (rollbackError) {
298
+ rollbackErrors.push(rollbackError.message);
299
+ }
300
+ }
301
+ if (rollbackErrors.length) {
302
+ throw new Error(`${error.message} FileGRC could not restore every file in the resource batch: ${rollbackErrors.join(" ")}`);
303
+ }
304
+ throw error;
305
+ }
306
+ }
307
+
308
+ function validateBatchRecord(record, ids) {
309
+ if (!record || Array.isArray(record) || typeof record !== "object") {
310
+ throw new Error("Every resource in a batch must be a JSON object.");
311
+ }
312
+ if (ids.has(record.id)) throw new Error(`Resource "${record.id}" appears more than once in the batch.`);
313
+ ids.add(record.id);
314
+ }
315
+
201
316
  export async function createResourceAndLink(input, record, linkTarget, options = {}) {
202
317
  return serializeWorkspaceMutation(input, (root) => createResourceAndLinkUnlocked(root, record, linkTarget, options));
203
318
  }
@@ -254,7 +369,8 @@ async function createResourceAndLinkUnlocked(input, record, linkTarget, options)
254
369
  async function createResourcesUnlocked(input, records) {
255
370
  if (!Array.isArray(records) || records.length === 0) throw new Error("At least one resource is required.");
256
371
  const loaded = await loadWorkspace(input);
257
- const before = await validateWorkspace(loaded);
372
+ const deferValidation = workspaceValidationDeferred();
373
+ const before = deferValidation ? null : await validateWorkspace(loaded);
258
374
  const ids = new Set();
259
375
  const writes = [];
260
376
  for (const record of records) {
@@ -276,9 +392,11 @@ async function createResourcesUnlocked(input, records) {
276
392
  await writeAtomic(item.path, item.record, { exclusive: true });
277
393
  written.push(item);
278
394
  }
279
- const result = await validateWorkspace(loaded.root);
280
- const introduced = newErrors(result, before);
281
- if (introduced.length) throw new Error(formatWriteFailure(introduced, "resource batch"));
395
+ if (!deferValidation) {
396
+ const result = await validateWorkspace(loaded.root);
397
+ const introduced = newErrors(result, before);
398
+ if (introduced.length) throw new Error(formatWriteFailure(introduced, "resource batch"));
399
+ }
282
400
  } catch (error) {
283
401
  for (const item of written.reverse()) await rm(item.path, { force: true });
284
402
  throw error;
@@ -288,7 +406,8 @@ async function createResourcesUnlocked(input, records) {
288
406
 
289
407
  async function createResourceUnlocked(input, record, options) {
290
408
  const loaded = await loadWorkspace(input);
291
- const before = await validateWorkspace(loaded);
409
+ const deferValidation = workspaceValidationDeferred();
410
+ const before = deferValidation ? null : await validateWorkspace(loaded);
292
411
  const path = resourcePath(loaded.root, loaded.model, record);
293
412
  try {
294
413
  await stat(path);
@@ -297,6 +416,7 @@ async function createResourceUnlocked(input, record, options) {
297
416
  if (error.code !== "ENOENT") throw error;
298
417
  }
299
418
  const contentWrites = await prepareContentWrites(loaded, record, options.content, { exclusive: true });
419
+ const nextRecord = await prepareApprovalBinding(loaded, record, contentWrites);
300
420
  const written = [];
301
421
  let recordWritten = false;
302
422
  try {
@@ -304,17 +424,19 @@ async function createResourceUnlocked(input, record, options) {
304
424
  await writeTextAtomic(item.path, item.source, { exclusive: true });
305
425
  written.push(item);
306
426
  }
307
- await writeAtomic(path, record, { exclusive: true });
427
+ await writeAtomic(path, nextRecord, { exclusive: true });
308
428
  recordWritten = true;
309
- const result = await validateWorkspace(loaded.root);
310
- const introduced = newErrors(result, before);
311
- if (introduced.length) throw new Error(formatWriteFailure(introduced, record.id));
429
+ if (!deferValidation) {
430
+ const result = await validateWorkspace(loaded.root);
431
+ const introduced = newErrors(result, before);
432
+ if (introduced.length) throw new Error(formatWriteFailure(introduced, record.id));
433
+ }
312
434
  } catch (error) {
313
435
  if (recordWritten) await rm(path, { force: true });
314
436
  for (const item of written) await rm(item.path, { force: true });
315
437
  throw error;
316
438
  }
317
- return { record, path };
439
+ return { record: nextRecord, path };
318
440
  }
319
441
 
320
442
  export async function updateResource(input, type, id, record, options = {}) {
@@ -326,19 +448,25 @@ async function updateResourceUnlocked(input, type, id, record, options) {
326
448
  throw new Error("The type and ID in the record must match the resource being updated.");
327
449
  }
328
450
  const loaded = await loadWorkspace(input);
329
- const before = await validateWorkspace(loaded);
451
+ const deferValidation = workspaceValidationDeferred();
452
+ const before = deferValidation ? null : await validateWorkspace(loaded);
330
453
  const path = resourcePath(loaded.root, loaded.model, record);
331
454
  const previous = await readFile(path, "utf8");
332
455
  assertRevision(previous, options.expectedRevision, "The record");
333
456
  const contentWrites = await prepareContentWrites(loaded, record, options.content, {
334
- expectedRevisions: options.expectedContentRevisions
457
+ expectedRevisions: options.expectedContentRevisions,
458
+ requireExpectedRevisions: options.requireExpectedContentRevisions
335
459
  });
460
+ const existing = loaded.entries.find(({ record: candidate }) => candidate.id === id)?.record;
461
+ const nextRecord = await prepareApprovalBinding(loaded, record, contentWrites, existing);
336
462
  try {
337
463
  for (const item of contentWrites) await writeTextAtomic(item.path, item.source);
338
- await writeAtomic(path, record);
339
- const result = await validateWorkspace(loaded.root);
340
- const introduced = newErrors(result, before);
341
- if (introduced.length) throw new Error(formatWriteFailure(introduced, id));
464
+ await writeAtomic(path, nextRecord);
465
+ if (!deferValidation) {
466
+ const result = await validateWorkspace(loaded.root);
467
+ const introduced = newErrors(result, before);
468
+ if (introduced.length) throw new Error(formatWriteFailure(introduced, id));
469
+ }
342
470
  } catch (error) {
343
471
  await writeTextAtomic(path, previous);
344
472
  for (const item of contentWrites) {
@@ -347,7 +475,7 @@ async function updateResourceUnlocked(input, type, id, record, options) {
347
475
  }
348
476
  throw error;
349
477
  }
350
- return { record, path };
478
+ return { record: nextRecord, path };
351
479
  }
352
480
 
353
481
  export async function updateContent(input, dataRelativePath, source, options = {}) {
@@ -368,8 +496,18 @@ async function updateContentUnlocked(input, dataRelativePath, source, options) {
368
496
  const path = resolveDataPath(loaded.root, dataRelativePath);
369
497
  const previous = await readFile(path, "utf8");
370
498
  assertRevision(previous, options.expectedRevision, "The Markdown file");
371
- await writeTextAtomic(path, source.endsWith("\n") ? source : `${source}\n`);
372
- return { path, dataRelativePath };
499
+ const before = await validateWorkspace(loaded);
500
+ const nextSource = source.endsWith("\n") ? source : `${source}\n`;
501
+ await writeTextAtomic(path, nextSource);
502
+ try {
503
+ const result = await validateWorkspace(loaded.root);
504
+ const introduced = newErrors(result, before);
505
+ if (introduced.length) throw new Error(formatWriteFailure(introduced, dataRelativePath));
506
+ return { path, dataRelativePath };
507
+ } catch (error) {
508
+ await writeTextAtomic(path, previous);
509
+ throw error;
510
+ }
373
511
  }
374
512
 
375
513
  export async function deleteResource(input, type, id, options = {}) {
@@ -378,7 +516,8 @@ export async function deleteResource(input, type, id, options = {}) {
378
516
 
379
517
  async function deleteResourceUnlocked(input, type, id, options) {
380
518
  const loaded = await loadWorkspace(input);
381
- const before = await validateWorkspace(loaded);
519
+ const deferValidation = workspaceValidationDeferred();
520
+ const before = deferValidation ? null : await validateWorkspace(loaded);
382
521
  const definition = getResourceDefinition(loaded.model, type);
383
522
  if (definition.singleton) throw new Error("Singleton records cannot be deleted.");
384
523
  const path = resourcePath(loaded.root, loaded.model, { type, id });
@@ -393,9 +532,11 @@ async function deleteResourceUnlocked(input, type, id, options) {
393
532
  try {
394
533
  await rm(path);
395
534
  for (const item of contentFiles) await rm(item.path, { force: true });
396
- const result = await validateWorkspace(loaded.root);
397
- const introduced = newErrors(result, before);
398
- if (introduced.length) throw new Error(formatWriteFailure(introduced, id));
535
+ if (!deferValidation) {
536
+ const result = await validateWorkspace(loaded.root);
537
+ const introduced = newErrors(result, before);
538
+ if (introduced.length) throw new Error(formatWriteFailure(introduced, id));
539
+ }
399
540
  } catch (error) {
400
541
  await writeTextAtomic(path, source, { mode });
401
542
  for (const item of contentFiles) {
@@ -418,11 +559,17 @@ export function resourcePath(input, model, record) {
418
559
  }
419
560
 
420
561
  async function writeAtomic(path, value, options = {}) {
421
- const source = `${JSON.stringify(value, null, 2)}\n`;
422
- await writeTextAtomic(path, source, options);
562
+ return measureTiming("writes", async () => {
563
+ const source = `${JSON.stringify(value, null, 2)}\n`;
564
+ await writeTextAtomicUnmeasured(path, source, options);
565
+ });
423
566
  }
424
567
 
425
568
  async function writeTextAtomic(path, source, options = {}) {
569
+ return measureTiming("writes", () => writeTextAtomicUnmeasured(path, source, options));
570
+ }
571
+
572
+ async function writeTextAtomicUnmeasured(path, source, options = {}) {
426
573
  await mkdir(dirname(path), { recursive: true });
427
574
  const temp = join(dirname(path), `.${randomUUID()}.tmp`);
428
575
  let mode = options.mode ?? 0o666;
@@ -454,7 +601,9 @@ async function writeTextAtomic(path, source, options = {}) {
454
601
 
455
602
  function newErrors(after, before) {
456
603
  const existing = new Set(before.diagnostics.filter(({ severity }) => severity === "error").map(diagnosticKey));
457
- return after.diagnostics.filter(({ severity }) => severity === "error").filter((item) => !existing.has(diagnosticKey(item)));
604
+ return after.diagnostics
605
+ .filter(({ severity }) => severity === "error")
606
+ .filter((item) => item.code === "unsupported-model" || !existing.has(diagnosticKey(item)));
458
607
  }
459
608
 
460
609
  function diagnosticKey(item) {
@@ -490,6 +639,12 @@ async function prepareContentWrites(loaded, record, content, options = {}) {
490
639
  try {
491
640
  previous = await readFile(path, "utf8");
492
641
  if (options.exclusive) throw new Error(`Content already exists at data/${dataRelativePath}.`);
642
+ if (
643
+ options.requireExpectedRevisions
644
+ && !Object.hasOwn(options.expectedRevisions ?? {}, dataRelativePath)
645
+ ) {
646
+ throw new Error(`A content revision is required for existing content at data/${dataRelativePath}.`);
647
+ }
493
648
  assertRevision(previous, options.expectedRevisions?.[dataRelativePath], `Content at data/${dataRelativePath}`);
494
649
  } catch (error) {
495
650
  if (error.code !== "ENOENT") throw error;
@@ -505,10 +660,93 @@ function assertRevision(source, expected, label) {
505
660
  }
506
661
  }
507
662
 
508
- function contentRevision(source) {
663
+ export function contentRevision(source) {
509
664
  return createHash("sha256").update(source).digest("hex");
510
665
  }
511
666
 
667
+ async function prepareApprovalBinding(loaded, record, contentWrites, previousRecord = null) {
668
+ if (record.type === "attestation") {
669
+ return prepareAttestationBinding(loaded, record, previousRecord);
670
+ }
671
+ const bindingField = approvalBindingField(record, loaded.model);
672
+ if (!bindingField) return record;
673
+ const nextRecord = structuredClone(record);
674
+ if (!approvalBound(record)) {
675
+ delete nextRecord[bindingField];
676
+ return nextRecord;
677
+ }
678
+ if (approvalBound(previousRecord) && previousRecord[bindingField]) {
679
+ nextRecord[bindingField] = structuredClone(previousRecord[bindingField]);
680
+ return nextRecord;
681
+ }
682
+ const proposed = new Map(contentWrites.map((item) => [item.dataRelativePath, item.source]));
683
+ const revisions = {};
684
+ for (const item of markdownEntries(loaded.model, nextRecord)) {
685
+ let source = proposed.get(item.path);
686
+ if (source === undefined) {
687
+ try {
688
+ source = await readFile(resolveDataPath(loaded.root, item.path), "utf8");
689
+ } catch (error) {
690
+ if (error.code === "ENOENT") continue;
691
+ throw error;
692
+ }
693
+ }
694
+ revisions[item.path] = contentRevision(source);
695
+ }
696
+ nextRecord[bindingField] = revisions;
697
+ return nextRecord;
698
+ }
699
+
700
+ async function prepareAttestationBinding(loaded, record, previousRecord = null) {
701
+ const nextRecord = structuredClone(record);
702
+ const bound = record.status === "completed" && record.attestationMethod === "git-approval";
703
+ if (!bound) {
704
+ delete nextRecord.contentRevisions;
705
+ return nextRecord;
706
+ }
707
+ if (
708
+ previousRecord?.status === "completed"
709
+ && previousRecord.attestationMethod === "git-approval"
710
+ && previousRecord.contentRevisions
711
+ ) {
712
+ nextRecord.contentRevisions = structuredClone(previousRecord.contentRevisions);
713
+ return nextRecord;
714
+ }
715
+ const revisions = {};
716
+ for (const id of record.subjectResourceIds || []) {
717
+ const subject = loaded.resources.find((candidate) => candidate.id === id);
718
+ if (!subject) continue;
719
+ for (const item of markdownEntries(loaded.model, subject)) {
720
+ try {
721
+ const source = await readFile(resolveDataPath(loaded.root, item.path), "utf8");
722
+ revisions[item.path] = contentRevision(source);
723
+ } catch (error) {
724
+ if (error.code !== "ENOENT") throw error;
725
+ }
726
+ }
727
+ }
728
+ nextRecord.contentRevisions = revisions;
729
+ return nextRecord;
730
+ }
731
+
732
+ function approvalBound(record) {
733
+ if (!record || !["policy", "document", "training"].includes(record.type)) return false;
734
+ const statuses = record.type === "policy"
735
+ ? ["approved", "active", "superseded", "retired"]
736
+ : record.type === "document"
737
+ ? ["active", "superseded", "retired"]
738
+ : ["active", "retired"];
739
+ return statuses.includes(record.status);
740
+ }
741
+
742
+ function approvalBindingField(record, model) {
743
+ if (["policy", "document"].includes(record?.type)) return "approvedContentRevisions";
744
+ if (record?.type === "training" && model.resources.training?.fields?.effectiveContentRevisions) {
745
+ return "effectiveContentRevisions";
746
+ }
747
+ return null;
748
+ }
749
+
512
750
  async function exclusiveContentFiles(loaded, record) {
513
751
  const candidates = markdownEntries(loaded.model, record).map(({ path }) => path);
514
752
  const files = [];