filegrc 0.4.0 → 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,185 @@
1
+ import { createHash } from "node:crypto";
2
+ import { applyResourceBatch } from "./files.js";
3
+ import { getGitSummary } from "./git.js";
4
+ import { loadWorkspace } from "./workspace.js";
5
+
6
+ export function collectionRevision(loaded, resourceType) {
7
+ const records = loaded.entries
8
+ .filter(({ record }) => record.type === resourceType)
9
+ .map(({ record, source }) => ({
10
+ id: record.id,
11
+ revision: createHash("sha256").update(source).digest("hex")
12
+ }))
13
+ .sort((left, right) => left.id.localeCompare(right.id));
14
+ const workspaceScope = {
15
+ assuranceGoal: loaded.workspace?.assuranceGoal ?? null,
16
+ candidateCoverage: loaded.workspace?.candidateCoverage ?? null,
17
+ systemIds: [...(loaded.workspace?.systemIds || [])].sort(),
18
+ frameworkIds: [...(loaded.workspace?.frameworkIds || [])].sort(),
19
+ requirementIds: [...(loaded.workspace?.requirementIds || [])].sort(),
20
+ controlIds: [...(loaded.workspace?.controlIds || [])].sort()
21
+ };
22
+ return createHash("sha256")
23
+ .update(JSON.stringify({ resourceType, records, workspaceScope }))
24
+ .digest("hex");
25
+ }
26
+
27
+ export function assessCollectionReviews(input) {
28
+ const loaded = input?.resources && input?.model && input?.entries
29
+ ? input
30
+ : null;
31
+ if (!loaded) throw new Error("Collection review assessment requires a loaded workspace.");
32
+ return Object.keys(loaded.model.collectionReviews || {})
33
+ .map((resourceType) => assessCollectionReview(loaded, resourceType));
34
+ }
35
+
36
+ export function assessCollectionReview(loaded, resourceType) {
37
+ const configuration = loaded.model.collectionReviews?.[resourceType];
38
+ if (!configuration) return null;
39
+ const records = loaded.resources.filter((record) => record.type === resourceType);
40
+ const reviewEntry = loaded.entries.find(({ record }) => (
41
+ record.type === "collection-review"
42
+ && record.resourceType === resourceType
43
+ && record.status !== "retired"
44
+ ));
45
+ const review = reviewEntry?.record || null;
46
+ const currentRevision = collectionRevision(loaded, resourceType);
47
+ const allowedDecisions = configuration.decisions || ["complete"];
48
+ const complete = Boolean(
49
+ review?.status === "active"
50
+ && allowedDecisions.includes(review.decision)
51
+ && review.collectionRevision === currentRevision
52
+ );
53
+ const stale = Boolean(
54
+ review?.status === "active"
55
+ && review.collectionRevision
56
+ && review.collectionRevision !== currentRevision
57
+ );
58
+ return {
59
+ resourceType,
60
+ configuration,
61
+ records,
62
+ recordCount: records.length,
63
+ review,
64
+ reviewRevision: reviewEntry
65
+ ? createHash("sha256").update(reviewEntry.source).digest("hex")
66
+ : null,
67
+ collectionRevision: currentRevision,
68
+ status: complete ? "current" : stale ? "stale" : "review-required",
69
+ complete,
70
+ message: complete
71
+ ? `${configuration.title} were reviewed on ${review.reviewedOn}.`
72
+ : stale
73
+ ? `${configuration.title} changed after the last confirmation. Review the current records again.`
74
+ : `Review ${configuration.title.toLowerCase()} before this page can be ready.`
75
+ };
76
+ }
77
+
78
+ export async function scaffoldCollectionReview(input = process.cwd(), options = {}) {
79
+ const loaded = await loadWorkspace(input);
80
+ const resourceType = requiredType(loaded, options.resourceType);
81
+ const assessment = assessCollectionReview(loaded, resourceType);
82
+ const allowedDecisions = assessment.configuration.decisions || ["complete"];
83
+ return {
84
+ resourceType,
85
+ decision: assessment.records.length
86
+ ? "complete"
87
+ : allowedDecisions.includes("zero-population") ? "zero-population" : null,
88
+ rationale: null,
89
+ reviewedByIds: [],
90
+ reviewedOn: null,
91
+ authoritativeSystemId: null
92
+ };
93
+ }
94
+
95
+ export async function planCollectionReview(input = process.cwd(), options = {}) {
96
+ const loaded = await loadWorkspace(input);
97
+ const resourceType = requiredType(loaded, options.resourceType);
98
+ const assessment = assessCollectionReview(loaded, resourceType);
99
+ const configuration = assessment.configuration;
100
+ const decision = String(options.decision || "").trim();
101
+ const rationale = String(options.rationale || "").trim();
102
+ const reviewedByIds = [...new Set((options.reviewedByIds || []).map(String).filter(Boolean))];
103
+ const reviewedOn = String(options.reviewedOn || "").trim();
104
+ const scopeRevision = String(options.scopeRevision || getGitSummary(loaded.root).commit || "uncommitted").trim();
105
+ const authoritativeSystemId = String(options.authoritativeSystemId || "").trim();
106
+ if (!(configuration.decisions || ["complete"]).includes(decision)) {
107
+ throw new Error(
108
+ `${configuration.title} review must use one of: ${(configuration.decisions || ["complete"]).join(", ")}.`
109
+ );
110
+ }
111
+ if (!assessment.records.length && decision === "complete") {
112
+ throw new Error(`${configuration.title} has no records. Use zero-population or another allowed conclusion.`);
113
+ }
114
+ if (assessment.records.length && decision === "zero-population") {
115
+ throw new Error(`${configuration.title} has ${assessment.records.length} records and cannot be confirmed as a zero population.`);
116
+ }
117
+ if (!rationale || !reviewedByIds.length || !reviewedOn) {
118
+ throw new Error(`${configuration.title} review needs review notes, a reviewer, and a review date.`);
119
+ }
120
+ if (decision === "externally-managed") {
121
+ const system = loaded.resources.find((record) => (
122
+ record.type === "system"
123
+ && record.id === authoritativeSystemId
124
+ && record.status === "active"
125
+ ));
126
+ if (!system) throw new Error(`${configuration.title} review needs an active authoritative System.`);
127
+ }
128
+ const existing = assessment.review;
129
+ const record = {
130
+ ...(existing || {
131
+ id: `collection-review-${resourceType}`,
132
+ type: "collection-review",
133
+ title: `${configuration.title} review`,
134
+ resourceType,
135
+ scopeResourceIds: [loaded.workspace.id]
136
+ }),
137
+ status: "active",
138
+ decision,
139
+ rationale,
140
+ reviewedByIds,
141
+ reviewedOn,
142
+ collectionRevision: assessment.collectionRevision,
143
+ scopeRevision,
144
+ ...(decision === "externally-managed" ? { authoritativeSystemId } : {})
145
+ };
146
+ if (decision !== "externally-managed") delete record.authoritativeSystemId;
147
+ return {
148
+ operation: "collection-review",
149
+ resourceType,
150
+ assessment,
151
+ changes: {
152
+ ...(existing ? { update: [record] } : { create: [record] }),
153
+ ...(existing ? {
154
+ expectedRevisions: {
155
+ [existing.id]: options.expectedRevision || assessment.reviewRevision
156
+ }
157
+ } : {}),
158
+ validateWholeWorkspace: true
159
+ }
160
+ };
161
+ }
162
+
163
+ export async function applyCollectionReview(input = process.cwd(), options = {}) {
164
+ if (options.confirmed !== true) {
165
+ throw new Error("Preview the collection review and confirm the write.");
166
+ }
167
+ const plan = await planCollectionReview(input, options);
168
+ const result = await applyResourceBatch(input, plan.changes);
169
+ const loaded = await loadWorkspace(input);
170
+ return {
171
+ ...plan,
172
+ result,
173
+ assessment: assessCollectionReview(loaded, plan.resourceType)
174
+ };
175
+ }
176
+
177
+ function requiredType(loaded, value) {
178
+ const resourceType = String(value || "").trim();
179
+ if (!loaded.model.collectionReviews?.[resourceType]) {
180
+ throw new Error(
181
+ `Collection review type must be one of: ${Object.keys(loaded.model.collectionReviews || {}).join(", ")}.`
182
+ );
183
+ }
184
+ return resourceType;
185
+ }
@@ -884,6 +884,14 @@ function buildControlCoverage({ audit, byId, controlIds, evidenceIds, model, rec
884
884
  && controlIdsForRecord(record, byId).has(controlId)
885
885
  && packetRecord(record, model, start, end, timezone)
886
886
  ));
