diff --git a/docs/data-model.md b/docs/data-model.md index 2070818..b54a3a1 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -602,7 +602,7 @@ Key format: `namespace`. | `statuses` | array of string | No | | | `criticalities` | array of enum | No | Values: `low`, `medium`, `high`, `critical` | | `membershipMode` | enum | Yes | Values: `as-of` | -| `cutoff` | enum | Yes | Values: `window-start`, `window-end` | +| `cutoff` | enum | Yes | Values: `window-start`, `window-end`, `review-date`, `coverage-end` | ### `obligation-member` @@ -1402,6 +1402,7 @@ Markdown companions: | `evidenceIds` | array of id | No | References: `evidence` | | `scheduledFor` | date | Yes | Scheduled for | | `cancellation` | object (`cancellation`) | Conditional | Required when `status` is `canceled`. Allowed when `status` is `canceled`. | +| `coverage` | object (`coverage-period`) | No | Work coverage | At least one of `attendeeIds`, `externalAttendees` is required when `status` is `complete`. @@ -2246,6 +2247,7 @@ Record Markdown: shown by default as an implicit companion file. | `reviewedOn` | date | Conditional | Required when `status` is `complete`. Allowed when `status` is `complete`. | | `scheduledFor` | date | No | Scheduled for | | `componentIds` | array of id | No | Components References: `component` | +| `coverage` | object (`coverage-period`) | No | Work coverage | #### `incident` @@ -2367,6 +2369,7 @@ Record Markdown: shown by default as an implicit companion file. | `scheduledFor` | date | Yes | Scheduled for | | `cancellation` | object (`cancellation`) | Conditional | Required when `status` is `canceled`. Allowed when `status` is `canceled`. | | `componentIds` | array of id | No | Components References: `component` | +| `coverage` | object (`coverage-period`) | No | Work coverage | #### `backup-test` @@ -2405,6 +2408,7 @@ Record Markdown: shown by default as an implicit companion file. | `scheduledFor` | date | No | Scheduled for | | `cancellation` | object (`cancellation`) | Conditional | Required when `status` is `canceled`. Allowed when `status` is `canceled`. | | `componentIds` | array of id | No | Components References: `component` | +| `coverage` | object (`coverage-period`) | No | Work coverage | ### Evidence @@ -2555,6 +2559,7 @@ Record Markdown: shown by default as an implicit companion file. | `externalActivity` | object (`external-activity-reference`) | Conditional | Required when `status` is `externally-managed`. Allowed when `status` is `externally-managed`. | | `ownerIds` | array of id | Yes | Owners Relation group: `accountable-party`. References: `person`, `team`, `appointment` | | `cancellation` | object (`cancellation`) | Conditional | Required when `status` is `canceled`. Allowed when `status` is `canceled`. | +| `coverage` | object (`coverage-period`) | No | Work coverage | ### Issues and Remediation @@ -2719,10 +2724,11 @@ Record Markdown: available when needed as an implicit companion file. | `approvedOn` | date | Conditional | Required when `status` is one of `approved`, `active`, `retired`. | | `effectiveAt` | timestamp | Conditional | Required when `status` is one of `active`, `retired`. | | `timezone` | string (timezone) | Conditional | Required when `status` is one of `approved`, `active`, `retired`. | -| `cutoverDecision` | enum | No | Values: `new-windows-only`, `supersede-open-window`, `keep-open-window` | +| `cutoverDecision` | enum | No | Values: `new-windows-only`, `supersede-open-window`, `keep-open-window`, `correct-open-window` | | `supersedesId` | id | No | References: `obligation-rule` | | `retiredOn` | date | Conditional | Required when `status` is `retired`. | | `preparationPurpose` | enum | No | Preparation purpose Values: `operation`, `applicability-review`, `test-execution` | +| `completionCoverage` | enum | No | Required proof coverage Values: `point-in-time`, `whole-period` | #### `obligation-occurrence` @@ -2761,6 +2767,8 @@ Record Markdown: shown by default as an implicit companion file. | `auditPopulationIds` | array of id | No | References: `audit-population` | | `ownerIds` | array of id | Yes | Relation group: `accountable-party`. References: `person`, `team`, `appointment` | | `programId` | id | Yes | References: `program` | +| `requiredCoverage` | object (`coverage-period`) | No | Required proof coverage | +| `populationSourceRevision` | string | No | Recorded population source commit | ### Audits diff --git a/docs/obligation-population-timing.md b/docs/obligation-population-timing.md new file mode 100644 index 0000000..dd76e32 --- /dev/null +++ b/docs/obligation-population-timing.md @@ -0,0 +1,34 @@ +# Obligation populations and work coverage + +A recurring review or test can inspect conditions on one date. Its yearly or monthly cadence does not by itself require proof covering every day in that period. Management must choose the criterion that matches the actual governing procedure. + +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. + +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. + +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. + +## Correct an inappropriate open snapshot rule + +Use this route only when the actual duty was a point review or test. It does not remove an explicit whole-period duty, postpone a deadline, expand proof scope, or approve independent schedule oversight. + +```sh +npx filegrc correct-obligation-rule RULE_ID --scaffold --json > correction.json +``` + +Fill the successor title and rationale, actual current `approvedByIds`, `approvedOn` and `effectiveAt`, and both confirmations. In `corrections`, select supported open occurrences and their actual `populationAsOf` dates. Keep `sourceRevision` and `expectedRevisions` unchanged. The successor preserves frequency, anchor, allowed window and selector filters. Its source occurrence and all prior approvals remain auditable. The correction retains passed, failed, exception and non-applicable rows, plus proof links. Known eligible additions enter as pending work. + +```sh +npx filegrc correct-obligation-rule RULE_ID correction.json --preview --json > preview.json +``` + +Review every resulting date, member disposition, proof link and new pending member. Copy the preview's `revision` into the payload's `confirmedRevision`, then apply the unchanged payload: + +```sh +npx filegrc correct-obligation-rule RULE_ID correction.json --yes --json +npx filegrc reconcile-obligation OBLIGATION_ID --scaffold --window-start ORIGINAL_START --json > occurrence.json +``` + +Review and fill the normal reconciliation conclusion, reviewers and actual reconciliation time. Save that mutation through `reconcile-obligation`, validate, and inspect the Git diff before committing. Keep original operating records byte-for-byte unchanged when their work facts remain accurate. A limited recovery test continues to prove only the recovery scope it actually tested. diff --git a/package-lock.json b/package-lock.json index 7f5e9d2..91056b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.20.8", + "version": "0.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.20.8", + "version": "0.21.0", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.20.8", + "version": "0.21.0", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.20.8", + "version": "0.21.0", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index b2100b5..11fc787 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.20.8", + "version": "0.21.0", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index 122ae96..d07592a 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.20.8", + "version": "0.21.0", "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 80b026d..183aa41 100644 --- a/packages/create-filegrc/src/defaults.js +++ b/packages/create-filegrc/src/defaults.js @@ -1136,7 +1136,7 @@ export function baselineRecordFiles(effectiveDate, starter = "security") { ? { ...obligation.recurrence, anchorDate: effectiveDate } : obligation.recurrence, ...(obligation.window ? { window: obligation.window } : {}), - ...(starterObligationSelector(obligation.id) ? { selector: starterObligationSelector(obligation.id) } : {}), + ...(starterObligationSelector(obligation.id) ? { selector: starterObligationSelector(obligation.id), completionCoverage: "point-in-time" } : {}), ...(obligation.id === "obligation-annual-penetration-test" ? { preparationPurpose: "applicability-review" } : ["backup-test", "exercise", "penetration-test", "vulnerability-scan", "security-scan", "alert-path-test"].includes(obligation.activityType) ? { preparationPurpose: "test-execution" } : {}), rationale: starterObligationRationale(obligation.id), @@ -1221,7 +1221,7 @@ function starterObligationSelector(obligationId) { return definition ? { ...definition, membershipMode: "as-of", - cutoff: "window-end" + cutoff: obligationId === "obligation-annual-critical-vendor-review" ? "window-end" : "review-date" } : null; } diff --git a/packages/create-filegrc/src/index.js b/packages/create-filegrc/src/index.js index d96e5fc..29daaa7 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.20.8", + version: "0.21.0", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.20.8", + version: "0.21.0", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/AGENTS.md b/packages/create-filegrc/template/AGENTS.md index ba1ebcd..dbdc181 100644 --- a/packages/create-filegrc/template/AGENTS.md +++ b/packages/create-filegrc/template/AGENTS.md @@ -188,6 +188,8 @@ Program records, owners, Person email addresses, and dates remain authoritative Run `npx filegrc obligations --json` to see scheduled work, event tasks, owners, deadlines, and requested proof. In Step 4, record the work shown there. Use `npx filegrc guide obligation --json` and `data/AGENTS.md` for the completion and event-trigger commands. The resulting dated operating record or completed Action Item is the output. +A periodic review or test may inspect a point in time without claiming continuous coverage. Read [Obligation population timing](https://github.com/Alignbase/filegrc/blob/main/docs/obligation-population-timing.md) before choosing `completionCoverage` and selector cutoff. A `review-date` snapshot requires the actual `--population-as-of` observation date and committed scope. A `whole-period` duty requires explicit proof range coverage after the recurrence period ends. Preserve existing end-cutoff campaigns and unfinished members. Waiting means current member work is recorded but the population is still open; final reconciliation remains required. Use `npx filegrc correct-obligation-rule RULE_ID --scaffold --json` for a reviewed successor correcting an inappropriate open snapshot rule, preserving the original window, proofs and approvals. Upgrade local and hosted readers to the 0.21 line before adopting these optional model v13 fields. + ## Headless Markdown Create and update JSON plus Markdown in one mutation envelope when practical. You can also inspect or replace a companion directly: diff --git a/packages/create-filegrc/template/README.md b/packages/create-filegrc/template/README.md index 48aef25..c5f5d4c 100644 --- a/packages/create-filegrc/template/README.md +++ b/packages/create-filegrc/template/README.md @@ -61,3 +61,5 @@ Your team performs the control work and reviews responses before submission. An For a nonmaterial administrative correction, choose **Nonmaterial amendment** on a supported record page or run `npx filegrc amend-nonmaterial RESOURCE_ID --scaffold`. The owner can correct an administrative label or a verified equivalent reporting email alias while preserving the original record and approval. Changes to commitments, recipients, access, scope, schedules, governed content or evidence conclusions keep their normal reviews. The nonmaterial amendment workflow is available from FileGRC 0.19.0 with model v13. Existing workspaces need no model migration. The 0.19 runtime also reads existing 0.18 workspaces; 0.18 runtimes reject repositories pinned to 0.19, so hosted services must approve and install the new runtime before owners adopt these records. + +Periodic reviews, population cutoffs, and whole-period proof have separate meanings. See the [Obligation population timing guide](https://github.com/Alignbase/filegrc/blob/main/docs/obligation-population-timing.md) before choosing or correcting a schedule. diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index 924869b..f0556ae 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.20.8", + "version": "0.21.0", "private": true, "description": "filegrc workspace for a SOC 2 program", "type": "module", diff --git a/packages/filegrc/model/v13.json b/packages/filegrc/model/v13.json index 1d7b863..167fdcc 100644 --- a/packages/filegrc/model/v13.json +++ b/packages/filegrc/model/v13.json @@ -4380,6 +4380,13 @@ "allowedWhen": { "status": "canceled" } + }, + "coverage": { + "type": "object", + "required": false, + "label": "Work coverage", + "description": "The actual date or period inspected by this work. A whole-period duty requires an explicit range covering its required period; a completed test does not imply future coverage.", + "objectType": "coverage-period" } }, "listFields": [ @@ -6020,6 +6027,13 @@ "component" ], "label": "Components" + }, + "coverage": { + "type": "object", + "required": false, + "label": "Work coverage", + "description": "The actual date or period inspected by this work. A whole-period duty requires an explicit range covering its required period; a completed test does not imply future coverage.", + "objectType": "coverage-period" } }, "listFields": [ @@ -6342,6 +6356,13 @@ "component" ], "label": "Components" + }, + "coverage": { + "type": "object", + "required": false, + "label": "Work coverage", + "description": "The actual date or period inspected by this work. A whole-period duty requires an explicit range covering its required period; a completed test does not imply future coverage.", + "objectType": "coverage-period" } }, "listFields": [ @@ -6481,6 +6502,13 @@ "component" ], "label": "Components" + }, + "coverage": { + "type": "object", + "required": false, + "label": "Work coverage", + "description": "The actual date or period inspected by this work. A whole-period duty requires an explicit range covering its required period; a completed test does not imply future coverage.", + "objectType": "coverage-period" } }, "listFields": [ @@ -8465,6 +8493,13 @@ "allowedWhen": { "status": "canceled" } + }, + "coverage": { + "type": "object", + "required": false, + "label": "Work coverage", + "description": "The actual date or period inspected by this work. A whole-period duty requires an explicit range covering its required period; a completed test does not imply future coverage.", + "objectType": "coverage-period" } }, "listFields": [ @@ -9030,7 +9065,8 @@ "values": [ "new-windows-only", "supersede-open-window", - "keep-open-window" + "keep-open-window", + "correct-open-window" ] }, "supersedesId": { @@ -9054,6 +9090,15 @@ ], "label": "Preparation purpose", "description": "Classify the adopted work. A review decides applicability or cadence; test execution needs a real result for the declared population." + }, + "completionCoverage": { + "type": "enum", + "values": [ + "point-in-time", + "whole-period" + ], + "label": "Required proof coverage", + "description": "Point-in-time work is executed within the allowed window. Whole-period work must prove the complete recurrence period, separately from its allowed execution window." } }, "listFields": [ @@ -9202,6 +9247,15 @@ "relation": [ "program" ] + }, + "requiredCoverage": { + "type": "object", + "objectType": "coverage-period", + "label": "Required proof coverage" + }, + "populationSourceRevision": { + "type": "string", + "label": "Recorded population source commit" } }, "listFields": [ @@ -11151,8 +11205,11 @@ "type": "enum", "values": [ "window-start", - "window-end" - ] + "window-end", + "review-date", + "coverage-end" + ], + "description": "window-start and window-end use inclusive calendar boundaries. review-date freezes an explicitly observed snapshot on its actual date. coverage-end uses the required whole-period proof boundary." } } }, diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index 30e9b26..cfd00d6 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.20.8", + "version": "0.21.0", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/agent.js b/packages/filegrc/src/agent.js index 761bb20..87bad5c 100644 --- a/packages/filegrc/src/agent.js +++ b/packages/filegrc/src/agent.js @@ -84,6 +84,12 @@ export function buildAgentGuide(loaded, type, options = {}) { return { type, + ...(["obligation-rule", "obligation-occurrence"].includes(type) ? { populationTiming: { + snapshot: "Use completionCoverage point-in-time with selector cutoff review-date. Choose the actual observed population date, retain the committed source, and reconcile actual member results. A completed periodic review does not assert whole-period operation.", + period: "Use completionCoverage whole-period for a complete recurrence period. Proof needs explicit range coverage and execution after the required period ends; use an allowed window that permits this work. coverage-end keeps the period population open through its inclusive end.", + correction: "npx filegrc correct-obligation-rule RULE_ID --scaffold --json", + historical: "Preserve old approvals, dates and frozen member dispositions. A correction requires actual current authorization and retained committed occurrence rows; it does not approve shared schedule oversight." + } } : {}), ...(type === "retention-schedule-item" && loaded.model.resources["retention-application"] ? { applyApprovedRule: "npx filegrc apply-retention-rule RULE_ID --scaffold" } : {}), nonmaterialAmendments: Object.fromEntries(Object.entries(loaded.model.nonmaterialAmendments || {}).filter(([, rule]) => rule.resourceTypes.includes(type))), ...(options.preparationChecks ? { preparationChecks: options.preparationChecks } : {}), diff --git a/packages/filegrc/src/audit-preparation.js b/packages/filegrc/src/audit-preparation.js index 24a9549..08c6aab 100644 --- a/packages/filegrc/src/audit-preparation.js +++ b/packages/filegrc/src/audit-preparation.js @@ -1000,7 +1000,7 @@ function occurrenceContinuityStage(audit, records, model, asOf, root) { includeComplete: true, model }); - const gapStatuses = new Set(["overdue", "blocked", "due", "proposed"]); + const gapStatuses = new Set(["overdue", "blocked", "due", "proposed", "waiting"]); const calendarGaps = plan.calendarItems.filter(({ status }) => gapStatuses.has(status)); const eventGaps = plan.eventRuns .filter((run) => run.status !== "canceled" && run.occurredOn >= periodStart && run.occurredOn <= periodThrough) diff --git a/packages/filegrc/src/cli.js b/packages/filegrc/src/cli.js index 2e58d03..f140d81 100644 --- a/packages/filegrc/src/cli.js +++ b/packages/filegrc/src/cli.js @@ -1,3 +1,4 @@ +import { scaffoldObligationRuleCorrection, previewObligationRuleCorrection, correctObligationRule } from "./obligation-rule-correction.js"; import { scaffoldRetentionApplication, saveRetentionApplication } from "./retention-applications.js"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; @@ -459,7 +460,7 @@ export async function runCli(argv = process.argv.slice(2)) { if (flags.json) console.log(JSON.stringify(result, null, 2)); else { printHostedAutomation(result.recommendations[0]); - console.log(`${result.counts.overdue} overdue, ${result.counts.blocked} blocked, ${result.counts.due} due, ${result.counts.upcoming} upcoming, ${result.counts.proposed} starter proposals`); + console.log(`${result.counts.overdue} overdue, ${result.counts.blocked} blocked, ${result.counts.due} due, ${result.counts.waiting} waiting, ${result.counts.upcoming} upcoming, ${result.counts.proposed} starter proposals`); for (const item of result.items) { const deadline = item.dueWindowEndAt || item.dueWindowEnd; if (!deadline) throw new Error(`Planned work "${item.title}" is missing a deadline.`); @@ -1057,7 +1058,8 @@ export async function runCli(argv = process.argv.slice(2)) { obligationId, programId: flags.program, windowStart: flags["window-start"], - completedOn: flags["completed-on"] + completedOn: flags["completed-on"], + populationAsOf: flags["population-as-of"] }); console.log(JSON.stringify(result, null, 2)); return result; @@ -1068,6 +1070,7 @@ export async function runCli(argv = process.argv.slice(2)) { programId: flags.program, windowStart: flags["window-start"] || mutation.completionContext?.windowStart, completionContext: mutation.completionContext, + populationAsOf: flags["population-as-of"] || mutation.populationAsOf, record: mutation.record, content: mutation.content, expectedRevision: expectedRevision(flags, mutation, `obligation/${obligationId}`) @@ -1092,6 +1095,24 @@ export async function runCli(argv = process.argv.slice(2)) { else console.log(`Activated obligation-rule/${ruleId}`); return result; } + if (command === "correct-obligation-rule") { + const [ruleId, file] = positionals; + if (!ruleId) throw new Error("An Obligation Rule ID is required."); + if (flags.scaffold) { + const result = await scaffoldObligationRuleCorrection(root, { ruleId }); + console.log(JSON.stringify(result, null, 2)); + return result; + } + if (!file || !flags.preview && !flags.yes) throw new Error("Pass a completed correction payload and --preview or --yes."); + const source = await readSetupPayload(file); + const payload = source.payload || source; + if (payload.ruleId !== ruleId) throw new Error("The correction payload must name the requested Obligation Rule."); + const result = flags.preview + ? await previewObligationRuleCorrection(root, payload) + : await withWorkflowDelta(root, () => correctObligationRule(root, payload)); + console.log(JSON.stringify(result, null, 2)); + return result; + } if (command === "reconcile-obligation") { if (Object.hasOwn(flags, "preview")) throw new Error("reconcile-obligation does not support --preview. Use --scaffold to inspect the occurrence before saving."); const [obligationId, file] = positionals; @@ -1102,6 +1123,7 @@ export async function runCli(argv = process.argv.slice(2)) { programId: flags.program, windowStart: flags["window-start"], asOf: flags["as-of"], + populationAsOf: flags["population-as-of"], correctFinalized: flags["correct-finalized"] === true }); console.log(JSON.stringify(result, null, 2)); @@ -1476,10 +1498,11 @@ Usage: filegrc references [--json] filegrc preview-mutation [--json] filegrc create [--json] - filegrc complete --scaffold --window-start YYYY-MM-DD [--program program-id] [--completed-on YYYY-MM-DD] - filegrc complete [--expected-revision hash] [--json] + filegrc complete --scaffold --window-start YYYY-MM-DD [--program program-id] [--completed-on YYYY-MM-DD] [--population-as-of YYYY-MM-DD] + filegrc complete [--population-as-of YYYY-MM-DD] [--expected-revision hash] [--json] + filegrc correct-obligation-rule [payload.json] [--scaffold | --preview | --yes] [--json] filegrc activate-obligation-rule [payload.json] [--scaffold] [--json] - filegrc reconcile-obligation --scaffold --window-start YYYY-MM-DD [--program program-id] [--as-of YYYY-MM-DD] [--correct-finalized] + filegrc reconcile-obligation --scaffold --window-start YYYY-MM-DD [--program program-id] [--as-of YYYY-MM-DD] [--population-as-of YYYY-MM-DD] [--correct-finalized] filegrc reconcile-obligation [--program program-id] [--expected-revision hash] [--json] filegrc correct-audit-population --scaffold [--as-of YYYY-MM-DD] filegrc correct-audit-population [--expected-revision hash] [--json] @@ -1828,6 +1851,7 @@ function agentOverview(model, connection) { previewMutation: "filegrc preview-mutation --json", create: "filegrc create ", complete: "filegrc complete ", + correctObligationRule: "filegrc correct-obligation-rule [--scaffold | --preview | --yes] --json", reconcileObligation: "filegrc reconcile-obligation --scaffold --window-start ", completeAction: "filegrc complete-action --completed-on ", completeEvent: "filegrc complete-event --completed-on ", diff --git a/packages/filegrc/src/files.js b/packages/filegrc/src/files.js index 91e0817..1afbcb3 100644 --- a/packages/filegrc/src/files.js +++ b/packages/filegrc/src/files.js @@ -21,6 +21,7 @@ export const INTERNAL_WORKFLOW_CAPABILITIES = Object.freeze({ auditPopulationSupersession: Symbol("audit-population-supersession"), obligationOccurrenceReconciliation: Symbol("obligation-occurrence-reconciliation"), obligationOccurrenceSupersession: Symbol("obligation-occurrence-supersession"), + obligationRuleCorrection: Symbol("obligation-rule-correction"), obligationRuleActivation: Symbol("obligation-rule-activation"), collectionReviewReassessment: Symbol("collection-review-reassessment"), reportingRouteSetProposal: Symbol("reporting-route-set-proposal"), @@ -756,7 +757,8 @@ function assertImmutableWorkflowRecord(existing, next, options = {}, loaded = nu && ![ INTERNAL_WORKFLOW_CAPABILITIES.obligationOccurrenceReconciliation, INTERNAL_WORKFLOW_CAPABILITIES.obligationOccurrenceSupersession, - INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation + INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation, + INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleCorrection ].includes(capability) ) { throw new Error(`Obligation occurrence "${existing.id}" is workflow-managed. Scaffold and save its reconciliation instead of editing it directly.`); @@ -770,7 +772,7 @@ function assertImmutableWorkflowRecord(existing, next, options = {}, loaded = nu ); } if (existing.type === "obligation-rule" && ["active", "retired"].includes(existing.status)) { - const retirement = capability === INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation + const retirement = [INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation, INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleCorrection].includes(capability) && existing.status === "active" && next.status === "retired" && preservesExisting(["status", "retiredOn"]); @@ -780,11 +782,11 @@ function assertImmutableWorkflowRecord(existing, next, options = {}, loaded = nu existing.type === "obligation-rule" && existing.status !== "active" && next.status === "active" - && capability !== INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation + && ![INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation, INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleCorrection].includes(capability) ) { throw new Error(`Obligation rule "${existing.id}" activation is workflow-managed. Review and activate the rule instead of editing it directly.`); } - if (existing.type === "obligation" && capability !== INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation) { + if (existing.type === "obligation" && ![INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation, INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleCorrection].includes(capability)) { const managedFields = ["scheduleMode", "ruleIds", "activeRuleId"]; if (managedFields.some((field) => JSON.stringify(next[field]) !== JSON.stringify(existing[field]))) { throw new Error(`Obligation "${existing.id}" rule adoption is workflow-managed. Activate a reviewed rule instead of editing its schedule binding directly.`); @@ -793,7 +795,7 @@ function assertImmutableWorkflowRecord(existing, next, options = {}, loaded = nu if ( existing.type === "obligation" && existing.scheduleMode === "rule" - && capability !== INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation + && ![INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation, INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleCorrection].includes(capability) && JSON.stringify(next.completionResourceIds) !== JSON.stringify(existing.completionResourceIds) ) { throw new Error(`Obligation "${existing.id}" historical completion links are immutable after rule activation.`); @@ -801,7 +803,8 @@ function assertImmutableWorkflowRecord(existing, next, options = {}, loaded = nu if (existing.type === "obligation-occurrence" && ["reconciled", "superseded"].includes(existing.status)) { const supersession = [ INTERNAL_WORKFLOW_CAPABILITIES.obligationOccurrenceSupersession, - INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation + INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation, + INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleCorrection ].includes(capability) && existing.status === "reconciled" && next.status === "superseded" @@ -917,7 +920,8 @@ function assertSpecializedWorkflowCreate(record, options = {}, loaded = null) { record?.type === "obligation-occurrence" && ![ INTERNAL_WORKFLOW_CAPABILITIES.obligationOccurrenceReconciliation, - INTERNAL_WORKFLOW_CAPABILITIES.obligationOccurrenceSupersession + INTERNAL_WORKFLOW_CAPABILITIES.obligationOccurrenceSupersession, + INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleCorrection ].includes(options.workflowCapability) ) { throw new Error(`Obligation occurrence "${record.id || ""}" is workflow-managed. Scaffold and save its reconciliation instead of creating it directly.`); diff --git a/packages/filegrc/src/index.js b/packages/filegrc/src/index.js index c9f52db..2747a7e 100644 --- a/packages/filegrc/src/index.js +++ b/packages/filegrc/src/index.js @@ -144,3 +144,5 @@ export { assessHostedAutomation, githubRepository, hostedAutomationForConnection export { amendedResourceView, scaffoldNonmaterialAmendment, saveNonmaterialAmendment } from "./nonmaterial-amendments.js"; export { scaffoldRetentionApplication, saveRetentionApplication, retentionApplicationIssues } from "./retention-applications.js"; + +export { scaffoldObligationRuleCorrection, previewObligationRuleCorrection, correctObligationRule } from "./obligation-rule-correction.js"; diff --git a/packages/filegrc/src/mutation.js b/packages/filegrc/src/mutation.js index 16a317a..4ce7d75 100644 --- a/packages/filegrc/src/mutation.js +++ b/packages/filegrc/src/mutation.js @@ -58,6 +58,7 @@ export function normalizeResourceMutation(value, options = {}) { content: value.content, revision: value.revision ?? undefined, contentRevisions: value.contentRevisions, + populationAsOf: value.populationAsOf, completionContext: value.completionContext }; } diff --git a/packages/filegrc/src/nonmaterial-amendments.js b/packages/filegrc/src/nonmaterial-amendments.js index 8f07257..96312fd 100644 --- a/packages/filegrc/src/nonmaterial-amendments.js +++ b/packages/filegrc/src/nonmaterial-amendments.js @@ -21,7 +21,7 @@ function hasAmendmentRuntimePin(loaded, commit = null) { ? runGitCommandSync(loaded.root, ["show", `${commit}:./package-lock.json`], { timeout: 5_000, maxBuffer: 2_000_000 }) : readFileSync(join(loaded.root, "package-lock.json"), "utf8"); const engine = JSON.parse(source)?.packages?.["node_modules/filegrc"]; - return !engine?.link && ["0.19.x", "0.20.x"].includes(runtimeCompatibilityLine(engine?.version)); + return !engine?.link && ["0.19.x", "0.20.x", "0.21.x"].includes(runtimeCompatibilityLine(engine?.version)); } catch (error) { rethrowGitReadDeadline(error); return false; } } @@ -57,14 +57,14 @@ export function nonmaterialAmendmentIssues(loaded, amendment) { const issues = []; const fail = (message) => issues.push({ severity: "error", code: "invalid-nonmaterial-amendment", resourceId: amendment.id, message }); if (!hasAmendmentRuntimePin(loaded)) { - fail("Upgrade and commit the repository dependency and lockfile to the supported FileGRC 0.19 or 0.20 line before adopting nonmaterial amendments. Reading an older repository does not authorize new records under an older pin."); + fail("Upgrade and commit the repository dependency and lockfile to the supported FileGRC 0.19, 0.20 or 0.21 line before adopting nonmaterial amendments. Reading an older repository does not authorize new records under an older pin."); return issues; } const rule = loaded.model.nonmaterialAmendments?.[amendment.category]; const head = getGitSummary(loaded.root).commit; const eventCommit = firstCommit(loaded, amendment.id); if (eventCommit && !hasAmendmentRuntimePin(loaded, eventCommit)) { - fail("The repository must adopt a supported 0.19 or 0.20 dependency and lockfile no later than its first amendment commit."); + fail("The repository must adopt a supported 0.19, 0.20 or 0.21 dependency and lockfile no later than its first amendment commit."); return issues; } const source = /^[a-f0-9]{40}$/.test(amendment.sourceCommit || "") && head @@ -189,7 +189,7 @@ export function amendedResourceView(loaded, target, options = {}) { export async function scaffoldNonmaterialAmendment(input, targetId, options = {}) { const loaded = input?.resources ? input : await loadWorkspace(input); - if (!hasAmendmentRuntimePin(loaded)) throw new Error("Upgrade and commit the repository dependency and lockfile to FileGRC 0.19 or 0.20 before adopting nonmaterial amendments."); + if (!hasAmendmentRuntimePin(loaded)) throw new Error("Upgrade and commit the repository dependency and lockfile to FileGRC 0.19, 0.20 or 0.21 before adopting nonmaterial amendments."); const entry = loaded.entries.find(({ record }) => record.id === targetId); if (!entry) throw new Error(`Resource "${targetId}" was not found.`); const head = getGitSummary(loaded.root).commit; diff --git a/packages/filegrc/src/obligation-rule-correction.js b/packages/filegrc/src/obligation-rule-correction.js new file mode 100644 index 0000000..00a735d --- /dev/null +++ b/packages/filegrc/src/obligation-rule-correction.js @@ -0,0 +1,285 @@ +import { isDeepStrictEqual } from "node:util"; +import { loadWorkspace } from "./workspace.js"; +import { + applyResourceBatch, + contentRevision, + INTERNAL_WORKFLOW_CAPABILITIES, +} from "./files.js"; +import { serializeWorkspaceMutation } from "./mutation.js"; +import { createResourceId } from "./id.js"; +import { getGitSummary } from "./git.js"; +import { currentCalendarDate, isRfc3339Timestamp } from "./time.js"; +import { parseCalendarDate } from "./recurrence.js"; +import { recordedCorrectionPopulation } from "./obligation-snapshot.js"; +import { calculateRevision, revisionsMatch } from "./revisions.js"; + +export async function scaffoldObligationRuleCorrection(input, options = {}) { + const loaded = await loadWorkspace(input); + const source = loaded.entries.find( + (e) => + e.record.type === "obligation-rule" && e.record.id === options.ruleId, + ); + if (!source || source.record.status !== "active") + throw new Error("Select an active rule to correct through a successor."); + if ( + !source.record.selector || + source.record.completionCoverage === "whole-period" + ) + throw new Error( + "Snapshot correction cannot remove a whole-period duty or replace an unscoped rule. Use normal prospective rule review.", + ); + const obligation = loaded.entries.find( + (e) => e.record.id === source.record.obligationId, + ); + const occurrences = loaded.entries.filter( + (e) => + e.record.type === "obligation-occurrence" && + e.record.ruleId === source.record.id && + e.record.status === "open", + ); + const rule = structuredClone(source.record); + for (const key of [ + "approvedByIds", + "approvedOn", + "effectiveAt", + "retiredOn", + "timezone", + "supersedesId", + "cutoverDecision", + ]) + delete rule[key]; + rule.id = createResourceId( + "obligation-rule", + `${source.record.title} snapshot correction`, + loaded.resources.map((r) => r.id), + ); + Object.assign(rule, { + title: `${source.record.title} snapshot correction`, + status: "proposed", + selector: { ...rule.selector, cutoff: "review-date" }, + completionCoverage: "point-in-time", + rationale: null, + }); + return { + ruleId: source.record.id, + record: rule, + approvedByIds: [], + approvedOn: null, + effectiveAt: null, + confirmPointInTimeDuty: false, + confirmRecordedPopulation: false, + corrections: occurrences.map(({ record }) => ({ + sourceOccurrenceId: record.id, + populationAsOf: null, + })), + sourceRevision: getGitSummary(loaded.root).commit, + expectedRevisions: Object.fromEntries( + [source, obligation, ...occurrences].map((e) => [ + e.record.id, + contentRevision(e.source), + ]), + ), + instructions: + "Record the actual management authorization, rationale and observation date for each selected open occurrence. Preserve the existing frequency, allowed window, proof, member dispositions and earlier approvals. Remove unselected occurrences from corrections; they remain governed by their original rule. This does not approve shared schedule-set oversight.", + }; +} + +export async function previewObligationRuleCorrection( + input, + payload, + options = {}, +) { + const loaded = await loadWorkspace(input), + scaffold = await scaffoldObligationRuleCorrection(input, { + ruleId: payload.ruleId, + }); + const prior = loaded.resources.find((r) => r.id === payload.ruleId), + obligation = loaded.resources.find((r) => r.id === prior.obligationId); + if (!payload.confirmPointInTimeDuty || !payload.confirmRecordedPopulation) + throw new Error( + "Confirm that the actual governing duty is point-in-time work and the retained population is supported by its recorded work.", + ); + const approvedOn = payload.approvedOn, + effectiveAt = payload.effectiveAt, + now = options.now ? new Date(options.now) : new Date(); + const today = currentCalendarDate(loaded.workspace.timezone, now); + if ( + !parseCalendarDate(approvedOn) || + approvedOn !== today || + !isRfc3339Timestamp(effectiveAt) || + currentCalendarDate(loaded.workspace.timezone, new Date(effectiveAt)) !== + today || + new Date(effectiveAt) > now + ) { + throw new Error( + "Record today's actual authorization date and effective timestamp, without backdating or future authorization.", + ); + } + if ( + !payload.approvedByIds?.length || + payload.approvedByIds.some( + (id) => + !loaded.resources.some( + (r) => r.id === id && r.type === "person" && r.status === "active", + ), + ) + ) + throw new Error( + "Name the active People who actually authorized the correction.", + ); + const expectedRule = { + ...scaffold.record, + id: payload.record?.id, + title: payload.record?.title, + rationale: payload.record?.rationale, + }; + if ( + !isDeepStrictEqual(expectedRule, payload.record) || + !String(payload.record.rationale || "").trim() + ) + throw new Error( + "A snapshot correction may change only its identity, title, rationale, cutoff and point-in-time completion criterion. Preserve the approved frequency, window and selector filters.", + ); + if ( + !payload.corrections?.length || + new Set(payload.corrections.map((c) => c.sourceOccurrenceId)).size !== + payload.corrections.length + ) + throw new Error("Select each supported open occurrence once."); + for (const [id, revision] of Object.entries(scaffold.expectedRevisions)) { + if (!revisionsMatch("content", payload.expectedRevisions?.[id], revision)) + throw new Error( + "The source rule, Obligation or open occurrence changed. Scaffold the correction again.", + ); + } + if (payload.sourceRevision !== scaffold.sourceRevision) + throw new Error("The committed population source changed. Scaffold again."); + const rule = { + ...payload.record, + status: "active", + approvedByIds: [...new Set(payload.approvedByIds)], + approvedOn, + effectiveAt, + timezone: loaded.workspace.timezone, + supersedesId: prior.id, + cutoverDecision: "correct-open-window", + }; + const updates = [ + { ...prior, status: "retired", retiredOn: today }, + { + ...obligation, + activeRuleId: rule.id, + ruleIds: [...new Set([...(obligation.ruleIds || []), rule.id])], + }, + ]; + const ids = loaded.resources.map((r) => r.id), + occurrences = []; + for (const correction of payload.corrections) { + const source = loaded.resources.find( + (r) => r.id === correction.sourceOccurrenceId, + ); + const date = correction.populationAsOf; + if ( + source?.type !== "obligation-occurrence" || + source.status !== "open" || + source.ruleId !== prior.id + ) + throw new Error( + "Only an open occurrence of this exact source rule can be corrected.", + ); + if ( + !parseCalendarDate(date) || + date < source.coverage.startsOn || + date > source.coverage.endsOn || + date > today + ) + throw new Error( + "Use the actual population observation date inside the original allowed window, never a future date.", + ); + const occurrence = { + ...structuredClone(source), + id: createResourceId( + "obligation-occurrence", + `${source.id} snapshot correction ${today}`, + ids, + ), + title: `${source.title} snapshot correction`, + status: "open", + ruleId: rule.id, + membershipCutoffAt: date, + supersedesId: source.id, + populationSourceRevision: payload.sourceRevision, + }; + ids.push(occurrence.id); + for (const key of [ + "collectionReviewId", + "collectionReviewCommit", + "collectionReviewRevision", + "collectionRevision", + "scopeRevision", + "requiredCoverage", + "conclusion", + "reviewedByIds", + "reconciledAt", + ]) + delete occurrence[key]; + const population = recordedCorrectionPopulation(loaded, occurrence, rule); + if (!population) + throw new Error( + "The original occurrence and population must be retained in the selected committed source before correction.", + ); + const members = new Map( + occurrence.members.map((member) => [member.resourceId, member]), + ); + occurrence.members = population.memberIds.map( + (id) => + members.get(id) || { + resourceId: id, + disposition: "expected", + result: "pending", + completionResourceIds: [], + }, + ); + occurrence.expectedCount = occurrence.members.filter( + (m) => m.disposition === "expected", + ).length; + occurrence.completedCount = occurrence.members.filter( + (m) => m.disposition === "expected" && m.result === "passed", + ).length; + occurrences.push(occurrence); + updates.push({ ...source, status: "superseded" }); + } + const reviewed = { ...payload }; + delete reviewed.confirmedRevision; + const revision = calculateRevision("content", JSON.stringify(reviewed)); + return { + revision, + rule, + occurrences, + updates, + instructions: + "Review the preserved dates, original and new population rows, N/A decisions and proof links. This writes a successor and open corrections; reconcile their actual results separately.", + }; +} + +export async function correctObligationRule(input, payload, options = {}) { + return serializeWorkspaceMutation(input, async (root) => { + const preview = await previewObligationRuleCorrection( + root, + payload, + options, + ); + if (!revisionsMatch("content", payload.confirmedRevision, preview.revision)) + throw new Error( + "Preview and confirm this exact correction before saving.", + ); + await applyResourceBatch(root, { + workflowCapability: + INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleCorrection, + create: [preview.rule, ...preview.occurrences], + update: preview.updates, + expectedRevisions: payload.expectedRevisions, + }); + return { rule: preview.rule, occurrences: preview.occurrences }; + }); +} diff --git a/packages/filegrc/src/obligation-snapshot.js b/packages/filegrc/src/obligation-snapshot.js new file mode 100644 index 0000000..094e746 --- /dev/null +++ b/packages/filegrc/src/obligation-snapshot.js @@ -0,0 +1,197 @@ +import { calculationMemo } from "./calculation-cache.js"; +import { committedOccurrencePopulation } from "./obligation-population.js"; +import { isDeepStrictEqual } from "node:util"; +import { currentCalendarDate } from "./time.js"; +import { recordsAtRevision } from "./reporting-route-integrity.js"; +import { selectScopedCollectionRecords } from "./collection-scope.js"; +import { + getGitSummary, + isDataHistoryAncestor, + getDataRecordHistoryIndex, +} from "./git.js"; + +const snapshotRecords = (loaded, revision) => + calculationMemo( + loaded.resources, + "obligation-snapshot-source", + [loaded.root, revision], + () => recordsAtRevision(loaded, revision), + ); + +const idsEqual = (a, b) => + JSON.stringify([...a].sort()) === JSON.stringify([...b].sort()); +const unchanged = (a, b, ignored = []) => + isDeepStrictEqual( + Object.fromEntries( + Object.entries(a || {}).filter(([k]) => !ignored.includes(k)), + ), + Object.fromEntries( + Object.entries(b || {}).filter(([k]) => !ignored.includes(k)), + ), + ); + +export function recordedCorrectionPopulation(loaded, occurrence, rule) { + if ( + !loaded.root || + !occurrence?.populationSourceRevision || + !occurrence.supersedesId || + rule?.cutoverDecision !== "correct-open-window" + ) + return null; + const head = getGitSummary(loaded.root).commit; + if ( + !head || + !isDataHistoryAncestor(loaded, occurrence.populationSourceRevision, head) + ) + return null; + const resources = snapshotRecords( + loaded, + occurrence.populationSourceRevision, + ); + let sourceId = occurrence.supersedesId; + const seen = new Set([occurrence.id]); + let prior = resources.find(({ id }) => id === sourceId); + while (!prior && !seen.has(sourceId)) { + seen.add(sourceId); + const predecessor = loaded.resources.find(({ id }) => id === sourceId); + if ( + !predecessor || + predecessor.ruleId !== occurrence.ruleId || + predecessor.programId !== occurrence.programId || + predecessor.obligationId !== occurrence.obligationId || + predecessor.occurrenceKey !== occurrence.occurrenceKey || + !unchanged(predecessor.coverage, occurrence.coverage) || + predecessor.membershipCutoffAt !== occurrence.membershipCutoffAt || + predecessor.populationSourceRevision !== + occurrence.populationSourceRevision + ) + return null; + sourceId = predecessor.supersedesId; + prior = resources.find(({ id }) => id === sourceId); + } + const current = loaded.resources.find(({ id }) => id === sourceId); + const priorRule = resources.find(({ id }) => id === prior?.ruleId); + if ( + prior?.type !== "obligation-occurrence" || + prior.status !== "open" || + !current || + !unchanged(prior, current, ["status"]) || + prior.programId !== occurrence.programId || + prior.obligationId !== occurrence.obligationId || + prior.occurrenceKey !== occurrence.occurrenceKey || + !unchanged(prior.coverage, occurrence.coverage) || + rule.supersedesId !== prior.ruleId || + priorRule?.completionCoverage === "whole-period" || + rule.completionCoverage !== "point-in-time" || + rule.selector?.cutoff !== "review-date" || + !unchanged(priorRule?.recurrence, rule.recurrence) || + !unchanged(priorRule?.window, rule.window) || + !unchanged(priorRule?.selector, rule.selector, ["cutoff"]) + ) + return null; + const eligibleHistory = committedOccurrencePopulation( + { ...loaded, resources }, + prior, + priorRule.selector, + occurrence.populationSourceRevision, + ); + if ( + !eligibleHistory.established || + prior.members.some((m) => !eligibleHistory.memberIds.includes(m.resourceId)) + ) + return null; + const program = resources.find(({ id }) => id === prior.programId); + const selected = selectScopedCollectionRecords( + { ...loaded, resources }, + rule.selector, + program, + ).map(({ id }) => id); + // Keep every old row, including retired members and N/A decisions. Include + // known eligible additions, which need their own proof or reviewed disposition. + return { + source: prior, + resources, + memberIds: [ + ...new Set([...prior.members.map((m) => m.resourceId), ...selected]), + ].sort(), + }; +} + +export function currentSnapshotPopulation(loaded, occurrence, rule) { + if ( + !loaded.root || + !occurrence?.scopeRevision || + occurrence.populationSourceRevision || + rule?.selector?.cutoff !== "review-date" + ) + return null; + const head = getGitSummary(loaded.root).commit; + if (!head || !isDataHistoryAncestor(loaded, occurrence.scopeRevision, head)) + return null; + let basis = occurrence; + const seen = new Set(); + while (basis.supersedesId && !seen.has(basis.id)) { + seen.add(basis.id); + const previous = loaded.resources.find((r) => r.id === basis.supersedesId); + if ( + !previous || + previous.ruleId !== occurrence.ruleId || + previous.occurrenceKey !== occurrence.occurrenceKey || + previous.scopeRevision !== occurrence.scopeRevision || + previous.membershipCutoffAt !== occurrence.membershipCutoffAt || + !unchanged(previous.coverage, occurrence.coverage) + ) + return null; + basis = previous; + } + const first = getDataRecordHistoryIndex(loaded.root) + .historiesById.get(basis.id) + ?.at(-1); + if (!first) { + if ( + occurrence.scopeRevision !== head || + (occurrence.membershipCutoffAt && + occurrence.membershipCutoffAt !== + currentCalendarDate( + loaded.resources.find((r) => r.type === "workspace")?.timezone || + "UTC", + )) + ) + return null; + } else { + const zone = + loaded.resources.find((r) => r.type === "workspace")?.timezone || "UTC"; + if ( + !first.timestamp || + currentCalendarDate(zone, new Date(first.timestamp)) !== + occurrence.membershipCutoffAt + ) + return null; + const initial = snapshotRecords(loaded, first.commit).find( + (r) => r.id === basis.id, + ); + if ( + !initial || + initial.scopeRevision !== occurrence.scopeRevision || + initial.ruleId !== occurrence.ruleId || + initial.membershipCutoffAt !== occurrence.membershipCutoffAt || + initial.occurrenceKey !== occurrence.occurrenceKey || + !unchanged(initial.coverage, occurrence.coverage) + ) + return null; + } + const resources = snapshotRecords(loaded, occurrence.scopeRevision); + const program = resources.find(({ id }) => id === occurrence.programId); + if (!program) return null; + return selectScopedCollectionRecords( + { ...loaded, resources }, + rule.selector, + program, + ) + .map(({ id }) => id) + .sort(); +} + +export function samePopulation(left, right) { + return idsEqual(left, right); +} diff --git a/packages/filegrc/src/obligation-timing.js b/packages/filegrc/src/obligation-timing.js new file mode 100644 index 0000000..ea74d31 --- /dev/null +++ b/packages/filegrc/src/obligation-timing.js @@ -0,0 +1,63 @@ +import { + addCalendarDays, + calendarOccurrence, + calendarOccurrenceIndex, + parseCalendarDate, +} from "./recurrence.js"; + +export function populationCutoffDate(selector, window, occurrence = null) { + if (selector?.cutoff === "review-date") + return occurrence?.membershipCutoffAt || window.populationAsOf || null; + if (selector?.cutoff === "coverage-end") + return ( + (occurrence?.requiredCoverage || window.requiredCoverage)?.endsOn || null + ); + return selector?.cutoff === "window-end" + ? window.dueWindowEnd + : window.dueWindowStart; +} + +export function populationIsFinal(selector, window, asOf, occurrence = null) { + if (!selector) return true; + const cutoff = populationCutoffDate(selector, window, occurrence); + if (!parseCalendarDate(cutoff)) return false; + // A review-date is a reviewed snapshot, not a claim about the remainder + // of that calendar day. Fixed window/period ends remain inclusive. + return selector.cutoff === "review-date" ? asOf >= cutoff : asOf > cutoff; +} + +export function wholePeriodCoverage(rule, recurrence, window, index = null) { + if (rule?.completionCoverage !== "whole-period") return null; + const cycle = + index ?? + calendarOccurrenceIndex( + recurrence, + addCalendarDays(window.dueWindowStart, -(rule.window?.startsAfter || 0)), + ); + const start = calendarOccurrence(recurrence, cycle), + next = calendarOccurrence(recurrence, cycle + 1); + return start && next + ? { kind: "range", startsOn: start, endsOn: addCalendarDays(next, -1) } + : null; +} + +export function completionCoversRequiredPeriod( + record, + required, + executionDate, + asOf, +) { + if (!required) return true; + const coverage = record.coverage; + return ( + coverage?.kind === "range" && + coverage.startsOn <= required.startsOn && + coverage.endsOn >= required.endsOn && + executionDate > required.endsOn && + (!asOf || required.endsOn < asOf) + ); +} + +export function requiredPeriodIsFinal(window, asOf) { + return !window.requiredCoverage || asOf > window.requiredCoverage.endsOn; +} diff --git a/packages/filegrc/src/obligations.js b/packages/filegrc/src/obligations.js index 9e51066..c39253d 100644 --- a/packages/filegrc/src/obligations.js +++ b/packages/filegrc/src/obligations.js @@ -1,3 +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 { occurrenceSelectorPopulation } from "./obligation-population.js"; import { occurrenceCompletionContext } from "./obligation-completion-context.js"; import { assessProgramAccessReview, providerAccessControls } from "./provider-access-completion.js"; @@ -135,12 +138,13 @@ export function planObligations(resources, options = {}) { for (const obligation of obligations) { const selectedRule = byId.get(obligation.activeRuleId); - const rule = obligation.scheduleMode === "rule" + const planningRule = obligation.scheduleMode === "rule" ? obligationRule(obligation, byId, { now, includeProposed: true }) || (selectedRule?.status === "active" && selectedRule.effectiveAt && new Date(selectedRule.effectiveAt) > new Date(now) ? selectedRule : null) : null; - const schedule = rule || obligation; + let rule = planningRule; + let schedule = rule || obligation; const activity = obligationActivity(model, obligation); const expectedCompletionTypes = activity.completionResourceTypes; const programStatus = programStatusByObligationId.get(obligation.id); @@ -200,6 +204,26 @@ export function planObligations(resources, options = {}) { const window = scanCalendarWindow(recurrence, schedule.window, index); if (!window || window.dueWindowStart > through) break; const cycleWindowStart = window.dueWindowStart; + rule = planningRule; + if (rule?.cutoverDecision === "correct-open-window") { + const retained = records.find(record => record.type === "obligation-occurrence" + && record.status !== "superseded" && record.obligationId === obligation.id + && record.programId === (program?.id || workspace?.id || "workspace") + && record.coverage?.kind === "range" && record.coverage.endsOn === window.dueWindowEnd + && record.coverage.startsOn >= window.dueWindowStart && record.coverage.startsOn <= window.dueWindowEnd); + const retainedRule = retained && byId.get(retained.ruleId); + if (retainedRule?.type === "obligation-rule" && retainedRule.obligationId === obligation.id) rule = retainedRule; + const seen = new Set(); + while (rule?.cutoverDecision === "correct-open-window" && !seen.has(rule.id)) { + seen.add(rule.id); + const authorizedOn = currentCalendarDate(rule.timezone || workspace?.timezone || "UTC", new Date(rule.effectiveAt)); + if (retained?.ruleId === rule.id || !retained && window.dueWindowStart >= authorizedOn) break; + const previous = byId.get(rule.supersedesId); + if (previous?.type !== "obligation-rule" || previous.obligationId !== obligation.id) break; + rule = previous; + } + } + schedule = rule || obligation; if (schedule.endsOn && window.dueWindowStart > schedule.endsOn) break; if (rule && activationDate && window.dueWindowStart < activationDate) { if (!modelSupports(model, "operating-preparation")) continue; @@ -224,6 +248,8 @@ export function planObligations(resources, options = {}) { window.dueWindowStart = activationDate; } if (window.overdueOn <= from) continue; + const requiredCoverage = wholePeriodCoverage(rule, recurrence, window, index); + if (requiredCoverage) window.requiredCoverage = requiredCoverage; const occurrenceKey = `${program?.id || workspace?.id || "workspace"}:${obligation.id}:${window.dueWindowStart}`; const reconciliation = currentOccurrence(records, occurrenceKey, obligation.id, rule?.id, window); const legacyCompletions = obligation.scheduleMode !== "rule" @@ -238,16 +264,19 @@ export function planObligations(resources, options = {}) { const completionResourceIds = reconciliation ? [...new Set((reconciliation.members || []).flatMap((member) => member.completionResourceIds || []))] : legacyCompletions.map((record) => record.id); - const membershipFinal = membershipIsFinal(schedule.selector, window, asOf); + const membershipFinal = membershipIsFinal(schedule.selector, window, asOf, reconciliation); const selectedMemberIds = schedule.selector ? selectScopedCollectionRecords({ resources: records, model }, schedule.selector, program).map(({ id }) => id).sort() : [...new Set(obligation.scopeResourceIds?.length ? obligation.scopeResourceIds : [workspace?.id].filter(Boolean))]; const recordedMemberIds = (reconciliation?.members || []).map(({ resourceId }) => resourceId); - const expectedMemberIds = reconciliation + const frozenSnapshotIds = reconciliation && schedule.selector?.cutoff === "review-date" + ? recordedCorrectionPopulation({ root: options.root, resources: records, model }, reconciliation, rule)?.memberIds + || currentSnapshotPopulation({ root: options.root, resources: records, model }, reconciliation, rule) : null; + const expectedMemberIds = frozenSnapshotIds || (reconciliation ? reconciliation.status === "open" && !membershipFinal ? occurrenceSelectorPopulation({ resources: records, model, root: options.root }, reconciliation, schedule.selector, program, { selectedIds: selectedMemberIds }).expectedIds : recordedMemberIds - : selectedMemberIds; + : selectedMemberIds); const completedMemberIds = new Set((reconciliation?.members || []) .filter((member) => member.result === "passed" && member.disposition === "expected") .map((member) => member.resourceId)); @@ -256,11 +285,11 @@ export function planObligations(resources, options = {}) { const completion = byId.get(completionId); return completionMatchesObligation(completion, obligation, model) && completionMatchesOccurrenceMember(completion, activity.aggregate?.completionMemberField, member.resourceId, model, records, reconciliation, obligation, workspace?.timezone, options.root, byId, program?.id) - && completionFallsInWindow(completion, window, rule?.timezone || workspace?.timezone || "UTC", reconciliation?.status === "reconciled" ? undefined : model) + && completionFallsInWindow(completion, window, rule?.timezone || workspace?.timezone || "UTC", reconciliation?.status === "reconciled" && !reconciliation.requiredCoverage ? undefined : model) && completionPassesOccurrence(completion, obligation, activity, model, records, reconciliation, asOf, workspace?.timezone, options.root, byId, program?.id); })) .map((member) => member.resourceId)); - const resolutionsComplete = membershipFinal + const resolutionsComplete = membershipFinal && requiredPeriodIsFinal(window, asOf) && expectedMemberIds.every((resourceId) => resolvedMemberIds.has(resourceId)); const conclusionValid = ["complete", "complete-with-exceptions", "zero-population"].includes(reconciliation?.conclusion) && resolutionsComplete; @@ -306,7 +335,7 @@ export function planObligations(resources, options = {}) { resolutionsComplete, conclusionValid, expectedCount: reconciliation?.status === "open" && !membershipFinal - ? expectedMemberIds.length + ? expectedMemberIds.filter((id) => !reconciliation.members.some((member) => member.resourceId === id && member.disposition !== "expected")).length : reconciliation?.expectedCount ?? expectedMemberIds.length, completedCount: reconciliation?.completedCount ?? completedMemberIds.size, membershipFinal, @@ -337,15 +366,18 @@ export function planObligations(resources, options = {}) { const dueWindowEnd = coverageEnd(occurrence.coverage); if (!dueWindowStart || !dueWindowEnd || dueWindowStart > through) continue; const overdueOn = addCalendarDays(dueWindowEnd, 1); - const window = { dueWindowStart, dueWindowEnd, overdueOn }; + const window = { dueWindowStart, dueWindowEnd, overdueOn, ...(occurrence.requiredCoverage ? { requiredCoverage: occurrence.requiredCoverage } : {}) }; const programStatus = programStatusByObligationId.get(obligation.id); const programBlocker = programStatus === "proposed" ? obligationProgramBlocker(obligation, byId, asOf, model, now) : null; const activity = obligationActivity(model, obligation); - const membershipFinal = membershipIsFinal(rule.selector, window, asOf); + const membershipFinal = membershipIsFinal(rule.selector, window, asOf, occurrence); const recordedMemberIds = (occurrence.members || []).map(({ resourceId }) => resourceId); - const expectedMemberIds = occurrence.status === "open" && !membershipFinal && rule.selector + const frozenSnapshotIds = rule.selector?.cutoff === "review-date" + ? recordedCorrectionPopulation({ root: options.root, resources: records, model }, occurrence, rule)?.memberIds + || currentSnapshotPopulation({ root: options.root, resources: records, model }, occurrence, rule) : null; + const expectedMemberIds = frozenSnapshotIds || (occurrence.status === "open" && !membershipFinal && rule.selector ? occurrenceSelectorPopulation({ resources: records, model, root: options.root }, occurrence, rule.selector, program).expectedIds - : recordedMemberIds; + : recordedMemberIds); const completedMemberIds = (occurrence.members || []) .filter(({ disposition, result }) => disposition === "expected" && result === "passed") .map(({ resourceId }) => resourceId); @@ -354,11 +386,11 @@ export function planObligations(resources, options = {}) { const completion = byId.get(completionId); return completionMatchesObligation(completion, obligation, model) && completionMatchesOccurrenceMember(completion, activity.aggregate?.completionMemberField, member.resourceId, model, records, occurrence, obligation, workspace?.timezone, options.root, byId, program?.id) - && completionFallsInWindow(completion, window, rule.timezone || workspace?.timezone || "UTC") + && completionFallsInWindow(completion, window, rule.timezone || workspace?.timezone || "UTC", occurrence.requiredCoverage ? model : undefined) && completionPassesOccurrence(completion, obligation, activity, model, records, occurrence, asOf, workspace?.timezone, options.root, byId, program?.id); })) .map(({ resourceId }) => resourceId); - const resolutionsComplete = membershipFinal && expectedMemberIds.every((resourceId) => resolvedMemberIds.includes(resourceId)); + const resolutionsComplete = 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" @@ -390,7 +422,9 @@ export function planObligations(resources, options = {}) { resolvedMemberIds, resolutionsComplete, conclusionValid, - expectedCount: occurrence.status === "open" && !membershipFinal ? expectedMemberIds.length : occurrence.expectedCount, + expectedCount: occurrence.status === "open" && !membershipFinal + ? expectedMemberIds.filter((id) => !occurrence.members.some((member) => member.resourceId === id && member.disposition !== "expected")).length + : occurrence.expectedCount, completedCount: occurrence.completedCount, membershipFinal, reconciliationStatus: occurrence.status, @@ -447,14 +481,18 @@ export function planObligations(resources, options = {}) { for (const item of calendarItems) { if (!item.ruleId) continue; const selector = byId.get(item.ruleId)?.selector; - item.membershipCutoffAt = selector?.cutoff === "window-end" ? item.dueWindowEnd : item.dueWindowStart; + const occurrence = byId.get(item.occurrenceId); + item.membershipCutoffAt = populationCutoffDate(selector, item, occurrence); item.memberWorkComplete = item.expectedMemberIds.every((id) => item.resolvedMemberIds.includes(id)); - item.awaitingPopulationCutoff = item.memberWorkComplete && !item.membershipFinal; + item.periodFinal = requiredPeriodIsFinal(item, asOf); + item.awaitingPeriodEnd = item.memberWorkComplete && !item.periodFinal; + item.awaitingPopulationCutoff = item.memberWorkComplete && !item.membershipFinal && Boolean(item.membershipCutoffAt); + if ((item.awaitingPopulationCutoff || item.awaitingPeriodEnd) && ["due", "overdue"].includes(item.status)) item.status = "waiting"; } const dateDeadlines = new Map(); const items = [...calendarItems, ...eventItems, ...standaloneItems] .sort((a, b) => comparePlannedItems(a, b, workspace?.timezone || "UTC", dateDeadlines)); - const counts = { overdue: 0, blocked: 0, due: 0, upcoming: 0, proposed: 0, complete: 0 }; + const counts = { overdue: 0, blocked: 0, due: 0, waiting: 0, upcoming: 0, proposed: 0, complete: 0 }; for (const item of items) { if (counts[item.status] !== undefined) counts[item.status] += 1; } @@ -709,6 +747,7 @@ async function completeObligationOccurrenceUnlocked(input, options) { const scaffold = await scaffoldObligationOccurrence(loaded.root, { obligationId: obligation.id, programId: program.id, windowStart: item.dueWindowStart, asOf: currentCalendarDate(loaded.workspace.timezone), + populationAsOf: options.populationAsOf, completionRecord }); const occurrence = scaffold.record; @@ -716,7 +755,7 @@ async function completeObligationOccurrenceUnlocked(input, options) { throw new Error("This completion does not cover any member of the occurrence population."); } const rule = loaded.resources.find(({ id }) => id === item.ruleId); - if (!rule?.selector && scaffold.membershipFinal && occurrence.members.length + if (!rule?.selector && scaffold.membershipFinal && scaffold.periodFinal && occurrence.members.length && occurrence.members.every((member) => member.disposition === "expected" && member.result === "passed" && member.completionResourceIds.includes(completionRecord.id))) { const reviewers = completionRecord.reviewerIds || completionRecord.reviewedByIds || []; @@ -789,6 +828,7 @@ export async function scaffoldObligationCompletion(input, options = {}) { const entry = loaded.entries.find(({ record }) => record.type === target.type && record.id === target.id); return { ...mutation, + ...(options.populationAsOf ? { populationAsOf: options.populationAsOf } : {}), revision: contentRevision(entry?.source || ""), ...(item.ruleId && !action ? { completionContext: obligationCompletionContext(loaded, item) } : {}), scaffold: { @@ -799,6 +839,8 @@ export async function scaffoldObligationCompletion(input, options = {}) { completionProfile: activity.completionProfile || null, workItemStatus: item.status, programStatus: item.programStatus || null, + populationAsOf: options.populationAsOf || null, + requiredCoverage: item.requiredCoverage || null, requiredFacts: loaded.model.completionProfiles?.[activity.completionProfile]?.requiredFacts || [], dueWindowStart: item.dueWindowStart || null, dueWindowEnd: item.dueWindowEnd || null, @@ -849,10 +891,15 @@ export async function scaffoldObligationOccurrence(input, options = {}) { const existing = current?.record.status === "open" ? current : null; const predecessor = current && !existing ? current : null; const rule = loaded.resources.find(({ id }) => id === item.ruleId); - const cutoff = rule?.selector?.cutoff === "window-end" ? item.dueWindowEnd : item.dueWindowStart; + const cutoff = populationCutoffDate(rule?.selector, { ...item, populationAsOf: options.populationAsOf }, current?.record); + if (!cutoff) throw new Error("Choose the actual population observation date with --population-as-of YYYY-MM-DD."); + if (rule?.selector?.cutoff === "review-date" && (cutoff < item.dueWindowStart || cutoff > item.dueWindowEnd || cutoff > asOf)) throw new Error("The population observation date must be inside this occurrence and cannot be in the future."); + const membershipFinal = membershipIsFinal(rule?.selector, { ...item, populationAsOf: cutoff }, asOf, current?.record); let historicalSnapshot = null; let historicalReview = null; - const needsHistoricalReview = Boolean(rule?.selector) && ((!existing && !predecessor && asOf >= cutoff) + const correctionBasis = existing && recordedCorrectionPopulation(loaded, existing.record, rule); + const snapshotIds = existing && currentSnapshotPopulation(loaded, existing.record, rule); + const needsHistoricalReview = Boolean(rule?.selector) && !correctionBasis && !snapshotIds && ((!existing && !predecessor && asOf >= cutoff) || (existing && !existing.record.collectionReviewId && asOf > cutoff)); if (needsHistoricalReview) { const candidates = loaded.entries.flatMap((entry) => { @@ -892,13 +939,23 @@ export async function scaffoldObligationOccurrence(input, options = {}) { const existingMembers = new Map(((existing || predecessor)?.record.members || []).map((member) => [member.resourceId, member])); const activity = obligationActivity(loaded.model, obligation); const completionMemberField = activity.aggregate?.completionMemberField; - const populationIds = existing + let sourceScopeRevision = existing?.record.scopeRevision; + let freshSnapshotIds = null; + if (rule?.selector?.cutoff === "review-date" && !existing && !predecessor && !historicalReview && cutoff === asOf) { + sourceScopeRevision = getGitSummary(loaded.root).commit; + const snapshotRecord = { programId: program.id, scopeRevision: sourceScopeRevision }; + freshSnapshotIds = currentSnapshotPopulation(loaded, snapshotRecord, rule); + if (!freshSnapshotIds || !samePopulation(freshSnapshotIds, selectScopedCollectionRecords(loaded, rule.selector, program).map(r => r.id))) { + throw new Error("Commit the actual population before capturing this review-date snapshot."); + } + } + const populationIds = correctionBasis?.memberIds || snapshotIds || freshSnapshotIds || (existing ? historicalSnapshot?.populationIds || item.expectedMemberIds : predecessor ? predecessor.record.members.map(({ resourceId }) => resourceId) : historicalReview ? historicalSnapshot.populationIds - : item.expectedMemberIds; + : item.expectedMemberIds); if (existing && historicalSnapshot) { const frozenIds = [...existingMembers.keys()].sort(); const reviewedIds = [...historicalSnapshot.populationIds].sort(); @@ -913,10 +970,10 @@ export async function scaffoldObligationOccurrence(input, options = {}) { const prior = existingMembers.get(resourceId) || {}; const matching = [...loaded.resources, ...(options.completionRecord && !byId.has(options.completionRecord.id) ? [options.completionRecord] : [])].filter((record) => ( completionMatchesObligation(record, obligation, loaded.model) - && completionFallsInWindow(record, item, loaded.workspace.timezone, existing?.record.status === "reconciled" ? undefined : loaded.model) + && completionFallsInWindow(record, item, loaded.workspace.timezone, existing?.record.status === "reconciled" && !item.requiredCoverage ? undefined : loaded.model) && completionMatchesMember(record, completionMemberField, resourceId, loaded.model, byId, obligation, program.id) )); - const accepted = matching.filter((record) => completionPassesActivity(record, activity, loaded.model, byId, asOf, loaded.workspace.timezone, obligation, program.id, loaded)); + const accepted = matching.filter((record) => completionCoversRequiredPeriod(record, item.requiredCoverage, executionDate(record, loaded.model, loaded.workspace.timezone), asOf) && completionPassesActivity(record, activity, loaded.model, byId, asOf, loaded.workspace.timezone, obligation, program.id, loaded)); return { ...prior, resourceId, @@ -942,11 +999,13 @@ export async function scaffoldObligationOccurrence(input, options = {}) { title: `${obligation.title} · ${windowStart}` }), status: "open", + ...(sourceScopeRevision ? { scopeRevision: sourceScopeRevision } : {}), programId: program.id, obligationId: obligation.id, ruleId: item.ruleId, occurrenceKey: item.occurrenceKey, coverage: { kind: "range", startsOn: item.dueWindowStart, endsOn: item.dueWindowEnd }, + ...(item.requiredCoverage ? { requiredCoverage: item.requiredCoverage } : {}), membershipCutoffAt: cutoff, ...(historicalReview ? { collectionReviewId: historicalReview.record.id, @@ -955,6 +1014,7 @@ export async function scaffoldObligationOccurrence(input, options = {}) { collectionRevision: historicalReview.record.collectionRevision, scopeRevision: historicalReview.record.scopeRevision } : predecessor ? Object.fromEntries([ + "populationSourceRevision", "collectionReviewId", "collectionReviewCommit", "collectionReviewRevision", @@ -976,8 +1036,9 @@ export async function scaffoldObligationOccurrence(input, options = {}) { operation: existing ? "update" : predecessor ? "supersede" : "create", record, revision: existing || predecessor ? contentRevision((existing || predecessor).source) : null, - membershipFinal: item.membershipFinal, - instructions: (item.membershipFinal ? "" : `Population remains open through ${cutoff}. Record work now, then reconcile after the cutoff. `) + "Review the frozen population as one occurrence. Link each member's dated completion, exception, or non-applicability decision. Set reconciled status, counts, conclusion, reviewers, and reconciledAt only after membership is final. Use a superseding record to correct a finalized occurrence." + membershipFinal, + periodFinal: requiredPeriodIsFinal(item, asOf), + instructions: (requiredPeriodIsFinal(item, asOf) ? "" : `The full period remains open through ${item.requiredCoverage.endsOn}. Reconcile after its inclusive final day. `) + (membershipFinal ? "" : `Population remains open through ${cutoff}. Record work now, then reconcile after the cutoff. `) + "Review the frozen population as one occurrence. Link each member's dated completion, exception, or non-applicability decision. Set reconciled status, counts, conclusion, reviewers, and reconciledAt only after membership and any required period are final. Use a superseding record to correct a finalized occurrence." }; } @@ -1002,7 +1063,7 @@ async function saveObligationOccurrenceUnlocked(input, options) { if (existing?.record.programId && existing.record.programId !== program.id) { throw new Error(`Existing occurrence "${record.id}" belongs to Program "${existing.record.programId}", not "${program.id}".`); } - if (record.supersedesId) { + if (record.supersedesId && !existing) { requireMutationRevision(options.expectedRevision, `Obligation occurrence "${record.supersedesId}"`); if (existing) throw new Error(`Superseding occurrence "${record.id}" already exists.`); const predecessor = loaded.entries.find(({ record: current }) => ( @@ -1025,6 +1086,7 @@ async function saveObligationOccurrenceUnlocked(input, options) { membershipCutoffAt: predecessor.record.membershipCutoffAt }; for (const field of [ + "populationSourceRevision", "collectionReviewId", "collectionReviewCommit", "collectionReviewRevision", @@ -1048,12 +1110,13 @@ async function saveObligationOccurrenceUnlocked(input, options) { obligationId: record.obligationId, programId: record.programId, windowStart, - asOf: currentCalendarDate(loaded.workspace.timezone, now) + asOf: currentCalendarDate(loaded.workspace.timezone, now), + populationAsOf: record.membershipCutoffAt }); assertOccurrenceDerivedFields(record, scaffold.record); assertOccurrenceMembersMatch(record, scaffold.record); - if (record.status === "reconciled" && !scaffold.membershipFinal) { - throw new Error(`Occurrence "${record.id}" cannot be reconciled until its membership cutoff is final.`); + if (record.status === "reconciled" && (!scaffold.membershipFinal || !scaffold.periodFinal)) { + throw new Error(`Occurrence "${record.id}" cannot be reconciled until its population cutoff and full required period are final.`); } if (existing) requireMutationRevision(options.expectedRevision, `Obligation occurrence "${record.id}"`); return existing @@ -1077,6 +1140,8 @@ function assertOccurrenceDerivedFields(record, scaffold) { "ruleId", "occurrenceKey", "coverage", + "requiredCoverage", + "populationSourceRevision", "membershipCutoffAt", "collectionReviewId", "collectionReviewCommit", @@ -1278,6 +1343,7 @@ function completionMatchesOccurrenceMember(record, field, memberId, model, resou function completionPassesOccurrence(record, obligation, activity, model, resources, occurrence, asOf, timezone, root, currentById, programId) { const context = occurrenceCompletionContext(root, occurrence, record, model, resources, timezone); const byId = context.resources === resources ? currentById : resourceIndex(context.resources).byId; + if (!completionCoversRequiredPeriod(record, occurrence.requiredCoverage, executionDate(record, context.model, timezone), asOf)) return false; return completionPassesActivity(record, activity, context.model, byId, occurrenceProofDate(occurrence, asOf, timezone), timezone, byId.get(obligation.id) || obligation, programId, { ...context, root }); } @@ -2126,6 +2192,7 @@ export function assessCompletionForObligation(record, obligation, loaded, option if (!completionMatchesObligation(record, obligation, loaded.model)) issues.push("Use the completion record type, activity profile, subtype, and Obligation linkage required by the adopted activity."); if (!completionPassesActivity(record, activity, loaded.model, byId, options.asOf, loaded.workspace?.timezone, obligation, options.programId, loaded)) issues.push("Finish the model-required actors, actual result, completion time, review and proof fields; a planned record or applicability decision is not test execution."); if (!date || options.asOf && date > options.asOf || options.notBefore && date < options.notBefore) issues.push("The actual completion date must cover the current implementation and cannot be in the future."); + if (!completionCoversRequiredPeriod(record, options.window?.requiredCoverage || options.requiredCoverage, date, options.asOf)) issues.push("This result must prove the complete required period after that period ends; a point-in-time or partial result is insufficient."); if (options.window && !completionFallsInWindow(record, options.window, loaded.workspace?.timezone || "UTC", loaded.model)) issues.push("This result falls outside the adopted occurrence's allowed completion dates."); const memberIds = options.memberIds || []; const missingMemberIds = memberIds.filter((id) => !completionMatchesMember(record, activity.aggregate?.completionMemberField, id, loaded.model, byId, obligation, options.programId)); @@ -2185,7 +2252,17 @@ function occurrenceStatus(window, asOf, complete) { // Planning and persisted occurrence validation must use the same activation // anchor, including the latest governing content cutover in operating models. -export function obligationScheduleRecurrence(obligation, rule, byId, model, timezone = "UTC") { +export function obligationScheduleRecurrence(obligation, rule, byId, model, timezone = "UTC", seen = new Set()) { + if (rule && seen.has(rule.id)) return { recurrence: null, activationDate: null }; + if (rule) seen.add(rule.id); + // A reviewed snapshot correction changes the population criterion, while + // keeping the original recurrence anchor and initial activation boundary. + if (rule?.cutoverDecision === "correct-open-window" && rule.supersedesId) { + const prior = byId.get(rule.supersedesId); + if (prior?.type === "obligation-rule" && prior.obligationId === obligation.id) { + return obligationScheduleRecurrence(obligation, prior, byId, model, timezone, seen); + } + } const schedule = rule || obligation; const configuredAnchor = schedule.recurrence?.anchorDate || schedule.startsOn; const activationDate = !modelSupports(model, "operating-preparation") @@ -2304,11 +2381,8 @@ function currentOccurrence(records, occurrenceKey, obligationId, ruleId, window) )) || null; } -export function membershipIsFinal(selector, window, asOf) { - if (!selector) return true; - if (selector.cutoff === "window-end") return asOf > window.dueWindowEnd; - if (["as-of", "event-subject"].includes(selector.membershipMode)) return asOf > window.dueWindowStart; - return asOf > window.dueWindowEnd; +export function membershipIsFinal(selector, window, asOf, occurrence = null) { + return populationIsFinal(selector, window, asOf, occurrence); } function obligationBelongsToProgram(obligation, program, byId, model) { diff --git a/packages/filegrc/src/policy-library.js b/packages/filegrc/src/policy-library.js index db69f6a..8bcc74b 100644 --- a/packages/filegrc/src/policy-library.js +++ b/packages/filegrc/src/policy-library.js @@ -1032,7 +1032,7 @@ async function buildPolicyLibraryPlan(loaded) { continue; } const priorSelector = { resourceType: vendorRule ? "vendor" : "system", statuses: ["active"], criticalities: ["high", "critical"], membershipMode: "as-of", cutoff: "window-end" }; - const nextSelector = { resourceType: vendorRule ? "vendor" : "system", statuses: ["active"], membershipMode: "as-of", cutoff: "window-end" }; + const nextSelector = { resourceType: vendorRule ? "vendor" : "system", statuses: ["active"], membershipMode: "as-of", cutoff: entry.record.selector?.cutoff === "review-date" && entry.record.completionCoverage === "point-in-time" ? "review-date" : "window-end" }; const vendorTitle = vendorRule && entry.record.title === "Annual critical and high-risk vendor review rule v1" ? "Annual Vendor Review rule v1" : entry.record.title; const selectorCurrent = !(vulnerabilityRule || vendorRule) || sameValue(entry.record.selector, nextSelector); if (entry.record.rationale === rationale && selectorCurrent && (!vendorRule || (sameValue(entry.record.window, vendorWindow) && entry.record.recurrence.anchorMode === "activation"))) { diff --git a/packages/filegrc/src/retention-applications.js b/packages/filegrc/src/retention-applications.js index b8aba99..8e5bb2f 100644 --- a/packages/filegrc/src/retention-applications.js +++ b/packages/filegrc/src/retention-applications.js @@ -34,9 +34,9 @@ export function retentionApplicationRuntimeSupported(loaded, commit = null, targ const read = (name) => JSON.parse(commit ? runGitCommandSync(loaded.root, ["show", `${commit}:./${name}`], { timeout: 5000, maxBuffer: 2000000 }) : readFileSync(join(loaded.root, name), "utf8")); const manifest = read("package.json"), lock = read("package-lock.json"); const entry = lock.packages?.["node_modules/filegrc"]; - const requested = /^(?:\^|~)?0\.(19|20)\.(0|[1-9]\d*)$/.exec(manifest.dependencies?.filegrc || ""); - const installed = /^0\.(19|20)\.(0|[1-9]\d*)$/.exec(entry?.version || ""); - const supported = (version) => version && (Number(version[1]) === 20 || !sourceCoverage && Number(version[1]) === 19 && Number(version[2]) >= 2); + const requested = /^(?:\^|~)?0\.(19|20|21)\.(0|[1-9]\d*)$/.exec(manifest.dependencies?.filegrc || ""); + const installed = /^0\.(19|20|21)\.(0|[1-9]\d*)$/.exec(entry?.version || ""); + const supported = (version) => version && ([20, 21].includes(Number(version[1])) || !sourceCoverage && Number(version[1]) === 19 && Number(version[2]) >= 2); return Boolean(supported(requested) && supported(installed) && lock.packages?.[""]?.dependencies?.filegrc === manifest.dependencies.filegrc && !entry?.link && installed[1] === requested[1] && Number(installed[2]) >= Number(requested[2])); diff --git a/packages/filegrc/src/runtime-version.js b/packages/filegrc/src/runtime-version.js index 07df611..5661c55 100644 --- a/packages/filegrc/src/runtime-version.js +++ b/packages/filegrc/src/runtime-version.js @@ -19,7 +19,8 @@ export function assessRuntimeCompatibility(runtimeVersion, repositoryVersion, op const repositoryLine = runtimeCompatibilityLine(repositoryVersion); // New optional record shapes require explicit adoption of their release line. // Newer readers preserve unchanged workspaces from the supported prior lines. - const supportedRepositoryLines = supportedLine === "0.20.x" ? ["0.18.x", "0.19.x", "0.20.x"] + const supportedRepositoryLines = supportedLine === "0.21.x" ? ["0.18.x", "0.19.x", "0.20.x", "0.21.x"] + : supportedLine === "0.20.x" ? ["0.18.x", "0.19.x", "0.20.x"] : supportedLine === "0.19.x" ? ["0.18.x", "0.19.x"] : [supportedLine]; const dataModelVersion = options.dataModelVersion ?? null; const requiredApiContractVersion = Object.hasOwn(options, "requiredApiContractVersion") ? options.requiredApiContractVersion : RUNTIME_API_CONTRACT_VERSION; diff --git a/packages/filegrc/src/server.js b/packages/filegrc/src/server.js index 510dce7..94827c8 100644 --- a/packages/filegrc/src/server.js +++ b/packages/filegrc/src/server.js @@ -1,3 +1,4 @@ +import { scaffoldObligationRuleCorrection, previewObligationRuleCorrection, correctObligationRule } from "./obligation-rule-correction.js"; import { scaffoldRetentionApplication, saveRetentionApplication } from "./retention-applications.js"; import { firstOperatingTasks } from "./operating-preparation.js"; import { createStateCalculator } from "./state-calculator.js"; @@ -448,6 +449,7 @@ export function createFilegrcServer(input = process.cwd(), options = {}) { programId: requestOptions.programId || payload.programId, windowStart: payload.completionContext?.windowStart, completionContext: payload.completionContext, + populationAsOf: payload.populationAsOf, record: payload.record, content: payload.content, expectedRevision: requireRevision(payload.revision, `obligation/${payload.obligationId}`) @@ -489,6 +491,18 @@ export function createFilegrcServer(input = process.cwd(), options = {}) { : payload.revision }))); } + if (request.method === "POST" && url.pathname.startsWith("/api/obligation-rule-corrections")) { + const action = url.pathname.slice("/api/obligation-rule-corrections".length); + if (["/scaffold", "/preview", ""].includes(action)) { + const payload = await readJson(request); + if (!safeSegment(payload.ruleId)) return json(response, 400, { error: "A safe Obligation Rule ID is required." }); + if (action === "/scaffold") return json(response, 200, await scaffoldObligationRuleCorrection(input, payload)); + if (action === "/preview") return json(response, 200, await previewObligationRuleCorrection(input, payload)); + return json(response, 201, await browserMutation(input, requestOptions, { + message: () => `Correct obligation rule: ${payload.ruleId}` + }, () => correctObligationRule(input, payload))); + } + } if (request.method === "POST" && url.pathname === "/api/obligation-rule-activations/scaffold") { const payload = await readJson(request); if (!safeSegment(payload.ruleId)) return json(response, 400, { error: "A safe Obligation Rule ID is required." }); diff --git a/packages/filegrc/src/validate.js b/packages/filegrc/src/validate.js index efcfa1c..a498a33 100644 --- a/packages/filegrc/src/validate.js +++ b/packages/filegrc/src/validate.js @@ -1,3 +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 { occurrenceSelectorPopulation } from "./obligation-population.js"; import { occurrenceCompletionContext } from "./obligation-completion-context.js"; import { attestationBindingIssue } from "./attestation-integrity.js"; @@ -95,6 +98,19 @@ export async function validateWorkspace(input = process.cwd()) { async function validateWorkspaceUnmeasured(input) { const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input); const diagnostics = [...loaded.diagnostics]; + const usesSnapshotTiming = loaded.resources.some(record => (["meeting", "vulnerability-scan", "exercise", "backup-test", "control-activity"].includes(record.type) && record.coverage) || record.completionCoverage || record.requiredCoverage + || record.populationSourceRevision || ["review-date", "coverage-end"].includes(record.selector?.cutoff) + || record.cutoverDecision === "correct-open-window"); + if (usesSnapshotTiming && loaded.root) { + let version; + try { + version = workspaceEngineVersion(loaded.root).lockedVersion; + } catch (cause) { + if (cause.code !== "FILEGRC_VERSION_DRIFT") throw cause; + diagnostics.push(error("snapshot-runtime-adoption-required", "package-lock.json", cause.message)); + } + if (version && runtimeCompatibilityLine(version) !== "0.21.x") diagnostics.push(error("snapshot-runtime-adoption-required", "package-lock.json", "Review-date snapshots and explicit period duties require adoption of FileGRC 0.21 before saving source records. Upgrade and verify every hosted reader first.")); + } let reconciliation = null; const { byId } = indexResources(loaded.resources); const seen = new Map(); @@ -508,6 +524,11 @@ function validateObligationRule(record, model, byId, path, diagnostics) { recurrence: record.recurrence, window: record.window }, model, byId, path, diagnostics); + 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") { + diagnostics.push(error("invalid-obligation-coverage-criterion", path, "Review-date requires a point-in-time duty; coverage-end requires a whole-period duty.")); + } if (record.selector?.resourceType) { const activity = obligation.activityType === "custom" ? { ...model.obligationActivities?.custom, ...obligation.customActivity } @@ -559,6 +580,16 @@ function validateObligationRule(record, model, byId, path, diagnostics) { if (prior?.type === "obligation-rule" && prior.obligationId !== record.obligationId) { diagnostics.push(error("wrong-obligation-rule", path, "A rule may supersede only a rule for the same Obligation.")); } + if (record.cutoverDecision === "correct-open-window") { + const strip = (value, ignored = []) => Object.fromEntries(Object.entries(value || {}).filter(([key]) => !ignored.includes(key))); + if (prior?.completionCoverage === "whole-period" || record.completionCoverage !== "point-in-time" + || record.selector?.cutoff !== "review-date" + || JSON.stringify(record.recurrence) !== JSON.stringify(prior?.recurrence) + || JSON.stringify(record.window) !== JSON.stringify(prior?.window) + || JSON.stringify(strip(record.selector, ["cutoff"])) !== JSON.stringify(strip(prior?.selector, ["cutoff"]))) { + diagnostics.push(error("invalid-obligation-rule-correction", path, "A snapshot correction must preserve its predecessor's cadence, allowed window and population filters, and cannot downgrade a whole-period duty.")); + } + } if (!record.cutoverDecision) { diagnostics.push(error("missing-rule-cutover", path, "A superseding rule needs an explicit cutover decision for any open occurrence.")); } @@ -582,7 +613,21 @@ function validateObligationOccurrence(record, model, resources, entries, root, w } const rule = byId.get(record.ruleId); const obligation = byId.get(record.obligationId); - if (record.collectionReviewId) { + const loaded = { root, resources, model }; + const correctionPopulation = recordedCorrectionPopulation(loaded, record, rule); + const snapshotPopulation = currentSnapshotPopulation(loaded, record, rule); + if (rule?.selector?.cutoff === "review-date") { + if (!correctionPopulation && !snapshotPopulation && !record.collectionReviewId) diagnostics.push(error("missing-obligation-snapshot-source", path, "A review-date snapshot needs its exact committed scope, retained correction source or immutable temporal Collection Review.")); + if (!parseCalendarDate(record.membershipCutoffAt) || record.membershipCutoffAt < record.coverage?.startsOn + || record.membershipCutoffAt > record.coverage?.endsOn || record.membershipCutoffAt > asOf) diagnostics.push(error("invalid-obligation-review-date", path, "The observed population date must be inside the allowed work window and cannot be future-dated.")); + } + if (record.populationSourceRevision && !correctionPopulation) { + diagnostics.push(error("invalid-obligation-correction-source", path, "A corrected population must preserve its committed original open occurrence and unchanged duty, window and selector filters.")); + } + if (correctionPopulation || snapshotPopulation) { + const population = correctionPopulation?.memberIds || snapshotPopulation; + if (!samePopulation(memberIds, population)) diagnostics.push(error("invalid-obligation-snapshot-population", path, "The snapshot must retain every supported member from its exact committed population source.")); + } else if (record.collectionReviewId) { const review = byId.get(record.collectionReviewId); const reviewEntry = entries.find(({ record: candidate }) => candidate.id === record.collectionReviewId); const historicalSnapshot = historicalCollectionReviewSnapshot( @@ -645,7 +690,7 @@ function validateObligationOccurrence(record, model, resources, entries, root, w if (record.supersedesId) { const prior = byId.get(record.supersedesId); if ( - prior?.type === "obligation-occurrence" + !correctionPopulation && prior?.type === "obligation-occurrence" && ( prior.programId !== record.programId || prior.obligationId !== record.obligationId @@ -717,7 +762,7 @@ function validateObligationOccurrence(record, model, resources, entries, root, w && (!modelSupports(model, "operating-preparation") || assessCompletionForObligation(completion, obligation, completionContext, { programId: record.programId, asOf: ["reconciled", "superseded"].includes(record.status) && record.reconciledAt - ? currentCalendarDate(workspaceTimezone, new Date(record.reconciledAt)) : asOf, window: { dueWindowStart: record.coverage.startsOn || record.coverage.on, dueWindowEnd: record.coverage.endsOn || record.coverage.on } }).complete)) validCompletions.push(completion); + ? currentCalendarDate(workspaceTimezone, new Date(record.reconciledAt)) : asOf, window: { dueWindowStart: record.coverage.startsOn || record.coverage.on, dueWindowEnd: record.coverage.endsOn || record.coverage.on, requiredCoverage: record.requiredCoverage } }).complete)) validCompletions.push(completion); } if (member.result === "passed" && validCompletions.length === 0) { diagnostics.push(error( @@ -759,10 +804,11 @@ function validateObligationOccurrence(record, model, resources, entries, root, w "An occurrence reconciliation time cannot be in the future." )); } + if (reconciledOn && !requiredPeriodIsFinal(record, reconciledOn)) diagnostics.push(error("obligation-period-still-open", path, "The full inclusive required period must end before reconciling any population, including zero or non-applicable members.")); const populationStillOpen = !membershipIsFinal(rule?.selector, { dueWindowStart: record.coverage?.startsOn, - dueWindowEnd: record.coverage?.endsOn - }, reconciledOn); + dueWindowEnd: record.coverage?.endsOn, requiredCoverage: record.requiredCoverage + }, reconciledOn, record); if (reconciledOn && populationStillOpen) { diagnostics.push(error( "obligation-population-still-open", @@ -794,7 +840,7 @@ function validateOccurrenceScheduleBinding(record, obligation, rule, resources, const timezone = resources.find(({ type }) => type === "workspace")?.timezone || "UTC"; const { recurrence, activationDate: cutover } = obligationScheduleRecurrence(obligation, rule, byId, model, timezone); if (!start || !validCalendarRecurrence(recurrence)) return; - const index = calendarOccurrenceIndex(recurrence, start); + const index = Math.max(0, calendarOccurrenceIndex(recurrence, addCalendarDays(start, -(rule.window?.startsAfter || 0)))); const window = calendarWindow(recurrence, rule.window, index); let expectedStart = window?.dueWindowStart || null; const expectedEnd = window?.dueWindowEnd || null; @@ -806,7 +852,22 @@ function validateOccurrenceScheduleBinding(record, obligation, rule, resources, && committedOccurrenceWindowMatches(root, record, resources)) expectedStart = start; else if (expectedEnd >= cutover) expectedStart = cutover; } - const expectedCutoff = rule.selector?.cutoff === "window-end" ? expectedEnd : expectedStart; + const expectedCoverage = wholePeriodCoverage(rule, recurrence, window, index); + const expectedCutoff = populationCutoffDate(rule.selector, { ...window, dueWindowStart: expectedStart, dueWindowEnd: expectedEnd, requiredCoverage: expectedCoverage }, record); + const correction = recordedCorrectionPopulation({ root, resources, model }, record, rule); + if (correction) { + const prior = correction.source; + const priorObligation = correction.resources.find(({ id }) => id === prior.obligationId); + const priorRule = correction.resources.find(({ id }) => id === prior.ruleId); + const issues = []; + validateOccurrenceScheduleBinding(prior, priorObligation, priorRule, correction.resources, model, root, path, issues); + if (issues.length) diagnostics.push(...issues); + if (!parseCalendarDate(record.membershipCutoffAt) || record.membershipCutoffAt < start + || record.membershipCutoffAt > record.coverage.endsOn || record.membershipCutoffAt > currentCalendarDate(timezone)) { + diagnostics.push(error("invalid-obligation-review-date", path, "The review date must be an actual observation inside the original allowed window.")); + } + return; + } const program = resources.find(({ type, id }) => type === "program" && id === record.programId); const expectedKey = `${program?.id || "program"}:${obligation.id}:${expectedStart}`; const governingRule = resources @@ -820,7 +881,8 @@ function validateOccurrenceScheduleBinding(record, obligation, rule, resources, )) .sort((left, right) => right.effectiveAt.localeCompare(left.effectiveAt))[0]; if ( - expectedStart !== start + JSON.stringify(record.requiredCoverage || null) !== JSON.stringify(expectedCoverage) + || expectedStart !== start || record.coverage.endsOn !== expectedEnd || record.membershipCutoffAt !== expectedCutoff || record.occurrenceKey !== expectedKey diff --git a/packages/filegrc/src/web.js b/packages/filegrc/src/web.js index c7247bb..35617d6 100644 --- a/packages/filegrc/src/web.js +++ b/packages/filegrc/src/web.js @@ -2341,11 +2341,11 @@ function renderObligations(main, params = new URLSearchParams()) { : ""; const activeOperation = operationLocked ? (acceptedTriggers.length ? renderEvents(acceptedTriggers, "active", "Trigger the matching workflow when an event occurs. filegrc adds every required action to the Work Queue with its owner and deadline.") : "") + - (currentItems.length ? renderQueue(currentItems, ["upcoming", "due", "overdue", "blocked"], "Complete work the program has already adopted. Each card shows its due window, source, and next action.") : "") + (currentItems.length ? renderQueue(currentItems, ["upcoming", "due", "overdue", "blocked", "waiting"], "Complete work the program has already adopted. Each card shows its due window, source, and next action.") : "") : ""; const setupTriggers = operationLocked ? proposedTriggers : orderedTriggers; const setupItems = operationLocked ? proposedItems : plan.items; - const setupStatuses = operationLocked ? ["proposed"] : ["upcoming", "due", "overdue", "blocked", "proposed"]; + const setupStatuses = operationLocked ? ["proposed"] : ["upcoming", "due", "overdue", "blocked", "waiting", "proposed"]; const operationSetupOpen = operationLocked ? "" : " open"; const operationSetupSummary = operationLocked ? 'Preview ' + proposedTriggers.length + ' proposed Policy Events and ' + proposedItems.length + ' proposed Work Queue items' @@ -2471,7 +2471,7 @@ function activePolicyEventTriggers(triggers) { } function activeOperationItems(items) { - return items.filter(({ status }) => ["overdue", "due", "blocked", "upcoming"].includes(status)); + return items.filter(({ status }) => ["overdue", "due", "blocked", "upcoming", "waiting"].includes(status)); } function policyEventTrigger(trigger, index, collapsed = false, scope = "events") { @@ -2537,12 +2537,12 @@ function obligationCard(item, collapsed = false) { : item.actionItemId ? '' : item.ruleId - ? (item.reconciliationStatus !== "reconciled" ? '' : '') + '' + ? (item.reconciliationStatus !== "reconciled" && item.status !== "waiting" ? '' : '') + '' : '' : ""); 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.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 ? '; 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 + '
'; } @@ -2615,15 +2615,18 @@ function openActionCompletion(item) { }); } -function openObligationCompletion(item) { +async function openObligationCompletion(item) { const obligation = state.resources.find(({ record }) => record.type === "obligation" && record.id === item.obligationId); if (!obligation) return showError("The obligation template could not be found."); const completion = obligationCompletionPlan(item); if (completion.blocked) return; + const populationAsOf = await observationDateForItem(item); + if (populationAsOf === null) return; openEditor(completion.type, null, { seed: obligationCompletionSeed(completion.type, item, obligation.record), obligationCompletion: { obligationId: item.obligationId, + populationAsOf, revision: obligation.revision, completionContext: item.ruleId ? { windowStart: item.dueWindowStart, @@ -2639,6 +2642,8 @@ function openObligationCompletion(item) { } async function openObligationReconciliation(item) { + const populationAsOf = await observationDateForItem(item); + if (populationAsOf === null) return; try { const response = await localFetch("/api/obligation-occurrences/scaffold", { method: "POST", @@ -2647,6 +2652,7 @@ async function openObligationReconciliation(item) { obligationId: item.obligationId, windowStart: item.dueWindowStart, asOf: currentDate(), + populationAsOf, correctFinalized: item.reconciliationStatus === "reconciled" }) }); @@ -2661,6 +2667,7 @@ async function openObligationReconciliation(item) { description: "Review each member and its proof. Counts and the conclusion are calculated from the rows below.", occurrenceReview: true, membershipFinal: scaffold.membershipFinal, + periodFinal: scaffold.periodFinal, occurrenceReconciliation: { revision: scaffold.revision }, saveLabel: scaffold.operation === "update" ? "Save reconciliation" : scaffold.operation === "supersede" ? "Create correction" : "Create population review" }); @@ -2669,6 +2676,81 @@ async function openObligationReconciliation(item) { } } +function observationDateForItem(item) { + const rule = state.resources.find(({ record }) => record.id === item.ruleId)?.record; + if (rule?.selector?.cutoff !== "review-date") return Promise.resolve(undefined); + const occurrence = state.resources.find(({ record }) => record.id === item.occurrenceId)?.record; + if (occurrence?.membershipCutoffAt) return Promise.resolve(occurrence.membershipCutoffAt); + return new Promise((resolve) => { + const dialog = document.createElement("dialog"); + dialog.className = "editor"; + dialog.innerHTML = '

