From 1e88631546360fc058966f7d4358056e98f0c375 Mon Sep 17 00:00:00 2001 From: Abe Wheeler Date: Thu, 5 Nov 2026 10:24:18 -0500 Subject: [PATCH] Align starter monthly and quarterly schedules with calendar periods --- docs/obligation-population-timing.md | 6 +++ package-lock.json | 8 ++-- package.json | 2 +- packages/create-filegrc/package.json | 2 +- packages/create-filegrc/src/defaults.js | 14 ++++++- packages/create-filegrc/src/index.js | 4 +- packages/create-filegrc/template/package.json | 2 +- .../test/calendar-schedules.test.js | 42 +++++++++++++++++++ packages/create-filegrc/test/create.test.js | 2 +- packages/filegrc/package.json | 2 +- 10 files changed, 72 insertions(+), 12 deletions(-) create mode 100644 packages/create-filegrc/test/calendar-schedules.test.js diff --git a/docs/obligation-population-timing.md b/docs/obligation-population-timing.md index 63701a2..201d31d 100644 --- a/docs/obligation-population-timing.md +++ b/docs/obligation-population-timing.md @@ -4,6 +4,12 @@ A recurring review or test can inspect conditions on one date. Its yearly or mon FileGRC 0.21 adds optional model v13 fields. Adopt the 0.21 dependency line and deploy tested readers before saving them. Earlier runtimes reject that dependency line. Unchanged prior workspaces keep their original rules and population cutoffs; installing this release does not convert them or require a model migration. +## Calendar months and quarters + +New starter monthly rules begin on the first day of a calendar month. Quarterly rules begin on January 1, April 1, July 1 or October 1. Program activation still limits the initial allowed window, so a mid-quarter start does not accept proof from before activation. Annual and explicitly activation-based schedules keep their existing anchors. + +Upgrading does not change approved schedules. To realign an existing schedule, create and review a successor rule through normal rule activation. Preserve its cadence, population, proof criteria and prior completed occurrences. If the current period's duty is already complete, set the successor's `recurrence.anchorDate` to the next calendar boundary, such as January 1 after an October quarterly completion. This starts the next duty without opening a duplicate current-period window. If work remains due this period, retain or replace that open work explicitly at cutover instead of skipping it. Review deadlines and any whole-period coverage separately; changing the anchor never proves work was performed or expands historical proof. + For a snapshot, set the rule's `completionCoverage` to `point-in-time` and selector `cutoff` to `review-date`. Supply `--population-as-of YYYY-MM-DD` when recording or scaffolding reconciliation. Use the actual population observation date, which may differ from the completion or edit date. A new same-day snapshot binds committed source scope. Commit that occurrence on its observation date to retain contemporaneous proof; later historical recording uses the temporal Collection Review route. A past snapshot needs an immutable temporal Collection Review, or the supported correction below backed by retained occurrence rows. Do not infer historical populations from today's inventory. For a whole-period duty, set `completionCoverage` to `whole-period`. The required range runs from the recurrence cycle's first day through the day before the next cycle, separately from the allowed execution window. Set an explicit allowed window that permits work after that period ends in every reachable calendar cycle, including leap years and longer months. FileGRC rejects a deadline on or before a required period end. Invalid open windows remain actionable Due or Overdue instead of Waiting. A reviewed successor may retire an invalid rule and supersede its open occurrence; the retained timing defects remain warnings and never establish completed work. Completion proof must state an explicit `coverage` range containing the entire required range, with actual execution after its full inclusive final day. A point result or early test cannot satisfy it. Selector `coverage-end` keeps the population open through that required period's inclusive end. Existing window-start and window-end cutoffs retain their inclusive behavior. diff --git a/package-lock.json b/package-lock.json index 652ee36..a11179d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.21.12", + "version": "0.21.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.21.12", + "version": "0.21.13", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.21.12", + "version": "0.21.13", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.21.12", + "version": "0.21.13", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index a2df6b3..78c31e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.21.12", + "version": "0.21.13", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index db82f26..8b9a484 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.21.12", + "version": "0.21.13", "description": "Create a filegrc workspace for a SOC 2 program", "license": "MIT", "repository": { diff --git a/packages/create-filegrc/src/defaults.js b/packages/create-filegrc/src/defaults.js index 183aa41..881b358 100644 --- a/packages/create-filegrc/src/defaults.js +++ b/packages/create-filegrc/src/defaults.js @@ -1133,7 +1133,7 @@ export function baselineRecordFiles(effectiveDate, starter = "security") { obligationId: obligation.id, activityDefinitionVersion: "1", recurrence: obligation.recurrence.mode === "calendar" - ? { ...obligation.recurrence, anchorDate: effectiveDate } + ? { ...obligation.recurrence, anchorDate: starterCalendarAnchor(obligation.recurrence, effectiveDate) } : obligation.recurrence, ...(obligation.window ? { window: obligation.window } : {}), ...(starterObligationSelector(obligation.id) ? { selector: starterObligationSelector(obligation.id), completionCoverage: "point-in-time" } : {}), @@ -1256,6 +1256,18 @@ function calendar(unit, interval, anchorDate) { }; } +function starterCalendarAnchor(recurrence, effectiveDate) { + if (recurrence.anchorDate) return recurrence.anchorDate; + if (recurrence.anchorMode === "activation" || recurrence.unit !== "month") return effectiveDate; + if (recurrence.interval === 1) return `${effectiveDate.slice(0, 7)}-01`; + if (recurrence.interval === 3) { + const month = Number(effectiveDate.slice(5, 7)); + const quarterMonth = Math.floor((month - 1) / 3) * 3 + 1; + return `${effectiveDate.slice(0, 4)}-${String(quarterMonth).padStart(2, "0")}-01`; + } + return effectiveDate; +} + function event(eventType) { return { mode: "event", eventType }; } diff --git a/packages/create-filegrc/src/index.js b/packages/create-filegrc/src/index.js index 437296d..90f95a2 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.12", + version: "0.21.13", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.21.12", + version: "0.21.13", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index 7cafe56..125b6d7 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.12", + "version": "0.21.13", "private": true, "description": "filegrc workspace for a SOC 2 program", "type": "module", diff --git a/packages/create-filegrc/test/calendar-schedules.test.js b/packages/create-filegrc/test/calendar-schedules.test.js new file mode 100644 index 0000000..6e140ee --- /dev/null +++ b/packages/create-filegrc/test/calendar-schedules.test.js @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { baselineRecordFiles } from "../src/defaults.js"; +import { loadModel, planObligations } from "../../filegrc/src/index.js"; + +test("starter monthly and quarterly rules follow calendar boundaries in every month", () => { + for (let month = 1; month <= 12; month += 1) { + const date = `2028-${String(month).padStart(2, "0")}-${month === 2 ? "29" : "25"}`; + const rules = baselineRecordFiles(date).map(({ record }) => record).filter(({ type }) => type === "obligation-rule"); + for (const { recurrence } of rules.filter(({ recurrence }) => recurrence.mode === "calendar")) { + const expected = recurrence.unit === "month" && recurrence.interval === 1 + ? `${date.slice(0, 7)}-01` + : recurrence.unit === "month" && recurrence.interval === 3 + ? `2028-${String(Math.floor((month - 1) / 3) * 3 + 1).padStart(2, "0")}-01` + : date; + assert.equal(recurrence.anchorDate, expected); + } + } +}); + +test("a reviewed next-quarter successor retains completed work without opening another current-quarter cycle", () => { + const resources = [ + { id: "workspace", type: "workspace", title: "Workspace", timezone: "UTC", dataModelVersion: "13" }, + { id: "owner", type: "person", title: "Owner", status: "active" }, + { id: "control", type: "control", title: "Control", status: "implemented" }, + { id: "obligation", type: "obligation", title: "Quarterly meeting", status: "active", activityType: "meeting", ownerIds: ["owner"], controlIds: ["control"], scheduleMode: "rule", ruleIds: ["old-rule", "new-rule"], activeRuleId: "new-rule" }, + { id: "old-rule", type: "obligation-rule", obligationId: "obligation", status: "retired", recurrence: { mode: "calendar", unit: "month", interval: 3, anchorDate: "2026-08-05" }, effectiveAt: "2026-10-23T00:00:00Z", timezone: "UTC", retiredOn: "2026-11-05" }, + { id: "new-rule", type: "obligation-rule", obligationId: "obligation", status: "active", recurrence: { mode: "calendar", unit: "month", interval: 3, anchorDate: "2027-01-01" }, effectiveAt: "2026-11-05T00:00:00Z", timezone: "UTC", supersedesId: "old-rule", cutoverDecision: "new-windows-only" }, + { id: "done", type: "obligation-occurrence", obligationId: "obligation", ruleId: "old-rule", status: "reconciled", occurrenceKey: "workspace:obligation:2026-10-23", coverage: { kind: "range", startsOn: "2026-10-23", endsOn: "2026-11-04" }, members: [], expectedCount: 0, completedCount: 0, conclusion: "zero-population", reconciledAt: "2026-10-27T12:00:00Z" } + ]; + const before = structuredClone(resources); + const plan = planObligations(resources, { model: loadModel("13"), asOf: "2026-11-05", through: "2027-03-31", now: "2026-11-05T12:00:00Z", includeComplete: true }); + assert.deepEqual(resources, before); + const historical = plan.items.find(({ occurrenceId }) => occurrenceId === "done"); + assert.equal(historical.status, "complete"); + assert.equal(historical.ruleId, "old-rule"); + const next = plan.items.filter(({ occurrenceId }) => occurrenceId !== "done"); + assert.equal(next.length, 1); + assert.equal(next[0].dueWindowStart, "2027-01-01"); + assert.equal(next[0].dueWindowEnd, "2027-03-31"); + assert.equal(next[0].status, "upcoming"); +}); diff --git a/packages/create-filegrc/test/create.test.js b/packages/create-filegrc/test/create.test.js index 4a8d053..98b08f7 100644 --- a/packages/create-filegrc/test/create.test.js +++ b/packages/create-filegrc/test/create.test.js @@ -532,7 +532,7 @@ test("creates a complete generic repository with one dependency", async (context mode: "calendar", unit: "month", interval: 1, - anchorDate: "2026-07-25" + anchorDate: "2026-07-01" }); assert.equal(obligationsById.get("obligation-monthly-endpoint-protection-verification").activityType, "endpoint-verification"); assert.equal(rulesByObligationId.get("obligation-quarterly-vulnerability-scan").recurrence.interval, 3); diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index 37afcd3..1ecfad2 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.21.12", + "version": "0.21.13", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": {