From afc851298ad33b2db1c3e8fde4202ebc3d466f16 Mon Sep 17 00:00:00 2001 From: Abe Wheeler Date: Sun, 1 Nov 2026 07:13:31 -0600 Subject: [PATCH] Preserve corrected open occurrence history after cutoff --- package-lock.json | 8 +++---- package.json | 2 +- packages/create-filegrc/package.json | 2 +- packages/create-filegrc/src/index.js | 4 ++-- packages/create-filegrc/template/package.json | 2 +- packages/filegrc/package.json | 2 +- packages/filegrc/src/obligation-snapshot.js | 11 +++++++++ packages/filegrc/src/validate.js | 7 +++--- .../obligation-snapshot-correction.test.js | 23 +++++++++++++++++++ 9 files changed, 48 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9a9eee0..2dad510 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.21.9", + "version": "0.21.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.21.9", + "version": "0.21.10", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.21.9", + "version": "0.21.10", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.21.9", + "version": "0.21.10", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index d46ae36..6eb71cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.21.9", + "version": "0.21.10", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index be5833b..eab4f3e 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.21.9", + "version": "0.21.10", "description": "Create a filegrc workspace for a SOC 2 program", "license": "MIT", "repository": { diff --git a/packages/create-filegrc/src/index.js b/packages/create-filegrc/src/index.js index 940f291..58bbb30 100644 --- a/packages/create-filegrc/src/index.js +++ b/packages/create-filegrc/src/index.js @@ -524,13 +524,13 @@ async function runCombinedSetup(target, input) { async function writeMinimalLockfile(target, name, versionRange) { const lock = { name, - version: "0.21.9", + version: "0.21.10", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.21.9", + version: "0.21.10", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index 1e809e9..96984ae 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.21.9", + "version": "0.21.10", "private": true, "description": "filegrc workspace for a SOC 2 program", "type": "module", diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index 22fc11c..dced624 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.21.9", + "version": "0.21.10", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/obligation-snapshot.js b/packages/filegrc/src/obligation-snapshot.js index 094e746..47a1fdf 100644 --- a/packages/filegrc/src/obligation-snapshot.js +++ b/packages/filegrc/src/obligation-snapshot.js @@ -117,6 +117,17 @@ export function recordedCorrectionPopulation(loaded, occurrence, rule) { }; } +export function retainedCorrectionSource(loaded, occurrence) { + if (occurrence?.status !== "superseded") return false; + // The successor binds the unchanged, previously open source to Git history. + // Its former cutoff must not later require a review that never governed it. + return loaded.resources.some((candidate) => { + if (candidate.type !== "obligation-occurrence" || !candidate.populationSourceRevision) return false; + const rule = loaded.resources.find((record) => record.id === candidate.ruleId); + return recordedCorrectionPopulation(loaded, candidate, rule)?.source.id === occurrence.id; + }); +} + export function currentSnapshotPopulation(loaded, occurrence, rule) { if ( !loaded.root || diff --git a/packages/filegrc/src/validate.js b/packages/filegrc/src/validate.js index 4e2efa7..1588eb8 100644 --- a/packages/filegrc/src/validate.js +++ b/packages/filegrc/src/validate.js @@ -3,7 +3,7 @@ import { reviewHistoricalWorkspace, reviewHistoryContext } from "./historical-wo import { reclassifyTemporalValidation } from "./temporal-diagnostics.js"; import { workspaceEngineVersion, runtimeCompatibilityLine } from "./runtime-version.js"; import { workflowSourceRuntimeIssues } from "./workflow-source-runtime.js"; -import { recordedCorrectionPopulation, currentSnapshotPopulation, samePopulation } from "./obligation-snapshot.js"; +import { recordedCorrectionPopulation, retainedCorrectionSource, currentSnapshotPopulation, samePopulation } from "./obligation-snapshot.js"; import { populationCutoffDate, wholePeriodCoverage, requiredPeriodIsFinal, wholePeriodRuleWindowIssue, wholePeriodOccurrenceWindowIssue } from "./obligation-timing.js"; import { occurrenceSelectorPopulation } from "./obligation-population.js"; import { occurrenceCompletionContext } from "./obligation-completion-context.js"; @@ -659,6 +659,7 @@ function validateObligationOccurrence(record, model, resources, entries, root, w const loaded = { root, resources, model }; const correctionPopulation = recordedCorrectionPopulation(loaded, record, rule); const snapshotPopulation = currentSnapshotPopulation(loaded, record, rule); + const retainedSource = retainedCorrectionSource(loaded, record); if (rule?.selector?.cutoff === "review-date") { if (!correctionPopulation && !snapshotPopulation && !record.collectionReviewId) diagnostics.push(error("missing-obligation-snapshot-source", path, "A review-date snapshot needs its exact committed scope, retained correction source or immutable temporal Collection Review.")); if (!parseCalendarDate(record.membershipCutoffAt) || record.membershipCutoffAt < record.coverage?.startsOn @@ -712,13 +713,13 @@ function validateObligationOccurrence(record, model, resources, entries, root, w "A historical occurrence must bind the exact immutable Collection Review revision, scope, cutoff coverage, and reviewed population." )); } - } else if (rule?.selector && record.membershipCutoffAt < asOf) { + } else if (rule?.selector && record.membershipCutoffAt < asOf && !retainedSource) { diagnostics.push(error( "missing-obligation-population-provenance", path, "A historical occurrence must bind an immutable Collection Review for its exact population cutoff." )); - } else if (rule?.selector) { + } else if (rule?.selector && !retainedSource) { const population = occurrenceSelectorPopulation({ root, resources, model }, record, rule.selector, byId.get(record.programId)); const missingRecorded = population.memberIds.some((id) => !memberIds.includes(id)); const unsupported = memberIds.some((id) => !population.expectedIds.includes(id)); diff --git a/packages/filegrc/test/obligation-snapshot-correction.test.js b/packages/filegrc/test/obligation-snapshot-correction.test.js index 2a8e4f7..48fd197 100644 --- a/packages/filegrc/test/obligation-snapshot-correction.test.js +++ b/packages/filegrc/test/obligation-snapshot-correction.test.js @@ -231,6 +231,29 @@ test("reviewed successor preserves the original window, historic proof and manag ); }); +test("retained open correction sources stay valid after their former cutoff without weakening provenance", async (t) => { + const f = await fixture(t); + await snapshotSuccessor(f); + const loaded = await loadWorkspace(f.root); + const source = loaded.entries.find(({ record }) => record.id === f.occurrence.id); + const successor = loaded.entries.find(({ record }) => record.supersedesId === source.record.id); + const afterCutoff = new Date(`${addCalendarDays(source.record.membershipCutoffAt, 1)}T18:00:00Z`); + t.mock.timers.enable({ apis: ["Date"], now: afterCutoff }); + const missingSource = async () => (await validateWorkspace(f.root)).diagnostics.some( + ({ code, path }) => code === "missing-obligation-population-provenance" && path.endsWith(`${source.record.id}.json`), + ); + assert.equal(await missingSource(), false); + + await writeJson(source.path, { ...source.record, title: "Altered retained source" }); + assert.equal(await missingSource(), true, "A changed historical source must not inherit the successor's proof."); + await writeJson(source.path, source.record); + await writeJson(successor.path, { ...successor.record, populationSourceRevision: "0".repeat(40) }); + assert.equal(await missingSource(), true, "An unsupported successor cannot excuse missing historical provenance."); + await writeJson(successor.path, successor.record); + await rm(successor.path); + assert.equal(await missingSource(), true, "Superseded status alone must not bypass the cutoff requirement."); +}); + test("snapshot correction rejects changed duties, backdating, stale confirmation and whole-period downgrades", async (t) => { const f = await fixture(t); let p = await scaffoldObligationRuleCorrection(f.root, { ruleId: f.rule.id });