Population observation date

Choose the date you actually reviewed this population. A snapshot covers members eligible on that date. It does not establish work throughout the occurrence period.

'; + let selected = null; + document.body.append(dialog); + dialog.addEventListener("close", () => { dialog.remove(); resolve(selected); }); + dialog.querySelector("[data-cancel]").addEventListener("click", () => dialog.close()); + dialog.querySelector("form").addEventListener("submit", (event) => { event.preventDefault(); selected = event.currentTarget.elements.observedOn.value; dialog.close(); }); + dialog.showModal(); + }); +} + +async function openObligationRuleCorrection(ruleId) { + try { + const response = await localFetch("/api/obligation-rule-corrections/scaffold", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ruleId }) + }); + const scaffold = await response.json(); + if (!response.ok) throw new Error(scaffold.error || "Could not prepare the cutoff correction."); + const dialog = document.createElement("dialog"); + dialog.className = "editor"; + const people = resourcesOfType("person").filter(({ record }) => record.status === "active"); + dialog.innerHTML = '

Correct snapshot cutoff

Use this only when the existing duty is a point-in-time review. The successor preserves the schedule, allowed work windows, original approvals, retained member decisions, and proof dates. It does not approve the shared schedule collection.

Authorized by' + people.map(({ record }) => '').join("") + '
Open occurrences to correct' + scaffold.corrections.map((correction, index) => '').join("") + '
'; + document.body.append(dialog); + dialog.showModal(); + dialog.addEventListener("close", () => dialog.remove()); + dialog.querySelectorAll("[data-cancel]").forEach((button) => button.addEventListener("click", () => dialog.close())); + const form = dialog.querySelector("form"); + const error = dialog.querySelector(".dialog-error"); + const apply = dialog.querySelector("[data-apply]"); + let previewedPayload = null; + const payload = () => ({ + ...scaffold, + record: { ...scaffold.record, rationale: form.elements.rationale.value }, + approvedOn: form.elements.approvedOn.value, + effectiveAt: form.elements.effectiveAt.value, + approvedByIds: [...form.querySelectorAll('[name="approver"]:checked')].map(({ value }) => value), + confirmPointInTimeDuty: form.elements.pointInTime.checked, + confirmRecordedPopulation: form.elements.population.checked, + corrections: [...form.querySelectorAll('[name="occurrence"]:checked')].map(({ value }) => ({ ...scaffold.corrections[Number(value)], populationAsOf: form.elements["population-" + value].value })) + }); + form.addEventListener("input", () => { previewedPayload = null; apply.hidden = true; }); + dialog.querySelector("[data-export]").addEventListener("click", () => { + const url = URL.createObjectURL(new Blob([JSON.stringify(previewedPayload || payload(), null, 2) + "\n"], { type: "application/json" })); + const link = document.createElement("a"); link.href = url; link.download = ruleId + "-correction.json"; link.click(); URL.revokeObjectURL(url); + }); + form.addEventListener("submit", async (event) => { + event.preventDefault(); error.textContent = ""; + try { + const candidate = payload(); + const response = await localFetch("/api/obligation-rule-corrections/preview", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(candidate) }); + const preview = await response.json(); + if (!response.ok) throw new Error(preview.error || "Correction preview failed."); + previewedPayload = { ...candidate, confirmedRevision: preview.revision }; + const output = dialog.querySelector(".correction-preview"); output.hidden = false; output.textContent = JSON.stringify(preview, null, 2); apply.hidden = false; + } catch (caught) { error.textContent = caught.message; } + }); + apply.addEventListener("click", async () => { + if (!previewedPayload) return; + setButtonBusy(apply, true, "Applying…"); + try { + const response = await localFetch("/api/obligation-rule-corrections", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(previewedPayload) }); + if (!response.ok) throw new Error(await responseMessage(response)); + applyMutationState(await response.json()); dialog.close(); render(); + } catch (caught) { error.textContent = caught.message; setButtonBusy(apply, false); } + }); + } catch (error) { showError(error.message); } +} + async function openObligationRuleActivation(ruleId) { try { const response = await localFetch("/api/obligation-rule-activations/scaffold", { @@ -3324,6 +3406,7 @@ function eventReminderPreview(triggers) { } function windowText(item) { + if (item.status === "waiting") return "Recorded work window: " + formatCalendarDate(item.dueWindowStart) + " through " + formatCalendarDate(item.dueWindowEnd) + "."; if (item.dueWindowEndAt) { return formatLocalDateTime(item.dueWindowStartAt) + " through " + formatLocalDateTime(item.dueWindowEndAt) + ". Overdue after that cutoff."; } @@ -3333,6 +3416,7 @@ function windowText(item) { } function timingText(item) { + if (item.status === "waiting") return item.awaitingPeriodEnd ? "Awaiting period end" : "Awaiting population cutoff"; if (item.canceledAction) return "Action canceled; resolve or cancel the event"; if (item.missingCompletion) return "Link required completion proof"; if (item.status === "blocked") return item.blockingReason || "Blocked"; @@ -3607,6 +3691,7 @@ function renderDetail(main, type, id, params = new URLSearchParams()) { const amendmentAllowed = !state.readOnly && amendmentTarget && Object.values(state.model.nonmaterialAmendments || {}).some((rule) => rule.resourceTypes.includes(amendmentTarget.record.type)) && (type !== "reporting-route-set" || ["approved", "proposed"].includes(entry.record.status)); const retentionApplicationAction = !state.readOnly && type === "retention-schedule-item" && entry.record.status === "active" && state.model.resources["retention-application"] ? '' : ""; + const ruleCorrectionAction = !state.readOnly && type === "obligation-rule" && entry.record.status === "active" && entry.record.selector && entry.record.completionCoverage !== "whole-period" ? '' : ""; const amendmentAction = amendmentAllowed ? '' : ""; const amendmentView = entry.nonmaterialView; const amendmentPanel = amendmentView?.amendmentIds.length ? '

