diff --git a/package-lock.json b/package-lock.json index 375ef1a..5f6a4d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.21.1", + "version": "0.21.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.21.1", + "version": "0.21.2", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.21.1", + "version": "0.21.2", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.21.1", + "version": "0.21.2", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index e8e6248..ed3fd52 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.21.1", + "version": "0.21.2", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index 78cb11c..b10f646 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.21.1", + "version": "0.21.2", "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 675ca1e..11c776c 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.1", + version: "0.21.2", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.21.1", + version: "0.21.2", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index b81de32..523d131 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.1", + "version": "0.21.2", "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 f62dca2..f58115d 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.21.1", + "version": "0.21.2", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/schedule-set.js b/packages/filegrc/src/schedule-set.js index e092730..72eb8f4 100644 --- a/packages/filegrc/src/schedule-set.js +++ b/packages/filegrc/src/schedule-set.js @@ -84,7 +84,11 @@ export function scheduleSetIssues(loaded, program, review, options = {}) { const rule = options.at ? obligationRule(obligation, byId, { now: options.at }) : byId.get(obligation.activeRuleId); if (obligation.status !== "active" || (obligation.scheduleMode === "rule" && (!rule || rule.obligationId !== obligation.id || !["active", "retired"].includes(rule.status) || decision.ruleId !== obligation.activeRuleId || !reviewedRuleChainContains(obligation, decision.ruleId, rule.id, byId) || !rule.approvedByIds?.length || !rule.approvedOn))) { - add(obligation.id, `${obligation.title} needs its adopted, approved rule effective at cutover.`); + add(obligation.id, decision.ruleId !== obligation.activeRuleId && obligation.scheduleMode === "rule" + ? `${obligation.title}: the schedule review must name the currently adopted rule. Independently review the current Program schedule set.` + : options.at + ? `${obligation.title} needs its adopted, approved rule effective at cutover.` + : `${obligation.title} needs its currently adopted, approved rule. Review the current Program schedule set.`); } else if (rule && (rule.approvedOn > currentCalendarDate(rule.timezone || loaded.workspace?.timezone || "UTC") || !Array.isArray(rule.approvedByIds) || rule.approvedByIds.some((id) => !personWasActiveOn(byId.get(id), rule.approvedOn)))) { add(obligation.id, `${obligation.title} needs an actual rule approval by People active on its approval date.`); diff --git a/packages/filegrc/src/web.js b/packages/filegrc/src/web.js index a48553b..13769cb 100644 --- a/packages/filegrc/src/web.js +++ b/packages/filegrc/src/web.js @@ -2544,7 +2544,7 @@ function obligationCard(item, collapsed = false) { : ""); const kind = item.kind === "event" ? "Policy Event Task" : item.kind === "action" ? "Assigned Follow-up" : properCase(item.activityType || "Recurring"); 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)) + '.

' + ? '

' + esc(String(item.completedCount || 0)) + ' of ' + esc(String(item.expectedCount || 0)) + ' expected members passed' + (item.awaitingPeriodEnd ? item.requiredCoverage?.endsOn ? '; work recorded, awaiting full period end ' + esc(item.requiredCoverage.endsOn) : '; review the required period end before reconciliation' : item.membershipFinal ? '' : item.membershipCutoffAt ? (item.awaitingPopulationCutoff ? '; work recorded, awaiting population cutoff ' : '; population remains open through ') + esc(item.membershipCutoffAt) : '; record the actual population observation date with the work') + '.

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

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

' + esc(windowText(item)) + '

' + population + (item.timingIssue ? '

' + esc(item.timingIssue) + '

' : '') + '
' + action + '
'; } @@ -5433,10 +5433,14 @@ function openEditor(type, entry = null, options = {}) { function occurrenceMemberReview(record, membershipFinal = true, periodFinal = true) { const members = record.members || []; const pending = !periodFinal - ? '

The full period remains open through ' + esc(record.requiredCoverage?.endsOn) + '. Reconcile after its inclusive final day, even for zero or non-applicable populations.

' + ? record.requiredCoverage?.endsOn + ? '

The full period remains open through ' + esc(record.requiredCoverage.endsOn) + '. Reconcile after its inclusive final day, even for zero or non-applicable populations.

' + : '

Review the required period end before reconciling this occurrence.

' : membershipFinal ? "" - : '

Population remains open through ' + esc(record.membershipCutoffAt) + '. Review now, then reconcile after the cutoff.

'; + : record.membershipCutoffAt + ? '

Population remains open through ' + esc(record.membershipCutoffAt) + '. Review now, then reconcile after the cutoff.

' + : '

Record the date you actually observed this population. Reconcile after reviewing its members and proof.

'; if (!members.length) { return '
Population0 members
' + pending + '

Confirm the zero population, then set the record to reconciled.

