filegrc 0.16.13 → 0.16.14

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/model/v11.json CHANGED
@@ -657,9 +657,9 @@
657
657
  },
658
658
  "control": {
659
659
  "title": "Control implementation oversight",
660
- "description": "After Step 3 implementation work is complete and before program content activation, confirm that the implemented Controls selected for the Program are ready to operate. Repeat the review after a material Control or Program scope change.",
660
+ "description": "After Step 3 implementation work is complete and before program content activation, confirm that the implemented Controls selected for the Program are ready to operate. Handle later changes through affected Controls, event work, and scheduled program reviews.",
661
661
  "reviewPoints": [
662
- "Perform this one collection review after Controls, evidence sources, and Obligations are ready, before management activates the approved program content.",
662
+ "Perform this one setup review after Controls, evidence sources, and Obligations are ready, before management activates the approved program content.",
663
663
  "Confirm owners, procedures, scope, operation patterns, mappings, Obligations, and authoritative evidence sources remain current.",
664
664
  "Record follow-up work separately when the review finds a gap."
665
665
  ],
@@ -2875,7 +2875,7 @@
2875
2875
  "description": "Management's actual Control implementation, mapped to Requirements, bounded Systems, operating Components, and authoritative evidence-source Components.",
2876
2876
  "guidance": {
2877
2877
  "policyBasis": "Controls translate approved Policies and applicable Requirements into owned procedures that management can operate and prove. FileGRC does not infer technical implementation from policy prose. Configuration facts belong in Controls, Components, Systems, Obligations, and Evidence.",
2878
- "cadence": "A Control owner may mark the Control implemented after recording its actual procedure, bounded System scope, operation pattern, authoritative evidence-source Components, implementation date, and enabled calendar or event Obligations. Confirm each source Component is active, has the evidence role required by the Control family and current access owners, and includes repeatable retrieval instructions in Record Markdown. Enabled Obligations remain dormant until all governing content is active and effective. Review the implemented Control collection periodically and after material changes.",
2878
+ "cadence": "A Control owner may mark the Control implemented after recording its actual procedure, bounded System scope, operation pattern, authoritative evidence-source Components, implementation date, and enabled calendar or event Obligations. Confirm each source Component is active, has the evidence role required by the Control family and current access owners, and includes repeatable retrieval instructions in Record Markdown. Enabled Obligations remain dormant until all governing content is active and effective. Review affected Controls after material changes and use scheduled program reviews for periodic oversight.",
2879
2879
  "reviewPoints": [
2880
2880
  "Confirm the Control is needed for the applicable Requirements, Commitments, and service boundary.",
2881
2881
  "Describe what people actually do to operate the Control.",
@@ -8669,7 +8669,7 @@
8669
8669
  "description": "A logical operational or technical building block that materially delivers a System, supports a Control, produces authoritative Evidence, or supports relevant operations.",
8670
8670
  "guidance": {
8671
8671
  "policyBasis": "System boundaries and Control implementations need explicit operational building blocks and evidence sources.",
8672
- "cadence": "Create only when the capability materially participates in an in-scope System. Review after architecture, provider, Control, evidence, access, or continuity changes.",
8672
+ "cadence": "Create only when the capability materially participates in an in-scope System. After a change, inspect its System uses and affected Controls. If the change is material to risk, data use, evidence, access, or continuity, assess it through the relevant System change Policy Event and its linked work. Follow the scheduled policy and program reviews; do not repeat setup collection reviews.",
8673
8673
  "reviewPoints": [
8674
8674
  "Choose at least one role for each related System and explain why the Component matters there.",
8675
8675
  "Link a Vendor only when that external organization primarily supplies this Component.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.16.13",
3
+ "version": "0.16.14",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -29,12 +29,48 @@ const unorderedStringArrayFields = new Set([
29
29
 
30
30
  const applicabilityDecisionFields = {
31
31
  requirement: ["frameworkId", "reference", "description", "parentRequirementId"],
32
+ control: ["statement", "requirementIds", "code", "systemIds"],
33
+ commitment: ["commitmentKind", "statement", "systemIds", "sourceResourceIds", "requirementIds", "controlIds", "customerFacing", "reportingRouteRequirements"],
34
+ "complementary-control": ["responsibleParty", "statement", "systemIds", "vendorId", "requirementIds", "commitmentIds", "relatedControlIds", "sourceDocumentIds"]
35
+ };
36
+ const legacyApplicabilityDecisionFields = {
37
+ requirement: applicabilityDecisionFields.requirement,
32
38
  control: ["statement", "requirementIds", "code", "activity", "controlType", "operationMode", "operationPattern", "systemIds", "policyIds", "componentIds", "evidenceSourceComponentIds"],
33
39
  commitment: ["commitmentKind", "statement", "systemIds", "sourceResourceIds", "requirementIds", "controlIds", "customerFacing", "reportingRouteRequirements"],
34
40
  "complementary-control": ["responsibleParty", "statement", "systemIds", "vendorId", "requirementIds", "commitmentIds", "relatedControlIds", "sourceDocumentIds", "componentIds"]
35
41
  };
36
42
 
37
43
  export function applicabilityScopeRevision(record, program, resources, model, options = {}) {
44
+ if (Number(model.modelVersion) >= 11 && !options.legacyDecisionScope) {
45
+ const linkedRequirements = resources.filter((candidate) => candidate.type === "requirement"
46
+ && (record.type === "requirement" ? candidate.id === record.id : (record.requirementIds || []).includes(candidate.id)));
47
+ const frameworkIds = new Set(linkedRequirements.map(({ frameworkId }) => frameworkId));
48
+ const scopedProgram = record.type === "requirement" ? {
49
+ ...program,
50
+ systemIds: [],
51
+ frameworkIds: (program?.frameworkIds || []).filter((id) => id === record.frameworkId),
52
+ requirementIds: (program?.requirementIds || []).filter((id) => id === record.id)
53
+ } : {
54
+ ...program,
55
+ systemIds: (program?.systemIds || []).filter((id) => (record.systemIds || []).includes(id)),
56
+ frameworkIds: (program?.frameworkIds || []).filter((id) => !frameworkIds.size || frameworkIds.has(id)),
57
+ requirementIds: (program?.requirementIds || []).filter((id) => linkedRequirements.some((candidate) => candidate.id === id))
58
+ };
59
+ const facts = {
60
+ modelVersion: model.modelVersion,
61
+ resource: applicabilityResourceFacts(record, model, options),
62
+ program: pick(scopedProgram || {}, ["id", "assuranceGoal", "systemIds", "frameworkIds", "requirementIds"], model, "program"),
63
+ requirements: linkedRequirements.sort(compareRecordIds).map((candidate) => pick(candidate,
64
+ ["id", "frameworkId", "reference", "description", "parentRequirementId"], model)),
65
+ frameworks: resources.filter(({ type, id }) => type === "framework" && frameworkIds.has(id))
66
+ .sort(compareRecordIds).map((candidate) => pick(candidate, ["id", "version", "status"], model)),
67
+ requirementDecisions: (record.type === "requirement" ? [] : program?.requirementApplicability || [])
68
+ .filter(({ requirementId }) => linkedRequirements.some(({ id }) => id === requirementId))
69
+ .map(({ requirementId, decision }) => ({ requirementId, decision }))
70
+ .sort((left, right) => left.requirementId.localeCompare(right.requirementId))
71
+ };
72
+ return calculateRevision("applicability-scope", stableJson(facts));
73
+ }
38
74
  const scopedResource = ["commitment", "control", "complementary-control"].includes(record?.type)
39
75
  && !options.legacyBroadScope && !options.legacyBroadCommitment;
40
76
  const linkedRequirementIds = new Set(record?.requirementIds || []);
@@ -204,10 +240,10 @@ function compareRecordIds(left, right) {
204
240
 
205
241
  function applicabilityResourceFacts(record, model, options) {
206
242
  const fields = Number(model.modelVersion) >= 11 && !options.legacyResourceProjection
207
- ? applicabilityDecisionFields[record.type] : null;
243
+ ? (options.legacyDecisionScope ? legacyApplicabilityDecisionFields : applicabilityDecisionFields)[record.type] : null;
208
244
  if (!fields) return canonicalObject(model, record.type, Object.fromEntries(Object.entries(record)
209
245
  .filter(([field]) => !excludedResourceFields.has(field))));
210
- const value = Object.fromEntries(["id", "type", "title", "externalIds", "extensions", ...fields]
246
+ const value = Object.fromEntries(["id", "type", ...(options.legacyDecisionScope ? ["title"] : []), "externalIds", "extensions", ...fields]
211
247
  .filter((field) => record[field] !== undefined).map((field) => [field, record[field]]));
212
248
  value.unavailable = ["not-applicable", "retired", "superseded"].includes(record.status);
213
249
  return canonicalObject(model, record.type, value);
@@ -226,10 +262,15 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
226
262
  if (Number(model.modelVersion) >= 11 && ["retired", "superseded"].includes(record?.status)) return false;
227
263
  const matches = (revision) => revisionsMatch("applicability-scope", review.scopeRevision, revision);
228
264
  if (matches(applicabilityScopeRevision(record, program, resources, model))) return true;
265
+ if (Number(model.modelVersion) >= 11 && matches(applicabilityScopeRevision(record, program, resources, model, {
266
+ legacyDecisionScope: true
267
+ }))) return true;
268
+ if (Number(model.modelVersion) >= 11 && applicabilityDecisionFields[record?.type]
269
+ && matches(applicabilityScopeRevision(record, program, resources, model, { legacyDecisionScope: true, legacyResourceProjection: true }))) return true;
229
270
  if (Number(model.modelVersion) >= 11 && applicabilityDecisionFields[record?.type]
230
271
  && matches(applicabilityScopeRevision(record, program, resources, model, { legacyResourceProjection: true }))) return true;
231
272
  if (["commitment", "control", "complementary-control"].includes(record?.type) && matches(applicabilityScopeRevision(
232
- record, program, resources, model, { legacyBroadScope: true }
273
+ record, program, resources, model, { legacyDecisionScope: true, legacyBroadScope: true }
233
274
  ))) return true;
234
275
  if (Number(model.modelVersion) >= 11 && applicabilityDecisionFields[record?.type] && root && historicalScopedReviewMatches(
235
276
  review.scopeRevision, record, program, resources, model, root
@@ -242,6 +283,7 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
242
283
  for (const legacyPolicyWithoutEffectiveOn of [false, true]) {
243
284
  if (matches(applicabilityScopeRevision(record, program, resources, model, {
244
285
  legacyPolicyStatus,
286
+ legacyDecisionScope: true,
245
287
  legacyPolicyWithoutApprovalBinding,
246
288
  legacyPolicyWithoutEffectiveOn
247
289
  }))) return true;
@@ -260,7 +302,8 @@ export function applicabilityReviewIsCurrent(review, record, program, resources,
260
302
  for (const policy of policies) {
261
303
  for (const value of legacyPolicyAlternatives(policy)) {
262
304
  if (matches(applicabilityScopeRevision(record, program, resources, model, {
263
- legacyPolicyOverrides: { [policy.id]: value }
305
+ legacyPolicyOverrides: { [policy.id]: value },
306
+ legacyDecisionScope: true
264
307
  }))) return true;
265
308
  }
266
309
  }
@@ -285,12 +328,14 @@ function historicalScopedReviewMatches(stored, record, program, resources, model
285
328
  if (!revisionsMatch("applicability-scope", current, applicabilityScopeRevision(
286
329
  historicalRecord, historicalProgram, historicalResources, historicalModel
287
330
  ))) continue;
288
- for (const legacyBroadScope of [false, true]) {
289
- for (const legacyResourceProjection of [false, true]) {
290
- if (revisionsMatch("applicability-scope", stored, applicabilityScopeRevision(
291
- historicalRecord, historicalProgram, historicalResources, historicalModel,
292
- { legacyBroadScope, legacyResourceProjection }
293
- ))) return true;
331
+ for (const legacyDecisionScope of [false, true]) {
332
+ for (const legacyBroadScope of [false, true]) {
333
+ for (const legacyResourceProjection of [false, true]) {
334
+ if (revisionsMatch("applicability-scope", stored, applicabilityScopeRevision(
335
+ historicalRecord, historicalProgram, historicalResources, historicalModel,
336
+ { legacyDecisionScope, legacyBroadScope, legacyResourceProjection }
337
+ ))) return true;
338
+ }
294
339
  }
295
340
  }
296
341
  }
@@ -690,18 +690,24 @@ function scopeRecordsChangedSinceRevision(loaded, audit, historicalAudit) {
690
690
  historical = null;
691
691
  }
692
692
  if (
693
- !scopeRecordIsRelevant(current, audit)
694
- && !scopeRecordIsRelevant(historical, historicalAudit)
693
+ !scopeRecordIsRelevant(current, audit, loaded)
694
+ && !scopeRecordIsRelevant(historical, historicalAudit, loaded)
695
695
  ) continue;
696
- if (JSON.stringify(canonicalScopeValue(current)) !== JSON.stringify(canonicalScopeValue(historical))) changed.push(path);
696
+ if (JSON.stringify(canonicalScopeValue(auditScopeRecordValue(current)))
697
+ !== JSON.stringify(canonicalScopeValue(auditScopeRecordValue(historical)))) changed.push(path);
697
698
  }
698
699
  return changed;
699
700
  }
700
701
 
701
- function scopeRecordIsRelevant(record, audit) {
702
+ function scopeRecordIsRelevant(record, audit, loaded) {
702
703
  if (!record || !audit) return false;
703
704
  const selectedSystemIds = new Set(audit.systemIds || []);
704
- const selectedFrameworkIds = new Set(audit.frameworkIds || []);
705
+ if (["commitment", "complementary-control"].includes(record.type)
706
+ && (record.systemIds || []).some((id) => selectedSystemIds.has(id))) return true;
707
+ if (record.type === "requirement" && (audit.frameworkIds || []).includes(record.frameworkId)) return true;
708
+ if (record.type === "component" && (record.systemUses || []).some(({ systemId, roles }) => (
709
+ selectedSystemIds.has(systemId) && (roles || []).includes("service-delivery")
710
+ ))) return true;
705
711
  const selectedIds = new Set([
706
712
  audit.programId,
707
713
  ...(audit.frameworkIds || []),
@@ -714,15 +720,36 @@ function scopeRecordIsRelevant(record, audit) {
714
720
  ...auditSubserviceVendorIds(audit),
715
721
  ...auditSubserviceComponentIds(audit)
716
722
  ].filter(Boolean));
717
- if (selectedIds.has(record.id)) return true;
718
- if (record.type === "requirement") return selectedFrameworkIds.has(record.frameworkId);
719
- if (["commitment", "complementary-control"].includes(record.type)) {
720
- return (record.systemIds || []).some((id) => selectedSystemIds.has(id));
723
+ const selectedControls = new Set(audit.controlIds || []);
724
+ for (const control of loaded.resources.filter(({ type, id }) => type === "control" && selectedControls.has(id))) {
725
+ for (const id of control.evidenceSourceComponentIds || []) selectedIds.add(id);
721
726
  }
722
- if (record.type === "component") {
723
- return (record.systemUses || []).some(({ systemId }) => selectedSystemIds.has(systemId));
724
- }
725
- return false;
727
+ return selectedIds.has(record.id);
728
+ }
729
+
730
+ const AUDIT_SCOPE_RECORD_FIELDS = {
731
+ program: ["id", "status", "assuranceGoal"],
732
+ framework: ["id", "version", "status", "publisher", "sourceReference", "effectiveOn", "retiredOn"],
733
+ requirement: ["id", "frameworkId", "reference", "description", "parentRequirementId"],
734
+ system: ["id", "status", "purpose", "servicesProvided", "boundary", "exclusions", "criticality", "ownerIds", "informationTypeIds", "classificationId", "internetExposed", "continuityObjectives"],
735
+ control: ["id", "status", "statement", "requirementIds", "code", "activity", "controlType", "systemIds", "ownerIds", "effectiveOn", "retiredOn", "operationMode", "operationPattern", "componentIds", "evidenceSourceComponentIds", "policyIds", "procedureRevision", "procedureEffectiveOn"],
736
+ commitment: ["id", "status", "commitmentKind", "statement", "systemIds", "requirementIds", "controlIds", "customerFacing", "sourceResourceIds", "effectiveOn", "ownerIds", "reportingRouteRequirements"],
737
+ "complementary-control": ["id", "status", "statement", "systemIds", "relatedControlIds", "responsibleParty", "vendorId", "componentIds", "commitmentIds", "sourceDocumentIds", "requirementIds", "effectiveOn"],
738
+ component: ["id", "status", "componentKind", "description", "environment", "vendorId", "systemUses", "informationUses", "evidenceSourceKinds", "ownerIds", "evidenceOwnerIds", "criticality", "classificationId", "internetExposed", "continuityObjectives"],
739
+ vendor: ["id", "category", "status", "criticality", "classificationId", "informationTypeIds", "standardAgreement", "agreementDocumentId", "startDate", "endDate"],
740
+ appointment: ["id", "status", "appointmentKind", "holderId", "scopeResourceIds", "startsOn", "endsOn"]
741
+ };
742
+
743
+ function auditScopeRecordValue(record) {
744
+ if (!record) return null;
745
+ const fields = AUDIT_SCOPE_RECORD_FIELDS[record.type] || ["id", "type", "status"];
746
+ return {
747
+ ...Object.fromEntries(fields.filter((field) => record[field] !== undefined)
748
+ .map((field) => [field, record[field]])),
749
+ ...(["control", "commitment", "complementary-control"].includes(record.type)
750
+ ? { unavailable: ["not-applicable", "retired", "superseded"].includes(record.status) }
751
+ : {})
752
+ };
726
753
  }
727
754
 
728
755
  function auditSubserviceVendorIds(audit) {
@@ -2,7 +2,8 @@ import { readFile } from "node:fs/promises";
2
2
  import { modelSupports } from "../model/index.js";
3
3
  import {
4
4
  collectionRevision,
5
- collectionRevisionMatches
5
+ collectionReviewDecisionMatches,
6
+ reviewedControlConflictIds
6
7
  } from "./collection-revision.js";
7
8
  import { collectionRecordProposals, retentionScheduleReviewScope, scopedCollectionRecords } from "./collection-scope.js";
8
9
  import { applyResourceBatch, applyRetentionScheduleReviewBatch, contentRevision, INTERNAL_WORKFLOW_CAPABILITIES } from "./files.js";
@@ -62,15 +63,17 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
62
63
  && review.coverage.on === review.reviewedOn
63
64
  && review.knowledgeCutoffAt
64
65
  && Array.isArray(review.populationResourceIds)
65
- && sameIds(review.populationResourceIds, records.map(({ id }) => id))
66
+ && (resourceType !== "retention-schedule-item" && Number(loaded.model.modelVersion) >= 11
67
+ || sameIds(review.populationResourceIds, records.map(({ id }) => id)))
66
68
  );
67
69
  const allowsEmptyCollection = allowedDecisions.some((decision) => (
68
70
  decision === "zero-population" || decision === "externally-managed"
69
71
  ));
70
- const revisionMatches = collectionRevisionMatches(
72
+ const revisionMatches = collectionReviewDecisionMatches(
71
73
  loaded,
72
74
  resourceType,
73
- review?.collectionRevision,
75
+ review,
76
+ records,
74
77
  {
75
78
  programId: program.id,
76
79
  authoritativeSourceId,
@@ -92,11 +95,14 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
92
95
  : [];
93
96
  const recordProposals = collectionRecordProposals(loaded, resourceType, records, program);
94
97
  const incompleteRecordProposals = recordProposals.filter(({ complete: ready }) => !ready);
98
+ const reviewerConflicts = resourceType === "control" && Number(loaded.model.modelVersion) >= 11 && review
99
+ ? reviewedControlConflictIds(loaded, review)
100
+ : reviewerEligibility?.reviewerConflictIds || [];
95
101
  const reviewersEligible = !reviewerEligibility || Boolean(
96
102
  review?.reviewedByIds?.length
97
103
  && review.reviewedByIds.every((id) => (
98
104
  personWasActiveOn(loaded.resources.find((record) => record.id === id), review.reviewedOn)
99
- && !reviewerEligibility.reviewerConflictIds.includes(id)
105
+ && !reviewerConflicts.includes(id)
100
106
  ))
101
107
  );
102
108
  const reviewScopeMatches = !workspaceWideRetentionReview
@@ -108,7 +114,8 @@ export function assessCollectionReview(loaded, resourceType, options = {}) {
108
114
  && temporalReview
109
115
  && reviewScopeMatches
110
116
  && reviewersEligible
111
- && !incompleteRecordProposals.length
117
+ && (resourceType !== "retention-schedule-item" && Number(loaded.model.modelVersion) >= 11
118
+ || !incompleteRecordProposals.length)
112
119
  && !approvalIssues.length
113
120
  );
114
121
  const stale = Boolean(
@@ -4,14 +4,16 @@ import { modelSupports } from "../model/index.js";
4
4
  import {
5
5
  authoritativeSourceRevisionValue,
6
6
  collectionRevisionInputs,
7
- collectionScopeRevisionFacts
7
+ collectionScopeRevisionFacts,
8
+ scopedCollectionRecords
8
9
  } from "./collection-scope.js";
9
10
  import { resolveDataPath } from "./paths.js";
10
11
  import { getFileAtRevision } from "./git.js";
11
12
  import { reviewHistoricalWorkspace, reviewHistoryCommits, reviewHistoryContext } from "./historical-workspace.js";
12
13
  import { resolveProgram } from "./program.js";
14
+ import { currentPartyPeople } from "./parties.js";
13
15
  import { markdownEntries } from "./resource-markdown.js";
14
- import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionsMatch } from "./revisions.js";
16
+ import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionDigest, revisionsMatch } from "./revisions.js";
15
17
 
16
18
  export function collectionRevision(loaded, resourceType, options = {}) {
17
19
  return calculateCollectionRevision(loaded, resourceType, options, false, options.scopeHashInput || "legacy", options.scopeFactsInput || "legacy");
@@ -44,6 +46,96 @@ export function collectionRevisionMatches(loaded, resourceType, storedRevision,
44
46
  return historicallyEquivalentCollection(loaded, resourceType, storedRevision, options, currentRevision);
45
47
  }
46
48
 
49
+ // Ordinary Collection Reviews record that setup was completed. Their original
50
+ // population remains evidence of what management reviewed at that time;
51
+ // subsequent operating records are assessed by their own workflows. The Data
52
+ // Retention Schedule is an approval of exact content and keeps its binding.
53
+ export function collectionReviewDecisionMatches(loaded, resourceType, review, records, options = {}) {
54
+ if (!review) return false;
55
+ if (resourceType === "retention-schedule-item" || Number(loaded.model.modelVersion) < 11) {
56
+ return collectionRevisionMatches(loaded, resourceType, review.collectionRevision, options);
57
+ }
58
+ if (!revisionDigest("collection", review.collectionRevision) || review.status !== "active") return false;
59
+ if (!originalCollectionReviewMatches(loaded, resourceType, review, options)) return false;
60
+ if (review.decision === "zero-population") return records.length === 0;
61
+ if (review.decision === "complete") return records.length > 0;
62
+ if (review.decision === "externally-managed") {
63
+ const id = review.authoritativeComponentId || review.authoritativeSystemId;
64
+ return loaded.resources.some((record) => record.id === id && record.status === "active");
65
+ }
66
+ return collectionRevisionMatches(loaded, resourceType, review.collectionRevision, options);
67
+ }
68
+
69
+ function originalCollectionReviewMatches(loaded, resourceType, review, options) {
70
+ const currentRevision = options.currentRevision || collectionRevision(loaded, resourceType, options);
71
+ if (revisionsMatch("collection", review.collectionRevision, currentRevision)) {
72
+ if (!review.populationResourceIds) return true;
73
+ const program = options.program || resolveProgram(loaded, options.programId || review.scopeResourceIds?.[0]);
74
+ const currentPopulation = scopedCollectionRecords(loaded, resourceType, program).map(({ id }) => id).sort();
75
+ const reviewedPopulation = [...review.populationResourceIds].sort();
76
+ return currentPopulation.length === reviewedPopulation.length
77
+ && currentPopulation.every((id, index) => id === reviewedPopulation[index]);
78
+ }
79
+ if (!loaded.root || !review.scopeRevision) {
80
+ return collectionRevisionMatches(loaded, resourceType, review.collectionRevision, { ...options, currentRevision });
81
+ }
82
+ const snapshot = reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), review.scopeRevision);
83
+ if (!snapshot) return false;
84
+ const programId = options.programId || options.program?.id || review.scopeResourceIds?.[0];
85
+ let program;
86
+ try {
87
+ program = resolveProgram(snapshot, programId);
88
+ } catch {
89
+ return false;
90
+ }
91
+ const currentProgram = resolveProgram(loaded, programId);
92
+ const selectionField = {
93
+ framework: "frameworkIds",
94
+ system: "systemIds",
95
+ component: "systemIds",
96
+ control: "controlIds"
97
+ }[resourceType];
98
+ if (selectionField && JSON.stringify([...(program[selectionField] || [])].sort())
99
+ !== JSON.stringify([...(currentProgram[selectionField] || [])].sort())) return false;
100
+ const population = scopedCollectionRecords(snapshot, resourceType, program).map(({ id }) => id).sort();
101
+ const reviewedPopulation = [...(review.populationResourceIds || [])].sort();
102
+ if (review.populationResourceIds && (
103
+ population.length !== reviewedPopulation.length
104
+ || population.some((id, index) => id !== reviewedPopulation[index])
105
+ )) return false;
106
+ return collectionRevisionMatches(snapshot, resourceType, review.collectionRevision, {
107
+ programId,
108
+ authoritativeSourceId: options.authoritativeSourceId,
109
+ historicalCommit: review.scopeRevision
110
+ });
111
+ }
112
+
113
+ export function reviewedControlConflictIds(loaded, review) {
114
+ const snapshot = loaded.root && /^[a-f0-9]{40,64}$/i.test(review?.scopeRevision || "")
115
+ ? reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), review.scopeRevision)
116
+ : null;
117
+ const source = snapshot || loaded;
118
+ let program;
119
+ try {
120
+ program = resolveProgram(source, review.scopeResourceIds?.[0]);
121
+ } catch {
122
+ return [];
123
+ }
124
+ const controls = scopedCollectionRecords(source, "control", program);
125
+ const controlIds = new Set(controls.map(({ id }) => id));
126
+ const byId = new Map(source.resources.map((record) => [record.id, record]));
127
+ const conflicts = new Set();
128
+ for (const record of [
129
+ ...controls,
130
+ ...source.resources.filter((record) => record.type === "obligation"
131
+ && record.status === "active"
132
+ && (record.controlIds || []).some((id) => controlIds.has(id)))
133
+ ]) {
134
+ for (const id of currentPartyPeople(record.ownerIds || [], byId)) conflicts.add(id);
135
+ }
136
+ return [...conflicts].sort();
137
+ }
138
+
47
139
  function historicallyEquivalentCollection(loaded, resourceType, stored, options, current) {
48
140
  if (!loaded.root) return false;
49
141
  const context = reviewHistoryContext(loaded.root, loaded);
package/src/model-docs.js CHANGED
@@ -47,7 +47,7 @@ export function generateModelDocumentation(model) {
47
47
  "",
48
48
  "## Collection review confirmations",
49
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.",
50
+ "FileGRC derives record issues, but it cannot infer that management finished an apparently complete or empty setup collection. Each configured collection review records the conclusion, reviewer, date, scope revision, and reviewed collection revision. Routine operating changes do not reopen a completed setup confirmation. The Data Retention Schedule keeps exact approval bindings.",
51
51
  "",
52
52
  "| Resource type | Review | Allowed conclusions | What to review |",
53
53
  "| --- | --- | --- | --- |",
@@ -242,6 +242,27 @@ export async function assessProgramReadiness(input, options = {}) {
242
242
  ...evidenceGateStages,
243
243
  await operationStage(loaded, program, scope, records, byId, asOf, evidenceReady, loaded.model, progressWindow)
244
244
  ];
245
+ for (const assessment of collectionReviews.filter(({ resourceType, complete }) => (
246
+ complete && resourceType !== "retention-schedule-item"
247
+ ))) {
248
+ for (const proposal of assessment.incompleteRecordProposals || []) {
249
+ stages.at(-1).items.push(item(
250
+ `operating-${assessment.resourceType}-${proposal.resourceId}`,
251
+ "action",
252
+ `Finish ${proposal.title}`,
253
+ "Finish or retire this new record, check its affected Systems and Controls, and start a change event only if the change is material under an approved Policy.",
254
+ { type: assessment.resourceType, id: proposal.resourceId, title: proposal.title },
255
+ {
256
+ resourceType: assessment.resourceType,
257
+ destination: { kind: "record", resourceType: assessment.resourceType, resourceId: proposal.resourceId },
258
+ commands: [
259
+ `npx filegrc guide ${assessment.resourceType} --json`,
260
+ `npx filegrc get ${shellArgument(proposal.resourceId)} --mutation`
261
+ ]
262
+ }
263
+ ));
264
+ }
265
+ }
245
266
  finalizeStage(stages.at(-1));
246
267
  const periodStarted = Boolean(
247
268
  program?.assuranceGoal === "soc-2-type-2"
@@ -408,14 +429,17 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
408
429
  && String(rationale || "").trim()
409
430
  ))
410
431
  ));
432
+ const componentSetupComplete = collectionReviews.find(({ resourceType }) => resourceType === "component")?.complete === true;
411
433
  items.push(item(
412
434
  "system-components",
413
- scope.components.length === completeComponents.length ? "complete" : "action",
414
- "Confirm scoped Components",
415
- scope.components.length
435
+ componentSetupComplete || scope.components.length === completeComponents.length ? "complete" : "action",
436
+ "Define scoped Component roles",
437
+ componentSetupComplete
438
+ ? "The initial Component scope was confirmed. Complete later Component changes through their own record and event work."
439
+ : scope.components.length
416
440
  ? `${completeComponents.length} of ${scope.components.length} Components have an active, owned, rationalized role in the selected Systems.`
417
441
  : "No Components are selected. This is valid only when the bounded Systems do not rely on a separately managed service-delivery, Control-support, evidence-source, or supporting-operations building block.",
418
- scope.components[0] || { type: "component" },
442
+ scope.components.find((component) => !completeComponents.includes(component)) || scope.components[0] || { type: "component" },
419
443
  {
420
444
  componentIds: scope.components.map(({ id }) => id),
421
445
  progressUnit: false
@@ -723,7 +747,7 @@ function reportingRouteItem(records, byId, asOf, timezone = "UTC") {
723
747
  }
724
748
 
725
749
  export function collectionReviewReadinessItem(assessment) {
726
- const firstIncomplete = assessment.incompleteRecordProposals?.[0]
750
+ const firstIncomplete = !assessment.complete && assessment.incompleteRecordProposals?.[0]
727
751
  || assessment.recordProposals?.find(({ complete }) => !complete);
728
752
  const needsFirstRecord = assessment.recordCount === 0
729
753
  && !(assessment.configuration.decisions || []).some((decision) => ["zero-population", "externally-managed"].includes(decision));
@@ -734,7 +758,7 @@ export function collectionReviewReadinessItem(assessment) {
734
758
  const approvalAction = !proposalAction && !pendingRetentionRow
735
759
  ? collectionApprovalIssueAction(assessment)
736
760
  : null;
737
- const proposalUnits = (assessment.recordProposals || []).map((proposal) => ({
761
+ const proposalUnits = (assessment.complete ? [] : assessment.recordProposals || []).map((proposal) => ({
738
762
  id: `${assessment.resourceType}-proposal-${proposal.resourceId}`,
739
763
  status: proposal.complete ? "complete" : "action",
740
764
  title: `${proposal.title} proposal`
package/src/validate.js CHANGED
@@ -6,7 +6,8 @@ import { getResourceDefinition, modelSupports } from "../model/index.js";
6
6
  import { collectionRecordProposals, retentionScheduleReviewScope, scopedCollectionRecords, selectScopedCollectionRecords } from "./collection-scope.js";
7
7
  import {
8
8
  collectionRevision,
9
- collectionRevisionMatches
9
+ collectionReviewDecisionMatches,
10
+ reviewedControlConflictIds
10
11
  } from "./collection-revision.js";
11
12
  import { isSafeGitName } from "./git-name.js";
12
13
  import { getFileBufferAtRevision, getFileObjectIdAtRevision, getFilePathAtRevision, getGitSummary, getWorkingFileObjectId, hasGitRevision, isDataHistoryAncestor } from "./git.js";
@@ -1276,10 +1277,11 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
1276
1277
  ? record.authoritativeComponentId || record.authoritativeSystemId
1277
1278
  : null
1278
1279
  });
1279
- const current = collectionRevisionMatches(
1280
+ const current = collectionReviewDecisionMatches(
1280
1281
  loaded,
1281
1282
  record.resourceType,
1282
- record.collectionRevision,
1283
+ record,
1284
+ scopedCollectionRecords(loaded, record.resourceType, program),
1283
1285
  {
1284
1286
  program,
1285
1287
  authoritativeSourceId: record.decision === "externally-managed"
@@ -1288,6 +1290,7 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
1288
1290
  currentRevision
1289
1291
  }
1290
1292
  );
1293
+ const originalPopulationCurrent = current && revisionsMatch("collection", record.collectionRevision, currentRevision);
1291
1294
  if (current && workspaceWideRetentionReview && !sameStringIds(record.scopeResourceIds, retentionScope.programIds)) {
1292
1295
  diagnostics.push(error(
1293
1296
  "invalid-retention-schedule-review-scope",
@@ -1295,7 +1298,7 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
1295
1298
  "The current Data Retention Schedule review must cover every active Program as one workspace-wide approval."
1296
1299
  ));
1297
1300
  }
1298
- if (current && incompleteProposals.length) {
1301
+ if (originalPopulationCurrent && incompleteProposals.length) {
1299
1302
  diagnostics.push(error(
1300
1303
  "incomplete-collection-review-proposals",
1301
1304
  path,
@@ -1303,19 +1306,7 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
1303
1306
  ));
1304
1307
  }
1305
1308
  if (current && record.resourceType === "control") {
1306
- const controls = scopedCollectionRecords(loaded, "control", program);
1307
- const controlIds = new Set(controls.map(({ id }) => id));
1308
- const conflictIds = new Set();
1309
- for (const control of controls) {
1310
- for (const id of currentPartyPeople(control.ownerIds || [], byId)) conflictIds.add(id);
1311
- }
1312
- for (const obligation of loaded.resources.filter((candidate) => (
1313
- candidate.type === "obligation"
1314
- && candidate.status === "active"
1315
- && (candidate.controlIds || []).some((id) => controlIds.has(id))
1316
- ))) {
1317
- for (const id of currentPartyPeople(obligation.ownerIds || [], byId)) conflictIds.add(id);
1318
- }
1309
+ const conflictIds = new Set(reviewedControlConflictIds(loaded, record));
1319
1310
  const conflictedReviewers = (record.reviewedByIds || []).filter((id) => conflictIds.has(id));
1320
1311
  if (conflictedReviewers.length) {
1321
1312
  diagnostics.push(error(
@@ -1369,7 +1360,8 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
1369
1360
  || record.coverage.on !== record.reviewedOn
1370
1361
  || !isRfc3339Timestamp(record.knowledgeCutoffAt)
1371
1362
  || currentCalendarDate(loaded.workspace.timezone, new Date(record.knowledgeCutoffAt)) !== record.reviewedOn
1372
- || (current && JSON.stringify([...record.populationResourceIds].sort()) !== JSON.stringify(currentPopulationIds))
1363
+ || (current && (record.resourceType === "retention-schedule-item" || Number(model.modelVersion) < 11)
1364
+ && JSON.stringify([...record.populationResourceIds].sort()) !== JSON.stringify(currentPopulationIds))
1373
1365
  )) {
1374
1366
  diagnostics.push(error(
1375
1367
  "invalid-current-collection-review",
@@ -1431,14 +1423,15 @@ function collectionReviewIsCurrent(loaded, resourceType, program) {
1431
1423
  ));
1432
1424
  if (!review || !(loaded.model.collectionReviews?.[resourceType]?.decisions || ["complete"]).includes(review.decision)) return false;
1433
1425
  const records = scopedCollectionRecords(loaded, resourceType, program);
1434
- const revisionMatches = collectionRevisionMatches(loaded, resourceType, review.collectionRevision, { program });
1426
+ const revisionMatches = collectionReviewDecisionMatches(loaded, resourceType, review, records, { program });
1435
1427
  if (!modelSupports(loaded.model, "temporal-collection-reviews")) return revisionMatches;
1436
1428
  return revisionMatches
1437
1429
  && review.coverage?.kind === "as-of"
1438
1430
  && review.coverage.on === review.reviewedOn
1439
1431
  && Boolean(review.knowledgeCutoffAt)
1440
1432
  && Array.isArray(review.populationResourceIds)
1441
- && JSON.stringify([...review.populationResourceIds].sort()) === JSON.stringify(records.map(({ id }) => id).sort());
1433
+ && (resourceType !== "retention-schedule-item" && Number(loaded.model.modelVersion) >= 11
1434
+ || JSON.stringify([...review.populationResourceIds].sort()) === JSON.stringify(records.map(({ id }) => id).sort()));
1442
1435
  }
1443
1436
 
1444
1437
  function validateCollectionReviewSet(resources, pathById, diagnostics) {
package/src/web.js CHANGED
@@ -1239,7 +1239,7 @@ function collectionReviewPanel(type, force = false) {
1239
1239
  const current = assessment.status === "current";
1240
1240
  const scheduleReview = type === "retention-schedule-item" && modelSupports("retention-schedule-approval");
1241
1241
  const scheduleBlocker = scheduleReview ? retentionScheduleApprovalBlocker() : null;
1242
- const proposalBlocker = assessment.incompleteRecordProposals?.length
1242
+ const proposalBlocker = !current && assessment.incompleteRecordProposals?.length
1243
1243
  ? "Complete " + assessment.incompleteRecordProposals.length + " record " + (assessment.incompleteRecordProposals.length === 1 ? "proposal" : "proposals") + " before the collection review."
1244
1244
  : null;
1245
1245
  const reviewBlocker = scheduleBlocker || proposalBlocker;
@@ -1263,7 +1263,7 @@ function collectionReviewPanel(type, force = false) {
1263
1263
  ? '<a class="button primary" href="#/resources/' + encodeURIComponent(type) + '?new=1">Add first ' + esc(state.model.resources[type].title.toLowerCase()) + '</a>'
1264
1264
  : '<button class="button ' + (current ? "" : "primary") + '" type="button" data-review-collection="' + esc(type) + '">' + (current ? scheduleReview ? "Approve a new revision" : "Review again" : scheduleReview ? "Review and approve schedule" : "Review and confirm") + '</button>';
1265
1265
  const status = reviewBlocker ? "Not ready" : current ? "Reviewed" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Stale" : "Review required";
1266
- const proposalSummary = assessment.recordProposals?.length
1266
+ const proposalSummary = !current && assessment.recordProposals?.length
1267
1267
  ? '<p class="collection-review-proposal-summary"><strong>' + assessment.recordProposals.filter(({ complete }) => complete).length + ' of ' + assessment.recordProposals.length + ' record proposals complete.</strong> The collection review follows every proposal.</p>'
1268
1268
  : "";
1269
1269
  const details = '<details ' + (current ? 'class="collection-review-details"' : "open") + '><summary>' + (current ? scheduleReview ? "Show approved revision" : "Show scope confirmation" : "What to review") + '</summary><div class="collection-review-detail-content"><div class="collection-review-head"><div><p class="kicker">' + (scheduleReview ? "Schedule approval" : "Scope confirmation") + '</p><h3>' + esc(configuration.title) + '</h3><p>' + esc(configuration.description) + '</p></div><span class="badge ' + (current ? "good" : "warn") + '">' + status + '</span></div>' + proposalSummary + '<ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul>' + (current ? reviewSummary : "") + '</div></details>';