Current administrative details

The source record and any original approval remain unchanged. These are owner-authorized administrative corrections.

' + amendmentView.amendmentIds.map((id) => '' + esc(state.resources.find(({ record }) => record.id === id)?.record.title || id) + '').join(" · ") + '

' : ""; @@ -3632,8 +3717,9 @@ function renderDetail(main, type, id, params = new URLSearchParams()) { ? '' + (applicabilityStatus === "stale" ? "Reconfirm" : "Review") + ' applicability' : ""; const recordMenu = '
' + (mayDelete ? '
' : '') + '
'; - main.innerHTML = '

' + esc(titleCase(entry.nonmaterialView?.record.title || entry.record.title)) + '

' + reportingRouteSetActions + amendmentAction + retentionApplicationAction + auditCycleAction + auditPopulationCorrectionAction + (type === "audit" ? 'Audit Evidence & Packet' : "") + governanceActions + lifecycleActions + issueActions + applicabilityAction + addRecordContentAction + (!state.readOnly && !routeSetLocked ? '' : "") + recordMenu + '
' + amendmentPanel + '
' + detailMain + supportPanels + (hasStructuredMetadata ? recordFieldsPanel : "") + '
'; + main.innerHTML = '

' + esc(titleCase(entry.nonmaterialView?.record.title || entry.record.title)) + '

