filegrc 0.7.1 → 0.8.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/server.js CHANGED
@@ -5,6 +5,7 @@ import { extname, join, resolve } from "node:path";
5
5
  import { performance } from "node:perf_hooks";
6
6
  import { getResourceDefinition } from "../model/index.js";
7
7
  import { prepareAuditWorkspace } from "./audit-preparation.js";
8
+ import { activateDocuments } from "./document-activation.js";
8
9
  import { createNextAuditCycle, planNextAuditCycle } from "./audit-transition.js";
9
10
  import { applyApplicabilityReviewWithContext, planApplicabilityReview } from "./batch-review.js";
10
11
  import { applyCollectionReview, planCollectionReview } from "./collection-review.js";
@@ -330,6 +331,13 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
330
331
  }, () => activatePolicies(input, { ...payload, confirmed: true }));
331
332
  return json(response, 200, result);
332
333
  }
334
+ if (request.method === "POST" && url.pathname === "/api/document-activations") {
335
+ const payload = await readJson(request);
336
+ const result = await browserMutation(input, options, {
337
+ message: (activation) => `Activate ${activation.documentIds.length} governed ${activation.documentIds.length === 1 ? "Document" : "Documents"}`
338
+ }, () => activateDocuments(input, { ...payload, confirmed: true }));
339
+ return json(response, 200, result);
340
+ }
333
341
  if (request.method === "POST" && url.pathname === "/api/resources") {
334
342
  const payload = normalizeResourceMutation(await readJson(request));
335
343
  const { record } = payload;
@@ -543,13 +551,8 @@ function browserMutation(input, options, mutationOptions, task) {
543
551
  includeDetails: false,
544
552
  validationProof: result?.[BROWSER_VALIDATION]
545
553
  }));
546
- if (result?.synchronization?.status === "syncing" && state.repository.status !== "syncing") {
547
- result.synchronization = {
548
- ...result.synchronization,
549
- status: state.repository.status === "synced" ? "synced" : "not-synced",
550
- synchronizedAt: state.repository.lastSuccessfulSynchronization ?? null,
551
- pushError: state.repository.backgroundSyncError ?? null
552
- };
554
+ if (result?.synchronization) {
555
+ result.synchronization = reconcileMutationSynchronization(result.synchronization, state.repository);
553
556
  }
554
557
  return {
555
558
  ...result,
@@ -564,6 +567,25 @@ function browserMutation(input, options, mutationOptions, task) {
564
567
  });
565
568
  }
566
569
 
570
+ export function reconcileMutationSynchronization(synchronization, repository) {
571
+ if (synchronization?.status !== "syncing" || repository.status === "syncing") return synchronization;
572
+ const backgroundFailed = repository.backgroundSynchronization?.status === "failed";
573
+ if (repository.status !== "synced" && !backgroundFailed) {
574
+ // A repository snapshot can finish just before a fast background push while
575
+ // its state is inspected just after the push. Keep the queued result until
576
+ // a verified success or failure replaces it.
577
+ return synchronization;
578
+ }
579
+ return {
580
+ ...synchronization,
581
+ status: repository.status === "synced" ? "synced" : "not-synced",
582
+ synchronizedAt: repository.lastSuccessfulSynchronization ?? null,
583
+ pushError: repository.backgroundSyncError
584
+ ?? repository.backgroundSynchronization?.error
585
+ ?? null
586
+ };
587
+ }
588
+
567
589
  function prefersFastMutation(request) {
568
590
  return String(request.headers.prefer || "")
569
591
  .split(",")
package/src/setup.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { modelSupports } from "../model/index.js";
1
2
  import { applyResourceBatch } from "./files.js";
2
3
  import { createResourceId } from "./id.js";
3
4
  import { resolveProgram, selectedRequirementIds } from "./program.js";
@@ -79,7 +80,9 @@ export function summarizeSetupResult(result) {
79
80
  commitment: result.commitment ? "saved" : "unchanged"
80
81
  },
81
82
  system: setupSystemSummary(result.system),
82
- target: setupTargetSummary(result.program || result.workspace, { modelVersion: result.program ? "4" : "3" }),
83
+ target: setupTargetSummary(result.program || result.workspace, {
84
+ modelVersion: result.workspace?.dataModelVersion || (result.program ? "5" : "3")
85
+ }),
83
86
  renderer: result.renderer ? setupRendererSummary(result.renderer) : null,
84
87
  commitment: result.commitment || null,
85
88
  onboardingComplete: result.onboardingComplete
@@ -131,7 +134,7 @@ function validateSetup(loaded, setup) {
131
134
  throw new Error(`System "${setup.systemId}" cannot be used for initial scope because it is ${system.status}.`);
132
135
  }
133
136
  }
