filegrc 0.11.0 → 0.12.0
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/index.js +9 -5
- package/model/v10.json +12122 -0
- package/model/v9.json +11647 -0
- package/package.json +1 -1
- package/src/agent.js +3 -0
- package/src/audit-populations.js +88 -0
- package/src/audit-preparation.js +7 -2
- package/src/cli.js +186 -13
- package/src/collection-review-integrity.js +118 -0
- package/src/collection-review.js +71 -7
- package/src/collection-scope.js +8 -0
- package/src/evidence-packet.js +328 -46
- package/src/files.js +364 -6
- package/src/git.js +1064 -149
- package/src/index.js +17 -1
- package/src/model-migration.js +221 -5
- package/src/obligations.js +834 -66
- package/src/policy-library/information-security-policy-v2.md +1 -1
- package/src/policy-library.js +85 -8
- package/src/program-path.js +11 -5
- package/src/program-readiness.js +84 -6
- package/src/reconciliation.js +332 -81
- package/src/reporting-route-integrity.js +542 -0
- package/src/reporting-route-sets.js +745 -0
- package/src/server.js +160 -47
- package/src/state.js +30 -8
- package/src/time.js +55 -0
- package/src/validate.js +978 -4
- package/src/web.js +491 -49
- package/src/workflow-history-integrity.js +872 -0
- package/src/workflow.js +36 -10
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -217,6 +217,9 @@ function applyModelScaffoldDefaults(record, loaded, options = {}) {
|
|
|
217
217
|
"soc-2-type-2": "soc-2-type-2"
|
|
218
218
|
}[program?.assuranceGoal];
|
|
219
219
|
if (kind) record.auditKind = kind;
|
|
220
|
+
if (modelSupports(loaded.model, "reporting-route-sets") && loaded.workspace?.timezone) {
|
|
221
|
+
record.timezone = loaded.workspace.timezone;
|
|
222
|
+
}
|
|
220
223
|
for (const field of ["frameworkIds", "systemIds", "requirementIds", "controlIds"]) {
|
|
221
224
|
if (field === "requirementIds" && modelSupports(loaded.model, "program-scope")) {
|
|
222
225
|
record[field] = (program.requirementApplicability || []).filter(({ decision }) => decision === "applicable").map(({ requirementId }) => requirementId);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyResourceBatch,
|
|
3
|
+
contentRevision,
|
|
4
|
+
createResource,
|
|
5
|
+
INTERNAL_WORKFLOW_CAPABILITIES,
|
|
6
|
+
updateResource
|
|
7
|
+
} from "./files.js";
|
|
8
|
+
import { modelSupports } from "../model/index.js";
|
|
9
|
+
import { currentCalendarDate } from "./time.js";
|
|
10
|
+
import { loadWorkspace } from "./workspace.js";
|
|
11
|
+
|
|
12
|
+
export async function scaffoldAuditPopulationCorrection(input, options = {}) {
|
|
13
|
+
const loaded = await loadWorkspace(input);
|
|
14
|
+
if (!modelSupports(loaded.model, "rolled-up-obligations")) {
|
|
15
|
+
throw new Error("Audit population correction requires data model v9.");
|
|
16
|
+
}
|
|
17
|
+
const entry = loaded.entries.find(({ record }) => (
|
|
18
|
+
record.type === "audit-population" && record.id === options.populationId
|
|
19
|
+
));
|
|
20
|
+
if (!entry) throw new Error(`Audit population "${options.populationId || ""}" was not found.`);
|
|
21
|
+
if (!["reconciled", "not-applicable"].includes(entry.record.status)) {
|
|
22
|
+
throw new Error(`Audit population "${entry.record.id}" must be finalized before it can be corrected.`);
|
|
23
|
+
}
|
|
24
|
+
const correctionDate = options.asOf || currentCalendarDate(loaded.workspace.timezone);
|
|
25
|
+
const affectedControlTests = loaded.resources
|
|
26
|
+
.filter((record) => record.type === "control-test" && record.populationId === entry.record.id)
|
|
27
|
+
.map(({ id, title, status, controlId }) => ({ id, title, status, controlId }));
|
|
28
|
+
const record = {
|
|
29
|
+
...entry.record,
|
|
30
|
+
id: `${entry.record.id}-correction-${correctionDate}`,
|
|
31
|
+
title: `${entry.record.title} correction`,
|
|
32
|
+
status: "planned",
|
|
33
|
+
supersedesId: entry.record.id
|
|
34
|
+
};
|
|
35
|
+
delete record.reconciledByIds;
|
|
36
|
+
delete record.reconciledOn;
|
|
37
|
+
delete record.conclusion;
|
|
38
|
+
return {
|
|
39
|
+
operation: "supersede",
|
|
40
|
+
record,
|
|
41
|
+
revision: contentRevision(entry.source),
|
|
42
|
+
affectedControlTests,
|
|
43
|
+
instructions: affectedControlTests.length
|
|
44
|
+
? "Correct the population facts and link the fixed replacement export. Saving preserves the original and marks tests based on it as stale until replacement tests use this correction."
|
|
45
|
+
: "Correct the population facts, link the fixed replacement export, and record a new reconciliation. Saving preserves the original as superseded."
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function saveAuditPopulation(input, options = {}) {
|
|
50
|
+
const record = options.record;
|
|
51
|
+
if (record?.type !== "audit-population") throw new Error("An Audit population record is required.");
|
|
52
|
+
const loaded = await loadWorkspace(input);
|
|
53
|
+
const existing = loaded.entries.find(({ record: current }) => current.id === record.id);
|
|
54
|
+
if (record.supersedesId) {
|
|
55
|
+
requireMutationRevision(options.expectedRevision, `Audit population "${record.supersedesId}"`);
|
|
56
|
+
if (existing) throw new Error(`Superseding Audit population "${record.id}" already exists.`);
|
|
57
|
+
const predecessor = loaded.entries.find(({ record: current }) => (
|
|
58
|
+
current.type === "audit-population" && current.id === record.supersedesId
|
|
59
|
+
));
|
|
60
|
+
if (!predecessor) throw new Error(`Superseded Audit population "${record.supersedesId}" was not found.`);
|
|
61
|
+
if (!["reconciled", "not-applicable"].includes(predecessor.record.status)) {
|
|
62
|
+
throw new Error(`Audit population "${predecessor.record.id}" must be finalized before it can be superseded.`);
|
|
63
|
+
}
|
|
64
|
+
if (
|
|
65
|
+
predecessor.record.auditId !== record.auditId
|
|
66
|
+
|| predecessor.record.populationKind !== record.populationKind
|
|
67
|
+
) {
|
|
68
|
+
throw new Error("A correction must keep the same Audit and population kind as its predecessor.");
|
|
69
|
+
}
|
|
70
|
+
return applyResourceBatch(input, {
|
|
71
|
+
workflowCapability: INTERNAL_WORKFLOW_CAPABILITIES.auditPopulationSupersession,
|
|
72
|
+
create: [record],
|
|
73
|
+
update: [{ ...predecessor.record, status: "superseded" }],
|
|
74
|
+
contentUpdates: { [record.id]: options.content || {} },
|
|
75
|
+
expectedRevisions: { [predecessor.record.id]: options.expectedRevision }
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (existing) requireMutationRevision(options.expectedRevision, `Audit population "${record.id}"`);
|
|
79
|
+
return existing
|
|
80
|
+
? updateResource(input, "audit-population", record.id, record, { expectedRevision: options.expectedRevision, content: options.content })
|
|
81
|
+
: createResource(input, record, { content: options.content });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function requireMutationRevision(revision, target) {
|
|
85
|
+
if (typeof revision !== "string" || revision.length === 0) {
|
|
86
|
+
throw new Error(`A revision is required when changing ${target}. Reload the resource and try again.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/audit-preparation.js
CHANGED
|
@@ -76,7 +76,7 @@ export async function assessAuditPreparation(input, options = {}) {
|
|
|
76
76
|
const programReadiness = options.programReadiness || await assessProgramReadiness(loaded, {
|
|
77
77
|
asOf: readinessAsOf,
|
|
78
78
|
generatedAt: options.generatedAt,
|
|
79
|
-
programId: audit?.programId
|
|
79
|
+
programId: audit?.programId || options.programId
|
|
80
80
|
});
|
|
81
81
|
const documentActivations = audit && modelSupports(loaded.model, "governed-document-activation")
|
|
82
82
|
? await auditDocumentActivationAssessments(loaded, audit, byId, calendarAsOf)
|
|
@@ -942,6 +942,7 @@ function occurrenceContinuityStage(audit, records, model, asOf) {
|
|
|
942
942
|
|| record.controlIds.some((id) => selectedControlIds.has(id))
|
|
943
943
|
));
|
|
944
944
|
const plan = planObligations(periodResources, {
|
|
945
|
+
programId: audit.programId,
|
|
945
946
|
from: periodStart,
|
|
946
947
|
asOf: periodThrough,
|
|
947
948
|
through: periodThrough,
|
|
@@ -1320,7 +1321,11 @@ function populationsStage(audit, records, byId, model) {
|
|
|
1320
1321
|
);
|
|
1321
1322
|
}
|
|
1322
1323
|
const populations = audit
|
|
1323
|
-
? records.filter((record) =>
|
|
1324
|
+
? records.filter((record) => (
|
|
1325
|
+
record.type === "audit-population"
|
|
1326
|
+
&& record.auditId === audit.id
|
|
1327
|
+
&& record.status !== "superseded"
|
|
1328
|
+
))
|
|
1324
1329
|
: [];
|
|
1325
1330
|
const templates = applicablePopulationTemplates(
|
|
1326
1331
|
audit,
|
package/src/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
4
4
|
import { ACTIVE_MODEL_VERSION, loadModel, SUPPORTED_MODEL_VERSIONS } from "../model/index.js";
|
|
5
5
|
import { buildAgentGuide, findResourceReferences, listResourceTypes, scaffoldResourceMutation } from "./agent.js";
|
|
6
6
|
import { assessAuditPreparation, prepareAuditWorkspace } from "./audit-preparation.js";
|
|
7
|
+
import { saveAuditPopulation, scaffoldAuditPopulationCorrection } from "./audit-populations.js";
|
|
7
8
|
import { createNextAuditCycle, planNextAuditCycle } from "./audit-transition.js";
|
|
8
9
|
import {
|
|
9
10
|
applyApplicabilityReviewWithContext,
|
|
@@ -36,12 +37,16 @@ import { generateModelDocumentation } from "./model-docs.js";
|
|
|
36
37
|
import { migrateModel, planModelMigration } from "./model-migration.js";
|
|
37
38
|
import { normalizeResourceMutation } from "./mutation.js";
|
|
38
39
|
import {
|
|
40
|
+
activateObligationRule,
|
|
39
41
|
completeObligationAction,
|
|
40
42
|
completeObligationEvent,
|
|
41
43
|
completeObligationOccurrence,
|
|
42
44
|
createObligationEvent,
|
|
43
45
|
planObligations,
|
|
44
|
-
|
|
46
|
+
saveObligationOccurrence,
|
|
47
|
+
scaffoldObligationCompletion,
|
|
48
|
+
scaffoldObligationOccurrence,
|
|
49
|
+
scaffoldObligationRuleActivation
|
|
45
50
|
} from "./obligations.js";
|
|
46
51
|
import {
|
|
47
52
|
planExternalReviewerGovernance,
|
|
@@ -53,6 +58,13 @@ import { activatePolicies, planPolicyActivation, scaffoldPolicyActivation } from
|
|
|
53
58
|
import { applyPolicyLibraryUpgrade, assessPolicyLibraryUpgrades } from "./policy-library.js";
|
|
54
59
|
import { buildAgentProgramPath } from "./program-path.js";
|
|
55
60
|
import { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
|
|
61
|
+
import {
|
|
62
|
+
approveReportingRouteSet,
|
|
63
|
+
assessReportingRouteSets,
|
|
64
|
+
cancelReportingRouteSet,
|
|
65
|
+
proposeReportingRouteSet,
|
|
66
|
+
scaffoldReportingRouteSet
|
|
67
|
+
} from "./reporting-route-sets.js";
|
|
56
68
|
import { planProgramAmendment } from "./program-amendment.js";
|
|
57
69
|
import { resolveProgram } from "./program.js";
|
|
58
70
|
import { resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
|
|
@@ -79,6 +91,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
79
91
|
"apply",
|
|
80
92
|
"check-docs",
|
|
81
93
|
"complete",
|
|
94
|
+
"correct-finalized",
|
|
82
95
|
"current",
|
|
83
96
|
"draft",
|
|
84
97
|
"help",
|
|
@@ -384,6 +397,7 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
384
397
|
if (command === "obligations") {
|
|
385
398
|
const loaded = await loadWorkspace(root);
|
|
386
399
|
const result = planObligations(loaded.resources, {
|
|
400
|
+
programId: flags.program,
|
|
387
401
|
asOf: flags["as-of"] ?? currentCalendarDate(loaded.workspace.timezone),
|
|
388
402
|
from: flags.from,
|
|
389
403
|
through: flags.through,
|
|
@@ -406,8 +420,12 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
406
420
|
item.actionItemId
|
|
407
421
|
? item.status === "blocked"
|
|
408
422
|
? `filegrc get ${item.actionItemId} --mutation`
|
|
409
|
-
: `filegrc complete-action ${item.actionItemId} --scaffold --completed-on YYYY-MM-DD`
|
|
410
|
-
:
|
|
423
|
+
: `filegrc complete-action ${item.actionItemId} --scaffold${flags.program ? ` --program ${flags.program}` : ""} --completed-on YYYY-MM-DD`
|
|
424
|
+
: item.ruleId && ["proposed", "approved"].includes(item.ruleStatus)
|
|
425
|
+
? `filegrc activate-obligation-rule ${item.ruleId} --scaffold`
|
|
426
|
+
: item.ruleId
|
|
427
|
+
? `filegrc reconcile-obligation ${item.obligationId} --scaffold --window-start ${item.dueWindowStart}${flags.program ? ` --program ${flags.program}` : ""}${item.reconciliationStatus === "reconciled" ? " --correct-finalized" : ""}`
|
|
428
|
+
: `filegrc complete ${item.obligationId} --scaffold --window-start ${item.dueWindowStart}${flags.program ? ` --program ${flags.program}` : ""} --completed-on YYYY-MM-DD`
|
|
411
429
|
].join("\t"));
|
|
412
430
|
}
|
|
413
431
|
if (result.triggers.length) {
|
|
@@ -464,6 +482,52 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
464
482
|
if (flags["require-ready"] && !result.evidenceReady) process.exitCode = 2;
|
|
465
483
|
return output;
|
|
466
484
|
}
|
|
485
|
+
if (command === "reporting-route-sets") {
|
|
486
|
+
const result = await assessReportingRouteSets(root, {
|
|
487
|
+
programId: flags.program,
|
|
488
|
+
at: flags.at
|
|
489
|
+
});
|
|
490
|
+
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
491
|
+
else if (!result.supported) console.log("Reporting Channel Sets are not supported by this workspace model.");
|
|
492
|
+
else console.log(result.issues.length
|
|
493
|
+
? `${result.issues.length} Reporting Channel Set action${result.issues.length === 1 ? "" : "s"} remain.`
|
|
494
|
+
: "Reporting Channel Set requirements are covered.");
|
|
495
|
+
return result;
|
|
496
|
+
}
|
|
497
|
+
if (command === "reporting-route-set") {
|
|
498
|
+
const [action, value] = positionals;
|
|
499
|
+
if (flags.scaffold || action === "scaffold") {
|
|
500
|
+
const scaffoldAction = flags.action || (action === "scaffold" && value) || "approve";
|
|
501
|
+
const result = scaffoldReportingRouteSet({
|
|
502
|
+
action: scaffoldAction,
|
|
503
|
+
routeSetId: flags.id,
|
|
504
|
+
timezone: flags.timezone,
|
|
505
|
+
effectiveAt: flags["effective-at"],
|
|
506
|
+
proposalCommit: flags["proposal-commit"],
|
|
507
|
+
expectedRevision: flags.revision,
|
|
508
|
+
predecessorExpectedRevision: flags["predecessor-revision"]
|
|
509
|
+
});
|
|
510
|
+
console.log(JSON.stringify(result, null, 2));
|
|
511
|
+
return result;
|
|
512
|
+
}
|
|
513
|
+
if (action === "propose") {
|
|
514
|
+
const result = await withWorkflowDelta(root, () => proposeReportingRouteSet(root, { routeSetId: value, expectedRevision: flags.revision }));
|
|
515
|
+
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
516
|
+
else console.log(`Proposed ${result.record.id}. Commit the proposal before approval.`);
|
|
517
|
+
return result;
|
|
518
|
+
}
|
|
519
|
+
if (["approve", "cancel"].includes(action)) {
|
|
520
|
+
if (!value) throw new Error(`Pass a JSON payload path for reporting-route-set ${action}.`);
|
|
521
|
+
const payload = JSON.parse(await readFile(resolve(value), "utf8"));
|
|
522
|
+
const result = action === "approve"
|
|
523
|
+
? await withWorkflowDelta(root, () => approveReportingRouteSet(root, payload))
|
|
524
|
+
: await withWorkflowDelta(root, () => cancelReportingRouteSet(root, payload));
|
|
525
|
+
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
526
|
+
else console.log(`${action === "approve" ? "Approved" : "Canceled"} ${result.record.id}. Commit this managed event before relying on it.`);
|
|
527
|
+
return result;
|
|
528
|
+
}
|
|
529
|
+
throw new Error("Use reporting-route-set scaffold [approve|cancel|successor], propose ROUTE_SET_ID, approve PAYLOAD.json, or cancel PAYLOAD.json.");
|
|
530
|
+
}
|
|
467
531
|
if (command === "program-amendment") {
|
|
468
532
|
const sourceResourceId = flags.source || positionals[0];
|
|
469
533
|
if (!sourceResourceId) throw new Error("Pass a source resource ID or --source resource-id.");
|
|
@@ -557,6 +621,7 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
557
621
|
? await withWorkflowDelta(root, () => applyReconciliation(root, {
|
|
558
622
|
candidateId: flags.candidate,
|
|
559
623
|
transitionFingerprint: flags.candidate,
|
|
624
|
+
programId: flags.program,
|
|
560
625
|
occurredOn: flags["occurred-on"],
|
|
561
626
|
occurredAt: flags["occurred-at"],
|
|
562
627
|
riskLevel: flags["risk-level"],
|
|
@@ -621,12 +686,16 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
621
686
|
}
|
|
622
687
|
if (command === "review-applicability") {
|
|
623
688
|
if (flags.scaffold) {
|
|
624
|
-
const result = await scaffoldApplicabilityReview(root, { type: flags.type });
|
|
689
|
+
const result = await scaffoldApplicabilityReview(root, { type: flags.type, programId: flags.program });
|
|
625
690
|
console.log(JSON.stringify(result, null, 2));
|
|
626
691
|
return result;
|
|
627
692
|
}
|
|
628
693
|
const payload = await readSetupPayload(positionals[0]);
|
|
629
|
-
const options = {
|
|
694
|
+
const options = {
|
|
695
|
+
...payload,
|
|
696
|
+
programId: flags.program || payload.programId,
|
|
697
|
+
confirmed: flags.yes === true
|
|
698
|
+
};
|
|
630
699
|
const result = flags.preview
|
|
631
700
|
? await planApplicabilityReview(root, options)
|
|
632
701
|
: await applyApplicabilityReviewWithContext(root, options, { includeWorkflowDelta: true });
|
|
@@ -638,7 +707,7 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
638
707
|
if (command === "review-collection") {
|
|
639
708
|
const resourceType = positionals[0] || flags.type;
|
|
640
709
|
if (flags.scaffold) {
|
|
641
|
-
const result = await scaffoldCollectionReview(root, { resourceType });
|
|
710
|
+
const result = await scaffoldCollectionReview(root, { resourceType, programId: flags.program });
|
|
642
711
|
console.log(JSON.stringify(result, null, 2));
|
|
643
712
|
return result;
|
|
644
713
|
}
|
|
@@ -646,6 +715,7 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
646
715
|
const options = {
|
|
647
716
|
...payload,
|
|
648
717
|
resourceType: resourceType || payload.resourceType,
|
|
718
|
+
programId: flags.program || payload.programId,
|
|
649
719
|
confirmed: flags.yes === true
|
|
650
720
|
};
|
|
651
721
|
const result = flags.preview
|
|
@@ -751,6 +821,7 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
751
821
|
}
|
|
752
822
|
if (command === "trigger") {
|
|
753
823
|
const result = await withWorkflowDelta(root, () => createObligationEvent(root, {
|
|
824
|
+
programId: flags.program,
|
|
754
825
|
eventType: positionals[0],
|
|
755
826
|
occurredOn: flags["occurred-on"],
|
|
756
827
|
occurredAt: flags["occurred-at"],
|
|
@@ -774,6 +845,7 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
774
845
|
start: flags.start,
|
|
775
846
|
end: flags.end,
|
|
776
847
|
auditId: flags.audit,
|
|
848
|
+
programId: flags.program,
|
|
777
849
|
output: flags.output
|
|
778
850
|
};
|
|
779
851
|
const generated = flags.preview
|
|
@@ -862,6 +934,7 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
862
934
|
if (flags.scaffold) {
|
|
863
935
|
const result = await scaffoldObligationCompletion(root, {
|
|
864
936
|
obligationId,
|
|
937
|
+
programId: flags.program,
|
|
865
938
|
windowStart: flags["window-start"],
|
|
866
939
|
completedOn: flags["completed-on"]
|
|
867
940
|
});
|
|
@@ -879,11 +952,81 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
879
952
|
else console.log(`Created ${result.created.type}/${result.created.id} and linked it to obligation/${obligationId}`);
|
|
880
953
|
return result;
|
|
881
954
|
}
|
|
955
|
+
if (command === "activate-obligation-rule") {
|
|
956
|
+
const [ruleId, file] = positionals;
|
|
957
|
+
if (!ruleId) throw new Error("An Obligation Rule ID is required.");
|
|
958
|
+
if (flags.scaffold) {
|
|
959
|
+
const result = await scaffoldObligationRuleActivation(root, { ruleId });
|
|
960
|
+
console.log(JSON.stringify(result, null, 2));
|
|
961
|
+
return result;
|
|
962
|
+
}
|
|
963
|
+
if (!file) throw new Error("A scaffolded Obligation Rule activation payload file is required.");
|
|
964
|
+
const source = await readSetupPayload(file);
|
|
965
|
+
const payload = source.payload || source;
|
|
966
|
+
const result = await withWorkflowDelta(root, () => activateObligationRule(root, { ...payload, ruleId }));
|
|
967
|
+
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
968
|
+
else console.log(`Activated obligation-rule/${ruleId}`);
|
|
969
|
+
return result;
|
|
970
|
+
}
|
|
971
|
+
if (command === "reconcile-obligation") {
|
|
972
|
+
const [obligationId, file] = positionals;
|
|
973
|
+
if (!obligationId) throw new Error("An Obligation ID is required.");
|
|
974
|
+
if (flags.scaffold) {
|
|
975
|
+
const result = await scaffoldObligationOccurrence(root, {
|
|
976
|
+
obligationId,
|
|
977
|
+
programId: flags.program,
|
|
978
|
+
windowStart: flags["window-start"],
|
|
979
|
+
asOf: flags["as-of"],
|
|
980
|
+
correctFinalized: flags["correct-finalized"] === true
|
|
981
|
+
});
|
|
982
|
+
console.log(JSON.stringify(result, null, 2));
|
|
983
|
+
return result;
|
|
984
|
+
}
|
|
985
|
+
if (!file) throw new Error("A scaffolded Obligation occurrence mutation file is required.");
|
|
986
|
+
const mutation = await readMutation(file);
|
|
987
|
+
if (mutation.record?.type !== "obligation-occurrence" || mutation.record.obligationId !== obligationId) {
|
|
988
|
+
throw new Error("The mutation must contain an Obligation occurrence for the requested Obligation.");
|
|
989
|
+
}
|
|
990
|
+
const loaded = await loadWorkspace(root);
|
|
991
|
+
const existing = loaded.resources.some(({ id, type }) => id === mutation.record.id && type === "obligation-occurrence");
|
|
992
|
+
const result = await withWorkflowDelta(root, () => saveObligationOccurrence(root, {
|
|
993
|
+
record: mutation.record,
|
|
994
|
+
programId: flags.program || mutation.record.programId,
|
|
995
|
+
content: mutation.content,
|
|
996
|
+
expectedRevision: expectedRevision(flags, mutation, `obligation-occurrence/${mutation.record.supersedesId || mutation.record.id}`)
|
|
997
|
+
}));
|
|
998
|
+
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
999
|
+
else console.log(`${existing ? "Updated" : "Created"} obligation-occurrence/${mutation.record.id}`);
|
|
1000
|
+
return result;
|
|
1001
|
+
}
|
|
1002
|
+
if (command === "correct-audit-population") {
|
|
1003
|
+
const [populationId, file] = positionals;
|
|
1004
|
+
if (!populationId) throw new Error("An Audit Population ID is required.");
|
|
1005
|
+
if (flags.scaffold) {
|
|
1006
|
+
const result = await scaffoldAuditPopulationCorrection(root, { populationId, asOf: flags["as-of"] });
|
|
1007
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1008
|
+
return result;
|
|
1009
|
+
}
|
|
1010
|
+
if (!file) throw new Error("A scaffolded Audit Population correction payload file is required.");
|
|
1011
|
+
const mutation = await readMutation(file);
|
|
1012
|
+
if (mutation.record?.type !== "audit-population" || mutation.record.supersedesId !== populationId) {
|
|
1013
|
+
throw new Error("The mutation must contain an Audit Population correction for the requested population.");
|
|
1014
|
+
}
|
|
1015
|
+
const result = await withWorkflowDelta(root, () => saveAuditPopulation(root, {
|
|
1016
|
+
record: mutation.record,
|
|
1017
|
+
content: mutation.content,
|
|
1018
|
+
expectedRevision: expectedRevision(flags, mutation, `audit-population/${populationId}`)
|
|
1019
|
+
}));
|
|
1020
|
+
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
1021
|
+
else console.log(`Created audit-population/${mutation.record.id} and superseded audit-population/${populationId}.`);
|
|
1022
|
+
return result;
|
|
1023
|
+
}
|
|
882
1024
|
if (command === "complete-action") {
|
|
883
1025
|
const [actionItemId, file] = positionals;
|
|
884
1026
|
if (flags.scaffold) {
|
|
885
1027
|
const result = await scaffoldObligationCompletion(root, {
|
|
886
1028
|
actionItemId,
|
|
1029
|
+
programId: flags.program,
|
|
887
1030
|
completedOn: flags["completed-on"]
|
|
888
1031
|
});
|
|
889
1032
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -906,6 +1049,7 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
906
1049
|
if (!eventId) throw new Error("A Policy Event ID is required.");
|
|
907
1050
|
const result = await withWorkflowDelta(root, () => completeObligationEvent(root, {
|
|
908
1051
|
eventId,
|
|
1052
|
+
programId: flags.program,
|
|
909
1053
|
completedOn: flags["completed-on"],
|
|
910
1054
|
expectedRevision: requireExpectedRevision(flags, `obligation-event/${eventId}`)
|
|
911
1055
|
}));
|
|
@@ -1172,17 +1316,19 @@ Usage:
|
|
|
1172
1316
|
filegrc workflow [audit-id] [--as-of YYYY-MM-DD] [--through YYYY-MM-DD] [--complete] [--require-ready] [--json]
|
|
1173
1317
|
filegrc period-health [audit-id] [--start YYYY-MM-DD --end YYYY-MM-DD] [--as-of YYYY-MM-DD] [--require-healthy] [--json]
|
|
1174
1318
|
filegrc milestone-check [--as-of YYYY-MM-DD] [--json]
|
|
1319
|
+
filegrc reporting-route-sets [--program program-id] [--at RFC3339] [--json]
|
|
1320
|
+
filegrc reporting-route-set scaffold <approve|cancel|successor> [--id route-set-id] | propose ROUTE_SET_ID | approve PAYLOAD.json | cancel PAYLOAD.json
|
|
1175
1321
|
filegrc scaffold <resource-type> --title text [--id resource-id] [--program program-id]
|
|
1176
1322
|
filegrc list [resource-type] [--workflow] [--json]
|
|
1177
1323
|
filegrc search <query> [--type resource-type] [--json]
|
|
1178
|
-
filegrc obligations [--as-of YYYY-MM-DD] [--from YYYY-MM-DD] [--through YYYY-MM-DD] [--now RFC3339] [--complete] [--json]
|
|
1324
|
+
filegrc obligations [--program program-id] [--as-of YYYY-MM-DD] [--from YYYY-MM-DD] [--through YYYY-MM-DD] [--now RFC3339] [--complete] [--json]
|
|
1179
1325
|
filegrc program-readiness [--as-of YYYY-MM-DD] [--require-ready] [--summary] [--json]
|
|
1180
1326
|
filegrc program-amendment <source-resource-id> [--json]
|
|
1181
1327
|
filegrc review-bindings <retention-or-mapping-id> [--json]
|
|
1182
1328
|
filegrc evidence-map [--as-of YYYY-MM-DD] [--json]
|
|
1183
1329
|
filegrc audit-readiness [audit-id] [--as-of YYYY-MM-DD] [--require-ready] [--json]
|
|
1184
1330
|
filegrc prepare-audit <audit-id> [--json]
|
|
1185
|
-
filegrc reconcile [--preview|--apply --candidate fingerprint (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) --yes] [--risk-level normal|high] [--json]
|
|
1331
|
+
filegrc reconcile [--preview|--apply --candidate fingerprint (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) --yes] [--program program-id] [--risk-level normal|high] [--json]
|
|
1186
1332
|
filegrc external-reviewer-setup --scaffold
|
|
1187
1333
|
filegrc external-reviewer-setup <reviewer.json|-> [--preview|--yes] [--json]
|
|
1188
1334
|
filegrc next-audit-cycle <prior-audit-id> [cycle.json|-] --start YYYY-MM-DD --end YYYY-MM-DD [--preview|--yes] [--json]
|
|
@@ -1192,17 +1338,22 @@ Usage:
|
|
|
1192
1338
|
filegrc activate-content [--scaffold | activation.json|-] [--program id] [--activated-by person-id] [--activated-on YYYY-MM-DD] [--effective-on YYYY-MM-DD] [--preview|--yes] [--json]
|
|
1193
1339
|
filegrc activate-documents [--scaffold | activation.json|-] [--program id | --audit id] [--activated-by person-id] [--activated-on YYYY-MM-DD] [--effective-on YYYY-MM-DD] [--preview|--yes] [--json]
|
|
1194
1340
|
filegrc policy-library [--json | --accept proposal-id --proposal-revision revision --yes]
|
|
1195
|
-
filegrc trigger <event-type> (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) [--risk-level normal|high] [--subject resource-id[,resource-id]] [--title text] [--json]
|
|
1196
|
-
filegrc evidence-packet [--audit audit-id] [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--output .filegrc/path] [--preview] [--require-ready] [--json]
|
|
1341
|
+
filegrc trigger <event-type> (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) [--program program-id] [--risk-level normal|high] [--subject resource-id[,resource-id]] [--title text] [--json]
|
|
1342
|
+
filegrc evidence-packet [--audit audit-id | --program program-id] [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--output .filegrc/path] [--preview] [--require-ready] [--json]
|
|
1197
1343
|
filegrc get [resource-type] <id> [--mutation]
|
|
1198
1344
|
filegrc references <id> [--json]
|
|
1199
1345
|
filegrc preview-mutation <preview.json|-> [--json]
|
|
1200
1346
|
filegrc create <mutation.json|-> [--json]
|
|
1201
|
-
filegrc complete <obligation-id> --scaffold --window-start YYYY-MM-DD [--completed-on YYYY-MM-DD]
|
|
1347
|
+
filegrc complete <obligation-id> --scaffold --window-start YYYY-MM-DD [--program program-id] [--completed-on YYYY-MM-DD]
|
|
1202
1348
|
filegrc complete <obligation-id> <completion-record.json|-> [--expected-revision hash] [--json]
|
|
1203
|
-
filegrc
|
|
1349
|
+
filegrc activate-obligation-rule <rule-id> [payload.json] [--scaffold] [--json]
|
|
1350
|
+
filegrc reconcile-obligation <obligation-id> --scaffold --window-start YYYY-MM-DD [--program program-id] [--as-of YYYY-MM-DD] [--correct-finalized]
|
|
1351
|
+
filegrc reconcile-obligation <obligation-id> <occurrence.json|-> [--program program-id] [--expected-revision hash] [--json]
|
|
1352
|
+
filegrc correct-audit-population <population-id> --scaffold [--as-of YYYY-MM-DD]
|
|
1353
|
+
filegrc correct-audit-population <population-id> <correction.json|-> [--expected-revision hash] [--json]
|
|
1354
|
+
filegrc complete-action <action-item-id> --scaffold [--program program-id] [--completed-on YYYY-MM-DD]
|
|
1204
1355
|
filegrc complete-action <action-item-id> <completion-record.json|-> --completed-on YYYY-MM-DD [--expected-revision hash] [--json]
|
|
1205
|
-
filegrc complete-event <obligation-event-id> --completed-on YYYY-MM-DD --expected-revision hash [--json]
|
|
1356
|
+
filegrc complete-event <obligation-event-id> --program program-id --completed-on YYYY-MM-DD --expected-revision hash [--json]
|
|
1206
1357
|
filegrc update <resource-type> <id> <mutation.json|-> [--json]
|
|
1207
1358
|
filegrc content <resource-type> <id> [slot] [--write markdown-file|-] [--expected-revision hash] [--json]
|
|
1208
1359
|
filegrc attach <evidence-id> <source-file> --expected-revision hash [--name file-name] [--json]
|
|
@@ -1288,6 +1439,27 @@ Start with:
|
|
|
1288
1439
|
# Use the next model version shown by the guide.`);
|
|
1289
1440
|
return;
|
|
1290
1441
|
}
|
|
1442
|
+
if (["reporting-route-set", "reporting-route-sets"].includes(command)) {
|
|
1443
|
+
console.log(`Usage:
|
|
1444
|
+
filegrc reporting-route-sets [--program program-id] [--at RFC3339] [--json]
|
|
1445
|
+
filegrc reporting-route-set scaffold approve [--id route-set-id] [--timezone IANA] [--effective-at RFC3339] [--proposal-commit commit] [--revision revision]
|
|
1446
|
+
filegrc reporting-route-set scaffold cancel [--id route-set-id] [--timezone IANA] [--revision revision]
|
|
1447
|
+
filegrc reporting-route-set scaffold successor [--id route-set-id] [--timezone IANA] [--effective-at RFC3339] [--proposal-commit commit] [--revision revision] [--predecessor-revision revision]
|
|
1448
|
+
filegrc reporting-route-set propose ROUTE_SET_ID [--revision revision] [--json]
|
|
1449
|
+
filegrc reporting-route-set approve PAYLOAD.json [--json]
|
|
1450
|
+
filegrc reporting-route-set cancel PAYLOAD.json [--json]
|
|
1451
|
+
|
|
1452
|
+
Reporting channels are the email addresses, phone numbers, web forms, in-person
|
|
1453
|
+
contacts, or other destinations people use to raise a concern. A set keeps the
|
|
1454
|
+
normal channel and its fallback together for one purpose. Inspect the rules that
|
|
1455
|
+
require them or advance a set through proposal, approval, and cancellation. CLI writes never create Git commits.
|
|
1456
|
+
Commit the proposal before approval, then commit the approval before its effective
|
|
1457
|
+
time. The action-specific payload scaffolds name the actual event time, IANA
|
|
1458
|
+
timezone, authority Appointment, Evidence, and current revisions. Approval and
|
|
1459
|
+
successor scaffolds also require the full proposal commit. A successor records
|
|
1460
|
+
its approval and the predecessor cancellation together.`);
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1291
1463
|
if (command === "program-readiness") {
|
|
1292
1464
|
console.log(`Usage:
|
|
1293
1465
|
filegrc program-readiness [options]
|
|
@@ -1515,6 +1687,7 @@ function agentOverview(model) {
|
|
|
1515
1687
|
previewMutation: "filegrc preview-mutation <preview.json> --json",
|
|
1516
1688
|
create: "filegrc create <mutation.json>",
|
|
1517
1689
|
complete: "filegrc complete <obligation-id> <completion-mutation.json>",
|
|
1690
|
+
reconcileObligation: "filegrc reconcile-obligation <obligation-id> --scaffold --window-start <date>",
|
|
1518
1691
|
completeAction: "filegrc complete-action <action-item-id> <completion-mutation.json> --completed-on <date>",
|
|
1519
1692
|
completeEvent: "filegrc complete-event <obligation-event-id> --completed-on <date>",
|
|
1520
1693
|
update: "filegrc update <resource-type> <id> <mutation.json>",
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { loadModel } from "../model/index.js";
|
|
3
|
+
import { collectionRevision } from "./collection-revision.js";
|
|
4
|
+
import { scopedCollectionRecords } from "./collection-scope.js";
|
|
5
|
+
import { getDataFilesAtRevision, getFileAtRevision, getRecordIdentityHistory, hasGitRevision } from "./git.js";
|
|
6
|
+
import { currentCalendarDate, isRfc3339Timestamp } from "./time.js";
|
|
7
|
+
|
|
8
|
+
export function collectionReviewRevision(record) {
|
|
9
|
+
const reviewedFacts = {
|
|
10
|
+
id: record.id,
|
|
11
|
+
type: record.type,
|
|
12
|
+
title: record.title,
|
|
13
|
+
resourceType: record.resourceType,
|
|
14
|
+
decision: record.decision,
|
|
15
|
+
rationale: record.rationale,
|
|
16
|
+
reviewedByIds: record.reviewedByIds,
|
|
17
|
+
reviewedOn: record.reviewedOn,
|
|
18
|
+
collectionRevision: record.collectionRevision,
|
|
19
|
+
scopeRevision: record.scopeRevision,
|
|
20
|
+
coverage: record.coverage,
|
|
21
|
+
knowledgeCutoffAt: record.knowledgeCutoffAt,
|
|
22
|
+
populationResourceIds: record.populationResourceIds,
|
|
23
|
+
scopeResourceIds: record.scopeResourceIds,
|
|
24
|
+
authoritativeComponentId: record.authoritativeComponentId,
|
|
25
|
+
supersedesId: record.supersedesId
|
|
26
|
+
};
|
|
27
|
+
return createHash("sha256").update(JSON.stringify(reviewedFacts)).digest("hex");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function historicalCollectionReviewSnapshot(root, record, model, timezone, resourceType, cutoff, selector = null, relativePath = null, expectedCommit = null) {
|
|
31
|
+
if (!historicalCollectionReviewIsUsable(record, model, timezone, resourceType, cutoff)) return null;
|
|
32
|
+
const reviewCommit = committedCollectionReviewMatch(root, record, relativePath, expectedCommit, timezone, cutoff);
|
|
33
|
+
if (!reviewCommit) return null;
|
|
34
|
+
const paths = getDataFilesAtRevision(root, record.scopeRevision);
|
|
35
|
+
if (!paths.length || !hasGitRevision(root, record.scopeRevision)) return null;
|
|
36
|
+
const entries = [];
|
|
37
|
+
for (const path of paths) {
|
|
38
|
+
const source = getFileAtRevision(root, record.scopeRevision, path);
|
|
39
|
+
if (source === null) return null;
|
|
40
|
+
try {
|
|
41
|
+
entries.push({ record: JSON.parse(source), source, relativePath: path.slice("data/".length) });
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const workspace = entries.find(({ relativePath, record: candidate }) => (
|
|
47
|
+
relativePath === "workspace.json" && candidate?.type === "workspace"
|
|
48
|
+
))?.record;
|
|
49
|
+
if (!workspace) return null;
|
|
50
|
+
const resources = entries.map(({ record: candidate }) => candidate);
|
|
51
|
+
const programId = (record.scopeResourceIds || []).find((id) => resources.some((candidate) => (
|
|
52
|
+
candidate.type === "program" && candidate.id === id
|
|
53
|
+
)));
|
|
54
|
+
const program = resources.find((candidate) => candidate.type === "program" && candidate.id === programId);
|
|
55
|
+
if (!program) return null;
|
|
56
|
+
const snapshot = { root, entries, resources, workspace, model: loadModel(workspace.dataModelVersion) };
|
|
57
|
+
const collectionIds = scopedCollectionRecords(snapshot, resourceType, program).map(({ id }) => id).sort();
|
|
58
|
+
const currentRevision = collectionRevision(snapshot, resourceType, {
|
|
59
|
+
program,
|
|
60
|
+
authoritativeSourceId: record.authoritativeComponentId
|
|
61
|
+
});
|
|
62
|
+
if (
|
|
63
|
+
record.collectionRevision !== currentRevision
|
|
64
|
+
|| JSON.stringify([...(record.populationResourceIds || [])].sort()) !== JSON.stringify(collectionIds)
|
|
65
|
+
) return null;
|
|
66
|
+
const selectedIds = selector
|
|
67
|
+
? resources.filter((candidate) => (
|
|
68
|
+
candidate.type === selector.resourceType
|
|
69
|
+
&& collectionIds.includes(candidate.id)
|
|
70
|
+
&& (!selector.statuses?.length || selector.statuses.includes(candidate.status))
|
|
71
|
+
&& (!selector.criticalities?.length || selector.criticalities.includes(candidate.criticality))
|
|
72
|
+
)).map(({ id }) => id).sort()
|
|
73
|
+
: collectionIds;
|
|
74
|
+
return { collectionIds, selectedIds, reviewCommit: reviewCommit.commit };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function committedCollectionReviewMatch(root, record, relativePath, expectedCommit, timezone, cutoff) {
|
|
78
|
+
const identityHistory = getRecordIdentityHistory(root, record.id);
|
|
79
|
+
const history = expectedCommit
|
|
80
|
+
? identityHistory.filter(({ commit }) => commit === expectedCommit)
|
|
81
|
+
: [...identityHistory].reverse();
|
|
82
|
+
const expected = collectionReviewRevision(record);
|
|
83
|
+
return history.find(({ commit, path }) => {
|
|
84
|
+
if (expectedCommit && commit !== expectedCommit) return false;
|
|
85
|
+
const source = getFileAtRevision(root, commit, path);
|
|
86
|
+
if (!source) return false;
|
|
87
|
+
try {
|
|
88
|
+
const historical = JSON.parse(source);
|
|
89
|
+
return historical.type === "collection-review"
|
|
90
|
+
&& historical.id === record.id
|
|
91
|
+
&& ["active", "retired"].includes(historical.status)
|
|
92
|
+
&& collectionReviewRevision(historical) === expected;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}) || null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function historicalCollectionReviewIsUsable(record, model, timezone, resourceType, cutoff) {
|
|
100
|
+
if (
|
|
101
|
+
record?.type !== "collection-review"
|
|
102
|
+
|| !["active", "retired"].includes(record.status)
|
|
103
|
+
|| record.resourceType !== resourceType
|
|
104
|
+
|| record.coverage?.kind !== "as-of"
|
|
105
|
+
|| record.coverage.on !== cutoff
|
|
106
|
+
|| !Array.isArray(record.populationResourceIds)
|
|
107
|
+
|| !record.collectionRevision
|
|
108
|
+
|| !record.scopeRevision
|
|
109
|
+
|| record.reviewedOn !== cutoff
|
|
110
|
+
|| !isRfc3339Timestamp(record.knowledgeCutoffAt)
|
|
111
|
+
|| currentCalendarDate(timezone, new Date(record.knowledgeCutoffAt)) !== cutoff
|
|
112
|
+
) return false;
|
|
113
|
+
const allowed = model.collectionReviews?.[resourceType]?.decisions || ["complete"];
|
|
114
|
+
if (!allowed.includes(record.decision)) return false;
|
|
115
|
+
return record.decision === "zero-population"
|
|
116
|
+
? record.populationResourceIds.length === 0
|
|
117
|
+
: record.populationResourceIds.length > 0;
|
|
118
|
+
}
|