' + reportingRouteSetActions + ruleCorrectionAction + amendmentAction + retentionApplicationAction + auditCycleAction + auditPopulationCorrectionAction + (type === "audit" ? 'Audit Evidence & Packet' : "") + governanceActions + lifecycleActions + issueActions + applicabilityAction + addRecordContentAction + (!state.readOnly && !routeSetLocked ? '' : "") + recordMenu + '
' + amendmentPanel + '
' + detailMain + supportPanels + (hasStructuredMetadata ? recordFieldsPanel : "") + '
'; main.querySelectorAll("[data-completion-requirements]").forEach((button) => button.addEventListener("click", () => openCompletionRequirementsDialog(button.dataset.completionRequirements))); + main.querySelector("[data-correct-obligation-rule]")?.addEventListener("click", () => openObligationRuleCorrection(entry.record.id)); main.querySelector("[data-select-program-for-review]")?.addEventListener("click", () => { selectProgram(entry.record.id, "#/resources/requirement?review=1"); }); @@ -4427,7 +4513,7 @@ async function openNonmaterialAmendmentDialog(entry, proposal = null, category = const evidence = resourcesOfType("evidence").filter(({ record }) => ["collected", "verified"].includes(record.status) && record.sourceKind === "file" && record.filePaths?.length && (record.sourceResourceIds || []).some((id) => [entry.record.id, proposal?.record.id].includes(id))); const dialog = document.createElement("dialog"); dialog.className = "editor nonmaterial-amendment-dialog"; - dialog.innerHTML = '

