filegrc 0.16.17 → 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 +16 -1
- package/package.json +1 -1
- package/src/agent.js +3 -1
- package/src/cli.js +5 -3
- package/src/collection-revision.js +21 -8
- package/src/collection-scope.js +28 -12
- package/src/program-amendment.js +3 -3
- package/src/program-path.js +10 -10
- package/src/program-readiness.js +16 -16
- package/src/retention.js +159 -35
- package/src/server.js +16 -2
- package/src/validate.js +14 -1
- package/src/web.js +9 -2
- package/src/workflow.js +11 -7
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/agent.js
CHANGED
|
@@ -122,13 +122,15 @@ export function buildAgentGuide(loaded, type, options = {}) {
|
|
|
122
122
|
oneOf: definition.oneOf ?? [],
|
|
123
123
|
markdown,
|
|
124
124
|
workflow: [
|
|
125
|
-
"Inspect existing records and
|
|
125
|
+
"Inspect the recommended action, existing records, relationships, source Markdown, and available evidence. Do the research, drafting, record edits, and validation you can do yourself.",
|
|
126
126
|
definition.singleton
|
|
127
127
|
? "Open the existing singleton record and update only gaps supported by facts from an authoritative source."
|
|
128
128
|
: "Reuse and update a suitable existing record. Create a scaffold only when no record covers the work, then replace every null value and empty required array with facts from an authoritative source.",
|
|
129
129
|
recommendedMarkdown.length
|
|
130
130
|
? "Keep model fields in JSON and use the recommended Markdown companion for the detailed work, decisions, results, exceptions, and follow-up that apply to this record."
|
|
131
131
|
: "Keep the current facts and lifecycle state in JSON. Add optional Record Markdown only when the model fields cannot explain the record clearly.",
|
|
132
|
+
"If a material fact, external action, approval, or management decision is still missing, ask the user one specific question. State what you inspected, the exact missing input, and what it unlocks. Do not hand off FileGRC commands, routine record entry, or a vague request to confirm. Continue the workflow after the answer without asking for another handoff.",
|
|
133
|
+
...(type === "control" ? ["Separate implementation from operation: document the working configuration, scope, owner, procedure, source, and enabled rules when its operation pattern needs them before marking Implemented. Record dated operating work and evidence only after a real cycle or event occurs; a ready schedule is not proof of operation."] : []),
|
|
132
134
|
...(type === "program" && modelSupports(loaded.model, "program-scope")
|
|
133
135
|
? ["Review Requirement applicability with npx filegrc review-applicability --type requirement --scaffold, then preview and apply the reviewed decisions as one validated batch."]
|
|
134
136
|
: []),
|
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 {
|
|
@@ -1743,7 +1745,7 @@ function agentOverview(model) {
|
|
|
1743
1745
|
commit: "git diff --check && git diff && git add <reviewed-paths> && git commit -m <reason>"
|
|
1744
1746
|
};
|
|
1745
1747
|
return {
|
|
1746
|
-
rule: "Treat data/ as the source of truth. Run guide before creating an unfamiliar type, validate
|
|
1748
|
+
rule: "Treat data/ as the source of truth. Inspect existing records and sources, then do the research, edits, and validation you can do. Ask the user only for a specific material fact, external action, approval, or decision you cannot establish; explain what it unlocks and continue after the answer. Run guide before creating an unfamiliar type, validate every write, review the Git diff, and commit a focused change.",
|
|
1747
1749
|
programPath: buildAgentProgramPath(model),
|
|
1748
1750
|
actions: Object.fromEntries(Object.entries(commands).map(([name, command]) => [
|
|
1749
1751
|
name,
|
|
@@ -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, 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
|
|
@@ -616,9 +629,12 @@ function dependencyRevisionValue(resourceType, record, legacy, historicalReviewM
|
|
|
616
629
|
return value;
|
|
617
630
|
}
|
|
618
631
|
|
|
619
|
-
function dependencyContentAffectsRevision(resourceType,
|
|
620
|
-
|
|
621
|
-
|
|
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) {
|
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/program-path.js
CHANGED
|
@@ -4,11 +4,11 @@ export const RESOURCE_INSTRUCTIONS = {
|
|
|
4
4
|
program: "Set the Program goal, owners, Systems, Frameworks, criteria decisions, and risk method.",
|
|
5
5
|
person: "Record the person’s actual job title and current status.",
|
|
6
6
|
appointment: "Assign a named authority to one Person with its start date and scope.",
|
|
7
|
-
team: "
|
|
7
|
+
team: "Use the current Person and Appointment records to set the Team’s members and chair; ask who holds a role only when those sources cannot establish it.",
|
|
8
8
|
system: "Define the service boundary, exclusions, owners, and information it handles.",
|
|
9
9
|
component: "Record a material service or evidence source and its role in each linked System.",
|
|
10
10
|
vendor: "Record each material external provider and the Components it supplies.",
|
|
11
|
-
classification: "
|
|
11
|
+
classification: "Use current Policy content and recorded information needs to propose handling categories, then apply the management decision to information and evidence. Ask about any category those sources do not settle.",
|
|
12
12
|
"information-type": "Name a stable category of information, set its default Classification, and link its uses.",
|
|
13
13
|
framework: "Select the criteria Framework and version used by the Program.",
|
|
14
14
|
requirement: "Review whether this criterion applies to the Program and record the decision and rationale.",
|
|
@@ -22,7 +22,7 @@ export const RESOURCE_INSTRUCTIONS = {
|
|
|
22
22
|
evidence: "Record a real artifact or approved reference, link its source and supported work, then have another person verify it.",
|
|
23
23
|
"risk-assessment": "Assess the in-scope service, record conclusions, and obtain an independent review.",
|
|
24
24
|
risk: "Name the threat and impact, assign an owner, rate the risk, and record its response.",
|
|
25
|
-
obligation: "
|
|
25
|
+
obligation: "Read the linked Policy and Control, fill the owner, cadence or trigger, deadline, and proof from those sources, then enable the rule when its Control is ready. Ask only for an unresolved management choice.",
|
|
26
26
|
"retention-schedule-item": "Set the Information Types, scope, cutoff, period, disposition, and sources for one retention rule.",
|
|
27
27
|
"obligation-event": "Record the actual event date and subject, then complete its linked Work Queue actions.",
|
|
28
28
|
"policy-review": "Record the review date, reviewer, decision, and any required follow-up.",
|
|
@@ -101,11 +101,11 @@ export const RESOURCE_OUTPUTS = {
|
|
|
101
101
|
};
|
|
102
102
|
|
|
103
103
|
export const RESOURCE_PAGE_SUMMARIES = {
|
|
104
|
-
person: "
|
|
104
|
+
person: "Use current people already recorded; ask for a missing person's identity or role only when needed.",
|
|
105
105
|
appointment: "Assign named authority.",
|
|
106
|
-
team: "
|
|
106
|
+
team: "Set shared owners and members from current People and Appointments.",
|
|
107
107
|
program: "Choose the goal, scope, criteria, controls, owners, and risk method.",
|
|
108
|
-
framework: "
|
|
108
|
+
framework: "Select the applicable SOC 2 framework and version from the installed catalog.",
|
|
109
109
|
requirement: "Decide which SOC 2 criteria apply.",
|
|
110
110
|
commitment: "Record customer promises that affect scope.",
|
|
111
111
|
"requirement-mapping": "Review how supplemental promises relate to Requirements and Controls.",
|
|
@@ -134,8 +134,8 @@ export const PROGRAM_PATH = [
|
|
|
134
134
|
description: "Ownership, criteria, and service boundary",
|
|
135
135
|
summary: "A reviewed Program scope with owners, criteria, Systems, and material providers.",
|
|
136
136
|
sections: [
|
|
137
|
-
{ id: "ownership", title: "Program Ownership", description: "
|
|
138
|
-
{ id: "criteria", title: "Program and Criteria", description: "Define the Program,
|
|
137
|
+
{ id: "ownership", title: "Program Ownership", description: "Assign program owners and set the normal security reporting channel and its fallback.", steps: ["Inspect current People and Appointments, then assign the program lead, policy owner, independent reviewer, and oversight team. Ask only for missing assignments.", "Use the actual normal and fallback routes to replace reporting-channel placeholders, then commit the proposal."], types: ["person", "appointment", "team", "reporting-route-set"], defaultOpen: true },
|
|
138
|
+
{ id: "criteria", title: "Program and Criteria", description: "Define the Program, select its Frameworks, record Program-scoped Requirement applicability, and connect customer commitments that shape the System or Control design.", steps: ["Set the Program goal, owners, risk method, and tentative timing from current records; ask for unresolved management choices.", "Compare each selected criterion with the service scope and record its applicability decision.", "Replace the starter Commitment prompt with the actual promise; add only needed supplemental mappings."], types: ["program", "framework", "requirement", "commitment", "requirement-mapping"], defaultOpen: true },
|
|
139
139
|
{ id: "boundary", title: "System Boundary", description: "Start with the bounded System. Add Components that materially deliver the service, support Controls, produce authoritative Evidence, or support relevant operations. Keep Vendor relationships and specific Assets separate.", steps: ["Define the service and exclusions in a bounded System selected by the Program.", "Add material Components and Vendors with their actual System relationships.", "Record the Information Types and Classifications the service uses."], types: ["system", "component", "vendor", "classification", "information-type"], defaultOpen: false }
|
|
140
140
|
],
|
|
141
141
|
resourceTypes: ["person", "appointment", "team", "reporting-route-set", "program", "framework", "requirement", "commitment", "requirement-mapping", "system", "component", "vendor", "classification", "information-type"],
|
|
@@ -160,7 +160,7 @@ export const PROGRAM_PATH = [
|
|
|
160
160
|
description: "Approve governed content and retention decisions",
|
|
161
161
|
summary: "Approved Policies, program Documents, Training content, and retention decisions.",
|
|
162
162
|
sections: [
|
|
163
|
-
{ id: "policy-content", title: "Policies", description: "Approve the program's governing content.", instructions: "Open each Policy, program Document, and Training record.
|
|
163
|
+
{ id: "policy-content", title: "Policies", description: "Approve the program's governing content.", instructions: "Open each Policy, program Document, and Training record. Draft from current approved decisions, replace placeholders, and resolve only material choices with management. A separate person then approves the exact Markdown revision.", output: "Approved content bound to the reviewed revisions.", steps: ["Draft the company's real rules, plans, and training material from current scope and approved decisions.", "Resolve missing owner, separate approver, Control links, audience, or intended values from authoritative records; ask only when a material decision remains.", "Have the independent approver review the exact Markdown revision and record their actual decision and date."], types: [], relatedLinks: [{ type: "policy", label: "Policies", href: "#/policies" }], defaultOpen: true },
|
|
164
164
|
{ id: "retention", title: "Data Retention Schedule", description: "Approve the schedule document and its completed rows together.", steps: ["Review the governing schedule and any legal hold or exception rules.", "Complete a row for each distinct information use, cutoff, retention period, and disposition.", "Have a separate reviewer approve the complete document and row set."], types: [], utility: "retention-schedule", defaultOpen: true }
|
|
165
165
|
],
|
|
166
166
|
resourceTypes: [],
|
|
@@ -184,7 +184,7 @@ export const PROGRAM_PATH = [
|
|
|
184
184
|
description: "Implement and prepare to operate",
|
|
185
185
|
summary: "Implemented Controls with ready evidence sources and enabled work schedules.",
|
|
186
186
|
sections: [
|
|
187
|
-
{ id: "controls", title: "Controls", description: "Put each selected Control into use and record
|
|
187
|
+
{ id: "controls", title: "Controls", description: "Put each selected Control into use and record its working design. Dated operating proof follows when work occurs.", steps: ["Open each Control, linked records, and current missing checks; do the setup that can be established from those sources.", "Configure the activity and record its procedure, Systems, owner, evidence sources, and any work rules its operation pattern requires.", "Mark it Implemented with its real start date when it works. Record later cycles and events separately with dated proof."], types: ["control"], defaultOpen: true },
|
|
188
188
|
{ id: "complementary-controls", title: "Complementary Controls", description: "Record customer or provider responsibilities that form part of the implementation boundary, or confirm there are none.", steps: ["Identify any customer or carved-out provider action an in-scope Control depends on.", "Record each dependency, or confirm that the current scope has none."], types: ["complementary-control"], defaultOpen: true },
|
|
189
189
|
{ id: "evidence-sources", title: "Evidence Sources", description: "Check that every Control points to an authoritative source that can produce its expected evidence.", steps: ["For each Control family, link an active Component with the required source kind, access owners, and retrieval instructions."], types: [], utility: "evidence-sources", defaultOpen: true },
|
|
190
190
|
{ id: "obligations", title: "Obligations", description: "Configure the calendar and event schedules that operate the Controls.", steps: ["Check the owner, trigger or cadence, due window, and required proof for each Obligation.", "Enable the rules that will run the implemented Controls."], types: ["obligation"], defaultOpen: true }
|
package/src/program-readiness.js
CHANGED
|
@@ -217,7 +217,7 @@ export async function assessProgramReadiness(input, options = {}) {
|
|
|
217
217
|
? item
|
|
218
218
|
: { ...item, status: "blocked", message: `Complete the Control collection review before activation. ${item.message}` };
|
|
219
219
|
}));
|
|
220
|
-
controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, enabled Obligations, and complete authoritative source ${modelSupports(loaded.model, "component-sources") ? "Components" : "Systems"}. When implementation is ready, perform one review of the implemented Control collection. Management then activates the unchanged approved program content as the Step 3 cutover.`;
|
|
220
|
+
controlStage.description = `Each implemented Control needs an owner, actual procedure, scope, operation pattern, mappings, an implementation date, enabled Obligations for scheduled or event work, and complete authoritative source ${modelSupports(loaded.model, "component-sources") ? "Components" : "Systems"}. When implementation is ready, perform one review of the implemented Control collection. Management then activates the unchanged approved program content as the Step 3 cutover.`;
|
|
221
221
|
const scopeReadinessStage = scopeStage(
|
|
222
222
|
program,
|
|
223
223
|
scope,
|
|
@@ -596,7 +596,7 @@ function scopeStage(workspace, scope, records, byId, model, collectionReviews =
|
|
|
596
596
|
items.push(item(
|
|
597
597
|
"criteria",
|
|
598
598
|
criteriaComplete ? "complete" : "action",
|
|
599
|
-
"
|
|
599
|
+
"Resolve criteria decisions and Control coverage",
|
|
600
600
|
criteriaComplete
|
|
601
601
|
? `${selectedTrustServicesRequirements.length} applicable Trust Services criteria, ${selectedDescriptionRequirements.length} SOC 2 Description Criteria, and ${scope.controls.length} Controls are in scope. Every applicable Trust Services criterion has at least one selected Control; Description Criteria govern the system description and do not map to Controls.`
|
|
602
602
|
: missingRequiredSecurityReferences.length || missingRequiredDescriptionReferences.length
|
|
@@ -992,7 +992,7 @@ function programOwnershipItem(records, byId) {
|
|
|
992
992
|
return item(
|
|
993
993
|
"program-ownership",
|
|
994
994
|
complete ? "complete" : "action",
|
|
995
|
-
"
|
|
995
|
+
"Assign program owners and oversight",
|
|
996
996
|
complete
|
|
997
997
|
? `${currentOwners.size} current ${currentOwners.size === 1 ? "person owns" : "people own"} the program records.${oversight ? " Security and Risk Oversight has a separate current chair." : ""}`
|
|
998
998
|
: detail.join(" "),
|
|
@@ -1619,7 +1619,7 @@ function policyActivationItem(assessment) {
|
|
|
1619
1619
|
[assessment.unresolvedExceptionIds.length, "unresolved Exceptions"]
|
|
1620
1620
|
].filter(([count]) => count).map(([count, label]) => `${count} ${label}`);
|
|
1621
1621
|
const message = assessment.state === "active-and-operating"
|
|
1622
|
-
? `Active and effective ${assessment.effectiveOn}; all ${assessment.linkedControlIds.length} linked Controls are implemented with Components, evidence sources, and enabled Obligations.`
|
|
1622
|
+
? `Active and effective ${assessment.effectiveOn}; all ${assessment.linkedControlIds.length} linked Controls are implemented with Components, evidence sources, and enabled Obligations where scheduled or event work requires them.`
|
|
1623
1623
|
: assessment.state === "ready-to-activate"
|
|
1624
1624
|
? "Implementation checks are complete. Include this approved Policy in the Step 3 cutover when you are ready for it to take effect."
|
|
1625
1625
|
: `${assessment.label}: ${counts.join(", ") || assessment.timingWarnings.join(" ")}. ${assessment.activationWarning || ""}`.trim();
|
|
@@ -2128,7 +2128,7 @@ const starterControlChecks = {
|
|
|
2128
2128
|
"control-monitoring-remediation": "Schedule the first Control review. Name who will inspect incidents, tests, Exceptions, overdue work, and follow-up closure when those records exist.",
|
|
2129
2129
|
"control-access-authorization": "Open access lists for in-scope Systems and Vendor tools. Check that accounts use unique identities and only needed permissions, and new grants require a business reason and approval.",
|
|
2130
2130
|
"control-strong-authentication": "Open the System and Vendor lists. For each tool used for production, source code, email, identity, or sensitive data, check MFA, unique accounts, default credentials, and admin roles.",
|
|
2131
|
-
"control-access-review-offboarding": "
|
|
2131
|
+
"control-access-review-offboarding": "Check whether key Systems and Vendor tools can export access lists. Set the first access review and name who removes access after a departure.",
|
|
2132
2132
|
"control-physical-workspace-security": "Walk through actual work areas and visitor access. Check who can enter nonpublic spaces and how devices, papers, screens, and conversations are protected.",
|
|
2133
2133
|
"control-data-classification-inventory": "List important data stores in Systems and Vendor tools. Check each has an owner, classification, approved purpose, and recorded location.",
|
|
2134
2134
|
"control-encryption-transmission": "For Systems, Vendor tools, and devices holding Confidential or Restricted data, check encryption at rest and in transit and who can manage the keys.",
|
|
@@ -2154,16 +2154,16 @@ export function controlImplementationSteps(control, checks, options = {}) {
|
|
|
2154
2154
|
if (!checks.implemented) steps.push(control.id === "control-strong-authentication" && options.targets?.length
|
|
2155
2155
|
? `Ensure MFA and unique accounts are enabled for ${options.targets.join(", ")}; check default credentials and admin roles.`
|
|
2156
2156
|
: starterControlChecks[control.id]
|
|
2157
|
-
||
|
|
2157
|
+
|| `Inspect the Systems, people, Vendors, and work linked to ${control.title || control.id}. Compare the actual configuration with this Control's statement and fix the specific gaps.`);
|
|
2158
2158
|
const record = [];
|
|
2159
|
-
if (!checks.owner) record.push("
|
|
2160
|
-
if (!checks.scope) record.push("
|
|
2161
|
-
if (!checks.operationPattern) record.push("
|
|
2162
|
-
if (!checks.procedure) record.push("write
|
|
2163
|
-
if (!checks.evidenceSource) record.push("link the
|
|
2159
|
+
if (!checks.owner) record.push("assign the actual owner from existing People or Appointments");
|
|
2160
|
+
if (!checks.scope) record.push("link the in-scope Systems already in the Program");
|
|
2161
|
+
if (!checks.operationPattern) record.push("record whether it runs continuously, per transaction, on a schedule, or after an event");
|
|
2162
|
+
if (!checks.procedure) record.push("write the current actors, configuration, and procedure in Record Markdown");
|
|
2163
|
+
if (!checks.evidenceSource) record.push("link the authoritative Component that can produce proof");
|
|
2164
2164
|
if (!checks.policyMapping && checks.policyMapping !== undefined) record.push("link the Policy it implements");
|
|
2165
2165
|
if (!checks.criteriaMapping && checks.criteriaMapping !== undefined) record.push("link the criteria it covers");
|
|
2166
|
-
if (!checks.implementationDate) record.push("record the
|
|
2166
|
+
if (!checks.implementationDate) record.push("record the date the working implementation took effect when marking Implemented");
|
|
2167
2167
|
if (checks.procedureRevision === false || checks.procedureEffective === false) {
|
|
2168
2168
|
record.push("record the Procedure revision and effective date");
|
|
2169
2169
|
}
|
|
@@ -2174,16 +2174,16 @@ export function controlImplementationSteps(control, checks, options = {}) {
|
|
|
2174
2174
|
? `Cover the missing evidence families: ${sourceTargets.length ? sourceTargets.join(" and ") : "this Control"}. For each, link an active Component with the matching kind, evidence owners, and report retrieval instructions.`
|
|
2175
2175
|
: null;
|
|
2176
2176
|
const queueStep = checks.workQueue === false
|
|
2177
|
-
? "
|
|
2177
|
+
? "Inspect linked Obligations and their Policy basis. Enable a suitable existing rule with its owner and deadline, or add a rule only if no existing one covers this work. A scheduled rule does not prove a cycle occurred."
|
|
2178
2178
|
: null;
|
|
2179
2179
|
if (!checks.implemented) {
|
|
2180
2180
|
if (sourceStep || queueStep) steps.push([sourceStep, queueStep].filter(Boolean).join(" "));
|
|
2181
|
-
else if (checks.applicability === false) steps.push("
|
|
2182
|
-
else steps.push("When these launch conditions hold, mark the Control Implemented
|
|
2181
|
+
else if (checks.applicability === false) steps.push("Compare this Control with the current Program scope and record its applicability decision with `review-applicability --scaffold --type control`; ask management only if the decision cannot be established from the scope and commitments.");
|
|
2182
|
+
else steps.push("When these launch conditions hold, mark the Control Implemented with its real effective date. Record dated proof later, when a cycle or event actually occurs.");
|
|
2183
2183
|
} else {
|
|
2184
2184
|
if (sourceStep) steps.push(sourceStep);
|
|
2185
2185
|
if (queueStep) steps.push(queueStep);
|
|
2186
|
-
if (checks.applicability === false) steps.push("
|
|
2186
|
+
if (checks.applicability === false) steps.push("Compare this Control with the current Program scope and record its applicability decision with `review-applicability --scaffold --type control`; ask management only if the decision cannot be established from the scope and commitments.");
|
|
2187
2187
|
}
|
|
2188
2188
|
return steps;
|
|
2189
2189
|
}
|
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
|
-
|
|
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)];
|
|
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)];
|
|
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)
|
|
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
|
|
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
|
@@ -1231,7 +1231,7 @@ function controlLaunchLabel(state) {
|
|
|
1231
1231
|
return ({
|
|
1232
1232
|
planned: "Planned: launch conditions remain",
|
|
1233
1233
|
partial: "Partially implemented: launch conditions remain",
|
|
1234
|
-
"ready-to-implement": "Launch checks ready:
|
|
1234
|
+
"ready-to-implement": "Launch checks ready: check the live configuration, then record its start date",
|
|
1235
1235
|
"implemented-with-gaps": "Implemented with gaps: fix the launch checks",
|
|
1236
1236
|
implemented: "Implemented"
|
|
1237
1237
|
})[state] || "Control launch";
|
|
@@ -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
|
|
package/src/workflow.js
CHANGED
|
@@ -466,13 +466,17 @@ function recordIncompleteReason(record, loaded, program) {
|
|
|
466
466
|
return {
|
|
467
467
|
state: "ready",
|
|
468
468
|
requiredness: appointmentRequiredness(record, loaded.model),
|
|
469
|
-
message: "
|
|
469
|
+
message: "Check current People and scoped Appointments, assign a qualified holder, and record the actual authority and start date. Ask management only if the holder or independence decision is unresolved."
|
|
470
470
|
};
|
|
471
471
|
}
|
|
472
472
|
const messages = {
|
|
473
473
|
draft: "Add the required details, relationships, and Markdown before review.",
|
|
474
474
|
planned: "Check this planned record against how your program works, then add the missing details.",
|
|
475
|
-
proposed: "
|
|
475
|
+
proposed: ["obligation", "obligation-rule"].includes(record.type)
|
|
476
|
+
? "Read the linked Policy and Control, fill the actual owner, schedule, deadline, and proof requirement, then activate the rule. Ask management only for a choice those sources do not settle."
|
|
477
|
+
: record.type === "reporting-route-set"
|
|
478
|
+
? "Fill the normal and fallback reporting routes from approved contact details, commit the proposal, and obtain the required separate approval before it takes effect. Ask only for missing route or authority decisions."
|
|
479
|
+
: "Inspect the source facts and type guide, complete the proposal, and obtain the required review or approval before activation.",
|
|
476
480
|
"in-review": "Have an independent reviewer approve the exact content revision.",
|
|
477
481
|
open: "Complete this work with the required proof, or formally close it without completion.",
|
|
478
482
|
"in-progress": "Finish the work, record the result, and link the completion proof.",
|
|
@@ -724,7 +728,7 @@ function reconciliationFindings(reconciliation) {
|
|
|
724
728
|
state: "ready",
|
|
725
729
|
severity: "warning",
|
|
726
730
|
requiredness: "conditional",
|
|
727
|
-
title: `
|
|
731
|
+
title: `Resolve ${fieldLabel(candidate.eventType)} transition`,
|
|
728
732
|
message: candidate.message,
|
|
729
733
|
subject: candidate.subject,
|
|
730
734
|
fieldPath: candidate.sourcePath,
|
|
@@ -1630,12 +1634,12 @@ function periodFinding(key, title, message, state, subject, actions, dependencie
|
|
|
1630
1634
|
function auditClosureNextStep(status) {
|
|
1631
1635
|
const steps = {
|
|
1632
1636
|
planned: {
|
|
1633
|
-
title: "
|
|
1634
|
-
message: "
|
|
1637
|
+
title: "Record the agreed engagement and start audit preparation",
|
|
1638
|
+
message: "Read the signed engagement terms, link them, record the agreed scope and management acknowledgement, then move the audit to in progress. Ask for missing terms or acknowledgement only if unavailable."
|
|
1635
1639
|
},
|
|
1636
1640
|
"in-progress": {
|
|
1637
1641
|
title: "Begin fieldwork",
|
|
1638
|
-
message: "Finish management preparation,
|
|
1642
|
+
message: "Finish management preparation, record the fieldwork dates agreed with the CPA firm, and move the audit to fieldwork when testing begins. Ask for the dates only if they are not in the engagement correspondence."
|
|
1639
1643
|
},
|
|
1640
1644
|
fieldwork: {
|
|
1641
1645
|
title: "Finish fieldwork and prepare the report",
|
|
@@ -1647,7 +1651,7 @@ function auditClosureNextStep(status) {
|
|
|
1647
1651
|
},
|
|
1648
1652
|
issued: {
|
|
1649
1653
|
title: "Record final report delivery",
|
|
1650
|
-
message: "
|
|
1654
|
+
message: "Read the issued report and delivery record, enter the opinion, report date, authorized recipient, and receipt, then move the audit to delivered. Ask only for missing delivery facts."
|
|
1651
1655
|
},
|
|
1652
1656
|
delivered: {
|
|
1653
1657
|
title: "Close the audit",
|