filegrc 0.13.5 → 0.15.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.
@@ -3,17 +3,21 @@ import { serializeWorkspaceMutation } from "./mutation.js";
3
3
  import { assessProgramReadiness } from "./program-readiness.js";
4
4
  import { currentCalendarDate } from "./time.js";
5
5
  import { loadWorkspace } from "./workspace.js";
6
+ import { modelSupports } from "../model/index.js";
6
7
 
7
8
  export async function scaffoldPolicyActivation(input = process.cwd(), options = {}) {
8
9
  const loaded = await loadWorkspace(input);
9
10
  const readiness = await assessProgramReadiness(loaded, { programId: options.programId });
10
- const approved = readiness.policyActivations.filter(({ state }) => (
11
+ const oversightReady = controlOversightComplete(loaded, readiness);
12
+ const approved = (oversightReady ? readiness.policyActivations : []).filter(({ state }) => (
11
13
  ["approved-implementation-pending", "ready-to-activate"].includes(state)
12
14
  ));
13
15
  const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)]));
14
16
  return {
15
17
  available: approved.length > 0,
16
- message: approved.length
18
+ message: !oversightReady
19
+ ? "Complete the current Control collection review before Program Content Activation."
20
+ : approved.length
17
21
  ? `${approved.length} approved ${approved.length === 1 ? "Policy is" : "Policies are"} available for the Step 3 cutover.`
18
22
  : "No Policy is ready for activation. Finish Step 2 approval, then resolve its Step 3 implementation gaps.",
19
23
  nextCommand: approved.length ? null : "npx filegrc program-path --next --json",
@@ -26,6 +30,10 @@ export async function scaffoldPolicyActivation(input = process.cwd(), options =
26
30
 
27
31
  export async function planPolicyActivation(input = process.cwd(), options = {}) {
28
32
  const loaded = await loadWorkspace(input);
33
+ const readiness = await assessProgramReadiness(loaded, { programId: options.programId });
34
+ if (!controlOversightComplete(loaded, readiness)) {
35
+ throw new Error("Complete the current Control collection review before Program Content Activation.");
36
+ }
29
37
  const policyIds = [...new Set((options.policyIds || []).map(String))];
30
38
  if (!policyIds.length) throw new Error("Policy activation needs at least one approved Policy.");
31
39
  const effectiveOn = String(options.effectiveOn || "").trim();
@@ -67,6 +75,12 @@ export async function planPolicyActivation(input = process.cwd(), options = {})
67
75
  };
68
76
  }
69
77
 
78
+ function controlOversightComplete(loaded, readiness) {
79
+ if (!modelSupports(loaded.model, "control-collection-oversight") || !readiness.scope.controlIds.length) return true;
80
+ return readiness.stages.find(({ id }) => id === "controls")?.items
81
+ .find(({ id }) => id === "collection-review-control")?.status === "complete";
82
+ }
83
+
70
84
  export async function activatePolicies(input = process.cwd(), options = {}) {
71
85
  if (options.confirmed !== true) throw new Error("Review the Policy activation cutover and confirm the write.");
72
86
  return serializeWorkspaceMutation(input, async (root) => {
@@ -1,3 +1,5 @@
1
+ import { ACTIVE_MODEL_VERSION, modelSupports } from "../model/index.js";
2
+
1
3
  export const RESOURCE_INSTRUCTIONS = {
2
4
  program: "Define one management compliance or assurance Program with its goal, bounded Systems, selected Frameworks, Requirement applicability decisions, Controls, owners, risk method, and candidate period.",
3
5
  person: "Record each person’s actual organizational job title. Keep named program authority, such as CISO, DPO, Policy Owner, or team chair, in dated Appointment records.",
@@ -105,13 +107,14 @@ export const PROGRAM_PATH = [
105
107
  id: "policies",
106
108
  number: 2,
107
109
  title: "Approve Policies",
108
- description: "Review governed content and approvals",
109
- summary: "Review and independently approve Policies, program Documents, and Training content.",
110
+ description: "Approve governed content and retention decisions",
111
+ summary: "Review and independently approve Policies, program Documents, Training content, and the Data Retention Schedule.",
110
112
  sections: [
111
- { id: "policy-content", title: "Policies", description: "Review every program Policy, Document, and Training record in one table, then bind independent approval to each exact revision.", steps: ["Review each governed Markdown artifact and replace every organization placeholder.", "Confirm the owner, separate approver, linked Controls, audience, and intended values that apply to each artifact.", "Record approval and its date against the exact revision. Leave approved content inactive until its Step 3 implementation cutover."], types: [], relatedLinks: [{ type: "policy", label: "Policies", href: "#/stage/policies" }], defaultOpen: true }
113
+ { id: "policy-content", title: "Policies", description: "Review every program Policy, Document, and Training record in one table, then bind independent approval to each exact revision.", steps: ["Review each governed Markdown artifact and replace every organization placeholder.", "Confirm the owner, separate approver, linked Controls, audience, and intended values that apply to each artifact.", "Record approval and its date against the exact revision. Leave approved content inactive until its Step 3 implementation cutover."], types: [], relatedLinks: [{ type: "policy", label: "Policies", href: "#/policies" }], defaultOpen: true },
114
+ { id: "retention", title: "Data Retention Schedule", description: "Approve the schedule document and its structured rows as one governed schedule.", steps: ["Review the schedule policy, legal holds, exceptions, and disposal requirements.", "Define rows only for Information Types and operational sources the organization actually uses.", "Confirm the complete document-and-row revision with one schedule review."], types: [], utility: "retention-schedule", defaultOpen: true }
112
115
  ],
113
116
  resourceTypes: [],
114
- supportingResourceTypes: ["policy", "document", "training"],
117
+ supportingResourceTypes: ["policy", "document", "training", "retention-schedule-item"],
115
118
  commands: [
116
119
  "filegrc guide policy --json",
117
120
  "filegrc guide document --json",
@@ -119,29 +122,33 @@ export const PROGRAM_PATH = [
119
122
  "filegrc list policy --json",
120
123
  "filegrc list document --json",
121
124
  "filegrc list training --json",
122
- "filegrc get POLICY_ID --mutation"
125
+ "filegrc get POLICY_ID --mutation",
126
+ "filegrc guide retention-schedule-item --json",
127
+ "filegrc review-collection retention-schedule-item --scaffold"
123
128
  ]
124
129
  },
125
130
  {
126
131
  id: "controls",
127
132
  number: 3,
128
133
  title: "Implement Controls",
129
- description: "Finish controls and their evidence sources",
130
- summary: "Describe each Control and connect its evidence source.",
134
+ description: "Implement and prepare to operate",
135
+ summary: "Implement Controls, configure their operating schedules and evidence sources, then activate the approved program.",
131
136
  sections: [
132
- { id: "catalog", title: "Control Catalog", description: "Implement approved requirements, configure retention and Obligations, activate approved program content, finish authoritative evidence sources, then activate the Policies at cutover.", steps: ["Open every planned Control and confirm its mappings and operation pattern.", "Write the real procedure in Record Markdown, add bounded System scope, and map the operating and authoritative evidence-source Components.", "Review every Retention Schedule Item against current information uses and approved sources. Do not infer periods or disposition behavior.", "Review and enable every calendar and event schedule as an Obligation. Enabled work remains dormant until its governing Policy is active.", "Confirm each source Component is active, has an evidence-source role and rationale in the Control's System scope, has current access owners, and includes repeatable retrieval instructions in Record Markdown.", "Implement every requirement linked from an approved program Document or Training record, then activate the unchanged approved revisions with separate activation dates and bindings.", "Use the activation review to inspect planned or partial Controls, inactive governed content, missing Components or sources, missing schedules, and unresolved Exceptions.", "Choose the approved Policies that should take effect, set the real effective date, and confirm the Step 3 cutover."], types: ["control", "complementary-control", "retention-schedule-item", "obligation"], defaultOpen: true }
137
+ { id: "controls", title: "Controls", description: "Implement the selected Controls and connect their scope, procedures, mappings, and operation patterns.", steps: ["Open every planned Control and confirm its mappings and operation pattern.", "Write the real procedure in Record Markdown, add bounded System scope, and map the operating and authoritative evidence-source Components."], types: ["control"], defaultOpen: true },
138
+ { id: "complementary-controls", title: "Complementary Controls", description: "Record customer or provider responsibilities that form part of the implementation boundary, or confirm there are none.", steps: ["Review whether an in-scope Control depends on a customer or carved-out provider action."], types: ["complementary-control"], defaultOpen: true },
139
+ { id: "evidence-sources", title: "Evidence Sources", description: "Check that every Control points to an authoritative source that can produce its expected evidence.", steps: ["Confirm each source Component is active, has the right evidence-source role, names current access owners, and includes repeatable retrieval instructions."], types: [], utility: "evidence-sources", defaultOpen: true },
140
+ { id: "obligations", title: "Obligations", description: "Configure the calendar and event schedules that operate the Controls.", steps: ["Review and enable every required calendar and event schedule."], types: ["obligation"], defaultOpen: true }
133
141
  ],
134
- resourceTypes: ["control", "complementary-control", "retention-schedule-item", "obligation"],
142
+ resourceTypes: ["control", "complementary-control", "obligation"],
135
143
  commands: [
136
144
  "filegrc guide control --json",
137
145
  "filegrc list control --json",
138
146
  "filegrc get CONTROL_ID --mutation",
139
147
  "filegrc guide obligation --json",
140
- "filegrc guide retention-schedule-item --json",
141
- "filegrc review-collection retention-schedule-item --scaffold",
142
148
  "filegrc list obligation --json",
143
149
  "filegrc review-collection component --scaffold",
144
150
  "filegrc review-collection complementary-control --scaffold",
151
+ "filegrc review-collection control --scaffold",
145
152
  "filegrc activate-content --scaffold",
146
153
  "filegrc activate-policies --scaffold",
147
154
  "filegrc evidence-map --json",
@@ -259,8 +266,51 @@ export const PROGRAM_PATH = [
259
266
  }
260
267
  ];
261
268
 
262
- export function buildAgentProgramPath(model) {
269
+ export function programPathForModel(model = ACTIVE_MODEL_VERSION) {
270
+ if (modelSupports(model, "retention-schedule-approval")) return PROGRAM_PATH;
263
271
  return PROGRAM_PATH.map((stage) => {
272
+ if (stage.id === "policies") {
273
+ return {
274
+ ...stage,
275
+ description: "Review governed content and approvals",
276
+ summary: "Review and independently approve Policies, program Documents, and Training content.",
277
+ sections: [{
278
+ ...stage.sections[0],
279
+ relatedLinks: [{ type: "policy", label: "Policies", href: "#/stage/policies" }]
280
+ }],
281
+ supportingResourceTypes: stage.supportingResourceTypes.filter((type) => type !== "retention-schedule-item"),
282
+ commands: stage.commands.filter((command) => !command.includes("retention-schedule-item"))
283
+ };
284
+ }
285
+ if (stage.id === "controls") {
286
+ const retention = {
287
+ id: "retention",
288
+ title: "Retention Schedule",
289
+ description: "Review each retention rule against current information uses and approved sources.",
290
+ steps: ["Confirm scope, cutoff, period, disposition, sources, owner, and approval for each rule."],
291
+ types: ["retention-schedule-item"],
292
+ defaultOpen: true
293
+ };
294
+ return {
295
+ ...stage,
296
+ description: "Finish controls and their evidence sources",
297
+ summary: "Describe each Control and connect its evidence source.",
298
+ sections: [...stage.sections.slice(0, 3), retention, ...stage.sections.slice(3)],
299
+ resourceTypes: [...stage.resourceTypes.slice(0, 2), "retention-schedule-item", ...stage.resourceTypes.slice(2)],
300
+ commands: [
301
+ ...stage.commands.slice(0, 5),
302
+ "filegrc guide retention-schedule-item --json",
303
+ "filegrc review-collection retention-schedule-item --scaffold",
304
+ ...stage.commands.slice(5)
305
+ ]
306
+ };
307
+ }
308
+ return stage;
309
+ });
310
+ }
311
+
312
+ export function buildAgentProgramPath(model) {
313
+ return programPathForModel(model).map((stage) => {
264
314
  const programResourceTypes = [...stage.resourceTypes, ...(stage.supportingResourceTypes || [])]
265
315
  .filter((type) => model.resources[type]);
266
316
  const resourcePages = programResourceTypes.map((type, index) => {
@@ -300,8 +350,8 @@ function agentCommand(command) {
300
350
  return command.startsWith("filegrc ") ? `npx ${command}` : command;
301
351
  }
302
352
 
303
- export function resourceProgramContext(type) {
304
- const stage = PROGRAM_PATH.find((candidate) => (
353
+ export function resourceProgramContext(type, model) {
354
+ const stage = programPathForModel(model).find((candidate) => (
305
355
  candidate.resourceTypes.includes(type) || (candidate.supportingResourceTypes || []).includes(type)
306
356
  ));
307
357
  if (!stage) return null;
@@ -57,14 +57,20 @@ export async function assessProgramReadiness(input, options = {}) {
57
57
  .map(collectionReviewReadinessItem));
58
58
  const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
59
59
  controlStage.items.push(...sourceStage.items);
60
- controlStage.items.push(...await assessRetentionReadiness(loaded, program, {
60
+ const retentionItems = await assessRetentionReadiness(loaded, program, {
61
61
  informationTypesReviewed: collectionReviews.find(({ resourceType }) => resourceType === "information-type")?.complete === true
62
- }));
63
- controlStage.items.push(...collectionReviews
62
+ });
63
+ const retentionReviewItems = collectionReviews
64
64
  .filter(({ resourceType }) => resourceType === "retention-schedule-item")
65
- .map(collectionReviewReadinessItem));
65
+ .map(collectionReviewReadinessItem);
66
+ if (modelSupports(loaded.model, "retention-schedule-approval")) {
67
+ policyStage.items.push(...retentionItems.filter(({ id }) => !id.startsWith("retention-source-coverage-")));
68
+ controlStage.items.push(...retentionItems.filter(({ id }) => id.startsWith("retention-source-coverage-")));
69
+ policyStage.items.push(...retentionReviewItems);
70
+ } else {
71
+ controlStage.items.push(...retentionItems, ...retentionReviewItems);
72
+ }
66
73
  const governedContent = await governedContentItems(scope, records, byId, readMarkdown, asOf, loaded.model);
67
- controlStage.items.push(...governedContent.items);
68
74
  const policyActivations = await assessPolicyActivations(
69
75
  requiredPolicies(scope, byId),
70
76
  scope.controls,
@@ -74,8 +80,22 @@ export async function assessProgramReadiness(input, options = {}) {
74
80
  asOf,
75
81
  loaded.model
76
82
  );
77
- controlStage.items.push(...policyActivations.map(policyActivationItem));
78
- controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, enabled Obligations, and complete authoritative source ${modelSupports(loaded.model, "component-sources") ? "Components" : "Systems"}. Activate unchanged approved program Documents and Training after their requirements are implemented, then activate approved Policies at the implementation cutover.`;
83
+ const controlOversight = collectionReviews.find(({ resourceType }) => resourceType === "control");
84
+ const oversightEligible = Boolean(controlOversight && controlOversightEligible(controlStage.items));
85
+ if (oversightEligible) {
86
+ controlStage.items.push(collectionReviewReadinessItem(controlOversight));
87
+ }
88
+ const oversightCurrent = oversightEligible && controlOversight.complete === true;
89
+ controlStage.items.push(...governedContent.items.map((item) => oversightCurrent || item.status === "complete"
90
+ ? item
91
+ : { ...item, status: "blocked", message: `Complete the Control collection review before activation. ${item.message}` }));
92
+ controlStage.items.push(...policyActivations.map((assessment) => {
93
+ const item = policyActivationItem(assessment);
94
+ return oversightCurrent || item.status === "complete"
95
+ ? item
96
+ : { ...item, status: "blocked", message: `Complete the Control collection review before activation. ${item.message}` };
97
+ }));
98
+ controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, enabled Obligations, and complete authoritative source ${modelSupports(loaded.model, "component-sources") ? "Components" : "Systems"}. When implementation is ready, perform one review of the implemented Control collection. Management then activates the unchanged approved program content as the Step 3 cutover.`;
79
99
  const scopeReadinessStage = scopeStage(
80
100
  program,
81
101
  scope,
@@ -166,6 +186,10 @@ export async function assessProgramReadiness(input, options = {}) {
166
186
  };
167
187
  }
168
188
 
189
+ export function controlOversightEligible(items) {
190
+ return items.length > 0 && items.every(({ status }) => status === "complete");
191
+ }
192
+
169
193
  export async function assessEvidenceMap(input, options = {}) {
170
194
  const readiness = await assessProgramReadiness(input, options);
171
195
  const evidenceItems = readiness.stages
@@ -883,6 +907,11 @@ function requiredGovernedDocuments(scope, records, byId, model) {
883
907
  && (
884
908
  linkedDocumentIds.has(record.id)
885
909
  || obligationDocumentIds.has(record.id)
910
+ || (
911
+ modelSupports(model, "retention-schedule-approval")
912
+ && record.documentKind === "schedule"
913
+ && record.workflowScope === "program"
914
+ )
886
915
  || (
887
916
  record.programRole === "required"
888
917
  && (record.controlIds || []).some((id) => selectedControlIds.has(id))
@@ -1300,12 +1329,7 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model) {
1300
1329
  implementationDate: Boolean(control.effectiveOn && control.effectiveOn <= asOf),
1301
1330
  ...(model.resources.control?.fields?.procedureRevision ? {
1302
1331
  procedureRevision: Boolean(control.procedureRevision),
1303
- procedureEffective: Boolean(control.procedureEffectiveOn && control.procedureEffectiveOn <= asOf),
1304
- implementationReview: Boolean(
1305
- control.implementationReviewedOn
1306
- && control.implementationReviewedOn <= asOf
1307
- && partiesIndependent(control.ownerIds, control.implementationReviewedByIds, byId)
1308
- )
1332
+ procedureEffective: Boolean(control.procedureEffectiveOn && control.procedureEffectiveOn <= asOf)
1309
1333
  } : {}),
1310
1334
  policyMapping: (control.policyIds || []).length > 0,
1311
1335
  criteriaMapping: (control.requirementIds || []).length > 0,
@@ -1699,7 +1723,6 @@ function controlCheckLabel(name) {
1699
1723
  implementationDate: "implementation date",
1700
1724
  procedureRevision: "effective procedure revision",
1701
1725
  procedureEffective: "procedure effective date",
1702
- implementationReview: "independent implementation review",
1703
1726
  policyMapping: "policy mapping",
1704
1727
  criteriaMapping: "criteria mapping",
1705
1728
  workQueue: "running Obligation schedules"
@@ -0,0 +1,121 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js";
4
+ import { markdownEntries } from "./resource-markdown.js";
5
+ import { resolveDataPath } from "./paths.js";
6
+ import { currentPartyPeople, partiesIndependent } from "./parties.js";
7
+ import {
8
+ nearDuplicateInformationTypes,
9
+ resourceReviewRevisionsSync,
10
+ retentionReviewResourceIds,
11
+ retentionRuleIsCurrent,
12
+ retentionUses
13
+ } from "./retention.js";
14
+
15
+ export function retentionScheduleApprovalIssues(loaded, program, rows, options = {}) {
16
+ const byId = new Map(loaded.resources.map((record) => [record.id, record]));
17
+ const issues = [];
18
+ const planned = rows.filter(({ status }) => status === "planned");
19
+ if (planned.length) {
20
+ issues.push(issue("planned-retention-schedule-row", `Complete or retire every planned retention row before approving the schedule revision: ${planned[0].title}.`));
21
+ }
22
+ const schedules = loaded.resources.filter((record) => (
23
+ record.type === "document"
24
+ && record.documentKind === "schedule"
25
+ && record.workflowScope === "program"
26
+ && !["superseded", "retired"].includes(record.status)
27
+ ));
28
+ if (!schedules.length) {
29
+ issues.push(issue("missing-retention-schedule-document", "Add and approve the Data Retention Schedule document before approving the schedule revision."));
30
+ }
31
+ if (schedules.length !== 1) {
32
+ if (schedules.length) {
33
+ issues.push(issue("multiple-retention-schedule-documents", "Keep exactly one current program Data Retention Schedule document before approving the schedule revision."));
34
+ }
35
+ }
36
+ const schedule = schedules.length === 1 ? schedules[0] : null;
37
+ const scheduleContent = schedule ? primaryMarkdown(loaded, schedule) : "";
38
+ const linkedControl = schedule && (schedule.controlIds || []).some((id) => (program.controlIds || []).includes(id));
39
+ if (
40
+ schedule
41
+ && (!["approved", "active"].includes(schedule.status)
42
+ || !schedule.approvedOn
43
+ || !schedule.approvedContentRevisions
44
+ || currentPartyPeople(schedule.ownerIds || [], byId).size === 0
45
+ || !partiesIndependent(schedule.ownerIds, schedule.approverIds, byId)
46
+ || !linkedControl
47
+ || !substantiveMarkdown(scheduleContent)
48
+ || openPlaceholderCount(scheduleContent) > 0
49
+ || !approvalBindingsMatch(loaded, schedule))
50
+ ) {
51
+ issues.push(issue("unapproved-retention-schedule-document", "Complete and independently approve the Data Retention Schedule document before approving the complete schedule."));
52
+ }
53
+ const activeRows = rows.filter(({ status }) => status === "active");
54
+ const mismatched = schedule
55
+ ? activeRows.filter(({ scheduleDocumentId }) => scheduleDocumentId !== schedule.id)
56
+ : [];
57
+ if (mismatched.length) {
58
+ issues.push(issue("retention-row-wrong-schedule-document", `Link every active retention row to the current Data Retention Schedule document before approval: ${mismatched[0].title}.`));
59
+ }
60
+ const revisions = resourceReviewRevisionsSync(
61
+ loaded,
62
+ activeRows.flatMap((row) => retentionReviewResourceIds(row, loaded))
63
+ );
64
+ const incomplete = activeRows.filter((row) => !retentionRuleIsCurrent(row, revisions, byId, loaded));
65
+ if (incomplete.length) {
66
+ issues.push(issue("incomplete-retention-schedule-row", `Complete the retention decisions before approving the schedule revision: ${incomplete[0].title}.`));
67
+ }
68
+ const usableRows = activeRows.filter((row) => !incomplete.includes(row));
69
+ const uncovered = retentionUses(loaded, program).find((use) => !usableRows.some((row) => (
70
+ (row.informationTypeIds || []).includes(use.informationTypeId)
71
+ && ((row.scopeResourceIds || []).includes(use.resource.id) || (row.scopeResourceIds || []).includes(program.id))
72
+ )));
73
+ if (uncovered) {
74
+ issues.push(issue(
75
+ "uncovered-retention-information-use",
76
+ `${uncovered.resource.title} uses ${byId.get(uncovered.informationTypeId)?.title || uncovered.informationTypeId}, but no active, current retention schedule row covers both the type and scope.`
77
+ ));
78
+ }
79
+ if (nearDuplicateInformationTypes(loaded.resources).length && !options.informationTypesReviewed) {
80
+ issues.push(issue("unreviewed-similar-information-types", "Review similar Information Types before approving the Data Retention Schedule."));
81
+ }
82
+ return issues;
83
+ }
84
+
85
+ function issue(code, message) {
86
+ return { code, message };
87
+ }
88
+
89
+ function primaryMarkdown(loaded, record) {
90
+ const definition = loaded.model.resources[record.type];
91
+ const selected = markdownEntries(loaded.model, record).find((entry) => (
92
+ definition.markdown?.[entry.name]?.primary || entry.name === loaded.model.recordContent?.slot
93
+ ));
94
+ if (!selected) return "";
95
+ try {
96
+ return readFileSync(resolveDataPath(loaded.root, selected.path), "utf8");
97
+ } catch {
98
+ return "";
99
+ }
100
+ }
101
+
102
+ function approvalBindingsMatch(loaded, record) {
103
+ const actual = {};
104
+ for (const entry of markdownEntries(loaded.model, record)) {
105
+ try {
106
+ actual[entry.path] = createHash("sha256")
107
+ .update(readFileSync(resolveDataPath(loaded.root, entry.path), "utf8"))
108
+ .digest("hex");
109
+ } catch (error) {
110
+ if (error.code !== "ENOENT") throw error;
111
+ }
112
+ }
113
+ const expected = record.approvedContentRevisions || {};
114
+ const actualKeys = Object.keys(actual).sort();
115
+ const expectedKeys = Object.keys(expected).sort();
116
+ return actualKeys.length === expectedKeys.length
117
+ && actualKeys.every((key, index) => (
118
+ key === expectedKeys[index]
119
+ && actual[key] === expected[key]
120
+ ));
121
+ }
package/src/retention.js CHANGED
@@ -1,8 +1,10 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { readFile } from "node:fs/promises";
2
3
  import { contentRevision } from "./files.js";
3
4
  import { resolveDataPath } from "./paths.js";
4
5
  import { programComponents } from "./program.js";
5
6
  import { markdownEntries } from "./resource-markdown.js";
7
+ import { modelSupports } from "../model/index.js";
6
8
 
7
9
  export async function assessRetentionReadiness(loaded, program, options = {}) {
8
10
  if (!loaded.model.resources["retention-schedule-item"]) return [];
@@ -113,7 +115,7 @@ export async function resourceReviewRevisions(loaded, ids) {
113
115
  const entry = entries.get(id);
114
116
  if (!entry || reviewing.has(id)) return null;
115
117
  reviewing.add(id);
116
- const parts = [entry.source];
118
+ const parts = [reviewSource(loaded, entry)];
117
119
  for (const markdown of markdownEntries(loaded.model, entry.record)) {
118
120
  try {
119
121
  parts.push(await readFile(resolveDataPath(loaded.root, markdown.path), "utf8"));
@@ -136,6 +138,37 @@ export async function resourceReviewRevisions(loaded, ids) {
136
138
  return new Map([...revisions].filter(([id]) => wanted.has(id)));
137
139
  }
138
140
 
141
+ export function resourceReviewRevisionsSync(loaded, ids) {
142
+ const wanted = new Set(ids);
143
+ const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
144
+ const revisions = new Map();
145
+ const reviewing = new Set();
146
+ const review = (id) => {
147
+ if (revisions.has(id)) return revisions.get(id);
148
+ const entry = entries.get(id);
149
+ if (!entry || reviewing.has(id)) return null;
150
+ reviewing.add(id);
151
+ const parts = [reviewSource(loaded, entry)];
152
+ for (const markdown of markdownEntries(loaded.model, entry.record)) {
153
+ try {
154
+ parts.push(readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8"));
155
+ } catch (error) {
156
+ if (error.code !== "ENOENT") throw error;
157
+ }
158
+ }
159
+ for (const sourceId of [...new Set(entry.record.sourceResourceIds || [])].sort()) {
160
+ const revision = review(sourceId);
161
+ if (revision) parts.push(`${sourceId}:${revision}`);
162
+ }
163
+ reviewing.delete(id);
164
+ const revision = contentRevision(parts.join("\n"));
165
+ revisions.set(id, revision);
166
+ return revision;
167
+ };
168
+ for (const id of wanted) review(id);
169
+ return new Map([...revisions].filter(([id]) => wanted.has(id)));
170
+ }
171
+
139
172
  export function retentionUses(loaded, program) {
140
173
  const systemIds = new Set(program.systemIds || []);
141
174
  const componentIds = new Set(programComponents(loaded, program).map(({ id }) => id));
@@ -157,6 +190,28 @@ export function retentionUses(loaded, program) {
157
190
  return [...new Map(uses.map((use) => [`${use.resource.id}:${use.informationTypeId}`, use])).values()];
158
191
  }
159
192
 
193
+ function reviewSource(loaded, entry) {
194
+ if (
195
+ modelSupports(loaded.model, "retention-schedule-approval")
196
+ && entry.record.type === "document"
197
+ && entry.record.documentKind === "schedule"
198
+ && entry.record.workflowScope === "program"
199
+ ) {
200
+ const approved = structuredClone(entry.record);
201
+ if (approved.status === "active") approved.status = "approved";
202
+ for (const field of [
203
+ "activationBasis",
204
+ "activatedByIds",
205
+ "activatedOn",
206
+ "activatedContentRevisions",
207
+ "effectiveOn",
208
+ "proposedEffectiveOn"
209
+ ]) delete approved[field];
210
+ return JSON.stringify(approved);
211
+ }
212
+ return entry.source;
213
+ }
214
+
160
215
  export function nearDuplicateInformationTypes(records) {
161
216
  const types = records.filter((record) => record.type === "information-type" && !["retired", "superseded"].includes(record.status));
162
217
  const pairs = [];
package/src/state.js CHANGED
@@ -150,17 +150,7 @@ export async function createAppStateSection(input, section, options = {}) {
150
150
  collectionReviews: Object.fromEntries(
151
151
  assessCollectionReviews(loaded, { programId: activeProgram.id }).map((assessment) => [
152
152
  assessment.resourceType,
153
- {
154
- resourceType: assessment.resourceType,
155
- configuration: assessment.configuration,
156
- recordCount: assessment.recordCount,
157
- review: assessment.review,
158
- reviewRevision: assessment.reviewRevision,
159
- collectionRevision: assessment.collectionRevision,
160
- status: assessment.status,
161
- complete: assessment.complete,
162
- message: assessment.message
163
- }
153
+ serializeCollectionReviewAssessment(assessment)
164
154
  ])
165
155
  ),
166
156
  applicabilityConstraints: Object.fromEntries(loaded.resources.flatMap((record) => {
@@ -318,17 +308,7 @@ async function createAppStateUnlocked(input, options) {
318
308
  const collectionReviews = Object.fromEntries(
319
309
  assessCollectionReviews(loaded, { programId: activeProgram.id }).map((assessment) => [
320
310
  assessment.resourceType,
321
- {
322
- resourceType: assessment.resourceType,
323
- configuration: assessment.configuration,
324
- recordCount: assessment.recordCount,
325
- review: assessment.review,
326
- reviewRevision: assessment.reviewRevision,
327
- collectionRevision: assessment.collectionRevision,
328
- status: assessment.status,
329
- complete: assessment.complete,
330
- message: assessment.message
331
- }
311
+ serializeCollectionReviewAssessment(assessment)
332
312
  ])
333
313
  );
334
314
  return {
@@ -356,6 +336,23 @@ async function createAppStateUnlocked(input, options) {
356
336
  };
357
337
  }
358
338
 
339
+ function serializeCollectionReviewAssessment(assessment) {
340
+ return {
341
+ resourceType: assessment.resourceType,
342
+ configuration: assessment.configuration,
343
+ recordCount: assessment.recordCount,
344
+ review: assessment.review,
345
+ reviewRevision: assessment.reviewRevision,
346
+ collectionRevision: assessment.collectionRevision,
347
+ status: assessment.status,
348
+ complete: assessment.complete,
349
+ message: assessment.message,
350
+ ...(assessment.eligibleReviewerIds ? { eligibleReviewerIds: assessment.eligibleReviewerIds } : {}),
351
+ ...(assessment.reviewerConflictIds ? { reviewerConflictIds: assessment.reviewerConflictIds } : {}),
352
+ ...(assessment.approvalIssues ? { approvalIssues: assessment.approvalIssues } : {})
353
+ };
354
+ }
355
+
359
356
  export async function createResourceDetail(input, type, id, options = {}) {
360
357
  if (input?.entries && input?.root) return createResourceDetailFromLoaded(input, type, id, options);
361
358
  return serializeWorkspaceMutation(input, async (root) => {
package/src/validate.js CHANGED
@@ -19,8 +19,9 @@ import {
19
19
  validCalendarRecurrence
20
20
  } from "./recurrence.js";
21
21
  import { resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
22
+ import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js";
22
23
  import { obligationIsEnabled } from "./program-lifecycle.js";
23
- import { partyPeople } from "./parties.js";
24
+ import { currentPartyPeople, partyPeople } from "./parties.js";
24
25
  import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
25
26
  import { currentCalendarDate, isRfc3339Timestamp, localDateTimeValue, timestampFromLocalDateTime } from "./time.js";
26
27
  import { recordTiming } from "./timing.js";
@@ -1273,6 +1274,55 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
1273
1274
  currentRevision
1274
1275
  }
1275
1276
  );
1277
+ if (current && record.resourceType === "control") {
1278
+ const controls = scopedCollectionRecords(loaded, "control", program);
1279
+ const controlIds = new Set(controls.map(({ id }) => id));
1280
+ const conflictIds = new Set();
1281
+ for (const control of controls) {
1282
+ for (const id of currentPartyPeople(control.ownerIds || [], byId)) conflictIds.add(id);
1283
+ }
1284
+ for (const obligation of loaded.resources.filter((candidate) => (
1285
+ candidate.type === "obligation"
1286
+ && candidate.status === "active"
1287
+ && (candidate.controlIds || []).some((id) => controlIds.has(id))
1288
+ ))) {
1289
+ for (const id of currentPartyPeople(obligation.ownerIds || [], byId)) conflictIds.add(id);
1290
+ }
1291
+ const conflictedReviewers = (record.reviewedByIds || []).filter((id) => conflictIds.has(id));
1292
+ if (conflictedReviewers.length) {
1293
+ diagnostics.push(error(
1294
+ "conflicted-control-collection-reviewer",
1295
+ path,
1296
+ `A Control collection reviewer cannot own an included Control or its enabled Obligation: ${conflictedReviewers.join(", ")}.`
1297
+ ));
1298
+ }
1299
+ }
1300
+ if (current && record.resourceType === "retention-schedule-item" && modelSupports(model, "retention-schedule-approval")) {
1301
+ const rows = scopedCollectionRecords(loaded, record.resourceType, program);
1302
+ for (const issue of retentionScheduleApprovalIssues(loaded, program || {}, rows, {
1303
+ informationTypesReviewed: collectionReviewIsCurrent(loaded, "information-type", program)
1304
+ })) {
1305
+ diagnostics.push(error(issue.code, path, issue.message));
1306
+ }
1307
+ const owners = new Set();
1308
+ for (const candidate of loaded.resources.filter((candidate) => (
1309
+ candidate.type === "document"
1310
+ && candidate.documentKind === "schedule"
1311
+ && candidate.workflowScope === "program"
1312
+ && !["superseded", "retired"].includes(candidate.status)
1313
+ || rows.includes(candidate)
1314
+ ))) {
1315
+ for (const id of currentPartyPeople(candidate.ownerIds || [], byId)) owners.add(id);
1316
+ }
1317
+ const conflictedReviewers = (record.reviewedByIds || []).filter((id) => owners.has(id));
1318
+ if (conflictedReviewers.length) {
1319
+ diagnostics.push(error(
1320
+ "conflicted-retention-schedule-reviewer",
1321
+ path,
1322
+ `A Data Retention Schedule reviewer cannot own its governing document or an included schedule row: ${conflictedReviewers.join(", ")}.`
1323
+ ));
1324
+ }
1325
+ }
1276
1326
  if (modelSupports(model, "temporal-collection-reviews")) {
1277
1327
  const temporalValues = [record.coverage, record.knowledgeCutoffAt, record.populationResourceIds];
1278
1328
  const hasTemporalBinding = temporalValues.every((value) => value !== undefined && value !== null);
@@ -1341,6 +1391,25 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
1341
1391
  }
1342
1392
  }
1343
1393
 
1394
+ function collectionReviewIsCurrent(loaded, resourceType, program) {
1395
+ const review = loaded.resources.find((record) => (
1396
+ record.type === "collection-review"
1397
+ && record.resourceType === resourceType
1398
+ && record.status === "active"
1399
+ && (!modelSupports(loaded.model, "program-scope") || (record.scopeResourceIds || []).includes(program?.id))
1400
+ ));
1401
+ if (!review || !(loaded.model.collectionReviews?.[resourceType]?.decisions || ["complete"]).includes(review.decision)) return false;
1402
+ const records = scopedCollectionRecords(loaded, resourceType, program);
1403
+ const revisionMatches = collectionRevisionMatches(loaded, resourceType, review.collectionRevision, { program });
1404
+ if (!modelSupports(loaded.model, "temporal-collection-reviews")) return revisionMatches;
1405
+ return revisionMatches
1406
+ && review.coverage?.kind === "as-of"
1407
+ && review.coverage.on === review.reviewedOn
1408
+ && Boolean(review.knowledgeCutoffAt)
1409
+ && Array.isArray(review.populationResourceIds)
1410
+ && JSON.stringify([...review.populationResourceIds].sort()) === JSON.stringify(records.map(({ id }) => id).sort());
1411
+ }
1412
+
1344
1413
  function validateCollectionReviewSet(resources, pathById, diagnostics) {
1345
1414
  const current = new Map();
1346
1415
  for (const review of resources.filter((record) => record.type === "collection-review" && record.status !== "retired")) {