Owner decision

Record nonmaterial amendment

Keep the original record and independent approval unchanged. Correct only an administrative label or an equivalent email alias. Changes to recipients, access, scope, commitments, schedules or content need normal review.

' + (rule.requiredConfirmations || []).map((key) => '').join("") + (scaffold.record.category === "reporting-email-alias" ? '
Fixed evidence of equivalence' + evidence.map(({ record }) => '').join("") + '
' : '') + (proposal && scaffold.record.category === "reporting-email-alias" ? '

This also withdraws the unapproved successor proposal. The original channel set remains in force.

' : '') + '
'; + dialog.innerHTML = '

Owner decision

Record nonmaterial amendment

Keep the original record and independent approval unchanged. Correct only an administrative label or an equivalent email alias. Changes to recipients, access, scope, commitments, schedules or content need normal review.

' + (rule.requiredConfirmations || []).map((key) => '').join("") + (scaffold.record.category === "reporting-email-alias" ? '
Fixed evidence of equivalence' + evidence.map(({ record }) => '').join("") + '
' : '') + (proposal && scaffold.record.category === "reporting-email-alias" ? '

This also withdraws the unapproved successor proposal. The original channel set remains in force.

' : '') + '
'; document.body.append(dialog); dialog.showModal(); dialog.addEventListener("close", () => dialog.remove()); @@ -5213,7 +5299,7 @@ function openEditor(type, entry = null, options = {}) { || implementationEditorDescription(type) || conciseResourceDescription(definition) || "Fill the core fields below. Git will record the author, time, reason, and diff when you commit this file."; - dialog.innerHTML = '

