filegrc 0.16.18 → 0.16.19

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
@@ -2603,7 +2603,7 @@
2603
2603
  "cadence": "Create before relying on a promise, review during audit scoping, and supersede it when the service or agreement changes.",
2604
2604
  "reviewPoints": [
2605
2605
  "Confirm the statement reflects a real customer promise, service requirement, or approved business objective that applies to the current scope.",
2606
- "Confirm which parts of the service the promise or objective affects.",
2606
+ "Confirm which parts of the service the promise or objective affects. When a linked Policy or Document has independent sections, name the exact governing Markdown heading in sourceSectionHeadings before reviewing downstream mappings.",
2607
2607
  "Use \"External\", \"Zero Population\", or \"Not Applicable\" only when it is true for the current scope."
2608
2608
  ],
2609
2609
  "sourceResourceIds": [
@@ -2657,6 +2657,12 @@
2657
2657
  "requirement"
2658
2658
  ]
2659
2659
  },
2660
+ "sourceSectionHeadings": {
2661
+ "type": "object",
2662
+ "objectType": "string-map",
2663
+ "label": "Reviewed source sections",
2664
+ "description": "Map a source Policy or Document ID to one exact Markdown heading when the Commitment comes from that section."
2665
+ },
2660
2666
  "requirementIds": {
2661
2667
  "type": "array",
2662
2668
  "items": "id",
@@ -2731,6 +2737,7 @@
2731
2737
  "formFields": [
2732
2738
  "systemIds",
2733
2739
  "sourceResourceIds",
2740
+ "sourceSectionHeadings",
2734
2741
  "requirementIds",
2735
2742
  "controlIds",
2736
2743
  "applicabilityReview",
@@ -7706,6 +7713,7 @@
7706
7713
  "reviewPoints": [
7707
7714
  "Confirm the Information Types and operational scope covered by this item.",
7708
7715
  "Confirm the cutoff, retention period, and disposition action from management sources.",
7716
+ "When a linked Policy or Document has independent sections, set sourceSectionHeadings to the exact governing Markdown heading. Keep a whole-source binding when the entire source governs the row.",
7709
7717
  "Keep the item planned until the owner completes every organization-specific value and binds the current source revisions."
7710
7718
  ],
7711
7719
  "sourceResourceIds": [
@@ -7779,6 +7787,12 @@
7779
7787
  ],
7780
7788
  "label": "Authority and source records"
7781
7789
  },
7790
+ "sourceSectionHeadings": {
7791
+ "type": "object",
7792
+ "objectType": "string-map",
7793
+ "label": "Reviewed source sections",
7794
+ "description": "Map a source Policy or Document ID to one exact Markdown heading when only that section governs this decision. Omit the ID when the complete source governs it."
7795
+ },
7782
7796
  "cutoff": {
7783
7797
  "type": "object",
7784
7798
  "objectType": "retention-cutoff",
@@ -7882,6 +7896,7 @@
7882
7896
  "scopeResourceIds",
7883
7897
  "scheduleDocumentId",
7884
7898
  "sourceResourceIds",
7899
+ "sourceSectionHeadings",
7885
7900
  "cutoff",
7886
7901
  "retentionPeriod",
7887
7902
  "dispositionAction",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.16.18",
3
+ "version": "0.16.19",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli.js CHANGED
@@ -586,12 +586,14 @@ export async function runCli(argv = process.argv.slice(2)) {
586
586
  ? [...new Set([...(record.sourceResourceIds || []), ...(record.targetResourceIds || [])])]
587
587
  : null;
588
588
  if (!dependencyIds) throw new Error("Review bindings are available for Retention Schedule records and Requirement Mappings.");
589
- const revisions = await resourceReviewRevisions(loaded, dependencyIds);
589
+ const revisions = await resourceReviewRevisions(loaded, dependencyIds, "legacy", record);
590
+ const missingSectionIds = dependencyIds.filter((id) => Object.hasOwn(record.sourceSectionHeadings || {}, id) && !revisions.has(id));
590
591
  const result = {
591
592
  resource: { type: record.type, id: record.id, title: record.title },
592
593
  dependencyIds,
593
594
  reviewedSourceRevisions: Object.fromEntries(revisions),
594
- missingResourceIds: dependencyIds.filter((id) => !revisions.has(id))
595
+ missingResourceIds: dependencyIds.filter((id) => !revisions.has(id) && !missingSectionIds.includes(id)),
596
+ ...(missingSectionIds.length ? { missingSectionIds } : {})
595
597
  };
596
598
  if (flags.json) console.log(JSON.stringify(result, null, 2));
597
599
  else {
@@ -30,6 +30,10 @@ export function collectionRevisionMatches(loaded, resourceType, storedRevision,
30
30
  // Accept both the pre-0.16 scope: input and the digest-only input used by
31
31
  // 0.16.0, as well as the older collection basis from 0.9.1.
32
32
  if (revisionsMatch("collection", storedRevision, currentRevision)) return true;
33
+ if (resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
34
+ && revisionsMatch("collection", storedRevision, calculateCollectionRevision(
35
+ loaded, resourceType, { ...options, legacyRetentionSourceBasis: true }, false
36
+ ))) return true;
33
37
  const allowOlderBasis = resourceType !== "retention-schedule-item"
34
38
  || !modelSupports(loaded.model, "retention-schedule-approval");
35
39
  for (const olderBasis of [false, true]) {
@@ -152,13 +156,17 @@ function historicallyEquivalentCollection(loaded, resourceType, stored, options,
152
156
  // Require the complete reviewed basis to be unchanged under the current
153
157
  // semantic calculation before accepting an older byte-sensitive digest.
154
158
  if (!revisionsMatch("collection", current, collectionRevision(snapshot, resourceType, historicalOptions))) continue;
155
- for (const legacy of [false, true]) {
156
- for (const scopeHashInput of ["legacy", "digest"]) {
157
- for (const scopeFactsInput of ["legacy", "source"]) {
158
- if (revisionsMatch("collection", stored, calculateCollectionRevision(
159
- snapshot, resourceType, { ...historicalOptions, historicalReviewMetadata: true },
160
- legacy, scopeHashInput, scopeFactsInput
161
- ))) return true;
159
+ for (const legacyRetentionSourceBasis of resourceType === "retention-schedule-item" ? [false, true] : [false]) {
160
+ for (const historicalReviewMetadata of legacyRetentionSourceBasis ? [false] : [true]) {
161
+ for (const legacy of [false, true]) {
162
+ for (const scopeHashInput of ["legacy", "digest"]) {
163
+ for (const scopeFactsInput of ["legacy", "source"]) {
164
+ if (revisionsMatch("collection", stored, calculateCollectionRevision(
165
+ snapshot, resourceType, { ...historicalOptions, historicalReviewMetadata, legacyRetentionSourceBasis },
166
+ legacy, scopeHashInput, scopeFactsInput
167
+ ))) return true;
168
+ }
169
+ }
162
170
  }
163
171
  }
164
172
  }
@@ -175,7 +183,12 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy, scop
175
183
  : Object.hasOwn(options, "program")
176
184
  ? options.program
177
185
  : resolveProgram(loaded, options.programId);
178
- const inputs = new Map(collectionRevisionInputs(loaded, resourceType, program, { legacy, historicalReviewMetadata: options.historicalReviewMetadata })
186
+ const inputs = new Map(collectionRevisionInputs(loaded, resourceType, program, {
187
+ legacy,
188
+ historicalReviewMetadata: options.historicalReviewMetadata,
189
+ historicalCommit: options.historicalCommit,
190
+ legacyRetentionSourceBasis: options.legacyRetentionSourceBasis
191
+ })
179
192
  .map((input) => [input.record.id, input]));
180
193
  const authoritativeSource = loaded.resources.find(({ id }) => id === options.authoritativeSourceId);
181
194
  if (authoritativeSource) {
@@ -1,6 +1,6 @@
1
1
  import { modelSupports } from "../model/index.js";
2
2
  import { programComponents, selectedRequirementIds } from "./program.js";
3
- import { retentionReviewResourceIds, retentionUses } from "./retention.js";
3
+ import { resourceReviewRevisionsSync, retentionReviewResourceIds, retentionUses } from "./retention.js";
4
4
  import { documentIsAuditSpecific } from "./program-lifecycle.js";
5
5
 
6
6
  export function scopedCollectionRecords(loaded, resourceType, program) {
@@ -210,6 +210,8 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
210
210
  const byId = new Map(loaded.resources.map((record) => [record.id, record]));
211
211
  const records = new Map(reviewed.map((record) => [record.id, record]));
212
212
  const reviewedIds = new Set(records.keys());
213
+ const scheduleDocumentIds = new Set(resourceType === "retention-schedule-item"
214
+ ? reviewed.map((record) => record.scheduleDocumentId).filter(Boolean) : []);
213
215
  const addIds = (ids) => {
214
216
  for (const id of ids || []) {
215
217
  const record = byId.get(id);
@@ -363,7 +365,7 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
363
365
  record,
364
366
  value: reviewedIds.has(record.id)
365
367
  ? resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
366
- ? retentionScheduleRowProposalValue(record, options.historicalReviewMetadata)
368
+ ? retentionScheduleRowProposalValue(record, loaded, options)
367
369
  : options.historicalReviewMetadata ? record : substantiveRevisionValue(record)
368
370
  : resourceType === "retention-schedule-item"
369
371
  && modelSupports(loaded.model, "retention-schedule-approval")
@@ -371,19 +373,25 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
371
373
  ? retentionScheduleDocumentProposalValue(record)
372
374
  : options.historicalReviewMetadata
373
375
  ? dependencyRevisionValue(resourceType, record, legacy, true, currentModel)
374
- : substantiveRevisionValue(dependencyRevisionValue(resourceType, record, legacy, false, currentModel)),
376
+ : substantiveRevisionValue(dependencyRevisionValue(resourceType, record, legacy, false, currentModel, scheduleDocumentIds, options.legacyRetentionSourceBasis)),
375
377
  includeContent: legacy
376
378
  || reviewedIds.has(record.id)
377
- || dependencyContentAffectsRevision(resourceType, record.type, options.historicalReviewMetadata, currentModel)
379
+ || dependencyContentAffectsRevision(resourceType, record, options.historicalReviewMetadata, currentModel, scheduleDocumentIds, options.legacyRetentionSourceBasis)
378
380
  }));
379
381
  }
380
382
 
381
- function retentionScheduleRowProposalValue(record, historicalReviewMetadata = false) {
382
- return Object.fromEntries(Object.entries(record).filter(([field]) => ![
383
+ function retentionScheduleRowProposalValue(record, loaded, options = {}) {
384
+ const value = Object.fromEntries(Object.entries(record).filter(([field]) => ![
383
385
  "approvedByIds",
384
386
  "approvedOn",
385
- ...(historicalReviewMetadata ? [] : ["reviewedSourceRevisions", "tags", "statusTransition"])
387
+ ...(options.historicalReviewMetadata ? [] : ["reviewedSourceRevisions", "tags", "statusTransition"])
386
388
  ].includes(field)));
389
+ if (!options.historicalReviewMetadata && !options.legacyRetentionSourceBasis) {
390
+ value.sourceDecisionRevisions = Object.fromEntries(resourceReviewRevisionsSync(
391
+ loaded, record.sourceResourceIds || [], "legacy", false, options.historicalCommit || null, record
392
+ ));
393
+ }
394
+ return value;
387
395
  }
388
396
 
389
397
  function retentionScheduleDocumentProposalValue(record) {
@@ -533,7 +541,7 @@ const dependencyFields = {
533
541
  obligation: ["id", "type", "status", "activityType", "scheduleMode", "ownerIds", "activeRuleId", "policyIds", "controlIds", "ruleIds"],
534
542
  "source-coverage": ["id", "type", "status", "sourceFamilyId", "coverageKind", "scopeResourceIds", "excludedPopulation", "retrieverIds", "collectionCadence", "retentionScheduleItemIds", "reconciliationMethod", "validFrom", "validThrough", "applicabilityReview", "readinessTestEvidenceIds", "ownerIds", "componentId"],
535
543
  evidence: ["id", "type", "status", "artifactKind", "readinessTest", "retrievalResult", "accessConfirmed", "coveredSourceFamilyIds", "sourceComponentId", "componentIds", "systemIds", "collectedOn", "collectorIds", "verifierIds", "verifiedOn"],
536
- "retention-schedule-item": ["id", "type", "status", "description", "informationTypeIds", "scopeResourceIds", "scheduleDocumentId", "sourceResourceIds", "ownerIds", "approvedByIds", "approvedOn", "reviewedSourceRevisions", "cutoff", "retentionPeriod", "dispositionAction", "dispositionInstructions"],
544
+ "retention-schedule-item": ["id", "type", "status", "description", "informationTypeIds", "scopeResourceIds", "scheduleDocumentId", "sourceResourceIds", "sourceSectionHeadings", "ownerIds", "approvedByIds", "approvedOn", "reviewedSourceRevisions", "cutoff", "retentionPeriod", "dispositionAction", "dispositionInstructions"],
537
545
  document: ["id", "type", "documentKind", "workflowScope", "ownerIds", "approverIds", "approvedOn", "approvedContentRevisions", "controlIds"],
538
546
  policy: ["id", "type", "programRole", "ownerIds", "approverIds", "approvedOn", "approvedContentRevisions", "relatedDocumentIds", "controlIds"],
539
547
  training: ["id", "type", "ownerIds", "approverIds", "approvedOn", "approvedContentRevisions", "controlIds"],
@@ -597,7 +605,12 @@ const dependencyContentTypes = {
597
605
  "complementary-control": new Set(["system", "document", "component"])
598
606
  };
599
607
 
600
- function dependencyRevisionValue(resourceType, record, legacy, historicalReviewMetadata = false, currentModel = false) {
608
+ function dependencyRevisionValue(resourceType, record, legacy, historicalReviewMetadata = false, currentModel = false, scheduleDocumentIds = new Set(), legacyRetentionSourceBasis = false) {
609
+ if (resourceType === "retention-schedule-item" && currentModel && !historicalReviewMetadata && !legacyRetentionSourceBasis
610
+ && (["policy", "requirement", "commitment", "control"].includes(record.type)
611
+ || record.type === "document" && record.documentKind !== "schedule" && !scheduleDocumentIds.has(record.id))) {
612
+ return { id: record.id, type: record.type };
613
+ }
601
614
  const controlDecision = currentModel && !historicalReviewMetadata && record.type === "control"
602
615
  && ["retention-schedule-item", "complementary-control"].includes(resourceType);
603
616
  const fields = controlDecision
@@ -616,9 +629,12 @@ function dependencyRevisionValue(resourceType, record, legacy, historicalReviewM
616
629
  return value;
617
630
  }
618
631
 
619
- function dependencyContentAffectsRevision(resourceType, dependencyType, historicalReviewMetadata = false, currentModel = false) {
620
- return ((!currentModel || historicalReviewMetadata) && resourceType === "complementary-control" && dependencyType === "control")
621
- || dependencyContentTypes[resourceType]?.has(dependencyType) || false;
632
+ function dependencyContentAffectsRevision(resourceType, dependency, historicalReviewMetadata = false, currentModel = false, scheduleDocumentIds = new Set(), legacyRetentionSourceBasis = false) {
633
+ if (resourceType === "retention-schedule-item" && currentModel && !historicalReviewMetadata && !legacyRetentionSourceBasis
634
+ && (dependency.type === "policy" || dependency.type === "document"
635
+ && dependency.documentKind !== "schedule" && !scheduleDocumentIds.has(dependency.id))) return false;
636
+ return ((!currentModel || historicalReviewMetadata) && resourceType === "complementary-control" && dependency.type === "control")
637
+ || dependencyContentTypes[resourceType]?.has(dependency.type) || false;
622
638
  }
623
639
 
624
640
  function sorted(values) {
@@ -128,7 +128,7 @@ export async function planProgramAmendment(input, options = {}) {
128
128
  const retention = related.filter((record) => (
129
129
  record.type === "retention-schedule-item"
130
130
  && record.status === "active"
131
- && reviewBindingsDiffer(retentionReviewResourceIds(record, loaded), record.reviewedSourceRevisions, currentReviewRevisions, loaded)
131
+ && reviewBindingsDiffer(retentionReviewResourceIds(record, loaded), record.reviewedSourceRevisions, currentReviewRevisions, loaded, record)
132
132
  ));
133
133
  if (retention.length) {
134
134
  missing.push({
@@ -161,10 +161,10 @@ export async function planProgramAmendment(input, options = {}) {
161
161
  };
162
162
  }
163
163
 
164
- function reviewBindingsDiffer(expectedIds, reviewed = {}, current, loaded) {
164
+ function reviewBindingsDiffer(expectedIds, reviewed = {}, current, loaded, reviewer = null) {
165
165
  const expected = new Set(expectedIds);
166
166
  if (Object.keys(reviewed).length !== expected.size) return true;
167
- return [...expected].some((id) => !resourceReviewRevisionMatches(loaded, current, id, reviewed[id]));
167
+ return [...expected].some((id) => !resourceReviewRevisionMatches(loaded, current, id, reviewed[id], reviewer));
168
168
  }
169
169
 
170
170
  export async function assessProgramAmendmentReadiness(loaded) {
package/src/retention.js CHANGED
@@ -156,79 +156,106 @@ export async function resourceReviewRevision(loaded, resourceId) {
156
156
  return (await resourceReviewRevisions(loaded, [resourceId])).get(resourceId) || null;
157
157
  }
158
158
 
159
- export async function resourceReviewRevisions(loaded, ids, scopeHashInput = "legacy") {
159
+ export async function resourceReviewRevisions(loaded, ids, scopeHashInput = "legacy", reviewer = null) {
160
160
  const wanted = new Set(ids);
161
161
  const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
162
162
  const revisions = new Map();
163
163
  const reviewing = new Set();
164
- const review = async (id) => {
165
- if (revisions.has(id)) return revisions.get(id);
166
- const entry = entries.get(id);
167
- if (!entry || reviewing.has(id)) return null;
168
- reviewing.add(id);
169
- const parts = [reviewSource(loaded, entry, scopeHashInput)];
164
+ const review = async (id, heading = null) => {
165
+ const key = `${id}\0${heading || ""}`;
166
+ if (revisions.has(key)) return revisions.get(key);
167
+ const entry = effectiveReviewEntry(loaded, entries.get(id));
168
+ if (!entry || reviewing.has(key)) return null;
169
+ reviewing.add(key);
170
+ const parts = [reviewSource(loaded, entry, scopeHashInput, false, heading)];
171
+ let matchedSection = !heading;
170
172
  for (const markdown of reviewMarkdownEntries(loaded, entry, false)) {
171
173
  try {
172
- parts.push(await readFile(resolveDataPath(loaded.root, markdown.path), "utf8"));
174
+ const source = entry.reviewCommit
175
+ ? entry.reviewFiles?.get(`data/${markdown.path}`)
176
+ ?? getFileAtRevision(loaded.root, entry.reviewCommit, `data/${markdown.path}`)
177
+ : await readFile(resolveDataPath(loaded.root, markdown.path), "utf8");
178
+ if (source !== null) {
179
+ const section = reviewedMarkdownSection(source, heading);
180
+ if (section !== null) { parts.push(section); matchedSection = true; }
181
+ }
173
182
  } catch (error) {
174
183
  if (error.code !== "ENOENT") throw error;
175
184
  }
176
185
  }
186
+ if (!matchedSection) { reviewing.delete(key); return null; }
187
+ let missingDependency = false;
177
188
  for (const sourceId of [...new Set(entry.record.sourceResourceIds || [])].sort()) {
178
- const revision = await review(sourceId);
189
+ const revision = await review(sourceId, sourceHeading(entry.record, sourceId));
179
190
  if (revision) parts.push(`${sourceId}:${revisionDigest("content", revision) || revision}`);
191
+ else missingDependency = true;
180
192
  }
181
- reviewing.delete(id);
193
+ reviewing.delete(key);
194
+ if (missingDependency) return null;
182
195
  const revision = contentRevision(parts.join("\n"));
183
- revisions.set(id, revision);
196
+ revisions.set(key, revision);
184
197
  return revision;
185
198
  };
199
+ const selected = new Map();
186
200
  for (const id of wanted) {
187
- await review(id);
201
+ selected.set(id, await review(id, sourceHeading(reviewer, id)));
188
202
  }
189
- return new Map([...revisions].filter(([id]) => wanted.has(id)));
203
+ return new Map([...selected].filter(([, revision]) => revision));
190
204
  }
191
205
 
192
- export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legacy", legacySource = false, historicalCommit = null) {
206
+ export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legacy", legacySource = false, historicalCommit = null, reviewer = null) {
193
207
  const wanted = new Set(ids);
194
208
  const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
195
209
  const revisions = new Map();
196
210
  const reviewing = new Set();
197
- const review = (id) => {
198
- if (revisions.has(id)) return revisions.get(id);
199
- const entry = entries.get(id);
200
- if (!entry || reviewing.has(id)) return null;
201
- reviewing.add(id);
202
- const parts = [reviewSource(loaded, entry, scopeHashInput, legacySource)];
211
+ const review = (id, heading = null) => {
212
+ const key = `${id}\0${heading || ""}`;
213
+ if (revisions.has(key)) return revisions.get(key);
214
+ const entry = historicalCommit ? entries.get(id) : effectiveReviewEntry(loaded, entries.get(id));
215
+ if (!entry || reviewing.has(key)) return null;
216
+ reviewing.add(key);
217
+ const parts = [reviewSource(loaded, entry, scopeHashInput, legacySource, heading)];
218
+ let matchedSection = !heading;
203
219
  for (const markdown of reviewMarkdownEntries(loaded, entry, legacySource)) {
204
220
  try {
205
- const source = historicalCommit
206
- ? loaded.historicalFiles
207
- ? loaded.historicalFiles.get(`data/${markdown.path}`) ?? null
208
- : getFileAtRevision(loaded.root, historicalCommit, `data/${markdown.path}`)
221
+ const source = (historicalCommit || entry.reviewCommit)
222
+ ? loaded.historicalFiles?.get(`data/${markdown.path}`)
223
+ ?? entry.reviewFiles?.get(`data/${markdown.path}`)
224
+ ?? getFileAtRevision(loaded.root, historicalCommit || entry.reviewCommit, `data/${markdown.path}`)
209
225
  : readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8");
210
- if (source !== null) parts.push(source);
226
+ if (source !== null) {
227
+ const section = legacySource ? source : reviewedMarkdownSection(source, heading);
228
+ if (section !== null) { parts.push(section); matchedSection = true; }
229
+ }
211
230
  } catch (error) {
212
231
  if (error.code !== "ENOENT") throw error;
213
232
  }
214
233
  }
234
+ if (!matchedSection) { reviewing.delete(key); return null; }
235
+ let missingDependency = false;
215
236
  for (const sourceId of [...new Set(entry.record.sourceResourceIds || [])].sort()) {
216
- const revision = review(sourceId);
237
+ const revision = review(sourceId, sourceHeading(entry.record, sourceId));
217
238
  if (revision) parts.push(`${sourceId}:${revisionDigest("content", revision) || revision}`);
239
+ else missingDependency = true;
218
240
  }
219
- reviewing.delete(id);
241
+ reviewing.delete(key);
242
+ if (missingDependency) return null;
220
243
  const revision = contentRevision(parts.join("\n"));
221
- revisions.set(id, revision);
244
+ revisions.set(key, revision);
222
245
  return revision;
223
246
  };
224
- for (const id of wanted) review(id);
225
- return new Map([...revisions].filter(([id]) => wanted.has(id)));
247
+ const selected = new Map();
248
+ for (const id of wanted) selected.set(id, review(id, sourceHeading(reviewer, id)));
249
+ return new Map([...selected].filter(([, revision]) => revision));
226
250
  }
227
251
 
228
- export function resourceReviewRevisionMatches(loaded, revisions, id, stored) {
229
- const current = revisions.get(id);
252
+ export function resourceReviewRevisionMatches(loaded, revisions, id, stored, reviewer = null) {
253
+ const current = sourceHeading(reviewer, id)
254
+ ? resourceReviewRevisionsSync(loaded, [id], "legacy", false, null, reviewer).get(id)
255
+ : revisions.get(id);
230
256
  if (!current || !revisionDigest("content", stored)) return false;
231
257
  if (revisionsMatch("content", stored, current)) return true;
258
+ if (sourceHeading(reviewer, id)) return false;
232
259
  const legacy = resourceReviewRevisionsSync(loaded, [id], "legacy", true).get(id);
233
260
  if (revisionsMatch("content", stored, legacy)) return true;
234
261
  // Version 0.16.0 used a bare digest as the hash input for scopeRevision.
@@ -293,7 +320,20 @@ export function retentionUses(loaded, program) {
293
320
  return [...new Map(uses.map((use) => [`${use.resource.id}:${use.informationTypeId}`, use])).values()];
294
321
  }
295
322
 
296
- function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = false) {
323
+ function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = false, heading = null) {
324
+ if (heading && !legacySource) {
325
+ const governingFields = entry.record.type === "policy"
326
+ ? ["policyKind", "effectiveOn", "supersedesId", "parentPolicyId", "relatedPolicyIds", "relatedDocumentIds", "requirementIds", "audience", "acknowledgementRequired", "programRole", "reportingRouteRequirements"]
327
+ : ["documentKind", "workflowScope", "effectiveOn", "supersedesId", "systemIds", "controlIds", "relatedDocumentIds", "audience", "acknowledgementRequired", "trainingIds", "classificationId", "programRole", "componentIds", "reportingRouteRequirements"];
328
+ return JSON.stringify({
329
+ id: entry.record.id,
330
+ type: entry.record.type,
331
+ governingStatus: ["approved", "active"].includes(entry.record.status)
332
+ ? "approved" : entry.record.status || null,
333
+ ...Object.fromEntries(["extensions", "externalIds", ...governingFields].filter((field) => entry.record[field] !== undefined)
334
+ .map((field) => [field, entry.record[field]]))
335
+ });
336
+ }
297
337
  if (
298
338
  modelSupports(loaded.model, "retention-schedule-approval")
299
339
  && entry.record.type === "document"
@@ -330,9 +370,93 @@ function reviewMarkdownEntries(loaded, entry, legacySource) {
330
370
  return markdownEntries(loaded.model, entry.record);
331
371
  }
332
372
 
373
+ const approvedReviewEntries = new WeakMap();
374
+
375
+ function effectiveReviewEntry(loaded, entry) {
376
+ if (!entry || !loaded.root || !["policy", "document", "training"].includes(entry.record.type)) return entry;
377
+ if (!["draft", "in-review", "approved", "active"].includes(entry.record.status)) return entry;
378
+ if (approvedReviewContentMatches(loaded, entry)) return entry;
379
+ if (["approved", "active"].includes(entry.record.status)) return entry;
380
+ let cache = approvedReviewEntries.get(loaded.resources);
381
+ if (!cache) {
382
+ cache = new Map();
383
+ approvedReviewEntries.set(loaded.resources, cache);
384
+ }
385
+ if (cache.has(entry.record.id)) return cache.get(entry.record.id) || entry;
386
+ const context = reviewHistoryContext(loaded.root, loaded);
387
+ for (const commit of [...reviewHistoryCommits(context)].reverse()) {
388
+ const snapshot = reviewHistoricalWorkspace(context, commit);
389
+ const historical = snapshot?.entries.find(({ record }) => record.id === entry.record.id);
390
+ if (!historical || !approvedReviewContentMatches(snapshot, historical, commit)) continue;
391
+ const approved = { ...historical, reviewCommit: commit, reviewFiles: snapshot.historicalFiles };
392
+ cache.set(entry.record.id, approved);
393
+ return approved;
394
+ }
395
+ cache.set(entry.record.id, null);
396
+ return entry;
397
+ }
398
+
399
+ function approvedReviewContentMatches(loaded, entry, commit = null) {
400
+ if (!["approved", "active"].includes(entry.record.status)) return false;
401
+ const bindings = entry.record.approvedContentRevisions || entry.record.effectiveContentRevisions;
402
+ if (!bindings) return true;
403
+ for (const markdown of reviewMarkdownEntries(loaded, entry, false)) {
404
+ const source = commit
405
+ ? loaded.historicalFiles?.get(`data/${markdown.path}`)
406
+ ?? getFileAtRevision(loaded.root, commit, `data/${markdown.path}`)
407
+ : (() => {
408
+ try { return readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8"); }
409
+ catch (error) { if (error.code === "ENOENT") return null; throw error; }
410
+ })();
411
+ if (!source || !revisionsMatch("content", bindings[markdown.path], contentRevision(source))) return false;
412
+ }
413
+ return true;
414
+ }
415
+
416
+ export function reviewedMarkdownSection(source, heading) {
417
+ if (!heading) return source;
418
+ const lines = source.split("\n");
419
+ const wanted = heading.trim().replace(/^#{1,6}\s+/, "").toLowerCase();
420
+ const headings = [];
421
+ const fencedLines = new Set();
422
+ let fence = null;
423
+ for (const [index, line] of lines.entries()) {
424
+ const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
425
+ if (fence) {
426
+ fencedLines.add(index);
427
+ if (marker?.[0] === fence[0] && marker.length >= fence.length
428
+ && /^\s*$/.test(line.slice(line.indexOf(marker) + marker.length))) fence = null;
429
+ continue;
430
+ }
431
+ if (marker) { fence = marker; fencedLines.add(index); continue; }
432
+ const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
433
+ if (match) headings.push({ index, level: match[1].length, title: match[2].trim().toLowerCase() });
434
+ }
435
+ const matches = headings.filter(({ title }) => title === wanted);
436
+ if (matches.length !== 1) return null;
437
+ const selected = matches[0];
438
+ const ancestors = [];
439
+ for (const candidate of headings.filter(({ index }) => index < selected.index)) {
440
+ while (ancestors.length && ancestors.at(-1).level >= candidate.level) ancestors.pop();
441
+ ancestors.push(candidate);
442
+ }
443
+ const next = headings.find(({ index, level }) => index > selected.index && level <= selected.level);
444
+ const context = ancestors.map(({ index }) => {
445
+ const following = headings.find((candidate) => candidate.index > index);
446
+ return lines.slice(index, following?.index).filter((_, offset) => !fencedLines.has(index + offset)).join("\n").trim();
447
+ });
448
+ return [...context, lines.slice(selected.index, next?.index).join("\n").trim()].join("\n\n");
449
+ }
450
+
451
+ function sourceHeading(record, sourceId) {
452
+ const headings = record?.sourceSectionHeadings;
453
+ return headings && Object.hasOwn(headings, sourceId) && typeof headings[sourceId] === "string"
454
+ ? headings[sourceId] : null;
455
+ }
456
+
333
457
  const reviewDecisionFields = {
334
458
  control: ["statement", "requirementIds", "code", "activity", "controlType", "operationMode", "operationPattern", "systemIds", "policyIds", "componentIds", "evidenceSourceComponentIds"],
335
- commitment: ["commitmentKind", "statement", "systemIds", "sourceResourceIds", "requirementIds", "controlIds", "customerFacing", "effectiveOn", "reportingRouteRequirements"],
459
+ commitment: ["commitmentKind", "statement", "systemIds", "sourceResourceIds", "sourceSectionHeadings", "requirementIds", "controlIds", "customerFacing", "effectiveOn", "reportingRouteRequirements"],
336
460
  "complementary-control": ["responsibleParty", "statement", "systemIds", "vendorId", "requirementIds", "commitmentIds", "relatedControlIds", "sourceDocumentIds", "componentIds"],
337
461
  "source-coverage": ["sourceFamilyId", "coverageKind", "scopeResourceIds", "excludedPopulation", "collectionCadence", "reconciliationMethod", "validFrom", "validThrough", "componentId", "retentionScheduleItemIds"],
338
462
  system: ["purpose", "servicesProvided", "boundary", "exclusions", "criticality", "informationTypeIds", "classificationId", "internetExposed", "continuityObjectives"],
@@ -450,7 +574,7 @@ export function retentionRuleIsCurrent(rule, revisions, byId = new Map(), loaded
450
574
  const dependencyIds = retentionReviewResourceIds(rule, loaded);
451
575
  if (Object.keys(rule.reviewedSourceRevisions).length !== dependencyIds.length) return false;
452
576
  return dependencyIds.every((id) => (
453
- resourceReviewRevisionMatches(loaded, revisions, id, rule.reviewedSourceRevisions?.[id])
577
+ resourceReviewRevisionMatches(loaded, revisions, id, rule.reviewedSourceRevisions?.[id], rule)
454
578
  ));
455
579
  }
456
580
 
package/src/server.js CHANGED
@@ -627,9 +627,23 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
627
627
  return json(response, 400, { error: "Pass one or more safe resource IDs." });
628
628
  }
629
629
  const loaded = await loadWorkspace(input);
630
- const revisions = await resourceReviewRevisions(loaded, ids);
630
+ const sourceSectionHeadings = Object.create(null);
631
+ for (const value of url.searchParams.getAll("section")) {
632
+ const separator = value.indexOf(":");
633
+ const id = value.slice(0, separator);
634
+ const heading = value.slice(separator + 1).trim();
635
+ if (separator < 1 || !ids.includes(id) || !heading || heading.length > 200) {
636
+ return json(response, 400, { error: "Each reviewed section must name a selected source and a Markdown heading." });
637
+ }
638
+ sourceSectionHeadings[id] = heading;
639
+ }
640
+ const revisions = await resourceReviewRevisions(loaded, ids, "legacy", { sourceSectionHeadings });
631
641
  const missing = ids.filter((id) => !revisions.has(id));
632
- if (missing.length) return json(response, 404, { error: `Resources not found: ${missing.join(", ")}.` });
642
+ if (missing.length) return json(response, 404, {
643
+ error: missing.some((id) => sourceSectionHeadings[id])
644
+ ? `A selected source heading is missing or ambiguous: ${missing.filter((id) => sourceSectionHeadings[id]).join(", ")}.`
645
+ : `Resources not found: ${missing.join(", ")}.`
646
+ });
633
647
  return json(response, 200, { revisions: Object.fromEntries(revisions) });
634
648
  }
635
649
  if (request.method === "PUT" && url.pathname === "/api/content") {
package/src/validate.js CHANGED
@@ -219,6 +219,19 @@ async function validateWorkspaceUnmeasured(input) {
219
219
  }
220
220
  validateNestedRelations(fieldName, value, field, loaded.model, byId, displayPath, diagnostics);
221
221
  }
222
+ if (record.sourceSectionHeadings) {
223
+ for (const [sourceId, heading] of Object.entries(record.sourceSectionHeadings)) {
224
+ if (!(record.sourceResourceIds || []).includes(sourceId)
225
+ || !["policy", "document"].includes(byId.get(sourceId)?.type)
226
+ || !String(heading || "").trim()) {
227
+ diagnostics.push(error(
228
+ "invalid-source-section",
229
+ displayPath,
230
+ `sourceSectionHeadings.${sourceId} must name a linked Policy or Document and a Markdown heading.`
231
+ ));
232
+ }
233
+ }
234
+ }
222
235
  validateIndependentApproval(record, byId, displayPath, diagnostics);
223
236
  validateCompletedObligationEvent(record, byId, loaded.model, displayPath, diagnostics);
224
237
  validateActionObligationRule(record, byId, displayPath, diagnostics);
@@ -1995,7 +2008,7 @@ function validateRetentionScheduleItem(record, loaded, byId, currentReviewRevisi
1995
2008
  const sources = retentionReviewResourceIds(record, loaded);
1996
2009
  const revisions = record.reviewedSourceRevisions || {};
1997
2010
  const missing = sources.filter((id) => (
1998
- !resourceReviewRevisionMatches(loaded, currentReviewRevisions, id, revisions[id])
2011
+ !resourceReviewRevisionMatches(loaded, currentReviewRevisions, id, revisions[id], record)
1999
2012
  )).concat(Object.keys(revisions).filter((id) => !sources.includes(id)));
2000
2013
  if (missing.length) {
2001
2014
  diagnostics.push(error(
package/src/web.js CHANGED
@@ -5358,6 +5358,8 @@ function editorField(type, name, field, value, required, editing, oneOfRequired
5358
5358
  control = stringMapEditor(value || {}, name, name === "reviewedSourceRevisions");
5359
5359
  const mapHelp = name === "reviewedSourceRevisions"
5360
5360
  ? "Bind the exact current JSON and Markdown revisions after reviewing every selected record."
5361
+ : name === "sourceSectionHeadings"
5362
+ ? "Use a linked Policy or Document ID and its exact Markdown heading. The selected section governs this decision."
5361
5363
  : "Add one named value per row.";
5362
5364
  return fieldWrap(name, "string-map", label, requiredMark, control, mapHelp, required);
5363
5365
  }
@@ -5558,7 +5560,11 @@ async function bindCurrentReviewRevisions(dialog, button) {
5558
5560
  setButtonBusy(button, true, "Binding…");
5559
5561
  let bound = false;
5560
5562
  try {
5561
- const query = ids.map((id) => "id=" + encodeURIComponent(id)).join("&");
5563
+ const sections = [...dialog.querySelectorAll('[data-field-group="sourceSectionHeadings"] .string-map-row')]
5564
+ .map((row) => [row.querySelector('[data-map-key]')?.value?.trim(), row.querySelector('[data-map-value]')?.value?.trim()])
5565
+ .filter(([id, heading]) => id && heading);
5566
+ const query = ids.map((id) => "id=" + encodeURIComponent(id))
5567
+ .concat(sections.map(([id, heading]) => "section=" + encodeURIComponent(id + ":" + heading))).join("&");
5562
5568
  const response = await localFetch("/api/review-revisions?" + query);
5563
5569
  if (!response.ok) throw new Error(await responseMessage(response));
5564
5570
  const { revisions } = await response.json();
@@ -6775,6 +6781,7 @@ dialog::backdrop{background:rgba(0,0,24,.62)}
6775
6781
  @media(max-width:760px){.workflow-findings{grid-template-columns:1fr}}
6776
6782
  .choice-tag{overflow-wrap:normal;font-size:10px}.form-field[data-kind="structured-object"],.string-map-property{grid-column:1/-1}.string-map-row .text-button{text-transform:none;letter-spacing:0;color:var(--muted);font-size:11px}
6777
6783
  .form-field[data-kind="string-map"]{grid-column:1/-1}.string-map-actions{display:flex;flex-wrap:wrap;gap:7px}
6784
+ .form-field[data-field-group="sourceSectionHeadings"] .string-map-row{grid-template-columns:minmax(0,1fr) minmax(0,2fr) auto}
6778
6785
  @media(max-width:760px){.setup-banner>.button{justify-self:start}.setup-draft-state{justify-content:flex-start;flex-wrap:wrap;gap:12px 22px}.setup-draft-state .button{width:100%}}
6779
6786
  @media(max-width:760px){.string-map-row{grid-template-columns:1fr 1fr}.string-map-row .text-button{grid-column:1/-1;justify-self:start}}
6780
6787