887
+ const zeroPopulationRecords = records.filter((record) => (
888
+ record.type === "audit-population"
889
+ && record.auditId === audit?.id
890
+ && record.status === "reconciled"
891
+ && record.conclusion === "complete"
892
+ && (record.controlIds || []).includes(controlId)
893
+ && byId.get(record.sourceEvidenceId)?.populationCount === 0
894
+ ));
887
895
  const linkedEvidenceIds = new Set(evidenceByControl.get(controlId) || []);
888
896
  for (const test of tests) {
889
897
  addIds(linkedEvidenceIds, test.evidenceIds);
@@ -909,7 +917,7 @@ function buildControlCoverage({ audit, byId, controlIds, evidenceIds, model, rec
909
917
  .map(({ id }) => id)
910
918
  .sort(),
911
919
  evidenceIds: [...linkedEvidenceIds].sort(),
912
- operatingRecordIds: operatingRecords.map(({ id }) => id).sort(),
920
+ operatingRecordIds: [...operatingRecords, ...zeroPopulationRecords].map(({ id }) => id).sort(),
913
921
  tests: tests.map((test) => ({
914
922
  ...testPopulationSummary(test, byId),
915
923
  id: test.id,
@@ -935,6 +943,17 @@ function controlIdsForRecord(record, byId, seen = new Set()) {
935
943
  for (const sourceId of record.sourceResourceIds || []) addIds(ids, controlIdsForRecord(byId.get(sourceId), byId, seen));
936
944
  if (record.sourceResourceId) addIds(ids, controlIdsForRecord(byId.get(record.sourceResourceId), byId, seen));
937
945
  if (record.obligationId) addIds(ids, byId.get(record.obligationId)?.controlIds);
946
+ for (const candidate of byId.values()) {
947
+ if (
948
+ candidate.type === "obligation"
949
+ && (candidate.completionResourceIds || []).includes(record.id)
950
+ ) {
951
+ addIds(ids, candidate.controlIds);
952
+ }
953
+ }
954
+ for (const subjectId of record.subjectResourceIds || []) {
955
+ addIds(ids, controlIdsForRecord(byId.get(subjectId), byId, seen));
956
+ }
938
957
  return ids;
939
958
  }
940
959
 
@@ -1003,7 +1022,13 @@ function packetGaps({
1003
1022
  } else if (!controlNeedsExternalEvidence(control, model) && !coverage.operatingRecordIds.length) {
1004
1023
  gaps.push(gap("error", "control-missing-filegrc-evidence", `${coverage.code || coverage.title} has no dated filegrc operating record in the packet.`, coverage.id));
1005
1024
  }
1006
- if (audit?.auditKind !== "soc-2-type-1" && coverage.status === "implemented" && !coverage.operatingRecordIds.length && coverage.operationMode !== "automated") {
1025
+ if (
1026
+ audit?.auditKind !== "soc-2-type-1"
1027
+ && coverage.status === "implemented"
1028
+ && !coverage.operatingRecordIds.length
1029
+ && !coverage.evidenceIds.length
1030
+ && coverage.operationMode !== "automated"
1031
+ ) {
1007
1032
  gaps.push(gap("warning", "control-missing-operating-record", `${coverage.code || coverage.title} has no dated operating record in the packet period.`, coverage.id));
1008
1033
  }
1009
1034
  for (const test of coverage.tests) {
@@ -1151,6 +1176,13 @@ function packetGaps({
1151
1176
  `${run.title}: ${action.title} was canceled. Complete the requirement or cancel the event with a documented reason.`,
1152
1177
  action.actionItemId
1153
1178
  ));
1179
+ } else if (action.status === "blocked") {
1180
+ gaps.push(gap(
1181
+ "error",
1182
+ "blocked-event-action",
1183
+ `${run.title}: ${action.title} is blocked. ${action.blockingReason || "Open the Action Item and resolve its named blockers."}`,
1184
+ action.actionItemId
1185
+ ));
1154
1186
  } else if (action.missingCompletion) {
1155
1187
  gaps.push(gap(
1156
1188
  "error",
@@ -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,7 +1,7 @@
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";
4
+ import { getResourceDefinition, loadModel } from "../model/index.js";
5
5
  import { serializeWorkspaceMutation, workspaceValidationDeferred } from "./mutation.js";
6
6
  import { isCanonicalDataPath, resolveDataPath, resolveWorkspaceRoot } from "./paths.js";
7
7
  import { markdownEntries } from "./resource-markdown.js";
@@ -214,6 +214,31 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
214
214
  throw new Error("Batch expected revisions must be keyed by resource ID.");
215
215
  }
216
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;
217
242
  const deferValidation = workspaceValidationDeferred();
218
243
  const before = deferValidation || changes.validateWholeWorkspace
219
244
  ? null
@@ -224,7 +249,7 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
224
249
  for (const record of creates) {
225
250
  validateBatchRecord(record, ids);
226
251
  if (existingById.has(record.id)) throw new Error(`Resource "${record.id}" already exists.`);
227
- const path = resourcePath(loaded.root, loaded.model, record);
252
+ const path = resourcePath(loaded.root, writeModel, record);
228
253
  writes.push({ operation: "create", path, record, previous: null, fileMode: 0o666 });
229
254
  }
230
255
  for (const record of updates) {
@@ -234,7 +259,7 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
234
259
  if (existing.record.type !== record.type) {
235
260
  throw new Error(`Resource "${record.id}" cannot change type.`);
236
261
  }
237
- const path = resourcePath(loaded.root, loaded.model, record);
262
+ const path = resourcePath(loaded.root, writeModel, record);
238
263
  const previous = await readFile(path, "utf8");
239
264
  const mode = (await stat(path)).mode & 0o777;
240
265
  assertRevision(
@@ -643,14 +668,15 @@ async function prepareApprovalBinding(loaded, record, contentWrites, previousRec
643
668
  if (record.type === "attestation") {
644
669
  return prepareAttestationBinding(loaded, record, previousRecord);
645
670
  }
646
- if (!["policy", "document"].includes(record.type)) return record;
671
+ const bindingField = approvalBindingField(record, loaded.model);
672
+ if (!bindingField) return record;
647
673
  const nextRecord = structuredClone(record);
648
674
  if (!approvalBound(record)) {
649
- delete nextRecord.approvedContentRevisions;
675
+ delete nextRecord[bindingField];
650
676
  return nextRecord;
651
677
  }
652
- if (approvalBound(previousRecord) && previousRecord.approvedContentRevisions) {
653
- nextRecord.approvedContentRevisions = structuredClone(previousRecord.approvedContentRevisions);
678
+ if (approvalBound(previousRecord) && previousRecord[bindingField]) {
679
+ nextRecord[bindingField] = structuredClone(previousRecord[bindingField]);
654
680
  return nextRecord;
655
681
  }
656
682
  const proposed = new Map(contentWrites.map((item) => [item.dataRelativePath, item.source]));
@@ -667,7 +693,7 @@ async function prepareApprovalBinding(loaded, record, contentWrites, previousRec
667
693
  }
668
694
  revisions[item.path] = contentRevision(source);
669
695
  }
670
- nextRecord.approvedContentRevisions = revisions;
696
+ nextRecord[bindingField] = revisions;
671
697
  return nextRecord;
672
698
  }
673
699
 
@@ -704,13 +730,23 @@ async function prepareAttestationBinding(loaded, record, previousRecord = null)
704
730
  }
705
731
 
706
732
  function approvalBound(record) {
707
- if (!record || !["policy", "document"].includes(record.type)) return false;
733
+ if (!record || !["policy", "document", "training"].includes(record.type)) return false;
708
734
  const statuses = record.type === "policy"
709
735
  ? ["approved", "active", "superseded", "retired"]
710
- : ["active", "superseded", "retired"];
736
+ : record.type === "document"
737
+ ? ["active", "superseded", "retired"]
738
+ : ["active", "retired"];
711
739
  return statuses.includes(record.status);
712
740
  }
713
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
+
714
750
  async function exclusiveContentFiles(loaded, record) {
715
751
  const candidates = markdownEntries(loaded.model, record).map(({ path }) => path);
716
752
  const files = [];
package/src/index.js CHANGED
@@ -1,6 +1,25 @@
1
- export { getResourceDefinition, loadModel } from "../model/index.js";
1
+ export {
2
+ ACTIVE_MODEL_VERSION,
3
+ getResourceDefinition,
4
+ loadModel,
5
+ SUPPORTED_MODEL_VERSIONS
6
+ } from "../model/index.js";
2
7
  export { buildAgentGuide, findResourceReferences, listResourceTypes, scaffoldResourceMutation } from "./agent.js";
3
8
  export { assessAuditPreparation, prepareAuditWorkspace } from "./audit-preparation.js";
9
+ export { createNextAuditCycle, planNextAuditCycle } from "./audit-transition.js";
10
+ export {
11
+ applyApplicabilityReview,
12
+ planApplicabilityReview,
13
+ scaffoldApplicabilityReview
14
+ } from "./batch-review.js";
15
+ export {
16
+ applyCollectionReview,
17
+ assessCollectionReview,
18
+ assessCollectionReviews,
19
+ collectionRevision,
20
+ planCollectionReview,
21
+ scaffoldCollectionReview
22
+ } from "./collection-review.js";
4
23
  export { buildWorkspace } from "./build.js";
5
24
  export { generateEvidencePacket, prepareEvidencePacket, writeEvidencePacket } from "./evidence-packet.js";
6
25
  export {
@@ -35,13 +54,20 @@ export {
35
54
  completeObligationEvent,
36
55
  completeObligationOccurrence,
37
56
  createObligationEvent,
38
- planObligations
57
+ planObligations,
58
+ scaffoldObligationCompletion
39
59
  } from "./obligations.js";
60
+ export {
61
+ planExternalReviewerGovernance,
62
+ scaffoldExternalReviewerGovernance,
63
+ setupExternalReviewerGovernance
64
+ } from "./external-reviewer.js";
40
65
  export { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
41
66
  export {
42
67
  buildAgentProgramPath,
43
68
  PROGRAM_PATH,
44
69
  RESOURCE_INSTRUCTIONS,
70
+ RESOURCE_PAGE_SUMMARIES,
45
71
  resourceProgramContext
46
72
  } from "./program-path.js";
47
73
  export {
@@ -53,9 +79,17 @@ export {
53
79
  } from "./recurrence.js";
54
80
  export { searchResources, searchableValues } from "./search.js";
55
81
  export { effectiveResourceStatus } from "./resource-status.js";
82
+ export { applyReconciliation, planReconciliation } from "./reconciliation.js";
56
83
  export { createFilegrcServer, serveWorkspace } from "./server.js";
57
84
  export { normalizeSetupPayload, planWorkspaceSetup, setupWorkspace, summarizeSetupResult } from "./setup.js";
58
85
  export { createAppState, createResourceDetail } from "./state.js";
59
86
  export { currentCalendarDate, formatCalendarDate, formatLocalDateTime } from "./time.js";
60
87
  export { validateWorkspace } from "./validate.js";
88
+ export {
89
+ assessWorkflow,
90
+ buildWorkflowDelta,
91
+ previewWorkflowMutation,
92
+ workflowForResource,
93
+ WORKFLOW_CONTRACT_VERSION
94
+ } from "./workflow.js";
61
95
  export { indexResources, loadWorkspace } from "./workspace.js";
package/src/model-docs.js CHANGED
@@ -45,6 +45,16 @@ export function generateModelDocumentation(model) {
45
45
  `| \`${name}\` | ${types.map((type) => `\`${type}\``).join(", ")} |`
46
46
  )),
47
47
  "",
48
+ "## Collection review confirmations",
49
+ "",
50
+ "FileGRC derives record issues, but it cannot infer that management reviewed an apparently complete or empty collection. Each configured collection review records the conclusion, reviewer, date, current scope revision, and exact collection revision. A record or material scope change makes the confirmation stale.",
51
+ "",
52
+ "| Resource type | Review | Allowed conclusions | What to review |",
53
+ "| --- | --- | --- | --- |",
54
+ ...Object.entries(model.collectionReviews || {}).map(([type, review]) => (
55
+ `| \`${type}\` | ${escapeCell(review.title)} | ${review.decisions.map((decision) => `\`${decision}\``).join(", ")} | ${escapeCell(review.reviewPoints.join(" "))} |`
56
+ )),
57
+ "",
48
58
  "## Relationship constraints",
49
59
  "",
50
60
  "Relationship constraints prevent cycles and duplicate active authority or access records.",
@@ -130,6 +140,11 @@ export function generateModelDocumentation(model) {
130
140
  lines.push(`Instructions: ${RESOURCE_INSTRUCTIONS[type] || resource.description}`, "");
131
141
  lines.push(`Policy basis: ${resource.guidance.policyBasis}`, "");
132
142
  lines.push(`Timing: ${resource.guidance.cadence}`, "");
143
+ if (resource.guidance.reviewPoints?.length) {
144
+ lines.push("When reviewing:", "");
145
+ for (const point of resource.guidance.reviewPoints) lines.push(`- ${point}`);
146
+ lines.push("");
147
+ }
133
148
  if (resource.guidance.sourceResourceIds?.length) {
134
149
  lines.push(`Default sources: ${resource.guidance.sourceResourceIds.map((id) => `\`${id}\``).join(", ")}`, "");
135
150
  }