' + (entry ? "Edit record" : options.actionCompletion ? "Complete assigned work" : options.obligationCompletion ? "Record obligation work" : "Create record") + '

' + esc(titleCase(entry?.record.title || record.title || definition.title)) + '

' + esc(editorDescription) + '

' + resourceReviewCriteria(type, true) + (options.occurrenceReview ? occurrenceMemberReview(record, options.membershipFinal) : "") + '
' + names.map((name) => editorField(type, name, fields[name], record[name], required.has(name) || conditionMatches(record, fields[name].requiredWhen), Boolean(entry), oneOf.has(name), activeOneOf.has(name), record.id)).join("") + '
' + + dialog.innerHTML = '

' + (entry ? "Edit record" : options.actionCompletion ? "Complete assigned work" : options.obligationCompletion ? "Record obligation work" : "Create record") + '

' + esc(titleCase(entry?.record.title || record.title || definition.title)) + '

' + esc(editorDescription) + '

' + resourceReviewCriteria(type, true) + (options.occurrenceReview ? occurrenceMemberReview(record, options.membershipFinal, options.periodFinal) : "") + '
' + names.map((name) => editorField(type, name, fields[name], record[name], required.has(name) || conditionMatches(record, fields[name].requiredWhen), Boolean(entry), oneOf.has(name), activeOneOf.has(name), record.id)).join("") + '
' + activeMarkdown.map((markdown) => { const generated = !entry?.content?.[markdown.name]; const source = entry?.content?.[markdown.name]?.source @@ -5324,6 +5410,7 @@ function openEditor(type, entry = null, options = {}) { contentRevisions, obligationId: options.obligationCompletion?.obligationId, completionContext: options.obligationCompletion?.completionContext, + populationAsOf: options.obligationCompletion?.populationAsOf, actionItemId: options.actionCompletion?.actionItemId, completedOn: options.actionCompletion?.completedOn, prefetchToken: prefetch?.token @@ -5341,9 +5428,11 @@ function openEditor(type, entry = null, options = {}) { }); } -function occurrenceMemberReview(record, membershipFinal = true) { +function occurrenceMemberReview(record, membershipFinal = true, periodFinal = true) { const members = record.members || []; - const pending = membershipFinal + 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.

' + : membershipFinal ? "" : '

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

