Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/obligation-population-timing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "filegrc-monorepo",
"version": "0.21.0",
"version": "0.21.1",
"private": true,
"workspaces": [
"packages/filegrc",
Expand Down
2 changes: 1 addition & 1 deletion packages/create-filegrc/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions packages/create-filegrc/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/create-filegrc/template/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/filegrc/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "filegrc",
"version": "0.21.0",
"version": "0.21.1",
"description": "Zero-dependency Git-native GRC engine",
"license": "MIT",
"repository": {
Expand Down
30 changes: 30 additions & 0 deletions packages/filegrc/src/obligation-timing.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
addCalendarDays,
calendarDayDifference,
validCalendarRecurrence,
calendarOccurrence,
calendarOccurrenceIndex,
parseCalendarDate,
Expand Down Expand Up @@ -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;
}
12 changes: 7 additions & 5 deletions packages/filegrc/src/obligations.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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.`);
}
Expand Down
13 changes: 11 additions & 2 deletions packages/filegrc/src/validate.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions packages/filegrc/src/web.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
? '<a class="obligation-action" href="#/resource/obligation-rule/' + encodeURIComponent(item.ruleId) + '">Review schedule</a>'
: proposedAction || (!state.readOnly && canAct && completion
? completion.blocked
? '<a class="obligation-action blocked" href="' + completion.href + '">' + esc(completion.blocked) + '</a>'
: item.actionItemId
Expand All @@ -2544,7 +2546,7 @@ function obligationCard(item, collapsed = false) {
const population = item.ruleId
? '<p><strong>' + esc(String(item.completedCount || 0)) + ' of ' + esc(String(item.expectedCount || 0)) + '</strong> 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)) + '.</p>'
: '';
return '<article class="obligation-card status-' + esc(item.status) + '" data-work-source="' + esc(type + ":" + id) + '"' + (collapsed ? ' data-collapsed hidden' : "") + '><div class="obligation-card-head"><span>' + esc(kind) + '</span><strong>' + esc(timingText(item)) + '</strong></div><h3><a href="#/resource/' + type + '/' + encodeURIComponent(id) + '">' + esc(titleCase(item.title)) + '</a></h3><p>' + esc(windowText(item)) + '</p>' + population + '<div class="obligation-card-foot"><div class="obligation-links">' + (item.policyIds || []).map(formatReference).join("") + '</div>' + action + '</div></article>';
return '<article class="obligation-card status-' + esc(item.status) + '" data-work-source="' + esc(type + ":" + id) + '"' + (collapsed ? ' data-collapsed hidden' : "") + '><div class="obligation-card-head"><span>' + esc(kind) + '</span><strong>' + esc(timingText(item)) + '</strong></div><h3><a href="#/resource/' + type + '/' + encodeURIComponent(id) + '">' + esc(titleCase(item.title)) + '</a></h3><p>' + esc(windowText(item)) + '</p>' + population + (item.timingIssue ? '<p>' + esc(item.timingIssue) + '</p>' : '') + '<div class="obligation-card-foot"><div class="obligation-links">' + (item.policyIds || []).map(formatReference).join("") + '</div>' + action + '</div></article>';
}

function actionCompletionPlan(item) {
Expand Down
9 changes: 7 additions & 2 deletions packages/filegrc/src/workflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
Expand All @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading