From 3ae2ff7677daab54736a5193af628fcc15a7e401 Mon Sep 17 00:00:00 2001 From: Abe Wheeler Date: Sun, 25 Oct 2026 19:56:13 -0500 Subject: [PATCH] Reject impossible whole-period execution deadlines --- docs/obligation-population-timing.md | 4 +- 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-timing.js | 30 +++++++++ packages/filegrc/src/obligations.js | 12 ++-- packages/filegrc/src/validate.js | 13 +++- packages/filegrc/src/web.js | 6 +- packages/filegrc/src/workflow.js | 9 ++- .../obligation-snapshot-correction.test.js | 61 ++++++++++++++++++- .../filegrc/test/whole-period-window.test.js | 41 +++++++++++++ 14 files changed, 171 insertions(+), 25 deletions(-) create mode 100644 packages/filegrc/test/whole-period-window.test.js diff --git a/docs/obligation-population-timing.md b/docs/obligation-population-timing.md index dd76e32..63701a2 100644 --- a/docs/obligation-population-timing.md +++ b/docs/obligation-population-timing.md @@ -6,9 +6,9 @@ FileGRC 0.21 adds optional model v13 fields. Adopt the 0.21 dependency line and 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 allowed window that permits work after that period ends. 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. +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. -When every current member has valid proof or a reviewed disposition but the population or required period remains open, Work Queue shows Waiting with its original deadline and cutoff or period end. Even zero or non-applicable populations wait until a whole-period duty ends. New eligible or unresolved members restore actionable work. Waiting suppresses due reminders, while period and audit completeness still require final population reconciliation. The day after a fixed cutoff, reconcile the final population; FileGRC does not silently close it. +When every current member has valid proof or a reviewed disposition but the population or required period remains open, Work Queue shows Waiting with its original deadline and cutoff or period end. Even zero or non-applicable populations wait until a whole-period duty ends. New eligible or unresolved members restore actionable work. Waiting suppresses due reminders, while period and audit completeness still require final population reconciliation. The day after a fixed cutoff, reconcile the final population; FileGRC does not silently close it. For a legacy window-end cutoff, that first final reconciliation day is also the first overdue day. Timely member proof remains timely, but the occurrence stays Overdue until its required reconciliation is recorded. FileGRC adds no implicit documentation grace or period coverage. ## Correct an inappropriate open snapshot rule diff --git a/package-lock.json b/package-lock.json index 91056b7..375ef1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.21.0", + "version": "0.21.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.21.0", + "version": "0.21.1", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.21.0", + "version": "0.21.1", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.21.0", + "version": "0.21.1", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 11fc787..e8e6248 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.21.0", + "version": "0.21.1", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index d07592a..78cb11c 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.21.0", + "version": "0.21.1", "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 29daaa7..675ca1e 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.0", + version: "0.21.1", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.21.0", + version: "0.21.1", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index f0556ae..b81de32 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.0", + "version": "0.21.1", "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 cfd00d6..f62dca2 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.21.0", + "version": "0.21.1", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/obligation-timing.js b/packages/filegrc/src/obligation-timing.js index ea74d31..b532a38 100644 --- a/packages/filegrc/src/obligation-timing.js +++ b/packages/filegrc/src/obligation-timing.js @@ -1,5 +1,7 @@ import { addCalendarDays, + calendarDayDifference, + validCalendarRecurrence, calendarOccurrence, calendarOccurrenceIndex, parseCalendarDate, @@ -61,3 +63,31 @@ export function completionCoversRequiredPeriod( export function requiredPeriodIsFinal(window, asOf) { return !window.requiredCoverage || asOf > window.requiredCoverage.endsOn; } + +// Whole-period proof is executed after the inclusive final day. A deadline +// inside that period cannot be repaired by recording N/A or waiting longer. +export function wholePeriodOccurrenceWindowIssue(window) { + return window.requiredCoverage && window.dueWindowEnd <= window.requiredCoverage.endsOn + ? "The allowed execution deadline must fall after the full inclusive required period. Review the schedule through a successor; retain the original deadline and proof." + : null; +} + +export function wholePeriodRuleWindowIssue(rule, recurrence = rule.recurrence) { + if (rule.completionCoverage !== "whole-period") return null; + if (!validCalendarRecurrence(recurrence)) return "A whole-period duty requires a supported calendar recurrence."; + const dueAfter = rule.window?.precision === "date" ? rule.window.dueAfter : null; + if (!Number.isSafeInteger(dueAfter)) return "A whole-period duty needs an explicit execution window extending after its inclusive period end."; + const step = recurrence.interval * (recurrence.unit === "year" ? 12 : 1); + if (!Number.isSafeInteger(step)) return "The whole-period recurrence exceeds the supported calendar range."; + const gcd = (a, b) => b ? gcd(b, a % b) : a; + // Gregorian month/year lengths repeat every 400 years. Check the reachable + // phases, rather than accepting a deadline that works only in the first year. + const cycles = ["month", "year"].includes(recurrence.unit) ? 4800 / gcd(step, 4800) : 1; + for (let index = 0; index < cycles; index += 1) { + const start = calendarOccurrence(recurrence, index), next = calendarOccurrence(recurrence, index + 1); + if (!start || !next) return index === 0 ? "The whole-period recurrence exceeds the supported calendar range." : null; + if (rule.endsOn && start > rule.endsOn) break; + if (dueAfter < calendarDayDifference(start, next)) return "A whole-period duty's execution deadline must follow every inclusive required period, including longer calendar cycles. Review a successor with adequate post-period execution time."; + } + return null; +} diff --git a/packages/filegrc/src/obligations.js b/packages/filegrc/src/obligations.js index c39253d..60b96bc 100644 --- a/packages/filegrc/src/obligations.js +++ b/packages/filegrc/src/obligations.js @@ -1,6 +1,6 @@ import { recordedCorrectionPopulation, currentSnapshotPopulation, samePopulation } from "./obligation-snapshot.js"; import { getGitSummary } from "./git.js"; -import { populationCutoffDate, populationIsFinal, wholePeriodCoverage, completionCoversRequiredPeriod, requiredPeriodIsFinal } from "./obligation-timing.js"; +import { populationCutoffDate, populationIsFinal, wholePeriodCoverage, completionCoversRequiredPeriod, requiredPeriodIsFinal, wholePeriodOccurrenceWindowIssue } from "./obligation-timing.js"; import { occurrenceSelectorPopulation } from "./obligation-population.js"; import { occurrenceCompletionContext } from "./obligation-completion-context.js"; import { assessProgramAccessReview, providerAccessControls } from "./provider-access-completion.js"; @@ -289,7 +289,7 @@ export function planObligations(resources, options = {}) { && completionPassesOccurrence(completion, obligation, activity, model, records, reconciliation, asOf, workspace?.timezone, options.root, byId, program?.id); })) .map((member) => member.resourceId)); - const resolutionsComplete = membershipFinal && requiredPeriodIsFinal(window, asOf) + const resolutionsComplete = !wholePeriodOccurrenceWindowIssue(window) && membershipFinal && requiredPeriodIsFinal(window, asOf) && expectedMemberIds.every((resourceId) => resolvedMemberIds.has(resourceId)); const conclusionValid = ["complete", "complete-with-exceptions", "zero-population"].includes(reconciliation?.conclusion) && resolutionsComplete; @@ -390,7 +390,7 @@ export function planObligations(resources, options = {}) { && completionPassesOccurrence(completion, obligation, activity, model, records, occurrence, asOf, workspace?.timezone, options.root, byId, program?.id); })) .map(({ resourceId }) => resourceId); - const resolutionsComplete = membershipFinal && requiredPeriodIsFinal(window, asOf) && expectedMemberIds.every((resourceId) => resolvedMemberIds.includes(resourceId)); + const resolutionsComplete = !wholePeriodOccurrenceWindowIssue(window) && membershipFinal && requiredPeriodIsFinal(window, asOf) && expectedMemberIds.every((resourceId) => resolvedMemberIds.includes(resourceId)); const conclusionValid = ["complete", "complete-with-exceptions", "zero-population"].includes(occurrence.conclusion) && resolutionsComplete; const successful = occurrence.status === "reconciled" @@ -483,10 +483,11 @@ export function planObligations(resources, options = {}) { const selector = byId.get(item.ruleId)?.selector; const occurrence = byId.get(item.occurrenceId); item.membershipCutoffAt = populationCutoffDate(selector, item, occurrence); + item.timingIssue = wholePeriodOccurrenceWindowIssue(item); item.memberWorkComplete = item.expectedMemberIds.every((id) => item.resolvedMemberIds.includes(id)); item.periodFinal = requiredPeriodIsFinal(item, asOf); - item.awaitingPeriodEnd = item.memberWorkComplete && !item.periodFinal; - item.awaitingPopulationCutoff = item.memberWorkComplete && !item.membershipFinal && Boolean(item.membershipCutoffAt); + item.awaitingPeriodEnd = !item.timingIssue && item.memberWorkComplete && !item.periodFinal; + item.awaitingPopulationCutoff = !item.timingIssue && item.memberWorkComplete && !item.membershipFinal && Boolean(item.membershipCutoffAt); if ((item.awaitingPopulationCutoff || item.awaitingPeriodEnd) && ["due", "overdue"].includes(item.status)) item.status = "waiting"; } const dateDeadlines = new Map(); @@ -879,6 +880,7 @@ export async function scaffoldObligationOccurrence(input, options = {}) { if (!item || !item.ruleId) { throw new Error(`No rule-based ${obligation.id} occurrence starts on ${windowStart}.`); } + if (item.timingIssue) throw new Error(item.timingIssue); if (item.programStatus !== "accepted" && !item.occurrenceId) { throw new Error(`Obligation "${obligation.id}" is still proposed. Activate its reviewed rule and governing program records first.`); } diff --git a/packages/filegrc/src/validate.js b/packages/filegrc/src/validate.js index a498a33..92c0e0f 100644 --- a/packages/filegrc/src/validate.js +++ b/packages/filegrc/src/validate.js @@ -1,6 +1,6 @@ import { workspaceEngineVersion, runtimeCompatibilityLine } from "./runtime-version.js"; import { recordedCorrectionPopulation, currentSnapshotPopulation, samePopulation } from "./obligation-snapshot.js"; -import { populationCutoffDate, wholePeriodCoverage, requiredPeriodIsFinal } from "./obligation-timing.js"; +import { populationCutoffDate, wholePeriodCoverage, requiredPeriodIsFinal, wholePeriodRuleWindowIssue, wholePeriodOccurrenceWindowIssue } from "./obligation-timing.js"; import { occurrenceSelectorPopulation } from "./obligation-population.js"; import { occurrenceCompletionContext } from "./obligation-completion-context.js"; import { attestationBindingIssue } from "./attestation-integrity.js"; @@ -524,6 +524,12 @@ function validateObligationRule(record, model, byId, path, diagnostics) { recurrence: record.recurrence, window: record.window }, model, byId, path, diagnostics); + if (record.completionCoverage === "whole-period") { + const workspaceTimezone = [...byId.values()].find(({ type }) => type === "workspace")?.timezone || "UTC"; + const { recurrence } = obligationScheduleRecurrence(obligation, record, byId, model, workspaceTimezone); + const timingIssue = wholePeriodRuleWindowIssue(record, recurrence); + if (timingIssue) diagnostics.push((record.status === "retired" ? warning : error)("invalid-obligation-period-window", path, timingIssue)); + } if (record.selector?.cutoff === "review-date" && record.completionCoverage !== "point-in-time" || record.selector?.cutoff === "coverage-end" && record.completionCoverage !== "whole-period" || record.completionCoverage === "whole-period" && record.selector?.cutoff === "review-date") { @@ -613,6 +619,8 @@ function validateObligationOccurrence(record, model, resources, entries, root, w } const rule = byId.get(record.ruleId); const obligation = byId.get(record.obligationId); + const timingIssue = wholePeriodOccurrenceWindowIssue({ dueWindowEnd: record.coverage?.endsOn, requiredCoverage: record.requiredCoverage }); + if (timingIssue) diagnostics.push((record.status === "superseded" ? warning : error)("invalid-obligation-period-window", path, timingIssue)); const loaded = { root, resources, model }; const correctionPopulation = recordedCorrectionPopulation(loaded, record, rule); const snapshotPopulation = currentSnapshotPopulation(loaded, record, rule); @@ -890,7 +898,8 @@ function validateOccurrenceScheduleBinding(record, obligation, rule, resources, || governingRule?.id !== rule.id || JSON.stringify([...(record.ownerIds || [])].sort()) !== JSON.stringify([...(obligation.ownerIds || [])].sort()) ) { - if (allowHistorical && rule.recurrence?.anchorMode === "activation") { + if (allowHistorical && (rule.recurrence?.anchorMode === "activation" + || record.status === "superseded" && rule.status === "retired")) { const basis = record.supersedesId ? resources.find(({ id }) => id === record.supersedesId) : record; const history = basis && getDataRecordHistoryIndex(root).historiesById.get(basis.id); // The first saved window must itself have been valid in its original diff --git a/packages/filegrc/src/web.js b/packages/filegrc/src/web.js index 35617d6..a48553b 100644 --- a/packages/filegrc/src/web.js +++ b/packages/filegrc/src/web.js @@ -2531,7 +2531,9 @@ function obligationCard(item, collapsed = false) { : ""; const canAct = completion?.blocked === "Assign current owner" || !["upcoming", "proposed"].includes(item.status); - const action = proposedAction || (!state.readOnly && canAct && completion + const action = item.timingIssue && item.ruleId + ? 'Review schedule' + : proposedAction || (!state.readOnly && canAct && completion ? completion.blocked ? '' + esc(completion.blocked) + '' : item.actionItemId @@ -2544,7 +2546,7 @@ function obligationCard(item, collapsed = false) { const population = item.ruleId ? '

