filegrc 0.16.18 → 0.16.20
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 +16 -1
- package/package.json +1 -1
- package/src/cli.js +4 -2
- package/src/collection-revision.js +21 -8
- package/src/collection-scope.js +32 -12
- package/src/program-amendment.js +3 -3
- package/src/retention.js +186 -46
- package/src/server.js +16 -2
- package/src/validate.js +14 -1
- package/src/web.js +8 -1
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
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
|
|
156
|
-
for (const
|
|
157
|
-
for (const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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, {
|
|
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) {
|
package/src/collection-scope.js
CHANGED
|
@@ -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, retentionSystemUses, 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
|
|
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
|
|
379
|
+
|| dependencyContentAffectsRevision(resourceType, record, options.historicalReviewMetadata, currentModel, scheduleDocumentIds, options.legacyRetentionSourceBasis)
|
|
378
380
|
}));
|
|
379
381
|
}
|
|
380
382
|
|
|
381
|
-
function retentionScheduleRowProposalValue(record,
|
|
382
|
-
|
|
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
|
|
@@ -612,13 +625,20 @@ function dependencyRevisionValue(resourceType, record, legacy, historicalReviewM
|
|
|
612
625
|
const value = Object.fromEntries(fields
|
|
613
626
|
.filter((field) => record[field] !== undefined)
|
|
614
627
|
.map((field) => [field, record[field]]));
|
|
628
|
+
if (resourceType === "retention-schedule-item" && currentModel && !historicalReviewMetadata
|
|
629
|
+
&& !legacyRetentionSourceBasis && record.type === "component") {
|
|
630
|
+
value.systemUses = retentionSystemUses(value.systemUses);
|
|
631
|
+
}
|
|
615
632
|
if (controlDecision) value.unavailable = ["not-applicable", "retired", "superseded"].includes(record.status);
|
|
616
633
|
return value;
|
|
617
634
|
}
|
|
618
635
|
|
|
619
|
-
function dependencyContentAffectsRevision(resourceType,
|
|
620
|
-
|
|
621
|
-
|
|
636
|
+
function dependencyContentAffectsRevision(resourceType, dependency, historicalReviewMetadata = false, currentModel = false, scheduleDocumentIds = new Set(), legacyRetentionSourceBasis = false) {
|
|
637
|
+
if (resourceType === "retention-schedule-item" && currentModel && !historicalReviewMetadata && !legacyRetentionSourceBasis
|
|
638
|
+
&& (dependency.type === "policy" || dependency.type === "document"
|
|
639
|
+
&& dependency.documentKind !== "schedule" && !scheduleDocumentIds.has(dependency.id))) return false;
|
|
640
|
+
return ((!currentModel || historicalReviewMetadata) && resourceType === "complementary-control" && dependency.type === "control")
|
|
641
|
+
|| dependencyContentTypes[resourceType]?.has(dependency.type) || false;
|
|
622
642
|
}
|
|
623
643
|
|
|
624
644
|
function sorted(values) {
|
package/src/program-amendment.js
CHANGED
|
@@ -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,92 +156,120 @@ 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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
reviewing.
|
|
169
|
-
|
|
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, reviewer)];
|
|
171
|
+
let matchedSection = !heading;
|
|
170
172
|
for (const markdown of reviewMarkdownEntries(loaded, entry, false)) {
|
|
171
173
|
try {
|
|
172
|
-
|
|
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(
|
|
193
|
+
reviewing.delete(key);
|
|
194
|
+
if (missingDependency) return null;
|
|
182
195
|
const revision = contentRevision(parts.join("\n"));
|
|
183
|
-
revisions.set(
|
|
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([...
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
reviewing.
|
|
202
|
-
|
|
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, reviewer)];
|
|
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
|
-
|
|
208
|
-
|
|
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)
|
|
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(
|
|
241
|
+
reviewing.delete(key);
|
|
242
|
+
if (missingDependency) return null;
|
|
220
243
|
const revision = contentRevision(parts.join("\n"));
|
|
221
|
-
revisions.set(
|
|
244
|
+
revisions.set(key, revision);
|
|
222
245
|
return revision;
|
|
223
246
|
};
|
|
224
|
-
|
|
225
|
-
|
|
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 =
|
|
252
|
+
export function resourceReviewRevisionMatches(loaded, revisions, id, stored, reviewer = null) {
|
|
253
|
+
const current = (sourceHeading(reviewer, id) || reviewer?.type === "retention-schedule-item")
|
|
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;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
258
|
+
if (!sourceHeading(reviewer, id)) {
|
|
259
|
+
const legacy = resourceReviewRevisionsSync(loaded, [id], "legacy", true).get(id);
|
|
260
|
+
if (revisionsMatch("content", stored, legacy)) return true;
|
|
261
|
+
// Version 0.16.0 used a bare digest as the hash input for scopeRevision.
|
|
262
|
+
// Read that binding without changing the legacy scope: basis restored here.
|
|
263
|
+
const compatible = resourceReviewRevisionsSync(loaded, [id], "digest", true).get(id);
|
|
264
|
+
if (revisionsMatch("content", stored, compatible)) return true;
|
|
265
|
+
}
|
|
266
|
+
return historicallyEquivalentReviewSource(loaded, id, stored, current, reviewer);
|
|
239
267
|
}
|
|
240
268
|
|
|
241
269
|
// Recompute the old byte-sensitive binding at each committed state. The
|
|
242
270
|
// semantic source graph must still equal today's graph, including linked
|
|
243
271
|
// records and Markdown, before the old approval is treated as current.
|
|
244
|
-
function historicallyEquivalentReviewSource(loaded, id, stored, current) {
|
|
272
|
+
function historicallyEquivalentReviewSource(loaded, id, stored, current, reviewer = null) {
|
|
245
273
|
if (!loaded.root) return false;
|
|
246
274
|
const context = reviewHistoryContext(loaded.root, loaded);
|
|
247
275
|
const { index } = context;
|
|
@@ -262,8 +290,13 @@ function historicallyEquivalentReviewSource(loaded, id, stored, current) {
|
|
|
262
290
|
if (relevantPaths && ![...changed.keys()].some((path) => relevantPaths.has(path) || path.endsWith(".md"))) continue;
|
|
263
291
|
const snapshot = reviewHistoricalWorkspace(context, commit);
|
|
264
292
|
if (!snapshot?.entries.some(({ record }) => record.id === id)) continue;
|
|
265
|
-
const semantic = resourceReviewRevisionsSync(snapshot, [id], "legacy", false, commit).get(id);
|
|
293
|
+
const semantic = resourceReviewRevisionsSync(snapshot, [id], "legacy", false, commit, reviewer).get(id);
|
|
266
294
|
if (!revisionsMatch("content", semantic, current)) continue;
|
|
295
|
+
// A row may narrow a source to its retention facts or a named Markdown
|
|
296
|
+
// section. Accept its old binding only when those facts still match the
|
|
297
|
+
// committed source that supplied the binding.
|
|
298
|
+
const original = resourceReviewRevisionsSync(snapshot, [id], "legacy", false, commit).get(id);
|
|
299
|
+
if (revisionsMatch("content", stored, original)) return true;
|
|
267
300
|
for (const scopeHashInput of ["legacy", "digest"]) {
|
|
268
301
|
const legacy = resourceReviewRevisionsSync(snapshot, [id], scopeHashInput, true, commit).get(id);
|
|
269
302
|
if (revisionsMatch("content", stored, legacy)) return true;
|
|
@@ -293,7 +326,20 @@ export function retentionUses(loaded, program) {
|
|
|
293
326
|
return [...new Map(uses.map((use) => [`${use.resource.id}:${use.informationTypeId}`, use])).values()];
|
|
294
327
|
}
|
|
295
328
|
|
|
296
|
-
function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = false) {
|
|
329
|
+
function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = false, heading = null, reviewer = null) {
|
|
330
|
+
if (heading && !legacySource) {
|
|
331
|
+
const governingFields = entry.record.type === "policy"
|
|
332
|
+
? ["policyKind", "effectiveOn", "supersedesId", "parentPolicyId", "relatedPolicyIds", "relatedDocumentIds", "requirementIds", "audience", "acknowledgementRequired", "programRole", "reportingRouteRequirements"]
|
|
333
|
+
: ["documentKind", "workflowScope", "effectiveOn", "supersedesId", "systemIds", "controlIds", "relatedDocumentIds", "audience", "acknowledgementRequired", "trainingIds", "classificationId", "programRole", "componentIds", "reportingRouteRequirements"];
|
|
334
|
+
return JSON.stringify({
|
|
335
|
+
id: entry.record.id,
|
|
336
|
+
type: entry.record.type,
|
|
337
|
+
governingStatus: ["approved", "active"].includes(entry.record.status)
|
|
338
|
+
? "approved" : entry.record.status || null,
|
|
339
|
+
...Object.fromEntries(["extensions", "externalIds", ...governingFields].filter((field) => entry.record[field] !== undefined)
|
|
340
|
+
.map((field) => [field, entry.record[field]]))
|
|
341
|
+
});
|
|
342
|
+
}
|
|
297
343
|
if (
|
|
298
344
|
modelSupports(loaded.model, "retention-schedule-approval")
|
|
299
345
|
&& entry.record.type === "document"
|
|
@@ -318,7 +364,7 @@ function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = f
|
|
|
318
364
|
return legacySource
|
|
319
365
|
? canonicalCalculatedRevisionJson(entry.source, scopeHashInput)
|
|
320
366
|
: canonicalCalculatedRevisionJson(JSON.stringify(substantiveReviewSource(
|
|
321
|
-
JSON.parse(entry.source), modelSupports(loaded.model, "retention-schedule-approval")
|
|
367
|
+
JSON.parse(entry.source), modelSupports(loaded.model, "retention-schedule-approval"), reviewer
|
|
322
368
|
)), scopeHashInput);
|
|
323
369
|
}
|
|
324
370
|
|
|
@@ -330,9 +376,93 @@ function reviewMarkdownEntries(loaded, entry, legacySource) {
|
|
|
330
376
|
return markdownEntries(loaded.model, entry.record);
|
|
331
377
|
}
|
|
332
378
|
|
|
379
|
+
const approvedReviewEntries = new WeakMap();
|
|
380
|
+
|
|
381
|
+
function effectiveReviewEntry(loaded, entry) {
|
|
382
|
+
if (!entry || !loaded.root || !["policy", "document", "training"].includes(entry.record.type)) return entry;
|
|
383
|
+
if (!["draft", "in-review", "approved", "active"].includes(entry.record.status)) return entry;
|
|
384
|
+
if (approvedReviewContentMatches(loaded, entry)) return entry;
|
|
385
|
+
if (["approved", "active"].includes(entry.record.status)) return entry;
|
|
386
|
+
let cache = approvedReviewEntries.get(loaded.resources);
|
|
387
|
+
if (!cache) {
|
|
388
|
+
cache = new Map();
|
|
389
|
+
approvedReviewEntries.set(loaded.resources, cache);
|
|
390
|
+
}
|
|
391
|
+
if (cache.has(entry.record.id)) return cache.get(entry.record.id) || entry;
|
|
392
|
+
const context = reviewHistoryContext(loaded.root, loaded);
|
|
393
|
+
for (const commit of [...reviewHistoryCommits(context)].reverse()) {
|
|
394
|
+
const snapshot = reviewHistoricalWorkspace(context, commit);
|
|
395
|
+
const historical = snapshot?.entries.find(({ record }) => record.id === entry.record.id);
|
|
396
|
+
if (!historical || !approvedReviewContentMatches(snapshot, historical, commit)) continue;
|
|
397
|
+
const approved = { ...historical, reviewCommit: commit, reviewFiles: snapshot.historicalFiles };
|
|
398
|
+
cache.set(entry.record.id, approved);
|
|
399
|
+
return approved;
|
|
400
|
+
}
|
|
401
|
+
cache.set(entry.record.id, null);
|
|
402
|
+
return entry;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function approvedReviewContentMatches(loaded, entry, commit = null) {
|
|
406
|
+
if (!["approved", "active"].includes(entry.record.status)) return false;
|
|
407
|
+
const bindings = entry.record.approvedContentRevisions || entry.record.effectiveContentRevisions;
|
|
408
|
+
if (!bindings) return true;
|
|
409
|
+
for (const markdown of reviewMarkdownEntries(loaded, entry, false)) {
|
|
410
|
+
const source = commit
|
|
411
|
+
? loaded.historicalFiles?.get(`data/${markdown.path}`)
|
|
412
|
+
?? getFileAtRevision(loaded.root, commit, `data/${markdown.path}`)
|
|
413
|
+
: (() => {
|
|
414
|
+
try { return readFileSync(resolveDataPath(loaded.root, markdown.path), "utf8"); }
|
|
415
|
+
catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
416
|
+
})();
|
|
417
|
+
if (!source || !revisionsMatch("content", bindings[markdown.path], contentRevision(source))) return false;
|
|
418
|
+
}
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function reviewedMarkdownSection(source, heading) {
|
|
423
|
+
if (!heading) return source;
|
|
424
|
+
const lines = source.split("\n");
|
|
425
|
+
const wanted = heading.trim().replace(/^#{1,6}\s+/, "").toLowerCase();
|
|
426
|
+
const headings = [];
|
|
427
|
+
const fencedLines = new Set();
|
|
428
|
+
let fence = null;
|
|
429
|
+
for (const [index, line] of lines.entries()) {
|
|
430
|
+
const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
|
|
431
|
+
if (fence) {
|
|
432
|
+
fencedLines.add(index);
|
|
433
|
+
if (marker?.[0] === fence[0] && marker.length >= fence.length
|
|
434
|
+
&& /^\s*$/.test(line.slice(line.indexOf(marker) + marker.length))) fence = null;
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (marker) { fence = marker; fencedLines.add(index); continue; }
|
|
438
|
+
const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
439
|
+
if (match) headings.push({ index, level: match[1].length, title: match[2].trim().toLowerCase() });
|
|
440
|
+
}
|
|
441
|
+
const matches = headings.filter(({ title }) => title === wanted);
|
|
442
|
+
if (matches.length !== 1) return null;
|
|
443
|
+
const selected = matches[0];
|
|
444
|
+
const ancestors = [];
|
|
445
|
+
for (const candidate of headings.filter(({ index }) => index < selected.index)) {
|
|
446
|
+
while (ancestors.length && ancestors.at(-1).level >= candidate.level) ancestors.pop();
|
|
447
|
+
ancestors.push(candidate);
|
|
448
|
+
}
|
|
449
|
+
const next = headings.find(({ index, level }) => index > selected.index && level <= selected.level);
|
|
450
|
+
const context = ancestors.map(({ index }) => {
|
|
451
|
+
const following = headings.find((candidate) => candidate.index > index);
|
|
452
|
+
return lines.slice(index, following?.index).filter((_, offset) => !fencedLines.has(index + offset)).join("\n").trim();
|
|
453
|
+
});
|
|
454
|
+
return [...context, lines.slice(selected.index, next?.index).join("\n").trim()].join("\n\n");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function sourceHeading(record, sourceId) {
|
|
458
|
+
const headings = record?.sourceSectionHeadings;
|
|
459
|
+
return headings && Object.hasOwn(headings, sourceId) && typeof headings[sourceId] === "string"
|
|
460
|
+
? headings[sourceId] : null;
|
|
461
|
+
}
|
|
462
|
+
|
|
333
463
|
const reviewDecisionFields = {
|
|
334
464
|
control: ["statement", "requirementIds", "code", "activity", "controlType", "operationMode", "operationPattern", "systemIds", "policyIds", "componentIds", "evidenceSourceComponentIds"],
|
|
335
|
-
commitment: ["commitmentKind", "statement", "systemIds", "sourceResourceIds", "requirementIds", "controlIds", "customerFacing", "effectiveOn", "reportingRouteRequirements"],
|
|
465
|
+
commitment: ["commitmentKind", "statement", "systemIds", "sourceResourceIds", "sourceSectionHeadings", "requirementIds", "controlIds", "customerFacing", "effectiveOn", "reportingRouteRequirements"],
|
|
336
466
|
"complementary-control": ["responsibleParty", "statement", "systemIds", "vendorId", "requirementIds", "commitmentIds", "relatedControlIds", "sourceDocumentIds", "componentIds"],
|
|
337
467
|
"source-coverage": ["sourceFamilyId", "coverageKind", "scopeResourceIds", "excludedPopulation", "collectionCadence", "reconciliationMethod", "validFrom", "validThrough", "componentId", "retentionScheduleItemIds"],
|
|
338
468
|
system: ["purpose", "servicesProvided", "boundary", "exclusions", "criticality", "informationTypeIds", "classificationId", "internetExposed", "continuityObjectives"],
|
|
@@ -345,7 +475,7 @@ const reviewDecisionFields = {
|
|
|
345
475
|
document: ["documentKind", "workflowScope", "template", "version", "effectiveOn", "supersedesId", "systemIds", "controlIds", "relatedDocumentIds", "audience", "acknowledgementRequired", "trainingIds", "classificationId", "programRole", "componentIds", "reportingRouteRequirements"]
|
|
346
476
|
};
|
|
347
477
|
|
|
348
|
-
function substantiveReviewSource(record, currentModel) {
|
|
478
|
+
function substantiveReviewSource(record, currentModel, reviewer = null) {
|
|
349
479
|
if (currentModel && reviewDecisionFields[record.type]) {
|
|
350
480
|
const fields = ["id", "type", "title", "extensions", "externalIds", ...reviewDecisionFields[record.type]];
|
|
351
481
|
const decision = Object.fromEntries(fields.filter((field) => record[field] !== undefined)
|
|
@@ -357,6 +487,9 @@ function substantiveReviewSource(record, currentModel) {
|
|
|
357
487
|
} else {
|
|
358
488
|
decision.status = record.status || null;
|
|
359
489
|
}
|
|
490
|
+
if (record.type === "component" && reviewer?.type === "retention-schedule-item") {
|
|
491
|
+
decision.systemUses = retentionSystemUses(decision.systemUses);
|
|
492
|
+
}
|
|
360
493
|
if (["control", "commitment", "complementary-control"].includes(record.type)) {
|
|
361
494
|
decision.nonApplicableDecision = ["not-applicable", "externally-managed", "zero-population"]
|
|
362
495
|
.includes(record.applicabilityReview?.decision) ? record.applicabilityReview.decision : null;
|
|
@@ -387,6 +520,13 @@ function substantiveReviewSource(record, currentModel) {
|
|
|
387
520
|
return source;
|
|
388
521
|
}
|
|
389
522
|
|
|
523
|
+
export function retentionSystemUses(uses) {
|
|
524
|
+
return uses?.map((use) => ({
|
|
525
|
+
...use,
|
|
526
|
+
roles: use.roles?.filter((role) => role !== "evidence-source")
|
|
527
|
+
}));
|
|
528
|
+
}
|
|
529
|
+
|
|
390
530
|
export function nearDuplicateInformationTypes(records) {
|
|
391
531
|
const types = records.filter((record) => record.type === "information-type" && !["retired", "superseded"].includes(record.status));
|
|
392
532
|
const pairs = [];
|
|
@@ -450,7 +590,7 @@ export function retentionRuleIsCurrent(rule, revisions, byId = new Map(), loaded
|
|
|
450
590
|
const dependencyIds = retentionReviewResourceIds(rule, loaded);
|
|
451
591
|
if (Object.keys(rule.reviewedSourceRevisions).length !== dependencyIds.length) return false;
|
|
452
592
|
return dependencyIds.every((id) => (
|
|
453
|
-
resourceReviewRevisionMatches(loaded, revisions, id, rule.reviewedSourceRevisions?.[id])
|
|
593
|
+
resourceReviewRevisionMatches(loaded, revisions, id, rule.reviewedSourceRevisions?.[id], rule)
|
|
454
594
|
));
|
|
455
595
|
}
|
|
456
596
|
|
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
|
|
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, {
|
|
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
|
|
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
|
|