'; if (!members.length) { @@ -7032,7 +7121,7 @@ dialog::backdrop{background:rgba(0,0,24,.62)} .commit-files code{font-size:10.8px;overflow-wrap:anywhere} .onboarding-progress{grid-template-columns:repeat(var(--onboarding-step-count),1fr)} .onboarding-git-status{display:flex;align-items:center;gap:9px;margin:14px 25px 0;padding:10px 12px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.onboarding-git-status strong,.onboarding-git-status small{display:block}.onboarding-git-status strong{font-size:12px}.onboarding-git-status small{color:var(--muted);font-size:10.8px;line-height:1.45;margin-top:3px}.onboarding-git-status code{font-size:10.8px} -.badge.status-overdue,.badge.status-blocked{background:#f7dfdc;color:#873027}.badge.status-due{background:#f6e8c9;color:#79500f}.badge.status-upcoming,.badge.status-proposed{background:var(--accent-soft);color:var(--accent)}.badge.status-complete{background:#dcefe4;color:#125733} +.badge.status-overdue,.badge.status-blocked{background:#f7dfdc;color:#873027}.badge.status-due{background:#f6e8c9;color:#79500f}.badge.status-upcoming,.badge.status-proposed{background:var(--accent-soft);color:var(--accent)}.badge.status-waiting{background:#e8ebed;color:#4b5960}.badge.status-complete{background:#dcefe4;color:#125733} .obligation-preview,.event-reminder-preview{display:grid;gap:8px}.obligation-preview a{display:flex;align-items:center;gap:9px;text-decoration:none;padding:7px 0;border-top:1px solid var(--line)}.obligation-preview a:first-child{border-top:0;padding-top:0}.obligation-preview strong,.obligation-preview small,.event-reminder-preview strong,.event-reminder-preview small{display:block}.obligation-preview strong,.event-reminder-preview strong{font-size:12px}.obligation-preview small,.event-reminder-preview small{font-size:10.8px;color:var(--muted);margin-top:2px}.event-reminder-preview{grid-template-columns:repeat(2,minmax(0,1fr))}.event-reminder-preview a{padding:10px;border-radius:7px;background:var(--surface-soft);text-decoration:none} .obligation-board{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:14px;align-items:start}.obligation-column{min-width:0}.obligation-column-head{display:flex;align-items:center;justify-content:space-between;margin:5px 1px 10px}.obligation-column-head>strong{font:500 26.4px Georgia,serif}.obligation-cards{display:grid;gap:9px}.obligation-card{background:var(--panel);border:1px solid var(--line);border-left:4px solid var(--accent-light);border-radius:9px;padding:14px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.obligation-card.status-overdue,.obligation-card.status-blocked{border-left-color:var(--red)}.obligation-card.status-due{border-left-color:#d89021}.obligation-card-head{display:flex;justify-content:space-between;gap:8px;text-transform:uppercase;letter-spacing:.06em;font-size:9.6px;color:var(--muted)}.obligation-card-head strong{color:var(--ink);text-align:right}.obligation-card h3{font-size:14.4px;margin:9px 0 7px}.obligation-card h3 a{text-decoration:none}.obligation-card p{font-size:10.8px;line-height:1.5;color:var(--muted);margin:0}.obligation-links{margin-top:10px}.workflow-section{margin-top:30px}.operation-gate{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:22px;border:1px solid var(--accent-light);border-radius:12px;background:var(--accent-wash)}.operation-gate h2{margin:5px 0 7px;font:500 25px Georgia,serif}.operation-gate p:not(.kicker){margin:0;max-width:680px;color:var(--muted)}.operation-setup-preview>summary{margin-top:18px;padding:14px 16px;border:1px solid var(--line);border-radius:9px;background:var(--panel);font-weight:700;cursor:pointer}.operation-setup-preview[open]>summary{margin-bottom:0}.section-head{display:flex;justify-content:space-between;margin-bottom:13px}.section-head h2{font:500 28.8px Georgia,serif;margin:6px 0}.section-head p:not(.kicker){font-size:13.2px;color:var(--muted);margin:0;max-width:720px}.policy-event-feedback{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;gap:10px;align-items:center;margin-top:14px;padding:12px 14px;border:1px solid #9ccfb2;border-radius:8px;background:#e7f5ec}.policy-event-feedback strong,.policy-event-feedback p{display:block}.policy-event-feedback strong{font-size:12px}.policy-event-feedback p{margin:3px 0 0;color:#315d44;font-size:10.8px}.policy-event-feedback .icon-button{width:30px;height:30px}.policy-event-list{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.policy-event-more{margin-top:10px}.policy-event-row{position:relative;display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0;padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}.policy-event-row[hidden]{display:none}.policy-event-name{min-width:0}.policy-event-title{display:flex;align-items:center;gap:6px;min-width:0}.policy-event-title>strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.policy-event-guide{display:inline-flex;flex:0 0 auto}.policy-event-guide .guide-trigger{width:20px;height:20px}.policy-event-guide .guide-trigger svg{width:14px;height:14px}.policy-event-name strong,.policy-event-name>small{display:block}.policy-event-name strong{font-size:12px}.policy-event-name>small{margin-top:2px;color:var(--muted);font-size:9.6px;line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.policy-event-row>.button{flex:none;padding:7px 9px;font-size:10.8px}.policy-event-tooltip{position:absolute;z-index:8;top:calc(100% + 7px);left:0;width:min(420px,calc(100vw - 48px));padding:12px 14px;border:1px solid var(--line);border-radius:8px;background:var(--panel);box-shadow:var(--shadow);opacity:0;visibility:hidden;transform:translateY(-3px);transition:opacity .12s,transform .12s,visibility 0s .12s;pointer-events:none}.policy-event-row:nth-child(3n) .policy-event-tooltip{right:0;left:auto}.policy-event-row:hover,.policy-event-row:focus-within{z-index:9}.policy-event-guide:hover .policy-event-tooltip,.policy-event-guide:focus-within .policy-event-tooltip{opacity:1;visibility:visible;transform:none;transition-delay:0s}.policy-event-tooltip>strong{font-size:12px}.policy-event-tooltip ol{display:grid;gap:7px;margin:9px 0 0;padding-left:20px}.policy-event-tooltip li span,.policy-event-tooltip li small{display:block}.policy-event-tooltip li span{font-size:10.8px}.policy-event-tooltip li small{margin-top:2px;color:var(--muted);font-size:9.6px;line-height:1.4} .obligation-card-foot{display:flex;align-items:flex-end;justify-content:space-between;gap:9px;margin-top:10px}.obligation-card-foot .obligation-links{margin-top:0;min-width:0}.obligation-action{flex:0 0 auto;border:0;border-radius:6px;background:var(--accent-soft);color:var(--accent);padding:7px 9px;font-family:inherit;font-size:10.8px;font-weight:700;line-height:1;text-decoration:none;cursor:pointer}.obligation-action:hover{filter:brightness(1.08)}.obligation-action.blocked{background:var(--surface-muted);color:var(--muted)}.obligation-more{width:100%;margin-top:9px}.workflow-section{scroll-margin-top:92px} @@ -7073,7 +7162,7 @@ dialog::backdrop{background:rgba(0,0,24,.62)} .badge.status-draft,.badge.status-planned,.badge.status-in-progress,.badge.status-medium{background:#483714;color:#ffd991} .badge.status-overdue,.badge.status-blocked{background:#4a252a;color:#ffb5ad} .badge.status-due{background:#483714;color:#ffd991} - .badge.status-complete{background:#173b2b;color:#a8edc4} + .badge.status-waiting{background:#e8ebed;color:#4b5960}.badge.status-complete{background:#173b2b;color:#a8edc4} .repository-override,.repository-sync-alert{border-color:#77612f;background:#382f19} .repository-override code{color:#ffe2a3} .workflow-finding-status.ready,.workflow-finding-status.due,.workflow-finding-status.open,.stage-page-completion-state,.readiness-state.warn,.preparation-status.later{background:#483714;color:#ffd991} diff --git a/packages/filegrc/src/workflow.js b/packages/filegrc/src/workflow.js index f28816c..f22fb9a 100644 --- a/packages/filegrc/src/workflow.js +++ b/packages/filegrc/src/workflow.js @@ -258,9 +258,9 @@ export function workflowForResource(workflow, type, id, loaded = null) { related: { findings: relatedFindings, workItems: relatedWorkItems, - recommended: contextualAction([...relatedFindings, ...relatedWorkItems].sort(compareRecommended)[0], loaded) + recommended: contextualAction([...relatedFindings, ...relatedWorkItems].filter((item) => item.state !== "waiting").sort(compareRecommended)[0], loaded) }, - recommended: contextualAction([...findings, ...workItems].sort(compareRecommended)[0], loaded) + recommended: contextualAction([...findings, ...workItems].filter((item) => item.state !== "waiting").sort(compareRecommended)[0], loaded) }; } @@ -925,18 +925,24 @@ async function assessPeriodHealth(loaded, options) { includeComplete: true, model: loaded.model }); - for (const item of occurrences.items.filter((item) => ["overdue", "blocked", "due", "proposed"].includes(item.status))) { + for (const item of occurrences.items.filter((item) => ["overdue", "blocked", "due", "proposed", "waiting"].includes(item.status))) { findings.push(periodFinding( `period.obligation.${item.key || stableSuffix(JSON.stringify(item))}`, item.title, - item.status === "proposed" + 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.` + : item.status === "proposed" ? "This occurrence is still a proposal because its governing policy, control, owner, or completion profile is incomplete." : item.status === "blocked" ? 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.`, - item.status === "overdue" + item.status === "waiting" + ? "waiting" + : item.status === "overdue" ? "overdue" : item.status === "blocked" ? "blocked" @@ -1308,6 +1314,7 @@ function obligationWorkItem(item, byId, asOf, programId) { ...(item.dueWindowStart ? { availableOn: item.dueWindowStart } : {}), ...(due ? { dueOn: due } : {}), state: item.status, + ...(item.timingStatus ? { timingStatus: item.timingStatus } : {}), priority: workPriority(item.status, due?.slice(0, 10), asOf), scope: item.scopeResourceIds || (subjectId ? [subjectId] : []), blockingReason: item.blockingReason || item.reason || null, @@ -1316,7 +1323,10 @@ function obligationWorkItem(item, byId, asOf, programId) { membershipFinal: item.membershipFinal, membershipCutoffAt: item.membershipCutoffAt, memberWorkComplete: item.memberWorkComplete, - awaitingPopulationCutoff: item.awaitingPopulationCutoff + awaitingPopulationCutoff: item.awaitingPopulationCutoff, + awaitingPeriodEnd: item.awaitingPeriodEnd, + periodFinal: item.periodFinal, + requiredCoverage: item.requiredCoverage || null } : {}), requiredCompletionProfile: item.completionProfile || item.activityType || null, completionProfile: { @@ -1357,7 +1367,7 @@ function obligationNextAction(item, programId) { if (item.ruleId) { return { kind: "command", - ...(item.awaitingPopulationCutoff ? { message: `Work is recorded for the current population. Reconcile after ${item.membershipCutoffAt}; include any additional eligible members through that cutoff.` } : {}), + ...(item.awaitingPeriodEnd ? { message: `Work is recorded for the current population. Reconcile after the full inclusive period ends on ${item.requiredCoverage.endsOn}.` } : item.awaitingPopulationCutoff ? { message: `Work is recorded for the current population. Reconcile after ${item.membershipCutoffAt}; include any additional eligible members through that cutoff.` } : {}), command: `npx filegrc reconcile-obligation ${shellArgument(item.obligationId)} --scaffold --window-start ${shellArgument(item.dueWindowStart)}${item.status === "complete" ? " --correct-finalized" : ""}${programOption}` }; } @@ -1397,7 +1407,7 @@ function buildAssessments({ program, audits, auditPreparations, obligationPlan, const periodHealthy = periodStarted && obligationPlan.counts.overdue === 0 && obligationPlan.counts.blocked === 0 - && !periodFindings.some(blockingFinding); + && !periodFindings.some((finding) => blockingFinding(finding) || finding.state === "waiting"); const auditValues = Object.values(auditPreparations); const auditReady = audits.length > 0 && auditValues.every((preparation) => preparation.status === "management-ready"); @@ -1540,7 +1550,7 @@ function recommendedAction(findings, workItems, program) { )); if (preferred) return preferred; } - return [...findings.filter(blockingFinding), ...workItems.filter(activeWork)] + return [...findings.filter(blockingFinding), ...workItems.filter((item) => activeWork(item) && item.state !== "waiting")] .sort(compareRecommended)[0] || null; } @@ -1569,6 +1579,7 @@ function findingPriority(finding) { } function workPriority(state, dueOn, asOf) { + if (state === "waiting") return 60; if (state === "overdue") return 0; if (dueOn && asOf && dueOn < asOf) return 5; if (state === "due") return 10; diff --git a/packages/filegrc/test/agent.test.js b/packages/filegrc/test/agent.test.js index dc5b350..8d80416 100644 --- a/packages/filegrc/test/agent.test.js +++ b/packages/filegrc/test/agent.test.js @@ -117,6 +117,7 @@ test("agent guides and scaffolds cover every resource type from the model", asyn "previewMutation", "create", "complete", + "correctObligationRule", "reconcileObligation", "completeAction", "completeEvent", diff --git a/packages/filegrc/test/nonmaterial-amendments.test.js b/packages/filegrc/test/nonmaterial-amendments.test.js index d327f9e..240fee3 100644 --- a/packages/filegrc/test/nonmaterial-amendments.test.js +++ b/packages/filegrc/test/nonmaterial-amendments.test.js @@ -330,3 +330,24 @@ test("amendment adoption and historical-source deadlines remain retryable", asyn finally { restoreSource(); } assert.deepEqual(nonmaterialAmendmentIssues(loaded, amendment), []); }); + +test("0.21 adoption preserves committed 0.19 amendments and permits unchanged amendment workflows", async (t) => { + const root = await fixture(t); + const p = await payload(root, "policy-example"); + await saveNonmaterialAmendment(root, p); + git(root, "add", "."); git(root, "commit", "-m", "Record owner label decision"); + const originalPaths = ["data/policies/policy-example.json", `data/nonmaterial-amendments/${p.record.id}.json`]; + const original = await Promise.all(originalPaths.map(path => readFile(join(root, path), "utf8"))); + await writeFile(join(root, "package.json"), JSON.stringify({ private: true, dependencies: { filegrc: "0.21.0" } })); + await writeFile(join(root, "package-lock.json"), JSON.stringify({ lockfileVersion: 3, packages: { "": { dependencies: { filegrc: "0.21.0" } }, "node_modules/filegrc": { version: "0.21.0" } } })); + git(root, "add", "."); git(root, "commit", "-m", "Adopt supported runtime"); + const loaded = await loadWorkspace(root); + assert.deepEqual(nonmaterialAmendmentIssues(loaded, loaded.resources.find(({ id }) => id === p.record.id)), []); + assert.deepEqual(await Promise.all(originalPaths.map(path => readFile(join(root, path), "utf8"))), original); + assert.equal((await validateWorkspace(root)).ok, true); + const next = await payload(root, "policy-example"); + next.record.replacementValue = "Reviewed administrative label"; + await saveNonmaterialAmendment(root, next); + git(root, "add", "."); git(root, "commit", "-m", "Record subsequent owner label decision"); + assert.equal((await validateWorkspace(root)).ok, true); +}); diff --git a/packages/filegrc/test/obligation-snapshot-correction.test.js b/packages/filegrc/test/obligation-snapshot-correction.test.js new file mode 100644 index 0000000..126b555 --- /dev/null +++ b/packages/filegrc/test/obligation-snapshot-correction.test.js @@ -0,0 +1,842 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { makeComprehensiveWorkspace } from "./fixtures.js"; +import { runCli } from "../src/cli.js"; +import { + commitWorkspaceFiles, + initializeGitWorkspace, + writeJson, + executeCli, +} from "./helpers.js"; +import { contentRevision } from "../src/files.js"; +import { loadWorkspace, validateWorkspace } from "../src/index.js"; +import { + scaffoldObligationOccurrence, + saveObligationOccurrence, + planObligations, + assessCompletionForObligation, +} from "../src/obligations.js"; +import { + scaffoldObligationRuleCorrection, + previewObligationRuleCorrection, + correctObligationRule, +} from "../src/obligation-rule-correction.js"; +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"; + +async function fixture(t, { unit = "year" } = {}) { + const root = await mkdtemp(join(tmpdir(), "filegrc-snapshot-correction-")); + t.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "11"); + let loaded = await loadWorkspace(root); + const today = currentCalendarDate(loaded.workspace.timezone), + start = addCalendarDays(today, -2), + observed = addCalendarDays(today, -1); + const oe = loaded.entries.find((e) => e.record.type === "obligation"), + re = loaded.entries.find((e) => e.record.type === "obligation-rule"); + const obligation = { + ...oe.record, + activityType: "vendor-review", + scopeResourceIds: [], + }; + const rule = { + ...re.record, + recurrence: { mode: "calendar", unit, interval: 1, anchorDate: start }, + window: { precision: "date", startsAfter: 0, dueAfter: 30 }, + selector: { + resourceType: "vendor", + membershipMode: "as-of", + cutoff: "window-end", + statuses: ["active"], + }, + }; + await writeJson(oe.path, obligation); + await writeJson(re.path, rule); + for (const e of loaded.entries.filter( + (e) => e.record.type === "obligation-occurrence", + )) + await rm(e.path); + const ve = loaded.entries.find((e) => e.record.type === "vendor-review"); + await writeJson(ve.path, { + ...ve.record, + completedOn: observed, + coverage: { kind: "as-of", on: observed }, + }); + const ce = loaded.entries.find((e) => e.record.type === "component"); + await writeJson(ce.path, { ...ce.record, vendorId: "vendor-example" }); + await initializeGitWorkspace(root); + await writeJson(join(root, "data/workspace.json"), { + ...loaded.workspace, + dataModelVersion: "13", + }); + await commitWorkspaceFiles(root, "Adopt current model"); + const s = await scaffoldObligationOccurrence(root, { + obligationId: obligation.id, + windowStart: start, + asOf: today, + }); + await saveObligationOccurrence(root, { record: s.record }); + await commitWorkspaceFiles(root, "Record reviewed members"); + return { + root, + today, + start, + observed, + obligation, + rule, + occurrence: s.record, + ve, + }; +} +async function snapshotSuccessor(f) { + let p = await scaffoldObligationRuleCorrection(f.root, { ruleId: f.rule.id }); + p = { + ...p, + approvedByIds: ["person-example"], + approvedOn: f.today, + effectiveAt: new Date().toISOString(), + confirmPointInTimeDuty: true, + confirmRecordedPopulation: true, + record: { + ...p.record, + rationale: "Review the actual observed population as a point snapshot.", + }, + corrections: [ + { sourceOccurrenceId: f.occurrence.id, populationAsOf: f.observed }, + ], + }; + const preview = await previewObligationRuleCorrection(f.root, p); + await correctObligationRule(f.root, { + ...p, + confirmedRevision: preview.revision, + }); + await commitWorkspaceFiles( + f.root, + "Approve snapshot successor without changing original duty timing", + ); + return preview.rule; +} +const item = async (f) => { + const l = await loadWorkspace(f.root); + return planObligations(l.resources, { + root: f.root, + model: l.model, + asOf: f.today, + from: f.start, + through: f.today, + includeComplete: true, + }).calendarItems.find((i) => i.obligationId === f.obligation.id); +}; + +test("reviewed successor preserves the original window, historic proof and management approval", async (t) => { + const f = await fixture(t); + const proof = await readFile(f.ve.path, "utf8"); + const before = await item(f); + assert.equal(before.status, "waiting"); + assert.equal(before.timingStatus, "due"); + let p = await scaffoldObligationRuleCorrection(f.root, { ruleId: f.rule.id }); + p = { + ...p, + approvedByIds: ["person-example"], + approvedOn: f.today, + effectiveAt: new Date().toISOString(), + confirmPointInTimeDuty: true, + confirmRecordedPopulation: true, + record: { + ...p.record, + rationale: + "Management confirms this duty inspects the service at the recorded review date.", + }, + corrections: [ + { sourceOccurrenceId: f.occurrence.id, populationAsOf: f.observed }, + ], + }; + const preview = await previewObligationRuleCorrection(f.root, p); + await correctObligationRule(f.root, { + ...p, + confirmedRevision: preview.revision, + }); + const l = await loadWorkspace(f.root); + const original = l.resources.find((r) => r.id === f.rule.id); + assert.deepEqual( + { ...original, status: f.rule.status, retiredOn: undefined }, + { ...f.rule, retiredOn: undefined }, + ); + assert.equal( + l.resources.find((r) => r.id === f.occurrence.id).status, + "superseded", + ); + const after = await item(f); + assert.equal(after.dueWindowStart, f.start); + assert.equal(after.dueWindowEnd, before.dueWindowEnd); + assert.equal(after.membershipFinal, true); + assert.equal(after.status, "due"); + assert.deepEqual(after.resolvedMemberIds, before.resolvedMemberIds); + const final = await scaffoldObligationOccurrence(f.root, { + obligationId: f.obligation.id, + windowStart: f.start, + asOf: f.today, + }); + await saveObligationOccurrence(f.root, { + record: { + ...final.record, + status: "reconciled", + conclusion: "complete", + reviewedByIds: ["person-example"], + reconciledAt: new Date().toISOString(), + }, + expectedRevision: final.revision, + }); + assert.equal((await item(f)).status, "complete"); + assert.equal(await readFile(f.ve.path, "utf8"), proof); + const v = await validateWorkspace(f.root); + assert.equal(v.ok, true, JSON.stringify(v.diagnostics)); + await commitWorkspaceFiles( + f.root, + "Record management correction and reconciliation", + ); + assert.equal((await validateWorkspace(f.root)).ok, true); + const correction = await scaffoldObligationOccurrence(f.root, { + obligationId: f.obligation.id, + windowStart: f.start, + asOf: f.today, + correctFinalized: true, + }); + assert.equal( + correction.record.populationSourceRevision, + preview.occurrences[0].populationSourceRevision, + ); + await saveObligationOccurrence(f.root, { + record: correction.record, + expectedRevision: correction.revision, + }); + const corrected = await loadWorkspace(f.root); + assert.equal( + corrected.resources.find((r) => r.id === final.record.id).status, + "superseded", + ); + assert.equal( + (await validateWorkspace(f.root)).ok, + true, + JSON.stringify((await validateWorkspace(f.root)).diagnostics), + ); +}); + +test("snapshot correction rejects changed duties, backdating, stale confirmation and whole-period downgrades", async (t) => { + const f = await fixture(t); + let p = await scaffoldObligationRuleCorrection(f.root, { ruleId: f.rule.id }); + p = { + ...p, + approvedByIds: ["person-example"], + approvedOn: f.today, + effectiveAt: new Date().toISOString(), + confirmPointInTimeDuty: true, + confirmRecordedPopulation: true, + record: { ...p.record, rationale: "Confirm periodic point review." }, + corrections: [ + { sourceOccurrenceId: f.occurrence.id, populationAsOf: f.observed }, + ], + }; + await assert.rejects( + previewObligationRuleCorrection(f.root, { ...p, approvedOn: f.observed }), + /actual authorization/, + ); + await assert.rejects( + previewObligationRuleCorrection(f.root, { + ...p, + record: { ...p.record, window: { ...p.record.window, dueAfter: 60 } }, + }), + /Preserve/, + ); + await assert.rejects(correctObligationRule(f.root, p), /Preview and confirm/); + const l = await loadWorkspace(f.root), + re = l.entries.find((e) => e.record.id === f.rule.id); + await writeJson(re.path, { + ...re.record, + completionCoverage: "whole-period", + }); + await assert.rejects( + scaffoldObligationRuleCorrection(f.root, { ruleId: f.rule.id }), + /whole-period/, + ); +}); + +test("whole-period proof must cover its complete range and be executed after its end", async (t) => { + const f = await fixture(t), + l = await loadWorkspace(f.root), + proof = l.resources.find((r) => r.type === "vendor-review"); + const requiredCoverage = { + kind: "range", + startsOn: f.start, + endsOn: f.observed, + }; + const options = { asOf: f.today, requiredCoverage }; + assert.equal( + assessCompletionForObligation(proof, f.obligation, l, options).complete, + false, + ); + assert.equal( + assessCompletionForObligation( + { ...proof, coverage: requiredCoverage, completedOn: f.observed }, + f.obligation, + l, + options, + ).complete, + false, + ); + assert.equal( + assessCompletionForObligation( + { ...proof, coverage: requiredCoverage, completedOn: f.today }, + f.obligation, + l, + options, + ).complete, + true, + ); +}); + +test("successor preserves unselected overlapping predecessor windows and their proof links", async (t) => { + const f = await fixture(t, { unit: "day" }); + const other = await scaffoldObligationOccurrence(f.root, { + obligationId: f.obligation.id, + windowStart: f.observed, + asOf: f.today, + }); + await saveObligationOccurrence(f.root, { record: other.record }); + await commitWorkspaceFiles(f.root, "Record another open overlapping window"); + let p = await scaffoldObligationRuleCorrection(f.root, { ruleId: f.rule.id }); + p = { + ...p, + approvedByIds: ["person-example"], + approvedOn: f.today, + effectiveAt: new Date().toISOString(), + confirmPointInTimeDuty: true, + confirmRecordedPopulation: true, + record: { + ...p.record, + rationale: "Correct only the selected point review.", + }, + corrections: [ + { sourceOccurrenceId: f.occurrence.id, populationAsOf: f.observed }, + ], + }; + const preview = await previewObligationRuleCorrection(f.root, p); + await correctObligationRule(f.root, { + ...p, + confirmedRevision: preview.revision, + }); + const l = await loadWorkspace(f.root), + plan = planObligations(l.resources, { + root: f.root, + model: l.model, + asOf: f.today, + from: f.start, + through: f.today, + includeComplete: true, + }); + const retained = plan.calendarItems.find( + (i) => i.occurrenceId === other.record.id, + ); + assert.ok(retained); + assert.equal(retained.ruleId, f.rule.id); + assert.deepEqual( + retained.completionResourceIds, + other.record.members.flatMap((m) => m.completionResourceIds), + ); + assert.equal( + plan.calendarItems.filter((i) => i.dueWindowStart === f.observed).length, + 1, + ); + const missing = planObligations(l.resources, { + root: f.root, + model: l.model, + asOf: f.today, + from: f.start, + through: f.today, + includeComplete: true, + }); + assert.ok(missing.calendarItems.some((i) => i.dueWindowStart === f.today)); +}); + +test("recorded non-applicability survives correction and known additions stay pending", async (t) => { + const f = await fixture(t); + let l = await loadWorkspace(f.root); + const old = l.entries.find((e) => e.record.id === f.occurrence.id); + const row = { + ...old.record.members[0], + disposition: "not-applicable", + result: "pending", + rationale: "The reviewed service has no applicable work in this procedure.", + }; + await saveObligationOccurrence(f.root, { + record: { + ...old.record, + members: [row], + expectedCount: 0, + completedCount: 0, + }, + expectedRevision: contentRevision(old.source), + }); + const vendor = l.resources.find((r) => r.type === "vendor"), + component = l.resources.find((r) => r.type === "component"); + await writeJson(join(f.root, "data/vendors/vendor-new.json"), { + ...vendor, + id: "vendor-new", + title: "New reviewed service", + }); + await writeJson(join(f.root, "data/components/component-new.json"), { + ...component, + id: "component-new", + title: "New service source", + vendorId: "vendor-new", + }); + await commitWorkspaceFiles( + f.root, + "Record explicit population decisions and eligible service", + ); + let p = await scaffoldObligationRuleCorrection(f.root, { ruleId: f.rule.id }); + p = { + ...p, + approvedByIds: ["person-example"], + approvedOn: f.today, + effectiveAt: new Date().toISOString(), + confirmPointInTimeDuty: true, + confirmRecordedPopulation: true, + record: { + ...p.record, + rationale: "Confirm snapshot duty and retain prior disposition.", + }, + corrections: [ + { sourceOccurrenceId: f.occurrence.id, populationAsOf: f.observed }, + ], + }; + const preview = await previewObligationRuleCorrection(f.root, p); + assert.deepEqual( + preview.occurrences[0].members.find((m) => m.resourceId === row.resourceId), + row, + ); + assert.equal( + preview.occurrences[0].members.find((m) => m.resourceId === "vendor-new") + .result, + "pending", + ); + await correctObligationRule(f.root, { + ...p, + confirmedRevision: preview.revision, + }); + const result = await item(f); + assert.equal(result.status, "due"); + assert.equal(result.memberWorkComplete, false); + assert.equal(result.expectedCount, 1); +}); + +test("fresh review-date snapshots freeze committed observed scope without converting it into period coverage", async (t) => { + const f = await fixture(t, { unit: "day" }); + await snapshotSuccessor(f); + const snapshot = await scaffoldObligationOccurrence(f.root, { + obligationId: f.obligation.id, + windowStart: f.today, + populationAsOf: f.today, + asOf: f.today, + }); + await saveObligationOccurrence(f.root, { record: snapshot.record }); + await commitWorkspaceFiles(f.root, "Retain contemporaneous observed scope"); + let l = await loadWorkspace(f.root); + assert.ok(snapshot.record.scopeRevision); + assert.equal(snapshot.record.requiredCoverage, undefined); + const current = () => + planObligations(l.resources, { + root: f.root, + model: l.model, + asOf: f.today, + from: f.today, + through: f.today, + includeComplete: true, + }).calendarItems.find((i) => i.dueWindowStart === f.today); + assert.equal(current().membershipFinal, true); + const vendor = l.resources.find((r) => r.type === "vendor"), + component = l.resources.find((r) => r.type === "component"); + await writeJson(join(f.root, "data/vendors/vendor-new.json"), { + ...vendor, + id: "vendor-new", + title: "Service added after snapshot", + }); + await writeJson(join(f.root, "data/components/component-new.json"), { + ...component, + id: "component-new", + title: "Later service source", + vendorId: "vendor-new", + }); + await commitWorkspaceFiles(f.root, "Record later eligible service"); + l = await loadWorkspace(f.root); + assert.deepEqual(current().expectedMemberIds, ["vendor-example"]); + const v = await validateWorkspace(f.root); + assert.equal(v.ok, true, JSON.stringify(v.diagnostics)); + await writeJson(join(f.root, "package-lock.json"), { + lockfileVersion: 3, + packages: { "node_modules/filegrc": { version: "0.20.8" } }, + }); + assert.ok( + (await validateWorkspace(f.root)).diagnostics.some( + (d) => d.code === "snapshot-runtime-adoption-required", + ), + ); +}); + +test("unsaved predecessor cycles remain required when correcting another window", async (t) => { + const f = await fixture(t, { unit: "day" }); + let p = await scaffoldObligationRuleCorrection(f.root, { ruleId: f.rule.id }); + p = { + ...p, + approvedByIds: ["person-example"], + approvedOn: f.today, + effectiveAt: new Date().toISOString(), + confirmPointInTimeDuty: true, + confirmRecordedPopulation: true, + record: { ...p.record, rationale: "Correct this recorded review only." }, + corrections: [ + { sourceOccurrenceId: f.occurrence.id, populationAsOf: f.observed }, + ], + }; + const preview = await previewObligationRuleCorrection(f.root, p); + await correctObligationRule(f.root, { + ...p, + confirmedRevision: preview.revision, + }); + const l = await loadWorkspace(f.root), + plan = planObligations(l.resources, { + root: f.root, + model: l.model, + asOf: f.today, + from: f.start, + through: f.today, + includeComplete: true, + }); + const unsaved = plan.calendarItems.find( + (i) => i.dueWindowStart === f.observed, + ); + assert.ok(unsaved); + assert.equal(unsaved.ruleId, f.rule.id); + assert.equal(unsaved.occurrenceId, null); + assert.equal(unsaved.status, "due"); +}); + +test("CLI scaffold observation date survives the normal completion envelope", async (t) => { + const f = await fixture(t, { unit: "day" }); + await snapshotSuccessor(f); + let l = await loadWorkspace(f.root); + const proof = { + ...l.resources.find((r) => r.type === "vendor-review"), + id: "vendor-review-current", + completedOn: f.today, + coverage: { kind: "as-of", on: f.today }, + }; + await writeJson( + join(f.root, "data/vendor-reviews/vendor-review-current.json"), + proof, + ); + await commitWorkspaceFiles(f.root, "Record current dated review"); + const result = await executeCli(runCli, process.execPath, [ + fileURLToPath(new URL("../bin/filegrc.js", import.meta.url)), + "complete", + f.obligation.id, + "--root", + f.root, + "--scaffold", + "--window-start", + f.today, + "--completed-on", + f.today, + "--population-as-of", + f.today, + "--json", + ]); + const mutation = JSON.parse(result.stdout); + assert.equal(mutation.populationAsOf, f.today); + mutation.record = proof; + delete mutation.content; + const file = join(f.root, "completion.json"); + await writeJson(file, mutation); + await executeCli(runCli, process.execPath, [ + fileURLToPath(new URL("../bin/filegrc.js", import.meta.url)), + "complete", + f.obligation.id, + file, + "--root", + f.root, + "--json", + ]); + const saved = await loadWorkspace(f.root); + assert.equal( + saved.resources.find( + (r) => + r.type === "obligation-occurrence" && r.coverage.startsOn === f.today, + ).membershipCutoffAt, + f.today, + ); +}); + +test("all newly supported operating coverage shapes require explicit runtime adoption", async (t) => { + const f = await fixture(t), + l = await loadWorkspace(f.root); + await writeJson(join(f.root, "package-lock.json"), { + lockfileVersion: 3, + packages: { "node_modules/filegrc": { version: "0.20.8" } }, + }); + for (const type of [ + "meeting", + "vulnerability-scan", + "exercise", + "backup-test", + "control-activity", + ]) { + const e = l.entries.find((e) => e.record.type === type); + assert.ok(e, type); + await writeJson(e.path, { + ...e.record, + coverage: { kind: "as-of", on: f.today }, + }); + assert.ok( + (await validateWorkspace(f.root)).diagnostics.some( + (d) => d.code === "snapshot-runtime-adoption-required", + ), + type, + ); + await writeJson(e.path, e.record); + } +}); + +test("inclusive whole-period final days do not elapse at morning execution", () => { + const coverage = { + kind: "range", + startsOn: "2026-10-01", + endsOn: "2026-10-31", + }; + assert.equal( + completionCoversRequiredPeriod( + { coverage }, + coverage, + "2026-10-31", + "2026-10-31", + ), + false, + ); + assert.equal( + completionCoversRequiredPeriod( + { coverage }, + coverage, + "2026-10-31", + "2026-11-01", + ), + false, + ); + assert.equal( + completionCoversRequiredPeriod( + { coverage }, + coverage, + "2026-11-01", + "2026-11-01", + ), + true, + ); +}); + +test("committing today's inventory cannot establish an unsupported historic snapshot", async (t) => { + const f = await fixture(t), + l = await loadWorkspace(f.root), + record = { + ...f.occurrence, + id: "occurrence-unsupported-history", + scopeRevision: getGitSummary(f.root).commit, + membershipCutoffAt: f.observed, + }; + await writeJson( + join( + f.root, + "data/obligation-occurrences/occurrence-unsupported-history.json", + ), + record, + ); + await commitWorkspaceFiles( + f.root, + "Retain invalid imported date for provenance rejection", + ); + const current = await loadWorkspace(f.root); + assert.equal( + currentSnapshotPopulation(current, record, { + ...f.rule, + selector: { ...f.rule.selector, cutoff: "review-date" }, + }), + null, + ); +}); + +test("whole-period proof keeps its execution date after reconciliation and retirement", async (t) => { + const f = await fixture(t); + const loaded = await loadWorkspace(f.root); + const requiredCoverage = { + kind: "range", + startsOn: f.start, + endsOn: f.start, + }; + for (const retired of [false, true]) { + const resources = loaded.resources.map((record) => { + if (record.id === f.obligation.id) + return { ...record, status: retired ? "retired" : "active" }; + if (record.id === f.rule.id) + return { + ...record, + status: retired ? "retired" : "active", + completionCoverage: "whole-period", + recurrence: { + mode: "calendar", + unit: "day", + interval: 1, + anchorDate: f.start, + }, + window: { precision: "date", startsAfter: 1, dueAfter: 2 }, + selector: { ...record.selector, cutoff: "window-start" }, + }; + if (record.type === "vendor-review") + return { ...record, coverage: requiredCoverage }; + if (record.id === f.occurrence.id) + return { + ...record, + status: "reconciled", + coverage: { kind: "range", startsOn: f.observed, endsOn: f.today }, + membershipCutoffAt: f.observed, + occurrenceKey: `${record.programId}:${record.obligationId}:${f.observed}`, + requiredCoverage, + conclusion: "complete", + reviewedByIds: ["person-example"], + reconciledAt: new Date().toISOString(), + }; + return record; + }); + const plan = planObligations(resources, { + model: loaded.model, + asOf: f.today, + from: f.start, + through: f.today, + includeComplete: true, + }); + const occurrence = plan.calendarItems.find( + (entry) => entry.occurrenceId === f.occurrence.id, + ); + assert.equal( + occurrence.status, + "complete", + retired ? "retained history" : "current rule", + ); + assert.deepEqual(occurrence.resolvedMemberIds, ["vendor-example"]); + } +}); + +test("whole-period zero and N/A populations wait for the period independently of fixed population finality", async (t) => { + const f = await fixture(t); + const loaded = await loadWorkspace(f.root); + const endsOn = addCalendarDays(f.today, 3); + for (const members of [ + [], + [ + { + ...f.occurrence.members[0], + disposition: "not-applicable", + result: "pending", + rationale: "The fixed scope has no applicable work.", + }, + ], + ]) { + const resources = loaded.resources.map((record) => { + if (record.id === f.obligation.id) + return { ...record, status: "retired" }; + if (record.id === f.rule.id) + return { + ...record, + status: "retired", + completionCoverage: "whole-period", + selector: { ...record.selector, cutoff: "window-start" }, + }; + if (record.id === f.occurrence.id) + return { + ...record, + requiredCoverage: { kind: "range", startsOn: f.start, endsOn }, + membershipCutoffAt: f.start, + members, + expectedCount: 0, + completedCount: 0, + status: "reconciled", + conclusion: members.length + ? "complete-with-exceptions" + : "zero-population", + reviewedByIds: ["person-example"], + reconciledAt: new Date().toISOString(), + }; + return record; + }); + const item = planObligations(resources, { + model: loaded.model, + asOf: f.today, + from: f.start, + through: f.today, + includeComplete: true, + }).calendarItems[0]; + assert.equal( + item.membershipFinal, + true, + "fixed-start population is still frozen", + ); + assert.equal(item.periodFinal, false); + assert.equal(item.resolutionsComplete, false); + assert.equal(item.status, "waiting"); + assert.equal(item.awaitingPeriodEnd, true); + } +}); + +test("open populations count expected work separately from reviewed non-applicability", async (t) => { + const f = await fixture(t); + const loaded = await loadWorkspace(f.root); + const resources = loaded.resources.map((record) => + record.id === f.occurrence.id + ? { + ...record, + members: record.members.map((member) => ({ + ...member, + disposition: "not-applicable", + result: "pending", + rationale: "Reviewed population member is outside this duty.", + })), + expectedCount: 0, + completedCount: 0, + } + : record, + ); + for (const retired of [false, true]) { + const scoped = resources.map((record) => + retired && [f.obligation.id, f.rule.id].includes(record.id) + ? { ...record, status: "retired" } + : record, + ); + const item = planObligations(scoped, { + model: loaded.model, + asOf: f.today, + from: f.start, + through: f.today, + includeComplete: true, + }).calendarItems.find( + (candidate) => candidate.occurrenceId === f.occurrence.id, + ); + assert.equal(item.membershipFinal, false); + assert.equal(item.memberWorkComplete, true); + assert.equal(item.expectedCount, 0); + assert.equal(item.completedCount, 0); + assert.equal(item.status, "waiting"); + } +}); diff --git a/packages/filegrc/test/obligations.test.js b/packages/filegrc/test/obligations.test.js index dbc206e..63746b0 100644 --- a/packages/filegrc/test/obligations.test.js +++ b/packages/filegrc/test/obligations.test.js @@ -309,6 +309,7 @@ test("plans flexible calendar windows with explicit due and overdue timing", () assert.deepEqual(due.counts, { overdue: 0, blocked: 0, + waiting: 0, due: 1, upcoming: 1, proposed: 0, @@ -362,6 +363,7 @@ test("puts standalone Action Items in Work Queue without a reverse source link", assert.deepEqual(plan.counts, { overdue: 0, blocked: 0, + waiting: 0, due: 0, upcoming: 1, proposed: 0, diff --git a/packages/filegrc/test/occurrence-lifecycle.test.js b/packages/filegrc/test/occurrence-lifecycle.test.js index 7c12499..dda3441 100644 --- a/packages/filegrc/test/occurrence-lifecycle.test.js +++ b/packages/filegrc/test/occurrence-lifecycle.test.js @@ -69,6 +69,9 @@ test("vendor termination preserves committed in-window work through cutoff recon from: f.start, through: f.today, includeComplete: true }).calendarItems.find(({ obligationId }) => obligationId === f.obligation.id); assert.deepEqual(item.expectedMemberIds, [vendor.record.id]); assert.equal(item.completedCount, 1); + assert.equal(item.status, "waiting"); + assert.equal(item.timingStatus, "due"); + assert.equal(item.awaitingPopulationCutoff, true); assert.equal((await validateWorkspace(f.root)).ok, true, JSON.stringify((await validateWorkspace(f.root)).diagnostics)); // A later inventory addition remains writable; it enters the derived queue // immediately and the owner must include it in the next reconciliation. @@ -76,6 +79,9 @@ test("vendor termination preserves committed in-window work through cutoff recon loaded = await loadWorkspace(f.root); const next = await scaffoldObligationOccurrence(f.root, { obligationId: f.obligation.id, windowStart: f.start, asOf: f.today }); assert.deepEqual(next.record.members.map(({ resourceId }) => resourceId), ["vendor-added", "vendor-example"]); + const actionable = planObligations(loaded.resources, { root: f.root, model: loaded.model, asOf: f.today, from: f.start, through: f.today }).calendarItems.find(({ obligationId }) => obligationId === f.obligation.id); + assert.equal(actionable.status, "due"); + assert.equal(actionable.awaitingPopulationCutoff, false); assert.equal(next.record.members.find(({ resourceId }) => resourceId === vendor.record.id).result, "passed"); occurrence = loaded.entries.find(({ record }) => record.id === f.occurrenceId); const dropped = { ...occurrence.record, members: [], expectedCount: 0, completedCount: 0 }; diff --git a/packages/filegrc/test/policy-library.test.js b/packages/filegrc/test/policy-library.test.js index c31e0c3..9a1b878 100644 --- a/packages/filegrc/test/policy-library.test.js +++ b/packages/filegrc/test/policy-library.test.js @@ -1457,7 +1457,10 @@ test("upgrades the preceding untouched Vendor starter and Policy", async (contex const priorRecords = JSON.parse(await readFile(new URL("./fixtures/security-starter-v5-vendor-records.json", import.meta.url), "utf8")); const current = new Map(); for (const { path, record } of priorRecords) { - current.set(record.id, JSON.parse(await readFile(join(root, path), "utf8"))); + const expected = JSON.parse(await readFile(join(root, path), "utf8")); + // A guidance upgrade preserves the older rule's coverage criterion. + if (record.type === "obligation-rule" && !record.completionCoverage) delete expected.completionCoverage; + current.set(record.id, expected); await writeJson(join(root, path), record); } const currentPolicy = await readFile(policyPath(root), "utf8"); diff --git a/packages/filegrc/test/render.test.js b/packages/filegrc/test/render.test.js index b37b7b6..79b3ae4 100644 --- a/packages/filegrc/test/render.test.js +++ b/packages/filegrc/test/render.test.js @@ -3009,3 +3009,33 @@ test("guided Evidence verification preserves hidden attachments and clears only assert.equal(external.filePaths, undefined); assert.equal(external.sourceComponentId, undefined); }); + + +test("waiting population work remains visible in the neutral queue", () => { + const source = APP_SCRIPT.slice(APP_SCRIPT.indexOf("function activeOperationItems"), APP_SCRIPT.indexOf("function policyEventTrigger", APP_SCRIPT.indexOf("function activeOperationItems"))); + const select = vm.runInNewContext(source + "\nactiveOperationItems"); + const waiting = { status: "waiting", title: "Population cutoff pending" }; + assert.equal(select([waiting, { status: "complete" }]).length, 1); + assert.equal(select([waiting])[0], waiting); + assert.match(APP_STYLES, /status-waiting\{background:#e8ebed;color:#4b5960/); +}); + +test("snapshot date selection reuses a frozen occurrence date and skips fixed cutoffs", async () => { + const source = APP_SCRIPT.slice(APP_SCRIPT.indexOf("function observationDateForItem"), APP_SCRIPT.indexOf("async function openObligationRuleCorrection")); + const rule = { id: "rule-snapshot", selector: { cutoff: "window-end" } }; + const occurrence = { id: "occurrence-snapshot", membershipCutoffAt: "2026-10-20" }; + const select = vm.runInNewContext(source + "\nobservationDateForItem", { state: { resources: [rule, occurrence].map((record) => ({ record })) } }); + const item = { ruleId: rule.id, occurrenceId: occurrence.id }; + assert.equal(await select(item), undefined); + rule.selector.cutoff = "review-date"; + assert.equal(await select(item), "2026-10-20"); +}); + + +test("waiting card timing never claims completion or an actionable deadline", () => { + const source = APP_SCRIPT.slice(APP_SCRIPT.indexOf("function windowText"), APP_SCRIPT.indexOf("function relativeEventWindow")); + const timing = vm.runInNewContext(source + "\n({ windowText, timingText })", { formatCalendarDate: String }); + const item = { status: "waiting", dueWindowStart: "2026-10-01", dueWindowEnd: "2026-10-31", overdueOn: "2026-11-01" }; + assert.equal(timing.timingText(item), "Awaiting population cutoff"); + assert.equal(timing.windowText(item), "Recorded work window: 2026-10-01 through 2026-10-31."); +}); diff --git a/packages/filegrc/test/retention-applications.test.js b/packages/filegrc/test/retention-applications.test.js index 7184430..9aedfc3 100644 --- a/packages/filegrc/test/retention-applications.test.js +++ b/packages/filegrc/test/retention-applications.test.js @@ -441,3 +441,15 @@ test("a later committed runtime upgrade cannot repair unsupported original adopt const loaded = await loadWorkspace(root), application = loaded.resources.find(({ type }) => type === "retention-application"); assert.ok(retentionApplicationIssues(loaded, application).some(({ message }) => message.includes("no later than its first retention application commit"))); }); + +test("runtime adoption preserves earlier service decisions and bounds the approved 0.21 line", async (t) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-retention-runtime-lines-")); + t.after(() => rm(root, { recursive: true, force: true })); + for (const [version, service, source] of [["0.19.1", false, false], ["0.19.2", true, false], ["0.20.8", true, true], ["0.21.0", true, true], ["0.22.0", false, false]]) { + await writeJson(join(root, "package.json"), { dependencies: { filegrc: version } }); + await writeJson(join(root, "package-lock.json"), { packages: { "": { dependencies: { filegrc: version } }, "node_modules/filegrc": { version } } }); + const loaded = { root, resources: [] }; + assert.equal(retentionApplicationRuntimeSupported(loaded), service, version); + assert.equal(retentionApplicationRuntimeSupported(loaded, null, "source-coverage"), source, version); + } +}); diff --git a/packages/filegrc/test/workflow.test.js b/packages/filegrc/test/workflow.test.js index 1f58889..ef3d081 100644 --- a/packages/filegrc/test/workflow.test.js +++ b/packages/filegrc/test/workflow.test.js @@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import vm from "node:vm"; import { promisify } from "node:util"; import { assessWorkflow, @@ -829,3 +830,22 @@ test("approved Exception expiry remains actionable through its lifecycle and Pro await writeJson(path, requested); assert.equal((await assess("2026-07-11")).workItems.some(({ source }) => source.id === record.id), false); }); + + +test("unfinalized population work prevents period completeness without becoming a recommended action", async () => { + const source = await readFile(new URL("../src/workflow.js", import.meta.url), "utf8"); + const buildSource = source.slice(source.indexOf("function buildAssessments"), source.indexOf("function assessment(")); + const build = vm.runInNewContext(buildSource + "\nbuildAssessments", { + stageComplete: () => true, + blockingFinding: (finding) => ["blocked", "overdue", "ready"].includes(finding.state), + assessment: (status) => ({ status }), + findingKeys: () => [] + }); + const input = { program: { stages: [], evidenceReady: true }, audits: [], auditPreparations: {}, obligationPlan: { counts: { overdue: 0, blocked: 0 } }, findings: [], validation: { ok: true }, coverage: { start: "2026-10-01", end: "2026-10-31" } }; + assert.equal(build(input).periodHealth.status, "complete"); + input.findings.push({ assessment: "period-health", state: "waiting" }); + assert.equal(build(input).periodHealth.status, "at-risk"); + const recommendationSource = source.slice(source.indexOf("function blockingFinding"), source.indexOf("function compareRecommended")); + const recommend = vm.runInNewContext(recommendationSource + "\nrecommendedAction", { activeWork: () => true, compareRecommended: () => 0 }); + assert.equal(recommend(input.findings, [{ state: "waiting" }], {}), null); +});