' + esc(String(item.completedCount || 0)) + ' of ' + esc(String(item.expectedCount || 0)) + ' expected members passed' + (item.awaitingPeriodEnd ? '; work recorded, awaiting full period end ' + esc(item.requiredCoverage.endsOn) : item.membershipFinal ? '' : (item.awaitingPopulationCutoff ? '; work recorded, awaiting population cutoff ' : '; population remains open through ') + esc(item.membershipCutoffAt)) + '.

' : ''; - return '
' + esc(kind) + '' + esc(timingText(item)) + '

' + esc(titleCase(item.title)) + '

' + esc(windowText(item)) + '

' + population + '
' + action + '
'; + return '
' + esc(kind) + '' + esc(timingText(item)) + '

' + esc(titleCase(item.title)) + '

' + esc(windowText(item)) + '

' + population + (item.timingIssue ? '

' + esc(item.timingIssue) + '

' : '') + '
' + action + '
'; } function actionCompletionPlan(item) { diff --git a/packages/filegrc/src/workflow.js b/packages/filegrc/src/workflow.js index f22fb9a..33dd5a4 100644 --- a/packages/filegrc/src/workflow.js +++ b/packages/filegrc/src/workflow.js @@ -929,7 +929,7 @@ async function assessPeriodHealth(loaded, options) { findings.push(periodFinding( `period.obligation.${item.key || stableSuffix(JSON.stringify(item))}`, item.title, - item.status === "waiting" + item.timingIssue || (item.status === "waiting" ? item.awaitingPeriodEnd ? `Current member work is recorded, but the required period remains open through ${item.requiredCoverage.endsOn}; reconcile after its inclusive final day.` : `Current member work is recorded, but the population remains open through ${item.membershipCutoffAt}; the occurrence has not been reconciled.` @@ -939,7 +939,7 @@ async function assessPeriodHealth(loaded, options) { ? item.blockingReason || "This work is blocked by an unresolved source record." : item.status === "due" ? `This occurrence is open within its allowed window and must be completed by ${item.dueWindowEnd}.` - : `The expected occurrence for ${item.dueWindowStart} through ${item.dueWindowEnd} has no accepted completion.`, + : `The expected occurrence for ${item.dueWindowStart} through ${item.dueWindowEnd} has no accepted completion.`), item.status === "waiting" ? "waiting" : item.status === "overdue" @@ -1320,6 +1320,7 @@ function obligationWorkItem(item, byId, asOf, programId) { blockingReason: item.blockingReason || item.reason || null, dependencies: blockingDependencies(item.blockingResourceIds, byId), ...(item.ruleId ? { + timingIssue: item.timingIssue || null, membershipFinal: item.membershipFinal, membershipCutoffAt: item.membershipCutoffAt, memberWorkComplete: item.memberWorkComplete, @@ -1352,6 +1353,10 @@ function obligationNextAction(item, programId) { command: `npx filegrc complete-action ${shellArgument(item.actionItemId)} --scaffold --completed-on YYYY-MM-DD${programOption}` }; } + if (item.ruleId && item.timingIssue) return { + kind: "command", message: item.timingIssue, + command: `npx filegrc guide obligation-rule --json` + }; if (item.ruleId && ["proposed", "approved"].includes(item.ruleStatus)) { return { kind: "command", diff --git a/packages/filegrc/test/obligation-snapshot-correction.test.js b/packages/filegrc/test/obligation-snapshot-correction.test.js index 126b555..2a8e4f7 100644 --- a/packages/filegrc/test/obligation-snapshot-correction.test.js +++ b/packages/filegrc/test/obligation-snapshot-correction.test.js @@ -12,13 +12,15 @@ import { writeJson, executeCli, } from "./helpers.js"; -import { contentRevision } from "../src/files.js"; +import { contentRevision, createResource } from "../src/files.js"; import { loadWorkspace, validateWorkspace } from "../src/index.js"; import { scaffoldObligationOccurrence, saveObligationOccurrence, planObligations, assessCompletionForObligation, + scaffoldObligationRuleActivation, + activateObligationRule, } from "../src/obligations.js"; import { scaffoldObligationRuleCorrection, @@ -29,7 +31,7 @@ import { currentSnapshotPopulation } from "../src/obligation-snapshot.js"; import { getGitSummary } from "../src/git.js"; import { completionCoversRequiredPeriod } from "../src/obligation-timing.js"; import { currentCalendarDate } from "../src/time.js"; -import { addCalendarDays } from "../src/recurrence.js"; +import { addCalendarDays, calendarOccurrence } from "../src/recurrence.js"; async function fixture(t, { unit = "year" } = {}) { const root = await mkdtemp(join(tmpdir(), "filegrc-snapshot-correction-")); @@ -840,3 +842,58 @@ test("open populations count expected work separately from reviewed non-applicab assert.equal(item.status, "waiting"); } }); + +test("impossible whole-period deadlines fail validation and remain actionable despite zero or N/A results", async (t) => { + const f = await fixture(t), loaded = await loadWorkspace(f.root); + const ruleEntry = loaded.entries.find(e => e.record.id === f.rule.id); + await writeJson(ruleEntry.path, { ...ruleEntry.record, completionCoverage: "whole-period" }); + const validation = await validateWorkspace(f.root); + assert.ok(validation.diagnostics.some(d => d.code === "invalid-obligation-period-window")); + await assert.rejects(scaffoldObligationOccurrence(f.root, { obligationId: f.obligation.id, windowStart: f.start, asOf: f.today }), /execution deadline/); + const requiredCoverage = { kind: "range", startsOn: f.start, endsOn: addCalendarDays(f.start, 364) }; + for (const members of [[], [{ ...f.occurrence.members[0], disposition: "not-applicable", result: "pending", rationale: "No applicable work." }]]) { + for (const status of ["open", "reconciled"]) { + const resources = loaded.resources.map(record => record.id === f.obligation.id ? { ...record, status: "retired" } + : record.id === f.rule.id ? { ...record, status: "retired", completionCoverage: "whole-period" } + : record.id === f.occurrence.id ? { ...record, requiredCoverage, members, expectedCount: 0, completedCount: 0, status, conclusion: "zero-population" } : record); + for (const asOf of [f.today, addCalendarDays(f.start, 31), addCalendarDays(f.start, 370)]) { + const item = planObligations(resources, { model: loaded.model, from: f.start, through: asOf, asOf, includeComplete: true }).calendarItems.find(i => i.occurrenceId === f.occurrence.id); + assert.ok(item.timingIssue); + assert.equal(item.status, asOf > f.occurrence.coverage.endsOn ? "overdue" : "due"); + assert.equal(item.awaitingPeriodEnd, false); + assert.equal(item.resolutionsComplete, false); + } + } + } +}); + + +test("a reviewed valid successor retains impossible prior timing as warnings without rewriting proof", async (t) => { + const f = await fixture(t), loaded = await loadWorkspace(f.root); + const re = loaded.entries.find(e => e.record.id === f.rule.id), oe = loaded.entries.find(e => e.record.id === f.occurrence.id); + const priorRule = { ...re.record, id: "obligation-rule-prior-whole-period", effectiveAt: `${f.today}T12:00:00Z`, approvedOn: f.today, supersedesId: f.rule.id, cutoverDecision: "supersede-open-window", completionCoverage: "whole-period" }; + const priorOccurrence = { ...oe.record, id: `${oe.record.id}-whole-period`, ruleId: priorRule.id, coverage: { ...oe.record.coverage, startsOn: f.today }, occurrenceKey: `${oe.record.programId}:${f.obligation.id}:${f.today}`, members: oe.record.members.map(member => ({ ...member, disposition: "not-applicable", result: "pending", completionResourceIds: [], rationale: "The fixed scope has no applicable events." })), expectedCount: 0, completedCount: 0, requiredCoverage: { kind: "range", startsOn: f.start, endsOn: addCalendarDays(calendarOccurrence(priorRule.recurrence, 1), -1) } }; + const priorRulePath = join(f.root, "data/obligation-rules", `${priorRule.id}.json`), priorOccurrencePath = join(f.root, "data/obligation-occurrences", `${priorOccurrence.id}.json`); + await writeJson(re.path, { ...re.record, status: "retired", retiredOn: f.today }); + await writeJson(oe.path, { ...oe.record, status: "superseded" }); + await writeJson(priorRulePath, priorRule); await writeJson(priorOccurrencePath, priorOccurrence); + const obligationEntry = loaded.entries.find(e => e.record.id === f.obligation.id); + await writeJson(obligationEntry.path, { ...obligationEntry.record, activeRuleId: priorRule.id, ruleIds: [...obligationEntry.record.ruleIds, priorRule.id] }); + await commitWorkspaceFiles(f.root, "Retain original impossible duty timing"); + const originalValidation = await validateWorkspace(f.root); + assert.equal(originalValidation.diagnostics.filter(d => d.severity === "error").every(d => d.code === "invalid-obligation-period-window"), true, JSON.stringify(originalValidation.diagnostics)); + const proof = await readFile(f.ve.path, "utf8"); + const proposed = { ...priorRule, id: "obligation-rule-post-period-successor", title: "Reviewed post-period execution time", status: "proposed", window: { ...priorRule.window, dueAfter: 400 } }; + for (const key of ["approvedByIds", "approvedOn", "effectiveAt", "timezone", "supersedesId", "cutoverDecision"]) delete proposed[key]; + await createResource(f.root, proposed); await commitWorkspaceFiles(f.root, "Propose adequate post-period execution time"); + const activation = await scaffoldObligationRuleActivation(f.root, { ruleId: proposed.id, approvedByIds: ["person-example"], effectiveAt: new Date(Date.now() + 300000).toISOString(), cutoverDecision: "supersede-open-window" }); + await activateObligationRule(f.root, { ...activation.payload, confirmedRevision: activation.review.revision }); + await commitWorkspaceFiles(f.root, "Approve successor without concluding the prior work"); + const validation = await validateWorkspace(f.root); + assert.equal(validation.ok, true, JSON.stringify(validation.diagnostics)); + assert.equal(validation.diagnostics.filter(d => d.code === "invalid-obligation-period-window" && d.severity === "warning").length, 2); + const after = await loadWorkspace(f.root), retired = after.resources.find(r => r.id === priorRule.id), superseded = after.resources.find(r => r.id === priorOccurrence.id); + assert.deepEqual({ ...retired, status: priorRule.status, retiredOn: undefined }, { ...priorRule, retiredOn: undefined }); + assert.deepEqual({ ...superseded, status: priorOccurrence.status }, priorOccurrence); + assert.equal(await readFile(f.ve.path, "utf8"), proof); +}); diff --git a/packages/filegrc/test/whole-period-window.test.js b/packages/filegrc/test/whole-period-window.test.js new file mode 100644 index 0000000..d7788ee --- /dev/null +++ b/packages/filegrc/test/whole-period-window.test.js @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { wholePeriodRuleWindowIssue, wholePeriodOccurrenceWindowIssue } from "../src/obligation-timing.js"; + +const rule = (unit, interval, anchorDate, dueAfter) => ({ + completionCoverage: "whole-period", + recurrence: { mode: "calendar", unit, interval, anchorDate }, + ...(dueAfter === undefined ? {} : { window: { precision: "date", startsAfter: 0, dueAfter } }) +}); + +test("whole-period execution windows include post-period time in every reachable calendar phase", () => { + for (const candidate of [ + rule("year", 1, "2026-10-25"), + rule("year", 1, "2026-10-25", 30), + rule("year", 1, "2026-10-25", 364), + rule("year", 1, "2026-10-25", 365), + rule("month", 1, "2026-02-01", 28), + rule("month", 1, "2026-02-01", 30), + rule("day", 3, "2026-03-07", 2), + rule("week", 2, "2026-10-25", 13), + { ...rule("day", 1, "2026-10-25", 30), recurrence: { mode: "event" } } + ]) assert.ok(wholePeriodRuleWindowIssue(candidate), JSON.stringify(candidate)); + for (const candidate of [ + rule("year", 1, "2026-10-25", 366), + rule("year", 4, "2026-10-25", 1461), + rule("month", 1, "2026-02-01", 31), + rule("month", 1, "2026-01-31", 31), + rule("day", 3, "2026-03-07", 3), + rule("week", 2, "2026-10-25", 14), + { ...rule("year", 1, "2026-10-25", 365), endsOn: "2026-12-31" } + ]) assert.equal(wholePeriodRuleWindowIssue(candidate), null, JSON.stringify(candidate)); + assert.equal(wholePeriodRuleWindowIssue({ ...rule("year", 1, "2026-10-25", 0), completionCoverage: "point-in-time" }), null); +}); + +test("a whole-period execution deadline on its inclusive final date remains impossible", () => { + const requiredCoverage = { kind: "range", startsOn: "2026-10-25", endsOn: "2027-10-24" }; + assert.ok(wholePeriodOccurrenceWindowIssue({ requiredCoverage, dueWindowEnd: "2027-10-24" })); + assert.ok(wholePeriodOccurrenceWindowIssue({ requiredCoverage, dueWindowEnd: "2026-11-22" })); + assert.equal(wholePeriodOccurrenceWindowIssue({ requiredCoverage, dueWindowEnd: "2027-10-25" }), null); + assert.equal(wholePeriodOccurrenceWindowIssue({ dueWindowEnd: "2026-11-22" }), null); +});