'; } diff --git a/packages/filegrc/test/render.test.js b/packages/filegrc/test/render.test.js index 79b3ae4..0eb6ae9 100644 --- a/packages/filegrc/test/render.test.js +++ b/packages/filegrc/test/render.test.js @@ -3039,3 +3039,32 @@ test("waiting card timing never claims completion or an actionable deadline", () assert.equal(timing.timingText(item), "Awaiting population cutoff"); assert.equal(timing.windowText(item), "Recorded work window: 2026-10-01 through 2026-10-31."); }); + + +test("snapshot population copy requests an actual observation date without inventing a cutoff", () => { + const card = APP_SCRIPT.slice(APP_SCRIPT.indexOf("function obligationCard"), APP_SCRIPT.indexOf("function actionCompletionPlan")); + const review = APP_SCRIPT.slice(APP_SCRIPT.indexOf("function occurrenceMemberReview"), APP_SCRIPT.indexOf("function applyOccurrenceMemberReview")); + const context = vm.createContext({ state: { readOnly: true }, esc: String, titleCase: String, properCase: String, formatReference: String, timingText: () => "Upcoming", windowText: () => "Allowed window", obligationCompletionPlan: () => null }); + vm.runInContext(card + review, context); + context.item = { obligationId: "review", ruleId: "rule-review", title: "Periodic review", status: "upcoming", kind: "calendar", expectedCount: 1, completedCount: 0, membershipFinal: false, membershipCutoffAt: null }; + let html = vm.runInContext("obligationCard(item)", context); + assert.match(html, /record the actual population observation date/); + assert.doesNotMatch(html, /through \.|null|undefined/); + context.record = { members: [], membershipCutoffAt: null }; + html = vm.runInContext("occurrenceMemberReview(record, false)", context); + assert.match(html, /date you actually observed this population/); + assert.doesNotMatch(html, /through \.|null|undefined/); + context.item.membershipCutoffAt = "2026-10-31"; + context.record.membershipCutoffAt = "2026-10-31"; + assert.match(vm.runInContext("obligationCard(item)", context), /population remains open through 2026-10-31/); + assert.match(vm.runInContext("occurrenceMemberReview(record, false)", context), /Population remains open through 2026-10-31/); + context.item.awaitingPeriodEnd = true; + context.item.requiredCoverage = { endsOn: "2026-12-31" }; + context.record.requiredCoverage = { endsOn: "2026-12-31" }; + assert.match(vm.runInContext("obligationCard(item)", context), /awaiting full period end 2026-12-31/); + assert.match(vm.runInContext("occurrenceMemberReview(record, true, false)", context), /inclusive final day/); + delete context.item.requiredCoverage; + delete context.record.requiredCoverage; + assert.match(vm.runInContext("obligationCard(item)", context), /review the required period end/); + assert.match(vm.runInContext("occurrenceMemberReview(record, true, false)", context), /Review the required period end/); +}); diff --git a/packages/filegrc/test/schedule-set.test.js b/packages/filegrc/test/schedule-set.test.js index 5b9d546..a09f0bc 100644 --- a/packages/filegrc/test/schedule-set.test.js +++ b/packages/filegrc/test/schedule-set.test.js @@ -320,3 +320,21 @@ test("replacement event coverage uses the rule effective at cutover", () => { assert.ok(scheduleSetIssues(loaded, program, review, { at: "2026-10-13T00:00:00Z" }).some(({ message }) => /required incident-declared event/.test(message))); assert.deepEqual(scheduleSetIssues(loaded, program, review, { at: "2099-12-01T00:00:00Z" }), []); }); + + +test("a successor awaiting schedule oversight does not imply missing historical cutover approval", () => { + const { loaded, program } = fixture(); + const obligation = loaded.resources.find(({ id }) => id === "calendar"); + const predecessor = loaded.resources.find(({ id }) => id === "rule-calendar"); + predecessor.status = "retired"; + loaded.resources.push({ ...predecessor, id: "rule-current", status: "active", supersedesId: predecessor.id, effectiveAt: "2026-10-25T00:00:00Z" }); + obligation.activeRuleId = "rule-current"; + const review = { scopeResourceIds: [program.id], scheduleDecisions: [adopted, exclusion] }; + const issues = scheduleSetIssues(loaded, program, review, { at: "2026-10-12T00:00:00Z" }); + assert.ok(issues.some(({ message }) => /schedule review must name the currently adopted rule/.test(message))); + assert.ok(issues.every(({ message }) => !/effective at cutover/.test(message))); + review.scheduleDecisions[0] = { ...adopted, ruleId: "rule-current" }; + assert.deepEqual(scheduleSetIssues(loaded, program, review, { at: "2026-10-12T00:00:00Z" }), []); + predecessor.approvedByIds = []; + assert.ok(scheduleSetIssues(loaded, program, review, { at: "2026-10-12T00:00:00Z" }).some(({ message }) => /effective at cutover/.test(message))); +});