134
- const classifications = String(loaded.model.modelVersion) === "4"
137
+ const classifications = modelSupports(loaded.model, "program-scope")
135
138
  ? loaded.resources.filter(({ type, status }) => type === "classification" && status === "active").map(({ id }) => id)
136
139
  : Object.keys(loaded.workspace.classificationDefinitions || {});
137
140
  if (classifications.length && !classifications.includes(setup.classificationId)) {
@@ -142,7 +145,7 @@ function validateSetup(loaded, setup) {
142
145
  function resolveClassificationId(loaded, value) {
143
146
  const normalized = String(value || "").trim().toLowerCase();
144
147
  if (!normalized) return value;
145
- const candidates = String(loaded.model.modelVersion) === "4"
148
+ const candidates = modelSupports(loaded.model, "program-scope")
146
149
  ? loaded.resources
147
150
  .filter(({ type, status }) => type === "classification" && status === "active")
148
151
  .map(({ id, title }) => ({ id, label: title }))
@@ -167,7 +170,7 @@ function findSetupSystem(resources, target, setup) {
167
170
 
168
171
  function buildSetupRecords(loaded, setup) {
169
172
  const target = resolveProgram(loaded);
170
- const v4 = String(loaded.model.modelVersion) === "4";
173
+ const v4 = modelSupports(loaded.model, "program-scope");
171
174
  const existingSystem = findSetupSystem(loaded.resources, target, setup);
172
175
  const systemId = existingSystem?.id || createResourceId(
173
176
  "system",
@@ -233,7 +236,7 @@ function buildSetupRecords(loaded, setup) {
233
236
  && !["superseded", "retired"].includes(record.status)
234
237
  && (record.systemIds || []).includes(systemId)
235
238
  ));
236
- const commitment = ["3", "4"].includes(String(loaded.model.modelVersion)) && !existingCommitment
239
+ const commitment = modelSupports(loaded.model, "guided-workflow") && !existingCommitment
237
240
  ? {
238
241
  id: createResourceId(
239
242
  "commitment",
package/src/soc2.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { modelSupports } from "../model/index.js";
1
2
  import { coverageEnd, coverageStart } from "./coverage.js";
2
3
 
3
4
  export const REQUIRED_SOC2_DESCRIPTION_REFERENCES = Array.from(
@@ -49,7 +50,7 @@ const SOC2_PROGRAM_GOALS = new Set([
49
50
 
50
51
  export function soc2RequirementApplicabilityConstraint(requirement, program, modelVersion = "4") {
51
52
  if (
52
- String(modelVersion) !== "4"
53
+ !modelSupports(modelVersion, "program-scope")
53
54
  || requirement?.type !== "requirement"
54
55
  || !SOC2_PROGRAM_GOALS.has(program?.assuranceGoal)
55
56
  ) return null;
@@ -171,7 +172,7 @@ export function soc2ReportEvidenceIssue(evidence, audit, modelVersion = "4") {
171
172
  message: `${evidence?.title || audit?.title || "The audit"} must be verified third-party-report Evidence with subtype soc2-report for the issued SOC 2 report.`
172
173
  };
173
174
  }
174
- const issuedOn = (String(modelVersion) === "4"
175
+ const issuedOn = (modelSupports(modelVersion, "program-scope")
175
176
  ? evidence.sourceGeneratedAt
176
177
  : evidence.sourceGeneratedAt || evidence.businessEventAt || evidence.collectedOn
177
178
  )?.slice(0, 10);
package/src/validate.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile, stat } from "node:fs/promises";
3
3
  import { performance } from "node:perf_hooks";
4
- import { getResourceDefinition } from "../model/index.js";
4
+ import { getResourceDefinition, modelSupports } from "../model/index.js";
5
5
  import { scopedCollectionRecords } from "./collection-scope.js";
6
6
  import { collectionRevision } from "./collection-revision.js";
7
7
  import { isSafeGitName } from "./git-name.js";
@@ -12,6 +12,7 @@ import { partyPeople } from "./parties.js";
12
12
  import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
13
13
  import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
14
14
  import { recordTiming } from "./timing.js";
15
+ import { personWasActiveOn } from "./soc2.js";
15
16
  import { indexResources, loadWorkspace } from "./workspace.js";
16
17
 
17
18
  const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -147,6 +148,9 @@ async function validateWorkspaceUnmeasured(input) {
147
148
  pathById,
148
149
  diagnostics
149
150
  );
151
+ if (modelSupports(loaded.model, "document-workflow-scope")) {
152
+ validateDocumentWorkflowScopes(loaded.resources, loaded.model, byId, pathById, diagnostics);
153
+ }
150
154
 
151
155
  diagnostics.sort((a, b) => `${a.severity}:${a.path}:${a.code}`.localeCompare(`${b.severity}:${b.path}:${b.code}`));
152
156
  return {
@@ -161,6 +165,116 @@ async function validateWorkspaceUnmeasured(input) {
161
165
  };
162
166
  }
163
167
 
168
+ function validateDocumentWorkflowScopes(resources, model, byId, pathById, diagnostics) {
169
+ const managementFields = [
170
+ "engagementTermsDocumentId",
171
+ ...(model.auditReadiness?.managementDocuments || []).map(({ field }) => field)
172
+ ];
173
+ const auditReferences = new Map();
174
+ const governedAuditReferences = new Map();
175
+ const addAuditReference = (map, documentId, audit, field) => {
176
+ if (!documentId) return;
177
+ if (!map.has(documentId)) map.set(documentId, []);
178
+ map.get(documentId).push({ audit, field });
179
+ };
180
+ for (const audit of resources.filter(({ type }) => type === "audit")) {
181
+ for (const field of managementFields) {
182
+ const documentId = audit[field];
183
+ if (!documentId) continue;
184
+ addAuditReference(auditReferences, documentId, audit, field);
185
+ addAuditReference(governedAuditReferences, documentId, audit, field);
186
+ const document = byId.get(documentId);
187
+ if (document?.type === "document" && document.workflowScope !== "engagement") {
188
+ diagnostics.push(error(
189
+ "invalid-document-workflow-scope",
190
+ pathById.get(audit.id) || `data/${audit.id}`,
191
+ `${field} must reference an engagement-scoped Document; "${document.title}" is scoped to the program workflow.`
192
+ ));
193
+ }
194
+ }
195
+ for (const documentId of audit.supplementalDocumentIds || []) {
196
+ addAuditReference(auditReferences, documentId, audit, "supplementalDocumentIds");
197
+ }
198
+ }
199
+
200
+ const programDocumentReferences = new Map();
201
+ const addProgramReference = (documentId, record, field) => {
202
+ if (!documentId) return;
203
+ if (!programDocumentReferences.has(documentId)) programDocumentReferences.set(documentId, []);
204
+ programDocumentReferences.get(documentId).push({ record, field });
205
+ };
206
+ for (const policy of resources.filter(({ type }) => type === "policy")) {
207
+ for (const documentId of policy.relatedDocumentIds || []) addProgramReference(documentId, policy, "relatedDocumentIds");
208
+ }
209
+ for (const obligation of resources.filter(({ type }) => type === "obligation")) {
210
+ for (const documentId of obligation.scopeResourceIds || []) {
211
+ if (byId.get(documentId)?.type === "document") addProgramReference(documentId, obligation, "scopeResourceIds");
212
+ }
213
+ if (byId.get(obligation.templateResourceId)?.type === "document") {
214
+ addProgramReference(obligation.templateResourceId, obligation, "templateResourceId");
215
+ }
216
+ }
217
+
218
+ for (const document of resources.filter(({ type }) => type === "document")) {
219
+ const path = pathById.get(document.id) || `data/${document.id}`;
220
+ const auditRefs = auditReferences.get(document.id) || [];
221
+ const auditIds = new Set(auditRefs.map(({ audit }) => audit.id));
222
+ const programRefs = programDocumentReferences.get(document.id) || [];
223
+ if (document.workflowScope === "engagement" && programRefs.length) {
224
+ const references = programRefs.map(({ record, field }) => `${record.id}.${field}`).join(", ");
225
+ diagnostics.push(error(
226
+ "engagement-document-in-program-workflow",
227
+ path,
228
+ `Engagement-scoped Document "${document.title}" cannot govern reusable program work through ${references}. Split the engagement deliverable from the program Document.`
229
+ ));
230
+ }
231
+ if (
232
+ document.workflowScope === "engagement"
233
+ && ["approved", "active"].includes(document.status)
234
+ && auditIds.size !== 1
235
+ ) {
236
+ diagnostics.push(error(
237
+ "invalid-engagement-document-audit-count",
238
+ path,
239
+ `Approved or active engagement Document "${document.title}" must belong to exactly one Audit; found ${auditIds.size}.`
240
+ ));
241
+ }
242
+ if (document.workflowScope === "program" && (governedAuditReferences.get(document.id) || []).length) {
243
+ diagnostics.push(error(
244
+ "program-document-in-engagement-workflow",
245
+ path,
246
+ `Program-scoped Document "${document.title}" cannot fill an Audit engagement or management-Document field.`
247
+ ));
248
+ }
249
+ if (document.activationBasis !== "legacy-v4") continue;
250
+ const historicalAuditIds = new Set(auditRefs
251
+ .filter(({ audit }) => ["issued", "delivered", "complete"].includes(audit.status))
252
+ .map(({ audit }) => audit.id));
253
+ if (document.workflowScope !== "engagement" || historicalAuditIds.size !== 1 || auditIds.size !== 1) {
254
+ diagnostics.push(error(
255
+ "invalid-legacy-document-activation",
256
+ path,
257
+ `activationBasis legacy-v4 is reserved for an engagement Document tied to exactly one issued, delivered, or completed Audit.`
258
+ ));
259
+ }
260
+ }
261
+
262
+ for (const document of resources.filter(({ type, activationBasis }) => (
263
+ type === "document" && activationBasis === "recorded"
264
+ ))) {
265
+ const invalidActorIds = (document.activatedByIds || []).filter((id) => (
266
+ !personWasActiveOn(byId.get(id), document.activatedOn)
267
+ ));
268
+ if (invalidActorIds.length) {
269
+ diagnostics.push(error(
270
+ "invalid-document-activation-actor",
271
+ pathById.get(document.id) || `data/${document.id}`,
272
+ `Document activation actors must have been active on ${document.activatedOn}: ${invalidActorIds.join(", ")}.`
273
+ ));
274
+ }
275
+ }
276
+ }
277
+
164
278
  function validateCollectionReview(record, loaded, byId, path, diagnostics) {
165
279
  if (record.status !== "active") return;
166
280
  const { model } = loaded;
@@ -175,7 +289,7 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
175
289
  ));
176
290
  return;
177
291
  }
178
- const program = String(model.modelVersion) === "4"
292
+ const program = modelSupports(model, "program-scope")
179
293
  ? (record.scopeResourceIds || []).map((id) => byId.get(id)).find(({ type } = {}) => type === "program")
180
294
  : null;
181
295
  const recordCount = scopedCollectionRecords(loaded, record.resourceType, program).length;
@@ -208,7 +322,7 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
208
322
  diagnostics.push(error(
209
323
  "inactive-authoritative-system",
210
324
  path,
211
- `${configuration.title} must name an active authoritative ${String(model.modelVersion) === "4" ? "Component" : "System"} for an externally managed conclusion.`
325
+ `${configuration.title} must name an active authoritative ${modelSupports(model, "component-sources") ? "Component" : "System"} for an externally managed conclusion.`
212
326
  ));
213
327
  }
214
328
  }
@@ -436,7 +550,7 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
436
550
  ? model.obligationActivities?.[obligation.activityType]?.completionResourceTypes || []
437
551
  : [];
438
552
  if (!expectedTypes.length) continue;
439
- const completionIds = ["3", "4"].includes(String(model.modelVersion))
553
+ const completionIds = modelSupports(model, "guided-workflow")
440
554
  ? action.completionResourceIds || []
441
555
  : [...(action.completionResourceIds || []), ...(action.evidenceIds || [])];
442
556
  const linked = [...new Set(completionIds)]
@@ -454,7 +568,7 @@ function validateCompletedObligationEvent(record, byId, model, path, diagnostics
454
568
 
455
569
  function validateCompletedObligationAction(record, byId, model, path, diagnostics) {
456
570
  if (
457
- !["3", "4"].includes(String(model.modelVersion))
571
+ !modelSupports(model, "guided-workflow")
458
572
  || record.status !== "done"
459
573
  || !record.obligationId
460
574
  ) return;
@@ -672,6 +786,9 @@ function validateCompletionDates(record, path, diagnostics) {
672
786
  ]);
673
787
  validateOrderedDates(record, path, diagnostics, ["startedAt", "endedAt"]);
674
788
  validateOrderedDates(record, path, diagnostics, ["fieldworkStart", "fieldworkEnd", "reportDate"]);
789
+ if (record.type === "document") {
790
+ validateOrderedDates(record, path, diagnostics, ["approvedOn", "activatedOn", "effectiveOn"]);
791
+ }
675
792
  if (record.acceptance) {
676
793
  validateOrderedDates(record.acceptance, path, diagnostics, ["acceptedOn", "expiresOn"], "acceptance.");
677
794
  }
@@ -766,8 +883,15 @@ async function validateAttestationBinding(record, model, root, byId, path, diagn
766
883
  }
767
884
 
768
885
  async function validateApprovalBinding(record, model, root, path, diagnostics) {
769
- const bindingField = approvalBindingField(record, model);
770
- if (!bindingField || !approvalBound(record) || !record[bindingField]) return;
886
+ const bindingFields = contentBindingFields(record, model);
887
+ for (const binding of bindingFields) {
888
+ await validateContentBinding(record, model, root, path, diagnostics, binding);
889
+ }
890
+ }
891
+
892
+ async function validateContentBinding(record, model, root, path, diagnostics, binding) {
893
+ const { field: bindingField, bound, label } = binding;
894
+ if (!bound(record) || !record[bindingField]) return;
771
895
  const actual = {};
772
896
  for (const item of markdownEntries(model, record)) {
773
897
  try {
@@ -787,24 +911,34 @@ async function validateApprovalBinding(record, model, root, path, diagnostics) {
787
911
  diagnostics.push(error(
788
912
  "approval-content-changed",
789
913
  path,
790
- `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.`
914
+ `${label} content no longer matches ${invalid.map((item) => `data/${item}`).join(", ")}. Move the record to draft or in-review, review the change, then approve and activate it again.`
791
915
  ));
792
916
  }
793
917
  }
794
918
 
795
919
  function approvalBound(record) {
796
920
  if (record.type === "policy") return ["approved", "active", "superseded", "retired"].includes(record.status);
797
- if (record.type === "document") return ["active", "superseded", "retired"].includes(record.status);
921
+ if (record.type === "document") return ["approved", "active", "superseded", "retired"].includes(record.status);
798
922
  if (record.type === "training") return ["active", "retired"].includes(record.status);
799
923
  return false;
800
924
  }
801
925
 
802
- function approvalBindingField(record, model) {
803
- if (["policy", "document"].includes(record.type)) return "approvedContentRevisions";
926
+ function contentBindingFields(record, model) {
927
+ const fields = [];
928
+ if (["policy", "document"].includes(record.type)) {
929
+ fields.push({ field: "approvedContentRevisions", bound: approvalBound, label: "Approved" });
930
+ }
931
+ if (record.type === "document" && model.resources.document?.fields?.activatedContentRevisions) {
932
+ fields.push({
933
+ field: "activatedContentRevisions",
934
+ bound: (candidate) => ["active", "superseded", "retired"].includes(candidate?.status),
935
+ label: "Activated"
936
+ });
937
+ }
804
938
  if (record.type === "training" && model.resources.training?.fields?.effectiveContentRevisions) {
805
- return "effectiveContentRevisions";
939
+ fields.push({ field: "effectiveContentRevisions", bound: approvalBound, label: "Approved" });
806
940
  }
807
- return null;
941
+ return fields;
808
942
  }
809
943
 
810
944
  function validateCoverage(record, path, diagnostics) {
@@ -858,7 +992,7 @@ function validateCoverage(record, path, diagnostics) {
858
992
 
859
993
  function validateClassification(record, loaded, path, diagnostics) {
860
994
  if (!record.classificationId) return;
861
- if (String(loaded.model.modelVersion) === "4") {
995
+ if (modelSupports(loaded.model, "program-scope")) {
862
996
  if (!loaded.resources.some(({ id, type }) => id === record.classificationId && type === "classification")) {
863
997
  diagnostics.push(error(
864
998
  "unknown-classification",