filegrc 0.15.0 → 0.15.2

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/retention.js CHANGED
@@ -10,18 +10,26 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
10
10
  if (!loaded.model.resources["retention-schedule-item"]) return [];
11
11
  const byId = new Map(loaded.resources.map((record) => [record.id, record]));
12
12
  const rules = loaded.resources.filter((record) => (
13
- record.type === "retention-schedule-item" && record.status === "active"
13
+ record.type === "retention-schedule-item"
14
+ && !["retired", "superseded"].includes(record.status)
14
15
  ));
15
- const revisions = await resourceReviewRevisions(loaded, rules.flatMap((rule) => retentionReviewResourceIds(rule, loaded)));
16
- const usableRules = rules.filter((rule) => retentionRuleIsCurrent(rule, revisions, byId, loaded));
16
+ const activeRules = rules.filter(({ status }) => status === "active");
17
+ const revisions = await resourceReviewRevisions(loaded, activeRules.flatMap((rule) => retentionReviewResourceIds(rule, loaded)));
18
+ const usableRules = activeRules.filter((rule) => retentionRuleIsCurrent(rule, revisions, byId, loaded));
19
+ const proposedRules = rules.filter(retentionRuleHasCoverageProposal);
17
20
  const uses = retentionUses(loaded, program);
18
- const items = rules.filter((rule) => !usableRules.includes(rule)).map((rule) => readinessItem(
21
+ const items = rules.map((rule) => readinessItem(
19
22
  `retention-rule-${rule.id}`,
20
- "action",
21
- `Review ${rule.title}`,
22
- "This active retention schedule item is incomplete or is not bound to every current source revision. Review it before relying on its period or disposition behavior.",
23
+ usableRules.includes(rule) ? "complete" : "action",
24
+ usableRules.includes(rule) ? `${rule.title} proposal is ready` : `Finish ${rule.title}`,
25
+ usableRules.includes(rule)
26
+ ? "The owner completed this row and bound it to its current source revisions. It will become part of the authoritative schedule only when the complete schedule is approved."
27
+ : rule.status === "planned"
28
+ ? "Finish the type, scope, cutoff, period, disposition, sources, and owner review for this proposed row before requesting schedule approval."
29
+ : "This row is incomplete or is not bound to every current source revision. Review it before requesting schedule approval.",
23
30
  rule,
24
31
  {
32
+ coverageState: usableRules.includes(rule) ? "proposed" : "incomplete",
25
33
  sourceResourceIds: retentionReviewResourceIds(rule, loaded),
26
34
  commands: [
27
35
  `npx filegrc get ${rule.id} --mutation`,
@@ -32,22 +40,52 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
32
40
  ]
33
41
  }
34
42
  ));
35
- items.push(...uses.map((use) => {
36
- const matches = usableRules.filter((rule) => ruleCoversUse(rule, use, program));
43
+ const usesByInformationType = new Map();
44
+ for (const use of uses) {
45
+ const grouped = usesByInformationType.get(use.informationTypeId) || [];
46
+ grouped.push(use);
47
+ usesByInformationType.set(use.informationTypeId, grouped);
48
+ }
49
+ items.push(...[...usesByInformationType].map(([informationTypeId, informationTypeUses]) => {
50
+ const absentUses = informationTypeUses.filter((use) => (
51
+ !proposedRules.some((rule) => ruleCoversUse(rule, use, program))
52
+ ));
53
+ const pendingUses = informationTypeUses.filter((use) => (
54
+ !usableRules.some((rule) => ruleCoversUse(rule, use, program))
55
+ ));
56
+ const approvalPending = !options.scheduleApproved;
57
+ const matches = usableRules.filter((rule) => (
58
+ informationTypeUses.some((use) => ruleCoversUse(rule, use, program))
59
+ ));
60
+ const informationTypeTitle = byId.get(informationTypeId)?.title || informationTypeId;
61
+ const affectedTitles = absentUses.map(({ resource }) => resource.title);
62
+ const affectedSummary = affectedTitles.length > 3
63
+ ? `${affectedTitles.slice(0, 3).join(", ")}, and ${affectedTitles.length - 3} more`
64
+ : affectedTitles.join(", ");
37
65
  return readinessItem(
38
- `retention-use-${use.resource.id}-${use.informationTypeId}`,
39
- matches.length ? "complete" : "action",
40
- `Decide retention for ${byId.get(use.informationTypeId)?.title || use.informationTypeId}`,
41
- matches.length
42
- ? `${use.resource.title} is covered by ${matches.map(({ title }) => title).join(", ")}.`
43
- : `${use.resource.title} uses this Information Type, but no active, current retention schedule item covers both the type and scope. Management must choose the cutoff, period, and disposition.`,
44
- use.resource,
66
+ `retention-use-${informationTypeId}`,
67
+ absentUses.length ? "action" : pendingUses.length || approvalPending ? "info" : "complete",
68
+ `Decide retention for ${informationTypeTitle}`,
69
+ absentUses.length
70
+ ? `${affectedTitles.length} in-scope ${affectedTitles.length === 1 ? "resource uses" : "resources use"} this Information Type without an active, current retention decision, including ${affectedSummary}. Management must choose the cutoff, period, disposition, and whether one program-wide rule or separate scoped rules apply.`
71
+ : pendingUses.length
72
+ ? `Proposed rows cover every in-scope type-and-scope pair. Finish the row review, then approve the complete schedule once instead of approving these coverage checks separately.`
73
+ : approvalPending
74
+ ? "Completed row proposals cover every in-scope type-and-scope pair. Approve the complete schedule once; these derived checks are not separate approval work."
75
+ : `The approved schedule covers all ${informationTypeUses.length} in-scope ${informationTypeUses.length === 1 ? "use" : "uses"}.`,
76
+ { type: "retention-schedule-item" },
45
77
  {
46
- informationTypeId: use.informationTypeId,
78
+ progressUnit: false,
79
+ coverageState: absentUses.length ? "absent" : pendingUses.length || approvalPending ? "proposed" : "approved",
80
+ informationTypeId,
81
+ affectedResourceIds: absentUses.map(({ resource }) => resource.id),
82
+ retentionScopeResourceIds: absentUses.length === informationTypeUses.length
83
+ ? [program.id]
84
+ : absentUses.map(({ resource }) => resource.id),
47
85
  retentionScheduleItemIds: matches.map(({ id }) => id),
48
86
  commands: [
49
87
  "npx filegrc guide retention-schedule-item --json",
50
- `npx filegrc scaffold retention-schedule-item --title ${shellArgument(`Retention for ${byId.get(use.informationTypeId)?.title || use.informationTypeId}`)}`
88
+ `npx filegrc scaffold retention-schedule-item --title ${shellArgument(`Retention for ${informationTypeTitle}`)}`
51
89
  ]
52
90
  }
53
91
  );
@@ -55,6 +93,11 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
55
93
 
56
94
  for (const coverage of loaded.resources.filter((record) => record.type === "source-coverage" && record.status === "active")) {
57
95
  const linked = (coverage.retentionScheduleItemIds || []).map((id) => byId.get(id)).filter(Boolean);
96
+ const proposed = linked.filter((rule) => (
97
+ rule.type === "retention-schedule-item"
98
+ && proposedRules.includes(rule)
99
+ && (rule.scopeResourceIds || []).includes(coverage.id)
100
+ ));
58
101
  const matching = linked.filter((rule) => (
59
102
  rule.type === "retention-schedule-item"
60
103
  && rule.status === "active"
@@ -63,13 +106,17 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
63
106
  ));
64
107
  items.push(readinessItem(
65
108
  `retention-source-coverage-${coverage.id}`,
66
- matching.length ? "complete" : "action",
109
+ matching.length && options.scheduleApproved ? "complete" : proposed.length ? "info" : "action",
67
110
  `Confirm retained evidence for ${coverage.title}`,
68
- matching.length
111
+ matching.length && options.scheduleApproved
69
112
  ? `The source-coverage record references a current schedule item scoped to this population.`
70
- : `The source-coverage record must reference an active, current schedule item whose scope includes ${coverage.id}. A draft schedule or unrelated rule does not satisfy this check.`,
113
+ : proposed.length
114
+ ? "A proposed schedule row covers this evidence source. Finish that row and approve the complete schedule before relying on it."
115
+ : `No proposed schedule row covers ${coverage.id}. Add the genuine type-and-scope decision before schedule approval.`,
71
116
  coverage,
72
117
  {
118
+ progressUnit: false,
119
+ coverageState: matching.length && options.scheduleApproved ? "approved" : proposed.length ? "proposed" : "absent",
73
120
  retentionScheduleItemIds: matching.map(({ id }) => id),
74
121
  commands: [
75
122
  `npx filegrc get ${coverage.id} --mutation`,
@@ -198,8 +245,11 @@ function reviewSource(loaded, entry) {
198
245
  && entry.record.workflowScope === "program"
199
246
  ) {
200
247
  const approved = structuredClone(entry.record);
201
- if (approved.status === "active") approved.status = "approved";
202
248
  for (const field of [
249
+ "status",
250
+ "approverIds",
251
+ "approvedOn",
252
+ "approvedContentRevisions",
203
253
  "activationBasis",
204
254
  "activatedByIds",
205
255
  "activatedOn",
@@ -232,6 +282,14 @@ function ruleCoversUse(rule, use, program) {
232
282
  return scope.has(use.resource.id) || scope.has(program.id);
233
283
  }
234
284
 
285
+ function retentionRuleHasCoverageProposal(rule) {
286
+ return Boolean(
287
+ (rule.informationTypeIds || []).length
288
+ && (rule.scopeResourceIds || []).length
289
+ && rule.scheduleDocumentId
290
+ );
291
+ }
292
+
235
293
  export function retentionReviewResourceIds(rule, loaded) {
236
294
  const programUseIds = [];
237
295
  if (loaded) {
@@ -259,7 +317,7 @@ export function retentionRuleIsCurrent(rule, revisions, byId = new Map(), loaded
259
317
  if (!(rule.informationTypeIds || []).length || !(rule.scopeResourceIds || []).length || !rule.scheduleDocumentId) return false;
260
318
  const schedule = byId.get(rule.scheduleDocumentId);
261
319
  if (schedule?.type !== "document" || schedule.documentKind !== "schedule" || schedule.workflowScope !== "program" || ["superseded", "retired"].includes(schedule.status)) return false;
262
- if (!(rule.ownerIds || []).length || !(rule.approvedByIds || []).length || !rule.approvedOn) return false;
320
+ if (!(rule.ownerIds || []).length) return false;
263
321
  if (!rule.reviewedSourceRevisions || typeof rule.reviewedSourceRevisions !== "object") return false;
264
322
  if (!retentionCutoffIsComplete(rule.cutoff) || !retentionPeriodIsComplete(rule.retentionPeriod)) return false;
265
323
  if (!["delete", "destroy", "erase", "anonymize", "transfer", "retain-permanently"].includes(rule.dispositionAction)) return false;
package/src/server.js CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  pushWorkspace,
34
34
  retryBrowserSync,
35
35
  runBrowserMutation,
36
+ synchronizeBrowserStartup,
36
37
  withGitCommandCache,
37
38
  withGitCommandDeadline
38
39
  } from "./git.js";
@@ -777,6 +778,18 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
777
778
  if (!Number.isInteger(port) || port < 0 || port > 65_535) {
778
779
  throw new Error("The server port must be an integer from 0 through 65535.");
779
780
  }
781
+ let startupSynchronization;
782
+ try {
783
+ startupSynchronization = await synchronizeBrowserStartup(input, {
784
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true
785
+ });
786
+ } catch (error) {
787
+ startupSynchronization = {
788
+ status: "failed",
789
+ updated: false,
790
+ message: error.message
791
+ };
792
+ }
780
793
  const loaded = await loadWorkspace(input);
781
794
  getResourceDefinition(loaded.model, "workspace");
782
795
  const server = createFilegrcServer(loaded.root, {
@@ -806,7 +819,8 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
806
819
  address: server.address(),
807
820
  url: `http://${urlHost(host)}:${server.address().port}`,
808
821
  requestedPort: port,
809
- usedFallbackPort
822
+ usedFallbackPort,
823
+ startupSynchronization
810
824
  };
811
825
  }
812
826
 
@@ -1,4 +1,7 @@
1
1
  import { resourceReviewRevisions, retentionReviewResourceIds, retentionRuleIsCurrent } from "./retention.js";
2
+ import { retentionScheduleIsAuthoritative } from "./collection-review.js";
3
+ import { modelSupports } from "../model/index.js";
4
+ import { resolveProgram } from "./program.js";
2
5
 
3
6
  export async function sourceCoverageComplete(record, loaded, program = loaded.workspace) {
4
7
  const structuredRetention = loaded.model.resources["retention-schedule-item"];
@@ -6,6 +9,10 @@ export async function sourceCoverageComplete(record, loaded, program = loaded.wo
6
9
  return false;
7
10
  }
8
11
  if (structuredRetention) {
12
+ const retentionProgram = modelSupports(loaded.model, "program-scope")
13
+ ? resolveProgram(loaded, program?.type === "program" ? program.id : undefined)
14
+ : program;
15
+ if (!retentionScheduleIsAuthoritative(loaded, retentionProgram)) return false;
9
16
  const linkedRules = (record.retentionScheduleItemIds || []).map((id) => (
10
17
  loaded.resources.find((resource) => resource.id === id && resource.type === "retention-schedule-item")
11
18
  ));
package/src/state.js CHANGED
@@ -347,6 +347,8 @@ function serializeCollectionReviewAssessment(assessment) {
347
347
  status: assessment.status,
348
348
  complete: assessment.complete,
349
349
  message: assessment.message,
350
+ ...(assessment.recordProposals ? { recordProposals: assessment.recordProposals } : {}),
351
+ ...(assessment.incompleteRecordProposals ? { incompleteRecordProposals: assessment.incompleteRecordProposals } : {}),
350
352
  ...(assessment.eligibleReviewerIds ? { eligibleReviewerIds: assessment.eligibleReviewerIds } : {}),
351
353
  ...(assessment.reviewerConflictIds ? { reviewerConflictIds: assessment.reviewerConflictIds } : {}),
352
354
  ...(assessment.approvalIssues ? { approvalIssues: assessment.approvalIssues } : {})
package/src/validate.js CHANGED
@@ -3,7 +3,7 @@ import { createReadStream } from "node:fs";
3
3
  import { readFile, stat } from "node:fs/promises";
4
4
  import { performance } from "node:perf_hooks";
5
5
  import { getResourceDefinition, modelSupports } from "../model/index.js";
6
- import { scopedCollectionRecords, selectScopedCollectionRecords } from "./collection-scope.js";
6
+ import { collectionRecordProposals, retentionScheduleReviewScope, scopedCollectionRecords, selectScopedCollectionRecords } from "./collection-scope.js";
7
7
  import {
8
8
  collectionRevision,
9
9
  collectionRevisionMatches
@@ -1224,6 +1224,10 @@ function sameRouteProposal(proposal, finalized) {
1224
1224
  return [...keys].every((key) => allowed.has(key) || JSON.stringify(proposal[key]) === JSON.stringify(finalized[key]));
1225
1225
  }
1226
1226
 
1227
+ function sameStringIds(left = [], right = []) {
1228
+ return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
1229
+ }
1230
+
1227
1231
  function validateTimestampTimezone(value, timezone, path, diagnostics, label) {
1228
1232
  if (!value || !timezone) return;
1229
1233
  const source = String(value);
@@ -1252,10 +1256,19 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
1252
1256
  const program = modelSupports(model, "program-scope")
1253
1257
  ? (record.scopeResourceIds || []).map((id) => byId.get(id)).find(({ type } = {}) => type === "program")
1254
1258
  : null;
1259
+ const workspaceWideRetentionReview = record.resourceType === "retention-schedule-item"
1260
+ && modelSupports(model, "retention-schedule-approval");
1261
+ const retentionScope = workspaceWideRetentionReview ? retentionScheduleReviewScope(loaded) : null;
1255
1262
  const recordCount = scopedCollectionRecords(loaded, record.resourceType, program).length;
1256
1263
  const currentPopulationIds = scopedCollectionRecords(loaded, record.resourceType, program)
1257
1264
  .map(({ id }) => id)
1258
1265
  .sort();
1266
+ const incompleteProposals = collectionRecordProposals(
1267
+ loaded,
1268
+ record.resourceType,
1269
+ scopedCollectionRecords(loaded, record.resourceType, program),
1270
+ program
1271
+ ).filter(({ complete }) => !complete);
1259
1272
  const currentRevision = collectionRevision(loaded, record.resourceType, {
1260
1273
  program,
1261
1274
  authoritativeSourceId: record.decision === "externally-managed"
@@ -1274,6 +1287,20 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
1274
1287
  currentRevision
1275
1288
  }
1276
1289
  );
1290
+ if (current && workspaceWideRetentionReview && !sameStringIds(record.scopeResourceIds, retentionScope.programIds)) {
1291
+ diagnostics.push(error(
1292
+ "invalid-retention-schedule-review-scope",
1293
+ path,
1294
+ "The current Data Retention Schedule review must cover every active Program as one workspace-wide approval."
1295
+ ));
1296
+ }
1297
+ if (current && incompleteProposals.length) {
1298
+ diagnostics.push(error(
1299
+ "incomplete-collection-review-proposals",
1300
+ path,
1301
+ `Complete every ${configuration.title.toLowerCase()} record proposal before the collection review. Remaining: ${incompleteProposals.map(({ title }) => title).join(", ")}.`
1302
+ ));
1303
+ }
1277
1304
  if (current && record.resourceType === "control") {
1278
1305
  const controls = scopedCollectionRecords(loaded, "control", program);
1279
1306
  const controlIds = new Set(controls.map(({ id }) => id));
@@ -1299,8 +1326,11 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
1299
1326
  }
1300
1327
  if (current && record.resourceType === "retention-schedule-item" && modelSupports(model, "retention-schedule-approval")) {
1301
1328
  const rows = scopedCollectionRecords(loaded, record.resourceType, program);
1302
- for (const issue of retentionScheduleApprovalIssues(loaded, program || {}, rows, {
1303
- informationTypesReviewed: collectionReviewIsCurrent(loaded, "information-type", program)
1329
+ for (const issue of retentionScheduleApprovalIssues(loaded, retentionScope, rows, {
1330
+ informationTypesReviewed: retentionScope.programIds.every((id) => (
1331
+ collectionReviewIsCurrent(loaded, "information-type", byId.get(id))
1332
+ )),
1333
+ approvalReview: record
1304
1334
  })) {
1305
1335
  diagnostics.push(error(issue.code, path, issue.message));
1306
1336
  }