From b154de6905155933e7e2b86cfcf4b9362803bd1e Mon Sep 17 00:00:00 2001 From: Abe Wheeler Date: Fri, 23 Oct 2026 09:33:51 -0500 Subject: [PATCH] Preserve setup proof within the adopted calendar cycle --- 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/obligations.js | 12 ++- packages/filegrc/src/operating-baseline.js | 13 ++- .../filegrc/test/operating-baseline.test.js | 101 ++++++++++++++++++ 9 files changed, 132 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 74d2721..b268e50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.18.15", + "version": "0.18.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.18.15", + "version": "0.18.16", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.18.15", + "version": "0.18.16", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.18.15", + "version": "0.18.16", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 23781e9..2467432 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.18.15", + "version": "0.18.16", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index 6d40f17..d5f3373 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.18.15", + "version": "0.18.16", "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 a26de6d..a43cc71 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.18.15", + version: "0.18.16", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.18.15", + version: "0.18.16", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index 44b5da1..e23f693 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.18.15", + "version": "0.18.16", "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 ba1c872..cfbf076 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.18.15", + "version": "0.18.16", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/obligations.js b/packages/filegrc/src/obligations.js index 124da2f..c46c504 100644 --- a/packages/filegrc/src/obligations.js +++ b/packages/filegrc/src/obligations.js @@ -118,6 +118,7 @@ export function planObligations(resources, options = {}) { throw new Error(`The obligation query must be narrowed; it includes more than ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} active obligations.`); } const calendarItems = []; + const cycleStartsByOccurrenceId = new Map(); const plannedOccurrenceKeys = new Set(); const triggerGroups = new Map(); let scannedCalendarOccurrences = 0; @@ -206,12 +207,13 @@ export function planObligations(resources, options = {}) { for (; ; index += 1) { const window = scanCalendarWindow(recurrence, schedule.window, index); if (!window || window.dueWindowStart > through) break; + const cycleWindowStart = window.dueWindowStart; if (schedule.endsOn && window.dueWindowStart > schedule.endsOn) break; if (rule && activationDate && window.dueWindowStart < activationDate) { if (!modelSupports(model, "operating-preparation")) continue; // A frozen full or partial cycle remains authoritative after later // content activation. The historical pass preserves its original key. - if (records.some((record) => record.type === "obligation-occurrence" + const frozen = records.find((record) => record.type === "obligation-occurrence" && record.programId === (program?.id || workspace?.id || "workspace") && record.obligationId === obligation.id && record.ruleId === rule.id && record.status !== "superseded" && record.coverage?.kind === "range" @@ -219,7 +221,11 @@ export function planObligations(resources, options = {}) { && record.coverage.startsOn >= window.dueWindowStart && record.coverage.startsOn <= activationDate && record.coverage.startsOn <= window.dueWindowEnd && record.occurrenceKey === `${program?.id || workspace?.id || "workspace"}:${obligation.id}:${record.coverage.startsOn}` - && record.coverage.endsOn === window.dueWindowEnd)) continue; + && record.coverage.endsOn === window.dueWindowEnd); + if (frozen) { + cycleStartsByOccurrenceId.set(frozen.id, cycleWindowStart); + continue; + } // Preserve the eligible remainder of an initial cycle at cutover. // Never allow pre-activation proof to satisfy that partial cycle. if (window.dueWindowEnd < activationDate) continue; @@ -280,6 +286,7 @@ export function planObligations(resources, options = {}) { key: `${obligation.id}:${window.dueWindowStart}`, kind: "calendar", activationOn: activationDate || null, + cycleWindowStart, configuredAnchor: configuredAnchor || null, initialCycleNotice: activationDate && window.dueWindowStart > activationDate ? `The first eligible cycle starts ${window.dueWindowStart}, after activation ${activationDate}. Check whether the approved rule needs a separate initial deadline; do not silently treat earlier cycles as completed.` @@ -364,6 +371,7 @@ export function planObligations(resources, options = {}) { calendarItems.push({ key: `${obligation.id}:${dueWindowStart}`, kind: "calendar", + cycleWindowStart: cycleStartsByOccurrenceId.get(occurrence.id) || dueWindowStart, obligationId: obligation.id, ruleId: rule.id, ruleStatus: rule.status, diff --git a/packages/filegrc/src/operating-baseline.js b/packages/filegrc/src/operating-baseline.js index 4b5a968..b2be2f1 100644 --- a/packages/filegrc/src/operating-baseline.js +++ b/packages/filegrc/src/operating-baseline.js @@ -62,8 +62,17 @@ export async function assessProgramBaseline(loaded, program, controls, asOf, mak if (!selected.length) continue; const controlIds = selected.map(({ id }) => id); const notBefore = selected.flatMap((control) => [control.effectiveOn, control.procedureEffectiveOn]).filter(Boolean).sort().at(-1); - const windows = schedules.map((schedule) => calendar.filter((task) => task.obligationId === schedule.id && task.dueWindowStart <= asOf) - .sort((a, b) => b.dueWindowStart.localeCompare(a.dueWindowStart))[0]).filter(Boolean); + const windows = schedules.map((schedule) => { + const task = calendar.filter((task) => task.obligationId === schedule.id && task.dueWindowStart <= asOf) + .sort((a, b) => b.dueWindowStart.localeCompare(a.dueWindowStart))[0]; + if (!task) return null; + // Setup proof may precede content cutover within the adopted cycle. + // Use the planner's exact rule, anchor and configured offsets instead of + // reconstructing a cycle from its shifted or clipped operating start. + return task.cycleWindowStart && task.cycleWindowStart < task.dueWindowStart + ? { ...task, dueWindowStart: task.cycleWindowStart } + : task; + }).filter(Boolean); const currentDate = (date) => Boolean(date && date <= asOf && (!notBefore || date >= notBefore)); const permittedDate = (date, relevantWindows = windows) => currentDate(date) && relevantWindows.every((window) => date >= window.dueWindowStart && date <= window.dueWindowEnd); diff --git a/packages/filegrc/test/operating-baseline.test.js b/packages/filegrc/test/operating-baseline.test.js index 874a489..23622e3 100644 --- a/packages/filegrc/test/operating-baseline.test.js +++ b/packages/filegrc/test/operating-baseline.test.js @@ -12,6 +12,7 @@ import { assessProgramReadiness } from "../src/program-readiness.js"; import { runCli } from "../src/cli.js"; import { captureCli } from "./helpers.js"; import { planModelMigration } from "../src/model-migration.js"; +import { planObligations } from "../src/obligations.js"; async function fixture(context, code) { const root = await mkdtemp(join(tmpdir(), "filegrc-full-baseline-")); @@ -345,3 +346,103 @@ test("provider reconciliation guide and scaffold expose nested model facts witho assert.equal(scaffold.record.populationCount, null); assert.notEqual(scaffold.record.status, "complete"); }); + + +test("later content cutover preserves risk setup proof within its adopted cycle", async (context) => { + const { loaded, program, controls, get, baseline } = await fixture(context, "RSK-01"); + const assessment = get("risk-assessment"); + const initial = await baseline("program-risk"); + for (const [field, ids] of Object.entries(initial.scope)) assessment[field] = ids; + Object.assign(assessment, { assessmentKind: "enterprise-risk", completedOn: "2026-10-05", approvedOn: "2026-10-06", + assessorIds: ["person-example"], reviewerIds: ["person-independent-approver-example"] }); + get("obligation").activityType = "risk-assessment"; + Object.assign(get("obligation-rule"), { recurrence: { mode: "calendar", unit: "month", interval: 3, anchorMode: "explicit", anchorDate: "2026-07-01" }, + effectiveAt: "2026-07-01T00:00:00Z", window: { precision: "date", startsAfter: 2, dueAfter: 89 } }); + assert.equal((await baseline("program-risk")).complete, true); + get("policy").effectiveOn = "2026-10-12"; + let result = await baseline("program-risk"); + assert.equal(result.complete, true, JSON.stringify(result.candidateResults)); + assert.equal(result.completionWindows[0].startsOn, "2026-10-03", "The configured cycle offset still applies"); + const task = planObligations(loaded.resources, { model: loaded.model, programId: program.id, asOf: "2026-10-13", through: "2026-10-13", includeComplete: true }).calendarItems[0]; + assert.equal(task.dueWindowStart, "2026-10-12", "Actual occurrence completion is still bounded by cutover"); + const frozen = { ...get("obligation-occurrence"), id: "obligation-occurrence-frozen-risk", type: "obligation-occurrence", + programId: program.id, obligationId: task.obligationId, ruleId: task.ruleId, occurrenceKey: task.occurrenceKey, + status: "open", coverage: { kind: "range", startsOn: task.dueWindowStart, endsOn: task.dueWindowEnd }, members: [] }; + loaded.resources.push(frozen); + assert.equal((await baseline("program-risk")).complete, true, "Recording the partial occurrence preserves setup proof eligibility"); + const frozenTask = planObligations(loaded.resources, { model: loaded.model, programId: program.id, asOf: "2026-10-13", through: "2026-10-13", includeComplete: true }).calendarItems.find((item) => item.occurrenceId === frozen.id); + assert.equal(frozenTask.dueWindowStart, "2026-10-12", "Frozen occurrence dates stay authoritative"); + assert.equal(frozenTask.cycleWindowStart, "2026-10-03"); + assessment.completedOn = "2026-10-02"; + assert.equal((await baseline("program-risk")).complete, false, "Proof before the configured offset stays ineligible"); + assessment.completedOn = "2026-09-30"; + assert.equal((await baseline("program-risk")).complete, false, "Previous-cycle work stays ineligible"); + assessment.completedOn = "2026-10-05"; + controls[0].procedureEffectiveOn = "2026-10-06"; + assert.equal((await baseline("program-risk")).complete, false, "A changed Control procedure still needs current proof"); +}); + +test("later content cutover preserves access setup exports without accepting stale or unsupported populations", async (context) => { + const { get, baseline } = await fixture(context, "IAM-01"); + const review = get("access-review"), grant = get("access-grant"), proof = get("evidence"); + get("obligation").activityType = "access-review"; + Object.assign(get("obligation-rule"), { recurrence: { mode: "calendar", unit: "month", interval: 3, anchorMode: "explicit", anchorDate: "2026-07-01" }, + effectiveAt: "2026-07-01T00:00:00Z", window: { precision: "date", startsAfter: 0, dueAfter: 89 } }); + Object.assign(review, { completedOn: "2026-10-05", approvedOn: "2026-10-06", populationCount: 1, exceptionCount: 0, + grantDecisions: [{ accessGrantId: grant.id, decision: "retain" }] }); + Object.assign(proof, { artifactKind: "population-export", sourceKind: "component", sourceComponentId: get("component").id, + collectedOn: "2026-10-05", verifiedOn: "2026-10-05", generatedAt: "2026-10-05T10:00:00Z", timezone: "UTC", + queryDescription: "All scoped provider accounts", populationCount: 1, + completenessValidation: "Compared all account classes with provider totals", accuracyValidation: "Checked account identifiers and permissions", + coverage: { kind: "as-of", on: "2026-10-05" } }); + delete proof.filePaths; + assert.equal((await baseline("provider-access")).complete, true); + get("policy").effectiveOn = "2026-10-12"; + assert.equal((await baseline("provider-access")).complete, true); + proof.generatedAt = "2026-09-30T10:00:00Z"; + assert.equal((await baseline("provider-access")).complete, false, "A prior-cycle export cannot support the current population"); + proof.generatedAt = "2026-10-05T10:00:00Z"; + proof.populationCount = 2; + assert.equal((await baseline("provider-access")).complete, false, "Unreconciled accounts still block readiness"); +}); + +test("setup eligibility retains the planner's rule, activation anchor and offset cycle", async (context) => { + for (const scenario of ["activation-anchor", "future-rule", "negative-offset", "cross-cycle-offset"]) { + await context.test(scenario, async (child) => { + const { loaded, get, baseline } = await fixture(child, "RSK-01"); + const assessment = get("risk-assessment"); + const initial = await baseline("program-risk"); + for (const [field, ids] of Object.entries(initial.scope)) assessment[field] = ids; + Object.assign(assessment, { assessmentKind: "enterprise-risk", completedOn: "2026-10-05", approvedOn: "2026-10-06", + assessorIds: ["person-example"], reviewerIds: ["person-independent-approver-example"] }); + const obligation = get("obligation"), rule = get("obligation-rule"); + obligation.activityType = "risk-assessment"; + Object.assign(rule, { recurrence: { mode: "calendar", unit: "month", interval: 3, anchorMode: "explicit", anchorDate: "2026-07-01" }, + effectiveAt: "2026-07-01T12:00:00Z", window: { precision: "date", startsAfter: 0, dueAfter: 89 } }); + get("policy").effectiveOn = "2026-10-12"; + let expected = false; + if (scenario === "activation-anchor") rule.recurrence.anchorMode = "activation"; + if (scenario === "future-rule") { + assessment.completedOn = "2026-09-20"; assessment.approvedOn = "2026-09-21"; + const successor = { ...rule, id: "obligation-rule-future-risk", supersedesId: rule.id, + effectiveAt: "2026-11-01T12:00:00Z", recurrence: { ...rule.recurrence, anchorDate: "2026-09-01" } }; + rule.status = "retired"; loaded.resources.push(successor); + obligation.ruleIds = [rule.id, successor.id]; obligation.activeRuleId = successor.id; + } + if (scenario === "negative-offset") { + get("policy").effectiveOn = "2026-07-01"; + rule.window = { precision: "date", startsAfter: -10, dueAfter: 80 }; + assessment.completedOn = "2026-08-20"; assessment.approvedOn = "2026-08-21"; + } + if (scenario === "cross-cycle-offset") { + rule.recurrence = { mode: "calendar", unit: "week", interval: 1, anchorMode: "explicit", anchorDate: "2026-10-01" }; + rule.effectiveAt = "2026-10-01T12:00:00Z"; + rule.window = { precision: "date", startsAfter: 10, dueAfter: 13 }; + assessment.completedOn = "2026-10-11"; assessment.approvedOn = "2026-10-12"; + expected = true; + } + const result = await baseline("program-risk"); + assert.equal(result.complete, expected, JSON.stringify(result.candidateResults)); + }); + } +});