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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
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
+ }
@@ -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) => record.type === "audit-population" && record.auditId === audit.id)
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
- scaffoldObligationCompletion
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
- : `filegrc complete ${item.obligationId} --scaffold --window-start ${item.dueWindowStart} --completed-on YYYY-MM-DD`
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 = { ...payload, confirmed: flags.yes === true };
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 complete-action <action-item-id> --scaffold [--completed-on YYYY-MM-DD]
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
+ }