From df668386c88cb2d1a71e462e3917f109c244fcf9 Mon Sep 17 00:00:00 2001 From: Abe Wheeler Date: Wed, 28 Oct 2026 16:34:55 -0500 Subject: [PATCH] fix: separate approval history from operating period gaps --- docs/architecture.md | 9 + package-lock.json | 8 +- package.json | 2 +- packages/create-filegrc/package.json | 2 +- packages/create-filegrc/src/index.js | 4 +- packages/create-filegrc/template/package.json | 2 +- packages/create-filegrc/test/create.test.js | 4 +- packages/filegrc/package.json | 2 +- packages/filegrc/src/activation-readiness.js | 36 +++- packages/filegrc/src/cli.js | 6 +- packages/filegrc/src/collection-review.js | 4 +- packages/filegrc/src/evidence-packet.js | 12 +- .../filegrc/src/governed-content-baseline.js | 141 +++++++++++++ packages/filegrc/src/index.js | 2 + packages/filegrc/src/obligations.js | 29 ++- packages/filegrc/src/operating-baseline.js | 34 +-- packages/filegrc/src/program-lifecycle.js | 40 +++- packages/filegrc/src/program-readiness.js | 21 +- packages/filegrc/src/reporting-route-sets.js | 12 +- .../src/retention-schedule-approval.js | 22 ++ packages/filegrc/src/retention.js | 22 +- packages/filegrc/src/temporal-diagnostics.js | 89 ++++++++ packages/filegrc/src/validate.js | 14 +- packages/filegrc/src/workflow.js | 35 ++-- packages/filegrc/test/appointment.test.js | 2 +- .../test/governed-content-baseline.test.js | 193 ++++++++++++++++++ packages/filegrc/test/model-migration.test.js | 4 +- .../filegrc/test/reporting-route-sets.test.js | 20 ++ .../test/retention-operating-baseline.test.js | 130 ++++++++++++ .../filegrc/test/temporal-diagnostics.test.js | 141 +++++++++++++ packages/filegrc/test/workflow.test.js | 31 ++- 31 files changed, 982 insertions(+), 91 deletions(-) create mode 100644 packages/filegrc/src/governed-content-baseline.js create mode 100644 packages/filegrc/src/temporal-diagnostics.js create mode 100644 packages/filegrc/test/governed-content-baseline.test.js create mode 100644 packages/filegrc/test/retention-operating-baseline.test.js create mode 100644 packages/filegrc/test/temporal-diagnostics.test.js diff --git a/docs/architecture.md b/docs/architecture.md index a1b42d5..fcf557a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -887,3 +887,12 @@ Never-effective Obligation Rules may be withdrawn with actual actors, date and r Raw edit-envelope export reads the target JSON and its companion bytes and revisions only. It does not calculate unrelated workflow or Git history. Actual mutation validation and stale-write protection remain unchanged. The new optional source shapes require explicit adoption of a dependency and lockfile for FileGRC 0.21.6 or newer. Shared actions and structural validation enforce the same minimum; unchanged older workspaces remain readable. Correcting a mistaken older pin restores current validity without rewriting business decisions. Historical snapshots retain their actual reader limitation and cannot supply a typed review as though the older reader supported it. + + +### Authorization history and selected coverage + +Approval authorizes an exact proposed revision. Activation and effective dates determine when that revision governs operation. A pending or approved successor does not pause a valid, still-effective predecessor. Operating checks resolve the actual lineage and cutovers; an invalid claimed cutover cannot borrow the predecessor to conceal a gap. + +A first recorded approval does not establish that a channel or control failed before that date. Reporting diagnostics describe missing authorization evidence with an exact Program and interval. When a valid baseline resolves that limitation wholly before selected coverage, validation retains it as `info`, with no current action. A resolved limitation wholly after an earlier examination is retained as subsequent information. Overlapping or unresolved evidence limitations remain actionable. Actual invalid changes, missing approvals and stale proof retain their normal checks. + +The selected Audit's agreed range or as-of date takes precedence over a management candidate period. `validateWorkspace` accepts optional `programId`, `auditId` and `coverage` context; shared workflow and packet checks use the same classification. Cached information is restored to its original diagnostic before assessing a different Program or examination interval. Global structural errors remain visible. Validation counts report `information` separately from errors and warnings. diff --git a/package-lock.json b/package-lock.json index 96ce311..f4933ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.21.7", + "version": "0.21.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.21.7", + "version": "0.21.8", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.21.7", + "version": "0.21.8", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.21.7", + "version": "0.21.8", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index e34f3b6..6035dc1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.21.7", + "version": "0.21.8", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index b162ea4..163a3cf 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.21.7", + "version": "0.21.8", "description": "Create a filegrc workspace for a SOC 2 program", "license": "MIT", "repository": { diff --git a/packages/create-filegrc/src/index.js b/packages/create-filegrc/src/index.js index d268796..1b81360 100644 --- a/packages/create-filegrc/src/index.js +++ b/packages/create-filegrc/src/index.js @@ -524,13 +524,13 @@ async function runCombinedSetup(target, input) { async function writeMinimalLockfile(target, name, versionRange) { const lock = { name, - version: "0.21.7", + version: "0.21.8", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.21.7", + version: "0.21.8", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index 7b927cd..29ef301 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.21.7", + "version": "0.21.8", "private": true, "description": "filegrc workspace for a SOC 2 program", "type": "module", diff --git a/packages/create-filegrc/test/create.test.js b/packages/create-filegrc/test/create.test.js index 0fd123a..4a8d053 100644 --- a/packages/create-filegrc/test/create.test.js +++ b/packages/create-filegrc/test/create.test.js @@ -636,7 +636,7 @@ test("creates a complete generic repository with one dependency", async (context installedLock.packages["node_modules/filegrc"] = { version: currentEngineVersion }; await writeFile(installedLockPath, JSON.stringify(installedLock)); const validation = await validateWorkspace(target); - assert.deepEqual(validation.counts, { resources: 224, errors: 0, warnings: 3 }); + assert.deepEqual(validation.counts, { resources: 224, errors: 0, warnings: 3, information: 0 }); assert.equal( validation.diagnostics.filter(({ code }) => code === "past-proposed-effective-date").length, 3 @@ -1315,7 +1315,7 @@ test("creates and configures a service from one JSON config", async (context) => assert.match(output, /npx filegrc program-path --next --json/); assert.match(output, /Confirm the selected assurance goal with management: SOC 2 Type 2/); const validation = await validateWorkspace(target); - assert.deepEqual(validation.counts, { resources: 226, errors: 0, warnings: 0 }); + assert.deepEqual(validation.counts, { resources: 226, errors: 0, warnings: 0, information: 0 }); const workspace = JSON.parse(await readFile(join(target, "data", "workspace.json"), "utf8")); assert.equal(workspace.systemIds, undefined); const program = JSON.parse(await readFile(join(target, "data", "programs", "program-soc-2.json"), "utf8")); diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index 9cb0668..c1d5a1f 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.21.7", + "version": "0.21.8", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/activation-readiness.js b/packages/filegrc/src/activation-readiness.js index 24bd0a2..dc60858 100644 --- a/packages/filegrc/src/activation-readiness.js +++ b/packages/filegrc/src/activation-readiness.js @@ -5,7 +5,7 @@ import { assessReportingRouteSets } from "./reporting-route-sets.js"; import { reportingRouteSourceAppliesToProgram } from "./reporting-route-integrity.js"; import { timestampFromLocalDateTime } from "./time.js"; -export async function reportingCutoverIssues(loaded, updates, { operatingAsOf } = {}) { +export async function reportingCutoverIssues(loaded, updates, { operatingAsOf, includeHistoryContext = false } = {}) { if (!modelSupports(loaded.model, "schedule-set-review")) return []; const sources = updates.filter((record) => ["policy", "document", "commitment", "risk"].includes(record.type) && Array.isArray(record.reportingRouteRequirements) && record.reportingRouteRequirements.length); @@ -29,10 +29,14 @@ export async function reportingCutoverIssues(loaded, updates, { operatingAsOf } if (requirement.programScope === "selected-programs" && !requirement.programIds?.includes(program.id)) continue; const at = new Date(requirement.effectiveAt) > new Date(effectiveAt) ? requirement.effectiveAt : effectiveAt; if (requirement.endsAt && new Date(requirement.endsAt) <= new Date(at)) continue; - const assessment = await assessReportingRouteSets(future, { programId: program.id, at }); + const assessment = await assessReportingRouteSets(future, { programId: program.id, at, operatingOnly: true }); const candidateIds = new Set(assessment.routeSets.filter(({ record }) => record.purposeKey === requirement.purposeKey).map(({ record }) => record.id)); const relevant = assessment.issues.filter((issue) => issue.resourceId === source.id || candidateIds.has(issue.resourceId)); - issues.push(...relevant.map((issue) => ({ ...issue, sourceId: source.id, programId: program.id }))); + for (const issue of relevant) { + const temporal = includeHistoryContext && issue.code === "uncovered-reporting-route-interval" + ? await resolvedReportingAuthorizationInterval(future, issue, { programId: program.id, purposeKey: requirement.purposeKey, at }) : null; + issues.push({ ...issue, sourceId: source.id, programId: program.id, ...(temporal ? { temporal } : {}) }); + } } } } @@ -93,3 +97,29 @@ export function historicalContentCutover(loaded, record) { const prior = snapshot.resources.find(({ id }) => id === record.id); return prior ? { snapshot, record: prior } : null; } + +// Missing earlier authorization records do not prove that channels were absent. +// A valid current baseline and a dated valid effective cutover can bound the +// retained evidence limitation. Proposal and approval review dates alone cannot. +async function resolvedReportingAuthorizationInterval(loaded, issue, { programId, purposeKey, at }) { + const startsAt = new Date(at), now = new Date(); + if (!Number.isFinite(startsAt.getTime()) || startsAt >= now) return null; + const current = await assessReportingRouteSets(loaded, { programId, at: now.toISOString(), operatingOnly: true }); + const covers = assessment => { + const baseline = assessment.routeSets.filter(item => item.record.purposeKey === purposeKey && item.effective && !item.canceled); + if (baseline.length !== 1 || baseline[0].state !== "complete") return false; + const ids = new Set([issue.resourceId, baseline[0].record.id]); + return !assessment.issues.some(item => ids.has(item.resourceId)); + }; + if (!covers(current)) return null; + const boundaries = [...new Set(loaded.resources.filter(record => record.type === "reporting-route-set" + && record.programId === programId && record.purposeKey === purposeKey) + .map(record => record.approval?.effectiveAt).filter(value => typeof value === "string" + && Number.isFinite(Date.parse(value)) && new Date(value) > startsAt && new Date(value) <= now))].sort((a, b) => Date.parse(a) - Date.parse(b)); + for (const boundary of boundaries) { + const assessment = await assessReportingRouteSets(loaded, { programId, at: boundary, operatingOnly: true }); + if (covers(assessment)) return { programId, startsAt: startsAt.toISOString(), endsAt: new Date(boundary).toISOString(), + resolved: true, evidenceLimitation: true, purposeKey }; + } + return null; +} diff --git a/packages/filegrc/src/cli.js b/packages/filegrc/src/cli.js index 42fa1be..dc52589 100644 --- a/packages/filegrc/src/cli.js +++ b/packages/filegrc/src/cli.js @@ -224,7 +224,7 @@ export async function runCli(argv = process.argv.slice(2)) { return; } if (command === "validate") { - const result = await validateWorkspace(positionals[0] ?? root); + const result = await validateWorkspace(positionals[0] ?? root, { programId: flags.program, auditId: flags.audit }); if (flags.json) console.log(JSON.stringify({ ok: result.ok, counts: result.counts, diagnostics: result.diagnostics }, null, 2)); else printValidation(result); if (!result.ok) process.exitCode = 1; @@ -1468,7 +1468,7 @@ async function readStdin() { function printValidation(result) { for (const item of result.diagnostics) console.log(`${item.severity.toUpperCase()} ${item.path}: ${item.message}`); - console.log(`${result.counts.resources} resources, ${result.counts.errors} errors, ${result.counts.warnings} warnings`); + console.log(`${result.counts.resources} resources, ${result.counts.errors} errors, ${result.counts.warnings} warnings${result.counts.information ? `, ${result.counts.information} information` : ""}`); } async function printVersion() { @@ -1483,7 +1483,7 @@ Usage: filegrc serve [root] [--host 127.0.0.1] [--port 8787] [--allow-non-authoritative-writes] filegrc setup [setup.json|-] [setup options] [--draft] [--preview] [--summary] [--json] filegrc build [root] [--output .filegrc/site] - filegrc validate [root] [--json] + filegrc validate [root] [--program PROGRAM_ID] [--audit AUDIT_ID] [--json] filegrc model [--json|--write-docs|--check-docs] filegrc migrate --to-model <${SUPPORTED_MODEL_VERSIONS.join("|")}> [--preview] [--decisions path] [--job-title text] [--starts-on YYYY-MM-DD] [--yes] [--json] filegrc describe diff --git a/packages/filegrc/src/collection-review.js b/packages/filegrc/src/collection-review.js index 3043996..59a876a 100644 --- a/packages/filegrc/src/collection-review.js +++ b/packages/filegrc/src/collection-review.js @@ -18,7 +18,7 @@ import { loadWorkspace } from "./workspace.js"; import { resolveProgram } from "./program.js"; import { currentCalendarDate } from "./time.js"; import { currentPartyPeople } from "./parties.js"; -import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js"; +import { retentionScheduleApprovalIssues, operatingRetentionScheduleWorkspace } from "./retention-schedule-approval.js"; import { markdownEntries } from "./resource-markdown.js"; import { resolveDataPath } from "./paths.js"; import { calculateRevision, calculatedRevisionDiagnostic, displayRevision, revisionsMatch } from "./revisions.js"; @@ -188,7 +188,7 @@ function assessCollectionReviewUncached(loaded, resourceType, options) { export function retentionScheduleIsAuthoritative(loaded, program) { return !modelSupports(loaded.model, "retention-schedule-approval") - || assessCollectionReview(loaded, "retention-schedule-item", { programId: program?.id }).complete; + || assessCollectionReview(operatingRetentionScheduleWorkspace(loaded), "retention-schedule-item", { programId: program?.id }).complete; } export async function scaffoldCollectionReview(input = process.cwd(), options = {}) { diff --git a/packages/filegrc/src/evidence-packet.js b/packages/filegrc/src/evidence-packet.js index 36accdf..38e3126 100644 --- a/packages/filegrc/src/evidence-packet.js +++ b/packages/filegrc/src/evidence-packet.js @@ -1,3 +1,4 @@ +import { governedContentCoversPeriod } from "./governed-content-baseline.js"; import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { copyFile, mkdir, readFile, readdir, readlink, rm, writeFile } from "node:fs/promises"; @@ -64,7 +65,7 @@ const NON_EVIDENCE_RECORD_TYPES = new Set([ "workspace" ]); export async function prepareEvidencePacket(input, options = {}) { - const validation = await validateWorkspace(input); + const validation = await validateWorkspace(input, { programId: options.programId, auditId: options.auditId, ...(options.start && options.end ? { coverage: { kind: "range", startsOn: options.start, endsOn: options.end } } : {}) }); if (!validation.ok) throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before generating evidence.`); const { loaded } = validation; const records = loaded.resources; @@ -1400,7 +1401,7 @@ function packetGaps({ for (const policyId of coverage.policyIds) { const policy = byId.get(policyId); if (!policy) continue; - if (!policyCoversPeriod(policy, start, end, byId)) { + if (!policyCoversPeriod(policy, start, end, byId, model)) { gaps.push(gap("error", "policy-not-effective-for-period", `${policy.title} does not show approved policy coverage for the full packet period.`, policy.id)); } } @@ -1919,16 +1920,17 @@ function auditGaps(gaps, audit, byId, records, start, end, model) { } } -function policyCoversPeriod(policy, start, end, byId, seen = new Set()) { +function policyCoversPeriod(policy, start, end, byId, model, seen = new Set()) { + if (modelSupports(model, "operating-preparation")) return governedContentCoversPeriod(policy, start, end, byId, model); if (seen.has(policy.id)) return false; seen.add(policy.id); if (!["approved", "active", "superseded"].includes(policy.status) || !policy.approvedOn || !policy.effectiveOn) return false; const predecessor = policy.supersedesId ? byId.get(policy.supersedesId) : null; if (policy.effectiveOn > end) { - return predecessor?.type === "policy" ? policyCoversPeriod(predecessor, start, end, byId, seen) : false; + return predecessor?.type === "policy" ? policyCoversPeriod(predecessor, start, end, byId, model, seen) : false; } if (policy.effectiveOn <= start) return true; - return predecessor?.type === "policy" ? policyCoversPeriod(predecessor, start, policy.effectiveOn, byId, seen) : false; + return predecessor?.type === "policy" ? policyCoversPeriod(predecessor, start, policy.effectiveOn, byId, model, seen) : false; } function isDescriptionRequirement(requirement) { diff --git a/packages/filegrc/src/governed-content-baseline.js b/packages/filegrc/src/governed-content-baseline.js new file mode 100644 index 0000000..b71b02e --- /dev/null +++ b/packages/filegrc/src/governed-content-baseline.js @@ -0,0 +1,141 @@ +import { getFileAtRevision } from "./git.js"; +import { readFileSync } from "node:fs"; +import { markdownEntries } from "./resource-markdown.js"; +import { resolveDataPath } from "./paths.js"; +import { modelSupports } from "../model/index.js"; +import { calculateRevision, revisionDigest, revisionsMatch } from "./revisions.js"; +import { partiesIndependent, partyPeople } from "./parties.js"; +import { personWasActiveOn } from "./soc2.js"; +import { parseCalendarDate } from "./recurrence.js"; + +const governedTypes = new Set(["policy", "document", "training"]); +const pendingStatuses = new Set(["draft", "in-review", "proposed", "approved"]); +const date = (value) => typeof value === "string" && parseCalendarDate(value) ? value : null; + +function contentRevisionBindingsMatch(left, right) { + if (!left || !right || Array.isArray(left) || Array.isArray(right)) return false; + const entries = Object.entries(left).sort(([a], [b]) => a.localeCompare(b)); + const other = Object.entries(right).sort(([a], [b]) => a.localeCompare(b)); + return entries.length > 0 && entries.length === other.length + && entries.every(([path, revision], index) => other[index][0] === path + && revisionsMatch("content", revision, other[index][1])); +} + +function bound(revisions) { + return revisions && !Array.isArray(revisions) && Object.keys(revisions).length > 0 + && Object.values(revisions).every((revision) => revisionDigest("content", revision)); +} + +function operatingStart(record, model) { + const effective = date(record?.effectiveOn); + if (!effective) return null; + if (record.type === "policy" || !modelSupports(model, record.type === "document" ? "governed-document-activation" : "governed-training-activation")) return effective; + if (record.type === "training" && record.activationBasis === "legacy-v5") return effective; + const activated = date(record.activatedOn); + return activated ? [effective, activated].sort().at(-1) : null; +} + +function operatingEnd(record) { + if (!["retired", "superseded"].includes(record.status)) return null; + return date(record.retiredOn) || date(record.statusTransition?.changedOn); +} + +function authorized(record, byId, model) { + if (!date(record.approvedOn) || !date(record.effectiveOn) || record.approvedOn > record.effectiveOn) return false; + if (!modelSupports(model, "operating-preparation")) return true; + if (!bound(record.approvedContentRevisions) + || !partiesIndependent(record.ownerIds || [], record.approverIds || record.approvedByIds || [], byId) + || [...partyPeople(record.approverIds || record.approvedByIds || [], byId)].some((id) => !personWasActiveOn(byId.get(id), record.approvedOn))) return false; + if (record.type === "policy") return true; + if (record.type === "training" && record.activationBasis === "legacy-v5") return true; + return record.activationBasis === "recorded" && date(record.activatedOn) + && record.approvedOn <= record.activatedOn + && record.activatedByIds?.length > 0 + && record.activatedByIds.every((id) => personWasActiveOn(byId.get(id), record.activatedOn)) + && contentRevisionBindingsMatch(record.approvedContentRevisions, record.activatedContentRevisions); +} + +function predecessorCoversScope(proposed, predecessor) { + for (const field of ["policyKind", "documentKind", "workflowScope"]) { + if ((proposed[field] ?? null) !== (predecessor[field] ?? null)) return false; + } + for (const field of ["requirementIds", "policyIds", "systemIds", "controlIds", "componentIds", "trainingIds"]) { + const oldIds = new Set(predecessor[field] || []); + if ((proposed[field] || []).some(id => !oldIds.has(id))) return false; + } + return true; +} + +// A proposal changes review work. Only an actual activation/effective cutover +// changes the operating baseline. A broken claimed cutover never falls back. +export function governedContentBaseline(record, byId, asOf, model, options = {}) { + if (!governedTypes.has(record?.type) || !date(asOf)) return null; + const seen = new Set(); + let candidate = record; + while (candidate && !seen.has(candidate.id)) { + seen.add(candidate.id); + if (pendingStatuses.has(candidate.status) || candidate.status === "active" && date(candidate.effectiveOn) > asOf) { + const prior = byId.get(candidate.supersedesId); + if (prior?.type !== record.type || !predecessorCoversScope(candidate, prior)) return null; + candidate = prior; + continue; + } + if (!["active", "superseded", "retired"].includes(candidate.status)) return null; + const successors = [...byId.values()].filter((next) => next.type === candidate.type && next.supersedesId === candidate.id + && ["active", "superseded", "retired"].includes(next.status) && date(next.effectiveOn) && next.effectiveOn <= asOf); + if (successors.length > 1) return null; + if (successors.length === 1) { + if (seen.has(successors[0].id)) return null; + candidate = successors[0]; continue; + } + const start = operatingStart(candidate, model); + const end = operatingEnd(candidate); + if (!start || start > asOf || (["superseded", "retired"].includes(candidate.status) && !end) + || end && end <= asOf || !authorized(candidate, byId, model) + || options.contentMatches && !options.contentMatches(candidate)) return null; + return candidate; + } + return null; +} + +export function pendingGovernedSuccessor(record, byId, asOf, model, options = {}) { + if (!pendingStatuses.has(record?.status) && !(record?.status === "active" && date(record.effectiveOn) > asOf)) return null; + const baseline = governedContentBaseline(record, byId, asOf, model, options); + return baseline && baseline.id !== record.id ? baseline : null; +} + +export function governedContentCoversPeriod(record, start, end, byId, model, options = {}) { + if (!date(start) || !date(end) || start > end) return false; + const lineage = new Set([record?.id]); + for (let changed = true; changed;) { + changed = false; + for (const candidate of byId.values()) { + if (candidate.type !== record?.type) continue; + if (lineage.has(candidate.id) && candidate.supersedesId && !lineage.has(candidate.supersedesId)) { lineage.add(candidate.supersedesId); changed = true; } + if (lineage.has(candidate.supersedesId) && !lineage.has(candidate.id)) { lineage.add(candidate.id); changed = true; } + } + } + const boundaries = new Set([start, end]); + for (const id of lineage) { + const candidate = byId.get(id); + if (!candidate) continue; + for (const value of [candidate.effectiveOn, candidate.activatedOn, operatingEnd(candidate)]) { + if (date(value) && value >= start && value <= end) boundaries.add(value); + } + } + return [...boundaries].every((asOf) => governedContentBaseline(record, byId, asOf, model, options)); +} + +export function governedApprovalContentMatches(loaded, record) { + const entries = markdownEntries(loaded.model, record); + if (!entries.length || Object.keys(record.approvedContentRevisions || {}).length !== entries.length) return false; + return entries.every(({ path }) => { + try { + const source = loaded.historicalFiles instanceof Map ? loaded.historicalFiles.get(`data/${path}`) + : loaded.historicalCommit ? getFileAtRevision(loaded.root, loaded.historicalCommit, `data/${path}`) + : readFileSync(resolveDataPath(loaded.root, path), "utf8"); + if (typeof source !== "string") return false; + return revisionsMatch("content", record.approvedContentRevisions?.[path], calculateRevision("content", source)); + } catch (error) { if (error.code === "ENOENT") return false; throw error; } + }); +} diff --git a/packages/filegrc/src/index.js b/packages/filegrc/src/index.js index 63349fe..39dbb4c 100644 --- a/packages/filegrc/src/index.js +++ b/packages/filegrc/src/index.js @@ -149,3 +149,5 @@ export { amendedResourceView, scaffoldNonmaterialAmendment, saveNonmaterialAmend export { scaffoldRetentionApplication, saveRetentionApplication, retentionApplicationIssues } from "./retention-applications.js"; export { scaffoldObligationRuleCorrection, previewObligationRuleCorrection, correctObligationRule } from "./obligation-rule-correction.js"; + +export { resolveAssessmentCoverage, classifyTemporalDiagnostic, reclassifyTemporalValidation } from "./temporal-diagnostics.js"; diff --git a/packages/filegrc/src/obligations.js b/packages/filegrc/src/obligations.js index 60b96bc..9d931e5 100644 --- a/packages/filegrc/src/obligations.js +++ b/packages/filegrc/src/obligations.js @@ -1,3 +1,4 @@ +import { governedContentBaseline } from "./governed-content-baseline.js"; import { recordedCorrectionPopulation, currentSnapshotPopulation, samePopulation } from "./obligation-snapshot.js"; import { getGitSummary } from "./git.js"; import { populationCutoffDate, populationIsFinal, wholePeriodCoverage, completionCoversRequiredPeriod, requiredPeriodIsFinal, wholePeriodOccurrenceWindowIssue } from "./obligation-timing.js"; @@ -188,7 +189,7 @@ export function planObligations(resources, options = {}) { const configuredAnchor = schedule.recurrence?.anchorDate || schedule.startsOn; const { recurrence, activationDate } = obligationScheduleRecurrence( - obligation, rule, byId, model, workspace?.timezone || "UTC" + obligation, rule, byId, model, workspace?.timezone || "UTC", new Set(), asOf ); if (!validCalendarRecurrence(recurrence)) continue; const from = rule @@ -534,13 +535,17 @@ function obligationProgramBlocker(obligation, byId, asOf, model, now = `${asOf}T return { type: "obligation", id: obligation.id, label: "Assign current owner" }; } for (const id of obligation.policyIds || []) { - const policy = byId.get(id); + const source = byId.get(id); + const policy = modelSupports(model, "operating-preparation") + ? governedContentBaseline(source, byId, asOf, model) : source; if (policy?.type !== "policy" || policy.status !== "active" || !policy.effectiveOn || policy.effectiveOn > asOf) { return { type: "policy", id, label: "Activate policy" }; } } - const inactiveContent = obligationGovernedContent(obligation, byId, model) - .find((record) => !governedContentIsOperating(record, asOf, model)); + const inactiveContent = obligationGovernedContent(obligation, byId, model, asOf) + .find((record) => modelSupports(model, "operating-preparation") + ? !governedContentBaseline(record, byId, asOf, model) + : !governedContentIsOperating(record, asOf, model)); if (inactiveContent) { return { type: inactiveContent.type, id: inactiveContent.id, label: `Activate ${inactiveContent.type}` }; } @@ -2254,7 +2259,7 @@ 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", seen = new Set()) { +export function obligationScheduleRecurrence(obligation, rule, byId, model, timezone = "UTC", seen = new Set(), asOf = currentCalendarDate(timezone)) { 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 @@ -2262,15 +2267,15 @@ export function obligationScheduleRecurrence(obligation, rule, byId, model, time 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); + return obligationScheduleRecurrence(obligation, prior, byId, model, timezone, seen, asOf); } } const schedule = rule || obligation; const configuredAnchor = schedule.recurrence?.anchorDate || schedule.startsOn; const activationDate = !modelSupports(model, "operating-preparation") - ? obligationActivationDate(obligation, byId, rule || model, timezone) - : [obligationActivationDate(obligation, byId, rule || model, timezone), - obligationActivationDate(obligation, byId, model, timezone)].filter(Boolean).sort().at(-1); + ? obligationActivationDate(obligation, byId, rule || model, timezone, asOf) + : [obligationActivationDate(obligation, byId, rule || model, timezone, asOf), + obligationActivationDate(obligation, byId, model, timezone, asOf)].filter(Boolean).sort().at(-1); return { activationDate, recurrence: { @@ -2286,7 +2291,7 @@ export function obligationScheduleRecurrence(obligation, rule, byId, model, time }; } -export function obligationActivationDate(obligation, byId, ruleOrModel, timezone = "UTC") { +export function obligationActivationDate(obligation, byId, ruleOrModel, timezone = "UTC", asOf = currentCalendarDate(timezone)) { if (ruleOrModel?.type === "obligation-rule") { return ruleOrModel.effectiveAt ? currentCalendarDate(ruleOrModel.timezone || timezone, new Date(ruleOrModel.effectiveAt)) @@ -2295,10 +2300,12 @@ export function obligationActivationDate(obligation, byId, ruleOrModel, timezone const model = ruleOrModel; const policyDates = (obligation.policyIds || []) .map((id) => byId.get(id)) + .map((record) => modelSupports(model, "operating-preparation") + ? governedContentBaseline(record, byId, asOf, model) || record : record) .filter((policy) => policy?.type === "policy") .map((policy) => policy.effectiveOn) .filter(Boolean); - const governedContentDates = obligationGovernedContent(obligation, byId, model) + const governedContentDates = obligationGovernedContent(obligation, byId, model, asOf) .flatMap((record) => modelSupports(model, "operating-preparation") ? [record.effectiveOn, record.activatedOn] : [record.effectiveOn]) .filter(Boolean); return [...policyDates, ...governedContentDates].sort().at(-1) || null; diff --git a/packages/filegrc/src/operating-baseline.js b/packages/filegrc/src/operating-baseline.js index 834d134..8bedd59 100644 --- a/packages/filegrc/src/operating-baseline.js +++ b/packages/filegrc/src/operating-baseline.js @@ -1,3 +1,4 @@ +import { governedContentBaseline, governedApprovalContentMatches } from "./governed-content-baseline.js"; import { assessProgramAccessReview, providerAccessPopulation, providerAccessControls } from "./provider-access-completion.js"; import { readFile } from "node:fs/promises"; import { markdownEntries, resourceDataPath } from "./resource-markdown.js"; @@ -12,6 +13,7 @@ import { programComponents, recordBelongsToProgram } from "./program.js"; import { selectScopedCollectionRecords } from "./collection-scope.js"; import { planObligations } from "./obligations.js"; import { currentCalendarDate, timestampFromLocalDateTime } from "./time.js"; +import { modelSupports } from "../model/index.js"; import { assertHistoricalRecordValid } from "./validate.js"; const unique = (values) => [...new Set(values.filter(Boolean))]; @@ -119,24 +121,32 @@ export async function assessProgramBaseline(loaded, program, controls, asOf, mak } else if (profile.id.startsWith("workforce-")) { const training = profile.id === "workforce-training"; const policyIds = unique([...program.policyIds || [], ...selected.flatMap(({ policyIds = [] }) => policyIds)]); - const scheduledSubjects = unique(schedules.flatMap((schedule) => [...ruleFor(schedule).scopeResourceIds || [], ...schedule.scopeResourceIds || [], ruleFor(schedule).templateResourceId, schedule.templateResourceId])); + const scheduleSubjectIds = (schedule) => unique([...(ruleFor(schedule).scopeResourceIds || schedule.scopeResourceIds || []), ruleFor(schedule).templateResourceId || schedule.templateResourceId]) + .map((id) => modelSupports(loaded.model, "operating-preparation") + ? governedContentBaseline(byId.get(id), byId, asOf, loaded.model)?.id || id : id); + const scheduledSubjects = modelSupports(loaded.model, "operating-preparation") + ? unique(schedules.flatMap(scheduleSubjectIds)) + : unique(schedules.flatMap((schedule) => [...ruleFor(schedule).scopeResourceIds || [], ...schedule.scopeResourceIds || [], ruleFor(schedule).templateResourceId, schedule.templateResourceId])); const subjects = loaded.resources.filter((subject) => valid(subject) && ["approved", "active"].includes(subject.status) && (training ? subject.type === "training" && (subject.controlIds?.some((id) => controlIds.includes(id)) || scheduledSubjects.includes(subject.id)) : subject.type === "policy" && policyIds.includes(subject.id) || subject.type === "document" && subject.acknowledgementRequired === true && subject.workflowScope === "program" && (subject.controlIds?.some((id) => controlIds.includes(id)) || scheduledSubjects.includes(subject.id)))); + const operatingSubjects = modelSupports(loaded.model, "operating-preparation") + ? [...new Map(subjects.map((subject) => { + const baseline = governedContentBaseline(subject, byId, asOf, loaded.model, + { contentMatches: (candidate) => governedApprovalContentMatches(loaded, candidate) }) || subject; + return [baseline.id, baseline]; + })).values()] : subjects; // All current internal workers are included, even when a new hire has not // yet acquired an owner role. Explicit schedule selectors can add the // applicable external workforce without pulling in external reviewers. const workers = loaded.resources.filter((person) => person.type === "person" && person.affiliation === "internal" && personWasActiveOn(person, asOf)); const internalWorkerIds = unique(workers.map(({ id }) => id)); - const subjectSchedules = (subject) => schedules.filter((schedule) => { - const rule = ruleFor(schedule); - return unique([...(rule.scopeResourceIds || schedule.scopeResourceIds || []), rule.templateResourceId || schedule.templateResourceId]).includes(subject.id); - }); - const subjectPopulations = subjects.map((subject) => { + const subjectSchedules = (subject) => schedules.filter((schedule) => scheduleSubjectIds(schedule).includes(subject.id)); + const subjectPopulations = operatingSubjects.map((subject) => { const matching = subjectSchedules(subject); const roleOnly = training && matching.length > 0 && matching.every(({ activityType }) => activityType === "role-training"); const people = roleOnly ? [] : [...internalWorkerIds]; @@ -156,7 +166,7 @@ export async function assessProgramBaseline(loaded, program, controls, asOf, mak const missingAudiences = subjectPopulations.filter(({ audienceConfigured }) => !audienceConfigured); const results = []; const missing = []; - for (const subject of subjects) { + for (const subject of operatingSubjects) { const subjectCurrent = subject.approvedOn && subject.approvedOn <= asOf && await currentProcedureContent(loaded, subject, subject.approvedContentRevisions); for (const personId of subjectPopulations.find(({ subjectId }) => subjectId === subject.id).personIds) { @@ -164,8 +174,8 @@ export async function assessProgramBaseline(loaded, program, controls, asOf, mak let accepted; for (const candidate of eligible) { if (subjectCurrent && valid(candidate) && candidate.status === "completed" && permittedDate(candidate.completedOn, windows.filter((window) => { - const schedule = byId.get(window.obligationId); const rule = ruleFor(schedule); - const ids = unique([...(rule.scopeResourceIds || schedule.scopeResourceIds || []), rule.templateResourceId || schedule.templateResourceId]); + const schedule = byId.get(window.obligationId); + const ids = scheduleSubjectIds(schedule); return !ids.length || ids.includes(subject.id); })) && candidate.completedOn >= (byId.get(personId)?.startDate || "") && candidate.completedOn >= subject.approvedOn @@ -177,15 +187,15 @@ export async function assessProgramBaseline(loaded, program, controls, asOf, mak else missing.push({ personId, subjectId: subject.id, candidateIds: eligible.map(({ id }) => id), missingFacts: [training ? "current-training-completion" : "current-policy-acknowledgement"] }); } } - complete = subjects.length > 0 && population.length > 0 && missing.length === 0 && missingAudiences.length === 0; + complete = operatingSubjects.length > 0 && population.length > 0 && missing.length === 0 && missingAudiences.length === 0; record = missing.length ? byId.get(missing[0].candidateIds[0]) : byId.get(results[0]); title = training ? "Complete current workforce training" : "Collect current workforce policy acknowledgements"; work = `${training ? "Have each current worker finish the approved security training and its passing criteria" : "Have each current worker read and acknowledge the approved program policies and required Documents"}. Compare the workforce roster with the person-by-subject gaps below, including new hires. Reuse completed Attestations only for the current content revision and applicable worker. Record actual completion dates and retain signed or external completion proof when used. An export rehearsal, pending assignment or old-content acknowledgement does not complete this work.`; acceptance = "Each current worker has a completed, unexpired Attestation for every applicable current subject revision. External or signed completion also retains actual completion Evidence. Resolve an empty roster or missing subjects through the existing inventory and content workflow."; details = { ...details, instructions: [...missingAudiences.map(({ subjectId }) => `Define the applicable people for ${byId.get(subjectId)?.title || subjectId} in its existing role-training Obligation selector or explicit person scope; do not assign it to unrelated workers.`), ...missing.map(({ personId, subjectId }) => `${byId.get(personId)?.title || personId}: ${training ? "complete training and its passing criteria" : "read and acknowledge"} for ${byId.get(subjectId)?.title || subjectId}; retain a dated Attestation of its current revision.`)], suggestedValues: missing.length ? { programId: program.id, personId: missing[0].personId, subjectResourceIds: [missing[0].subjectId] } : {}, - populationIds: population, subjectPopulations, subjectIds: subjects.map(({ id }) => id), missingMembers: missing, resultIds: unique(results), - missingFacts: complete ? [] : [...(!subjects.length ? ["approved-subjects"] : []), ...(!population.length ? ["current-workforce"] : []), ...(missingAudiences.length ? ["configured-role-training-audience"] : []), ...unique(missing.flatMap(({ missingFacts }) => missingFacts))] }; + populationIds: population, subjectPopulations, subjectIds: operatingSubjects.map(({ id }) => id), missingMembers: missing, resultIds: unique(results), + missingFacts: complete ? [] : [...(!operatingSubjects.length ? ["approved-subjects"] : []), ...(!population.length ? ["current-workforce"] : []), ...(missingAudiences.length ? ["configured-role-training-audience"] : []), ...unique(missing.flatMap(({ missingFacts }) => missingFacts))] }; } else if (profile.id === "provider-access") { const { componentIds, grants } = providerAccessPopulation(loaded, program, selected); const assessments = await Promise.all(candidates.map(async (candidate) => { diff --git a/packages/filegrc/src/program-lifecycle.js b/packages/filegrc/src/program-lifecycle.js index 0f9d278..1434ffa 100644 --- a/packages/filegrc/src/program-lifecycle.js +++ b/packages/filegrc/src/program-lifecycle.js @@ -2,6 +2,7 @@ import { currentPartyPeople } from "./parties.js"; import { modelSupports } from "../model/index.js"; import { revisionsMatch } from "./revisions.js"; import { obligationRule } from "./obligation-rule.js"; +import { governedContentBaseline } from "./governed-content-baseline.js"; const requiredDocumentsByControlCache = new WeakMap(); @@ -76,10 +77,12 @@ export function contentRevisionBindingsMatch(left, right) { )); } -export function obligationGovernedDocuments(obligation, byId, model) { +export function obligationGovernedDocuments(obligation, byId, model, asOf = null) { if (!modelSupports(model, "governed-document-activation")) return []; const policyDocumentIds = (obligation.policyIds || []).flatMap((id) => { - const policy = byId.get(id); + const source = byId.get(id); + const policy = asOf && modelSupports(model, "operating-preparation") + ? governedContentBaseline(source, byId, asOf, model) || source : source; return policy?.type === "policy" ? policy.relatedDocumentIds || [] : []; }); const directDocumentIds = [ @@ -91,15 +94,17 @@ export function obligationGovernedDocuments(obligation, byId, model) { .flatMap((id) => requiredDocumentsByControl.get(id) || []); return [...new Set([...policyDocumentIds, ...directDocumentIds, ...controlDocumentIds])] .map((id) => byId.get(id)) + .map((record) => asOf && modelSupports(model, "operating-preparation") + ? governedContentBaseline(record, byId, asOf, model) || record : record) .filter((record) => ( record?.type === "document" - && !["superseded", "retired"].includes(record.status) + && (asOf && modelSupports(model, "operating-preparation") || !["superseded", "retired"].includes(record.status)) && !documentIsAuditSpecific(record, model) )); } -export function obligationGovernedContent(obligation, byId, model) { - const documents = obligationGovernedDocuments(obligation, byId, model); +export function obligationGovernedContent(obligation, byId, model, asOf = null) { + const documents = obligationGovernedDocuments(obligation, byId, model, asOf); if (!modelSupports(model, "governed-training-activation")) return documents; const directIds = [ ...(obligation.scopeResourceIds || []), @@ -107,7 +112,10 @@ export function obligationGovernedContent(obligation, byId, model) { ]; const training = [...new Set(directIds)] .map((id) => byId.get(id)) - .filter((record) => record?.type === "training" && !["superseded", "retired"].includes(record.status)); + .map((record) => asOf && modelSupports(model, "operating-preparation") + ? governedContentBaseline(record, byId, asOf, model) || record : record) + .filter((record) => record?.type === "training" + && (asOf && modelSupports(model, "operating-preparation") || !["superseded", "retired"].includes(record.status))); return [...documents, ...training]; } @@ -138,15 +146,29 @@ export function obligationProgramStatus(obligation, byId, asOf, model, now = `${ if (currentPartyPeople(obligation.ownerIds || [], byId).size === 0) return "proposed"; const policyIds = obligation.policyIds || []; const policiesReady = policyIds.every((id) => { - const policy = byId.get(id); + const source = byId.get(id); + if (modelSupports(model, "operating-preparation")) { + const baseline = governedContentBaseline(source, byId, asOf, model); + if (baseline) return true; + // Existing standalone Policy operation keeps its original eligibility. + // Approval findings remain separate from recording actual completed work; + // substituting a successor still requires the proved baseline above. + const hasEffectiveSuccessor = [...byId.values()].some((record) => record.type === "policy" + && record.supersedesId === source?.id && ["active", "superseded", "retired"].includes(record.status) + && record.effectiveOn && record.effectiveOn <= asOf); + if (source?.supersedesId || hasEffectiveSuccessor) return false; + } + const policy = source; return policy?.type === "policy" && policy.status === "active" && policy.effectiveOn && policy.effectiveOn <= asOf; }); if (!policiesReady) return "proposed"; - const governedContentReady = obligationGovernedContent(obligation, byId, model) - .every((record) => governedContentIsOperating(record, asOf, model)); + const governedContentReady = obligationGovernedContent(obligation, byId, model, asOf) + .every((record) => modelSupports(model, "operating-preparation") + ? governedContentBaseline(record, byId, asOf, model) !== null + : governedContentIsOperating(record, asOf, model)); if (!governedContentReady) return "proposed"; const controlIds = obligation.controlIds || []; if (!controlIds.length) return "accepted"; diff --git a/packages/filegrc/src/program-readiness.js b/packages/filegrc/src/program-readiness.js index 6336bb0..14814df 100644 --- a/packages/filegrc/src/program-readiness.js +++ b/packages/filegrc/src/program-readiness.js @@ -1,3 +1,4 @@ +import { governedContentBaseline, pendingGovernedSuccessor, governedApprovalContentMatches } from "./governed-content-baseline.js"; import { planWorkQueue, sourceDeadlineValidation } from "./work-queue.js"; import { withAssessmentCache } from "./calculation-cache.js"; import { assessOperatingPreparation, firstOperatingTasks } from "./operating-preparation.js"; @@ -303,6 +304,20 @@ async function assessProgramReadinessUncached(input, options = {}) { policyStage, controlStage ]; + if (modelSupports(loaded.model, "operating-preparation")) { + for (const current of evidenceGateStages) { + for (const entry of current.items) { + const record = byId.get(entry.resourceId); + const baseline = pendingGovernedSuccessor(record, byId, asOf, loaded.model, + { contentMatches: (candidate) => governedApprovalContentMatches(loaded, candidate) }); + if (!baseline) continue; + entry.status = "info"; + entry.progressUnit = false; + entry.operatingBaselineId = baseline.id; + entry.message = `${baseline.title || baseline.id} remains the effective operating baseline while this successor awaits cutover. ${entry.message}`; + } + } + } const prioritizedSetupItems = prioritizeReviewDependencies(evidenceGateStages, loaded, program); for (const current of evidenceGateStages) finalizeStage(current); const evidenceReady = evidenceGateStages.every((current) => current.counts.action === 0); @@ -1430,6 +1445,8 @@ async function governedContentItems(scope, records, byId, readMarkdown, asOf, mo assignmentScheduled: operatingSchedules.some((obligation) => ( obligation.templateResourceId === record.id || (obligation.scopeResourceIds || []).includes(record.id) + || modelSupports(model, "operating-preparation") && [obligation.templateResourceId, ...(obligation.scopeResourceIds || [])] + .some((id) => governedContentBaseline(byId.get(id), byId, asOf, model)?.id === record.id) )), activationRecorded: ["recorded", "legacy-v5"].includes(record.activationBasis), activated: record.activationBasis === "legacy-v5" || Boolean(record.activatedOn), @@ -1612,7 +1629,9 @@ async function assessPolicyActivations(policies, controls, records, byId, readMa : []; const missingGovernedDocumentIds = linkedGovernedDocumentIds.filter((id) => { const document = byId.get(id); - return !governedDocumentIsOperating(document, asOf, model); + return modelSupports(model, "operating-preparation") + ? !governedContentBaseline(document, byId, asOf, model) + : !governedDocumentIsOperating(document, asOf, model); }); const relevantExceptions = records.filter((record) => ( record.type === "exception" diff --git a/packages/filegrc/src/reporting-route-sets.js b/packages/filegrc/src/reporting-route-sets.js index 2fbeedc..1887cf9 100644 --- a/packages/filegrc/src/reporting-route-sets.js +++ b/packages/filegrc/src/reporting-route-sets.js @@ -124,7 +124,7 @@ export async function assessReportingRouteSets(input = process.cwd(), options = record.purposeKey === requirement.purposeKey && effective && !canceled )); if (!candidates.length) { - issues.push(issue("uncovered-reporting-route-interval", requirement.sourceId, `No committed approved Reporting Channel Set covers ${requirement.purposeKey} at ${at.toISOString()}.`)); + issues.push(issue("uncovered-reporting-route-interval", requirement.sourceId, `The retained records do not establish an approved, in-force Reporting Channel Set for ${requirement.purposeKey} at ${at.toISOString()}. This is an authorization-evidence limitation, not proof that a reporting channel was unavailable.`)); continue; } if (candidates.length > 1) { @@ -149,7 +149,14 @@ export async function assessReportingRouteSets(input = process.cwd(), options = issues.push(issue("reporting-route-dependencies-not-independent", route.id, `${requirement.sourceId} requires independent channel dependencies or an applicable Exception.`)); } } - for (const assessment of assessments) issues.push(...assessment.issues); + for (const assessment of assessments) { + const claimedOperating = ["approved", "canceled"].includes(assessment.record.status) + && Number.isFinite(Date.parse(assessment.record.approval?.effectiveAt)) + && new Date(assessment.record.approval.effectiveAt) <= at + && !(assessment.record.status === "canceled" && Number.isFinite(Date.parse(assessment.record.cancellation?.canceledAt)) + && new Date(assessment.record.cancellation.canceledAt) <= at); + if (!options.operatingOnly || claimedOperating) issues.push(...assessment.issues); + } return { supported: true, at: at.toISOString(), @@ -343,6 +350,7 @@ export async function assessReportingRoutePeriod(input = process.cwd(), options snapshots.push(await assessReportingRouteSets(loaded, { ...options, at, + operatingOnly: true, [PERIOD_REPOSITORY_SNAPSHOT]: repository })); } diff --git a/packages/filegrc/src/retention-schedule-approval.js b/packages/filegrc/src/retention-schedule-approval.js index 5434ea5..903208f 100644 --- a/packages/filegrc/src/retention-schedule-approval.js +++ b/packages/filegrc/src/retention-schedule-approval.js @@ -1,3 +1,6 @@ +import { governedContentBaseline, governedApprovalContentMatches } from "./governed-content-baseline.js"; +import { modelSupports } from "../model/index.js"; +import { currentCalendarDate } from "./time.js"; import { applicationCoversUse, retentionUseWasIndependentlyReviewed } from "./retention-applications.js"; import { readFileSync } from "node:fs"; import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js"; @@ -142,3 +145,22 @@ function approvalBindingsMatch(loaded, record) { key === expectedKeys[index] && revisionsMatch("content", actual[key], expected[key]) )); } + +// Proposed schedule successors remain review work. The operating authority uses +// only the exact live schedule and its rows, while retaining today's source, +// inventory and row facts so a real material change cannot reuse stale approval. +export function operatingRetentionScheduleWorkspace(loaded, asOf = currentCalendarDate(loaded.workspace?.timezone || "UTC")) { + if (!modelSupports(loaded.model, "operating-preparation")) return loaded; + const byId = new Map(loaded.resources.map((record) => [record.id, record])); + const excluded = new Set(); + for (const schedule of loaded.resources.filter((record) => record.type === "document" + && record.documentKind === "schedule" && record.workflowScope === "program")) { + const baseline = governedContentBaseline(schedule, byId, asOf, loaded.model, + { contentMatches: (record) => governedApprovalContentMatches(loaded, record) }); + if (baseline?.documentKind === "schedule" && baseline.workflowScope === "program" && baseline.id !== schedule.id) excluded.add(schedule.id); + } + if (!excluded.size) return loaded; + const include = (record) => !excluded.has(record.id) + && !(record.type === "retention-schedule-item" && excluded.has(record.scheduleDocumentId)); + return { ...loaded, resources: loaded.resources.filter(include), entries: loaded.entries.filter(({ record }) => include(record)) }; +} diff --git a/packages/filegrc/src/retention.js b/packages/filegrc/src/retention.js index 8bf7774..924838c 100644 --- a/packages/filegrc/src/retention.js +++ b/packages/filegrc/src/retention.js @@ -1,3 +1,5 @@ +import { governedContentBaseline, governedApprovalContentMatches } from "./governed-content-baseline.js"; +import { currentCalendarDate } from "./time.js"; import { applicationCoversUse, retentionApplicationIssues, retentionUseWasIndependentlyReviewed } from "./retention-applications.js"; import { calculationMemo, hasCalculationCache } from "./calculation-cache.js"; import { readFileSync } from "node:fs"; @@ -433,8 +435,9 @@ const approvedReviewEntries = new WeakMap(); function effectiveReviewEntry(loaded, entry) { if (!entry || !loaded.root || !["policy", "document", "training"].includes(entry.record.type)) return entry; if (!["draft", "in-review", "approved", "active"].includes(entry.record.status)) return entry; - if (approvedReviewContentMatches(loaded, entry)) return entry; - if (["approved", "active"].includes(entry.record.status)) return entry; + const approvedAwaitingCutover = modelSupports(loaded.model, "operating-preparation") && entry.record.status === "approved"; + if (approvedReviewContentMatches(loaded, entry) && !approvedAwaitingCutover) return entry; + if (entry.record.status === "active") return entry; let cache = approvedReviewEntries.get(loaded.resources); if (!cache) { cache = new Map(); @@ -445,7 +448,20 @@ function effectiveReviewEntry(loaded, entry) { for (const commit of [...reviewHistoryCommits(context)].reverse()) { const snapshot = reviewHistoricalWorkspace(context, commit); const historical = snapshot?.entries.find(({ record }) => record.id === entry.record.id); - if (!historical || !approvedReviewContentMatches(snapshot, historical, commit)) continue; + const asOf = currentCalendarDate(loaded.workspace?.timezone || "UTC"); + if (approvedAwaitingCutover && historical && ["retired", "superseded"].includes(historical.record.status) + && (!historical.record.statusTransition?.changedOn || historical.record.statusTransition.changedOn <= asOf)) break; + if (!historical || !approvedReviewContentMatches(snapshot, historical, commit)) { + if (approvedAwaitingCutover && historical?.record.status === "active" && historical.record.effectiveOn <= asOf) break; + continue; + } + if (approvedAwaitingCutover) { + if (historical.record.status !== "active" || historical.record.effectiveOn > asOf) continue; + const byId = new Map(snapshot.resources.map((record) => [record.id, record])); + const baseline = governedContentBaseline(historical.record, byId, asOf, loaded.model, + { contentMatches: (record) => governedApprovalContentMatches(snapshot, record) }); + if (!baseline || baseline.id !== entry.record.id || baseline.status !== "active") break; + } const approved = { ...historical, reviewCommit: commit, reviewFiles: snapshot.historicalFiles }; cache.set(entry.record.id, approved); return approved; diff --git a/packages/filegrc/src/temporal-diagnostics.js b/packages/filegrc/src/temporal-diagnostics.js new file mode 100644 index 0000000..92f267b --- /dev/null +++ b/packages/filegrc/src/temporal-diagnostics.js @@ -0,0 +1,89 @@ +import { addCalendarDays } from "./recurrence.js"; +import { timestampFromLocalDateTime } from "./time.js"; + +// Coverage selection is explicit: an examination's agreed period wins over a +// candidate period. Ambiguous or invalid selections never borrow another scope. +export function resolveAssessmentCoverage(loaded, options = {}) { + const resources = loaded.resources || []; + const programs = resources.filter(record => record.type === "program" && record.status !== "retired"); + const audit = options.auditId ? resources.find(record => record.type === "audit" && record.id === options.auditId) : null; + if (options.auditId && !audit) return null; + if (options.programId && audit?.programId && options.programId !== audit.programId) return null; + const requestedProgramId = options.programId || audit?.programId; + const program = requestedProgramId ? programs.find(record => record.id === requestedProgramId) + : programs.length === 1 ? programs[0] : null; + if (!program) return null; + // A selected audit must belong to the selected Program. + if (audit && audit.programId !== program.id) return null; + const coverage = audit ? audit.coverage : options.coverage ?? program.candidateCoverage; + if (!coverage || typeof coverage !== "object") return null; + const kind = coverage.kind || (!audit && options.coverage && coverage.start && coverage.end ? "range" : undefined); + const start = kind === "as-of" ? coverage.on : coverage.start || coverage.startsOn; + const end = kind === "as-of" ? coverage.on : coverage.end || coverage.endsOn; + if (!["range", "as-of"].includes(kind) || !validDate(start) || !validDate(end) || start > end) return null; + try { + timestampFromLocalDateTime(`${start}T00:00:00`, loaded.workspace?.timezone || "UTC"); + timestampFromLocalDateTime(`${end}T00:00:00`, loaded.workspace?.timezone || "UTC"); + } catch { return null; } + return { start, end, kind, basis: audit ? "audit" : options.coverage != null ? "explicit" : "candidate", + programId: program.id, auditId: audit?.id || null }; +} + +// Domains opt in only after proving a bounded, resolved evidence limitation. +// This does not reinterpret structural errors or proof of operating failures. +export function classifyTemporalDiagnostic(diagnostic, loaded, options = {}) { + const base = restoreTemporalDiagnostic(diagnostic); + const temporal = base.temporal; + if (base.severity !== "warning" || !temporal?.programId || temporal.resolved !== true || temporal.evidenceLimitation !== true) return base; + if (options.programId && options.programId !== temporal.programId) return base; + const coverage = resolveAssessmentCoverage(loaded, { ...options, programId: temporal.programId }); + if (!coverage) return base; + const startsAt = instant(temporal.startsAt), endsAt = instant(temporal.endsAt); + if (startsAt === null || endsAt === null || startsAt >= endsAt) return base; + const coverageStartsAt = Date.parse(timestampFromLocalDateTime(`${coverage.start}T00:00:00`, loaded.workspace?.timezone || "UTC")); + const coverageEndsAt = Date.parse(timestampFromLocalDateTime(`${addCalendarDays(coverage.end, 1)}T00:00:00`, loaded.workspace?.timezone || "UTC")); + const relation = endsAt <= coverageStartsAt ? "before" : startsAt >= coverageEndsAt ? "after" : null; + if (!relation) return base; + const context = relation === "before" + ? `Retained historical context: this resolved authorization or evidence limitation ended by the start of selected coverage on ${coverage.start}.` + : `Retained subsequent context: this resolved authorization or evidence limitation began after selected coverage ended on ${coverage.end}.`; + return { ...base, severity: "info", assessmentCoverage: coverage, + temporal: { ...temporal, relation, originalSeverity: base.severity, originalMessage: base.message }, + message: `${base.message} ${context} It does not establish an operating violation or require current follow-up.` }; +} + +// Cached validation can be reused in another view only after restoring each +// temporal warning and assessing it against that view's selected coverage. +export function reclassifyTemporalValidation(validation, loaded, options = {}) { + const diagnostics = (validation.diagnostics || []).map(diagnostic => classifyTemporalDiagnostic(diagnostic, loaded, options)); + const errors = diagnostics.filter(({ severity }) => severity === "error").length; + const counts = { ...validation.counts, errors, + warnings: diagnostics.filter(({ severity }) => severity === "warning").length, + information: diagnostics.filter(({ severity }) => severity === "info").length }; + const descriptors = Object.getOwnPropertyDescriptors(validation); + for (const [key, value] of Object.entries({ diagnostics, counts, ok: validation.ok !== false && errors === 0 })) { + descriptors[key] = { value, enumerable: true, configurable: true, writable: true }; + } + return Object.defineProperties(Object.create(Object.getPrototypeOf(validation)), descriptors); +} + +function restoreTemporalDiagnostic(diagnostic) { + const temporal = diagnostic.temporal; + if (diagnostic.severity !== "info" || !temporal || temporal.originalSeverity !== "warning" || typeof temporal.originalMessage !== "string") return diagnostic; + const { assessmentCoverage, ...base } = diagnostic; + const { relation, ...originalTemporal } = temporal; + return { ...base, temporal: originalTemporal, severity: temporal.originalSeverity, message: temporal.originalMessage }; +} + +function validDate(value) { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const parsed = new Date(`${value}T00:00:00Z`); + return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; +} + +function instant(value) { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.test(value) + || !validDate(value.slice(0, 10))) return null; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/packages/filegrc/src/validate.js b/packages/filegrc/src/validate.js index b4b4a3c..ea368ea 100644 --- a/packages/filegrc/src/validate.js +++ b/packages/filegrc/src/validate.js @@ -1,3 +1,4 @@ +import { reclassifyTemporalValidation } from "./temporal-diagnostics.js"; import { workspaceEngineVersion, runtimeCompatibilityLine } from "./runtime-version.js"; import { workflowSourceRuntimeIssues } from "./workflow-source-runtime.js"; import { recordedCorrectionPopulation, currentSnapshotPopulation, samePopulation } from "./obligation-snapshot.js"; @@ -87,16 +88,16 @@ const COMPLETION_TIMESTAMP_FIELDS = [ ]; const DEFERRED_VALIDATION_CONCURRENCY = 16; -export async function validateWorkspace(input = process.cwd()) { +export async function validateWorkspace(input = process.cwd(), options = {}) { const timingStarted = performance.now(); try { - return await withAssessmentCache(() => validateWorkspaceUnmeasured(input)); + return await withAssessmentCache(() => validateWorkspaceUnmeasured(input, options)); } finally { recordTiming("validation", performance.now() - timingStarted); } } -async function validateWorkspaceUnmeasured(input) { +async function validateWorkspaceUnmeasured(input, options) { const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input); const diagnostics = [...loaded.diagnostics]; diagnostics.push(...workflowSourceRuntimeIssues(loaded)); @@ -314,9 +315,10 @@ async function validateWorkspaceUnmeasured(input) { } if (modelSupports(loaded.model, "reporting-route-sets")) { validateReportingRouteSets(loaded, byId, pathById, diagnostics); - for (const issue of await reportingCutoverIssues(loaded, loaded.resources.filter((record) => ["active", "open", "monitoring"].includes(record.status)))) { + for (const issue of await reportingCutoverIssues(loaded, loaded.resources.filter((record) => ["active", "open", "monitoring"].includes(record.status)), { includeHistoryContext: true })) { const source = byId.get(issue.sourceId); - diagnostics.push((contentCutoverChanged(loaded, source, pathById.get(source?.id)) || historicalContentCutover(loaded, source) ? error : warning)(issue.code, pathById.get(issue.sourceId), issue.message)); + const diagnostic = (contentCutoverChanged(loaded, source, pathById.get(source?.id)) || historicalContentCutover(loaded, source) ? error : warning)(issue.code, pathById.get(issue.sourceId), issue.message); + diagnostics.push({ ...diagnostic, programId: issue.programId, ...(issue.temporal ? { temporal: issue.temporal } : {}) }); } } if (modelSupports(loaded.model, "schedule-set-review")) { @@ -380,7 +382,7 @@ async function validateWorkspaceUnmeasured(input) { value: reconciliation, enumerable: false }); - return result; + return reclassifyTemporalValidation(result, loaded, options); } async function runDeferredValidationTasks(tasks) { diff --git a/packages/filegrc/src/workflow.js b/packages/filegrc/src/workflow.js index 04ab963..fec2e2b 100644 --- a/packages/filegrc/src/workflow.js +++ b/packages/filegrc/src/workflow.js @@ -1,3 +1,5 @@ +import { reclassifyTemporalValidation } from "./temporal-diagnostics.js"; +import { governedContentCoversPeriod, governedApprovalContentMatches } from "./governed-content-baseline.js"; import { assessReportingRoutePeriod } from "./reporting-route-sets.js"; import { assessPeriodImpact } from "./period-impact.js"; import { markdownEntries } from "./resource-markdown.js"; @@ -103,13 +105,12 @@ async function assessWorkflowUnmeasured(input, options = {}) { includeComplete: Boolean(options.includeComplete), model: loaded.model }); - const validation = options.validation || await validateWorkspace(loaded); + const rawValidation = options.validation || await validateWorkspace(loaded); const reconciliation = options.reconciliation || await planReconciliation(loaded); const coverage = normalizeCoverage( - options.coverage - || (options.auditId ? audits[0]?.coverage : null) - || programRecord?.candidateCoverage + (options.auditId ? audits[0]?.coverage : options.coverage || programRecord?.candidateCoverage) ); + const validation = reclassifyTemporalValidation(rawValidation, loaded, { programId: programRecord.id, auditId: options.auditId, coverage }); const periodFindings = await assessPeriodHealth(loaded, { coverage, asOf, @@ -129,7 +130,7 @@ async function assessWorkflowUnmeasured(input, options = {}) { ...await auditLifecycleFindings(loaded, audits, programRecord) ]; const findings = [ - ...validationFindings(validation, loaded, program, reconciliation), + ...validationFindings(validation, loaded, program, reconciliation, programRecord.id), ...removeRedundantRecordFindings(recordFinalizationFindings(loaded, programRecord, asOf, validation.ok), guidedFindings), ...guidedFindings ]; @@ -376,19 +377,19 @@ function auditFindings(preparation) { })); } -function validationFindings(validation, loaded, program, reconciliation) { - const currentChannels = program.stages.flatMap(stage => stage.items || []).some(item => item.id === "reporting-channel-cutover" && item.status === "complete"); +function validationFindings(validation, loaded, program, reconciliation, programId) { const entriesByPath = new Map(loaded.entries.map((entry) => [ `data/${entry.relativePath}`, entry.record ])); return (validation?.diagnostics || []).filter(diagnostic => diagnostic.path !== ".filegrc/hosted-automation.json") + .filter(diagnostic => !(diagnostic.programId || diagnostic.temporal?.programId) || diagnostic.severity === "error" || (diagnostic.programId || diagnostic.temporal?.programId) === programId) .filter(diagnostic => diagnostic.code !== "unreconciled-committed-transition" || !reconciliation?.candidates?.some(candidate => candidate.sourcePath === diagnostic.path && candidate.committedRevision)) .map((diagnostic) => { const record = entriesByPath.get(diagnostic.path); - const historicalChannelGap = diagnostic.code === "uncovered-reporting-route-interval" && diagnostic.severity === "warning" && currentChannels; + const historicalInformation = diagnostic.severity === "info"; const historicalFormatContext = diagnostic.code === "historical-source-runtime-adoption" && diagnostic.severity === "warning"; - const state = historicalChannelGap || historicalFormatContext ? "complete" : "ready"; + const state = historicalInformation || historicalFormatContext ? "complete" : "ready"; const code = `structural.${diagnostic.code}.${record?.id || pathKey(diagnostic.path)}.${stableSuffix(diagnostic.message)}`; return { key: code, @@ -399,11 +400,13 @@ function validationFindings(validation, loaded, program, reconciliation) { severity: diagnostic.severity, requiredness: "required", title: record?.title || "Workspace structure", - message: historicalChannelGap ? `${diagnostic.message} This historical gap remains disclosed; do not backdate approval. Current channels are operating, and period coverage is checked separately.` : diagnostic.message, + message: diagnostic.message, + ...(diagnostic.temporal ? { temporal: diagnostic.temporal } : {}), + ...(diagnostic.assessmentCoverage ? { assessmentCoverage: diagnostic.assessmentCoverage } : {}), fieldPath: diagnostic.path, ...(record ? { subject: { type: record.type, id: record.id } } : {}), dependencies: [], - actions: historicalChannelGap || historicalFormatContext ? [] : record + actions: historicalInformation || historicalFormatContext ? [] : record ? [mutationAction(record)] : [{ kind: "command", command: "npx filegrc validate --json" }] }; @@ -872,9 +875,11 @@ async function assessPeriodHealth(loaded, options) { const historical = ["retired", "superseded"].includes(record.status); const changedOn = record.statusTransition?.changedOn; if (historical && changedOn && changedOn <= coverage.start) continue; - if (record.status !== "active" || !record.effectiveOn || record.effectiveOn > coverage.start) { - findings.push(periodGap(record, "policy", record.effectiveOn, coverage)); - } + const covered = modelSupports(loaded.model, "operating-preparation") + ? governedContentCoversPeriod(record, coverage.start, [coverage.end, options.asOf].sort()[0], recordById, loaded.model, + { contentMatches: (policy) => governedApprovalContentMatches(loaded, policy) }) + : record.status === "active" && record.effectiveOn && record.effectiveOn <= coverage.start; + if (!covered) findings.push(periodGap(record, "policy", record.effectiveOn, coverage)); } if ( record.type === "control" @@ -909,7 +914,7 @@ async function assessPeriodHealth(loaded, options) { const routePeriod = await assessReportingRoutePeriod(loaded, { programId: target.id, start: coverage.start, end: periodThrough, timezone: loaded.workspace.timezone || "UTC" }); for (const issue of routePeriod.issues) { const source = recordById.get(issue.resourceId); - findings.push(periodFinding(`period.reporting-channel.${issue.code}.${issue.resourceId}.${stableSuffix(issue.message)}`, "Review reporting-channel period coverage", `${issue.message} Preserve the actual gap and approvals; record its audit impact or an allowed Exception rather than backdating.`, "ready", source ? { type: source.type, id: source.id } : { type: target.type, id: target.id }, source ? [mutationAction(source)] : [])); + findings.push(periodFinding(`period.reporting-channel.${issue.code}.${issue.resourceId}.${stableSuffix(issue.message)}`, "Review reporting-channel period coverage", `${issue.message} Review the available authorization evidence for this interval and preserve actual approvals and cutovers. Do not infer an operating failure from an approval date or backdate the record.`, "ready", source ? { type: source.type, id: source.id } : { type: target.type, id: target.id }, source ? [mutationAction(source)] : [])); } } const periodResources = loaded.resources.filter((record) => ( diff --git a/packages/filegrc/test/appointment.test.js b/packages/filegrc/test/appointment.test.js index ff75b84..b2f6f37 100644 --- a/packages/filegrc/test/appointment.test.js +++ b/packages/filegrc/test/appointment.test.js @@ -23,7 +23,7 @@ test("creates and validates a scoped active appointment", async (context) => { startsOn: "2026-08-02" }); - assert.deepEqual((await validateWorkspace(root)).counts, { resources: 4, errors: 0, warnings: 0 }); + assert.deepEqual((await validateWorkspace(root)).counts, { resources: 4, errors: 0, warnings: 0, information: 0 }); assert.equal( JSON.parse(await readFile(join(root, "data", "appointments", "appointment-policy-owner.json"), "utf8")).holderId, "person-owner" diff --git a/packages/filegrc/test/governed-content-baseline.test.js b/packages/filegrc/test/governed-content-baseline.test.js new file mode 100644 index 0000000..beeaea3 --- /dev/null +++ b/packages/filegrc/test/governed-content-baseline.test.js @@ -0,0 +1,193 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { loadModel } from "../model/index.js"; +import { calculateRevision } from "../src/revisions.js"; +import { governedContentBaseline, governedContentCoversPeriod, pendingGovernedSuccessor } from "../src/governed-content-baseline.js"; +import { obligationProgramStatus } from "../src/program-lifecycle.js"; +import { obligationScheduleRecurrence, planObligations } from "../src/obligations.js"; + +const model = loadModel("13"); +const owner = { id: "person-owner", type: "person", status: "active" }; +const reviewer = { id: "person-reviewer", type: "person", status: "active" }; +const control = { id: "control-example", type: "control", status: "implemented" }; +const revision = calculateRevision("content", "The approved operating procedure."); +function content(type, id = `${type}-baseline`) { + return { id, type, title: id, status: "active", ownerIds: [owner.id], approverIds: [reviewer.id], + programRole: "required", controlIds: [control.id], ...(type === "document" ? { workflowScope: "program" } : {}), + approvedOn: "2026-01-01", effectiveOn: "2026-01-02", approvedContentRevisions: { [`${id}.md`]: revision }, + ...(type === "policy" ? {} : { activationBasis: "recorded", activatedOn: "2026-01-02", activatedByIds: [owner.id], activatedContentRevisions: { [`${id}.md`]: revision } }) }; +} +function index(...records) { return new Map([owner, reviewer, control, ...records].map((record) => [record.id, record])); } +const obligation = { id: "obligation-example", type: "obligation", status: "active", title: "Review program access", activityType: "access-review", + ownerIds: [owner.id], controlIds: [control.id], recurrence: { mode: "calendar", interval: 1, unit: "month", anchorMode: "activation" } }; + +for (const type of ["policy", "document", "training"]) { + test(`${type} proposals preserve the authorized operating baseline until cutover`, () => { + const prior = content(type); + for (const status of ["draft", "in-review", "approved", "active"]) { + const next = { ...content(type, `${type}-successor`), status, supersedesId: prior.id, + approvedOn: "2026-06-01", effectiveOn: status === "active" ? "2026-07-01" : undefined, + ...(type === "policy" ? {} : { activatedOn: status === "active" ? "2026-07-01" : undefined }) }; + const byId = index(prior, next); + assert.equal(governedContentBaseline(next, byId, "2026-06-15", model)?.id, prior.id); + assert.equal(pendingGovernedSuccessor(next, byId, "2026-06-15", model)?.id, prior.id); + assert.equal(governedContentCoversPeriod(next, "2026-01-02", "2026-06-15", byId, model), true); + const task = { ...obligation, ...(type === "policy" ? { policyIds: [next.id] } : type === "training" ? { templateResourceId: next.id } : {}) }; + assert.equal(obligationProgramStatus(task, byId, "2026-06-15", model), "accepted"); + const planned = obligationScheduleRecurrence(task, null, byId, model, "UTC", new Set(), "2026-06-15"); + assert.equal(planned.activationDate, "2026-01-02"); + assert.equal(planned.recurrence.anchorDate, "2026-01-02"); + } + }); + + test(`${type} actual cutover requires continuous authorization and exact activation proof`, () => { + const prior = { ...content(type), status: "superseded", statusTransition: { changedOn: "2026-07-01" } }; + const next = { ...content(type, `${type}-successor`), supersedesId: prior.id, + approvedOn: "2026-06-01", effectiveOn: "2026-07-01", ...(type === "policy" ? {} : { activatedOn: "2026-07-01" }) }; + assert.equal(governedContentCoversPeriod(next, "2026-01-02", "2026-07-10", index(prior, next), model), true); + assert.equal(governedContentCoversPeriod(prior, "2026-01-02", "2026-07-10", index(prior, next), model), true); + assert.equal(governedContentBaseline(prior, index(prior, next), "2026-07-01", model)?.id, next.id); + const task = { ...obligation, ...(type === "policy" ? { policyIds: [next.id] } : type === "training" ? { templateResourceId: next.id } : {}) }; + assert.equal(obligationScheduleRecurrence(task, null, index(prior, next), model, "UTC", new Set(), "2026-07-10").activationDate, "2026-07-01"); + assert.equal(governedContentCoversPeriod(next, "2026-01-02", "2026-07-10", index({ ...prior, statusTransition: { changedOn: "2026-06-30" } }, next), model), false); + for (const invalid of [ + { ...next, approvedOn: "2026-07-02" }, + { ...next, approvedContentRevisions: {} }, + { ...next, approverIds: [owner.id] }, + ...(type === "policy" ? [] : [{ ...next, activatedOn: undefined }, { ...next, activatedContentRevisions: { [`${next.id}.md`]: calculateRevision("content", "Unreviewed change") } }]) + ]) { + assert.equal(governedContentBaseline(invalid, index(prior, invalid), "2026-07-10", model), null); + assert.equal(governedContentCoversPeriod(invalid, "2026-01-02", "2026-07-10", index(prior, invalid), model), false); + } + }); + + test(`${type} pending successor cannot replace missing, retired or stale prior proof`, () => { + const prior = content(type); + const next = { ...content(type, `${type}-successor`), status: "approved", supersedesId: prior.id }; + assert.equal(governedContentBaseline(next, index(next), "2026-06-15", model), null); + assert.equal(governedContentBaseline(next, index({ ...prior, status: "retired", statusTransition: { changedOn: "2026-06-01" } }, next), "2026-06-15", model), null); + assert.equal(governedContentBaseline(next, index(prior, next), "2026-06-15", model, { contentMatches: () => false }), null); + assert.equal(governedContentCoversPeriod(next, "2026-01-01", "2026-06-15", index(prior, next), model), false); + }); +} + +test("required Document successor does not pause or shift an existing recurring obligation", () => { + const prior = content("document"); + const next = { ...content("document", "document-successor"), status: "approved", supersedesId: prior.id, + approvedOn: "2026-06-01", effectiveOn: "2026-07-01", activatedOn: undefined }; + const resources = [...index(prior, next).values(), obligation]; + const options = { model, asOf: "2026-06-15", from: "2026-06-01", through: "2026-06-30", includeComplete: true }; + const baseline = planObligations(resources.filter(({ id }) => id !== next.id), options); + const proposed = planObligations(resources, options); + assert.deepEqual(proposed.items, baseline.items); + assert.equal(proposed.programStatuses[obligation.id], "accepted"); + assert.equal(obligationProgramStatus(obligation, index(next), "2026-06-15", model), "proposed"); +}); + +test("readiness keeps governed successor review visible without replacing the active baseline", async (context) => { + const { mkdtemp, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { makeComprehensiveWorkspace } = await import("./fixtures.js"); + const { loadWorkspace } = await import("../src/workspace.js"); + const { assessProgramReadiness } = await import("../src/program-readiness.js"); + const root = await mkdtemp(join(tmpdir(), "filegrc-pending-baseline-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "13"); + const loaded = await loadWorkspace(root); + const successors = ["document", "training"].map((type) => { + const prior = loaded.resources.find((record) => record.type === type); + return { ...prior, id: `${type}-successor`, status: "approved", programRole: "required", supersedesId: prior.id, + approvedOn: "2026-07-01", effectiveOn: undefined, activatedOn: undefined, activatedContentRevisions: undefined }; + }); + loaded.resources.push(...successors); + loaded.resources.push({ ...obligation, id: "obligation-training-successor", activityType: "training", + ownerIds: ["person-example"], controlIds: ["control-example"], templateResourceId: "training-successor", + recurrence: { mode: "calendar", interval: 1, unit: "month", anchorDate: "2026-06-30" } }); + const readiness = await assessProgramReadiness(loaded, { asOf: "2026-07-15" }); + const priorTraining = readiness.stages.flatMap(({ items }) => items).find(({ id }) => id === "training-training-example"); + assert.equal(priorTraining.checks.assignmentScheduled, true); + for (const successor of successors) { + const items = readiness.stages.flatMap(({ items }) => items).filter(({ resourceId }) => resourceId === successor.id); + assert.ok(items.length > 0); + assert.ok(items.every(({ status, operatingBaselineId }) => status === "info" && operatingBaselineId === successor.supersedesId)); + } + const firstRequired = successors[0]; + delete firstRequired.supersedesId; + const missingBaseline = await assessProgramReadiness(loaded, { asOf: "2026-07-15" }); + assert.ok(missingBaseline.stages.flatMap(({ items }) => items).some(({ resourceId, status }) => resourceId === firstRequired.id && status !== "info")); +}); + +test("workforce baseline retains existing acknowledgements until successor adoption", async (context) => { + const { mkdtemp, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { makeComprehensiveWorkspace } = await import("./fixtures.js"); + const { loadWorkspace } = await import("../src/workspace.js"); + const { assessProgramBaseline } = await import("../src/operating-baseline.js"); + const root = await mkdtemp(join(tmpdir(), "filegrc-workforce-baseline-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "13"); + const loaded = await loadWorkspace(root); + const program = loaded.resources.find(({ type }) => type === "program"); + const selected = loaded.resources.find(({ type }) => type === "control"); + const policy = loaded.resources.find(({ type }) => type === "policy"); + const successor = { ...policy, id: "policy-successor", status: "approved", supersedesId: policy.id, + approvedOn: "2026-07-01" }; + delete successor.effectiveOn; + loaded.model.operatingPreparation.baselineProfiles = [{ id: "workforce-policy", resourceType: "attestation", activities: [], controlCodes: [selected.code] }]; + const check = (_id, _title, complete, _work, _acceptance, _record, _type, details) => ({ complete, ...details }); + const before = await assessProgramBaseline(loaded, program, [selected], "2026-07-15", check); + assert.deepEqual(before[0].subjectIds, [policy.id]); + loaded.resources.push(successor); + selected.policyIds.push(successor.id); + const pending = await assessProgramBaseline(loaded, program, [selected], "2026-07-15", check); + assert.deepEqual(pending[0].subjectIds, [policy.id]); + assert.deepEqual(pending[0].missingMembers, before[0].missingMembers); + delete successor.supersedesId; + const firstRequired = await assessProgramBaseline(loaded, program, [selected], "2026-07-15", check); + assert.ok(firstRequired[0].subjectIds.includes(successor.id)); + assert.ok(firstRequired[0].missingMembers.some(({ subjectId }) => subjectId === successor.id)); + successor.supersedesId = policy.id; + successor.status = "active"; + successor.effectiveOn = "2026-07-10"; + const { readFile, writeFile } = await import("node:fs/promises"); + const body = await readFile(join(root, "data", "policies", `${policy.id}.md`), "utf8"); + await writeFile(join(root, "data", "policies", `${successor.id}.md`), body); + successor.approvedContentRevisions = { [`policies/${successor.id}.md`]: calculateRevision("content", body) }; + const adopted = await assessProgramBaseline(loaded, program, [selected], "2026-07-15", check); + assert.deepEqual(adopted[0].subjectIds, [successor.id]); + assert.ok(adopted[0].missingMembers.some(({ subjectId }) => subjectId === successor.id)); +}); + +test("a pending successor cannot borrow authorization for newly expanded governing scope", () => { + for (const type of ["policy", "document", "training"]) { + const prior = content(type); + const next = { ...content(type, `${type}-expanded`), status: "approved", supersedesId: prior.id, + controlIds: [control.id, "control-new-scope"] }; + assert.equal(governedContentBaseline(next, index(prior, next), "2026-06-15", model), null); + assert.equal(governedContentBaseline(prior, index(prior, next), "2026-06-15", model)?.id, prior.id); + assert.equal(pendingGovernedSuccessor(next, index(prior, next), "2026-06-15", model), null); + } +}); + +test("recording existing Policy-governed work keeps approval findings separate without bypassing successor cutovers", () => { + const prior = { ...content("policy"), approvedOn: "2026-06-01", effectiveOn: "2025-10-23" }; + const task = { ...obligation, policyIds: [prior.id] }; + assert.equal(governedContentBaseline(prior, index(prior), "2025-10-27", model), null, + "an approval after the claimed effective date remains an authorization gap"); + assert.equal(obligationProgramStatus(task, index(prior), "2025-10-27", model), "accepted", + "actual historical work under an existing active Policy stays recordable"); + assert.equal(governedContentCoversPeriod(prior, "2025-10-23", "2025-10-27", index(prior), model), false, + "recording work does not erase the strict period authorization gap"); + const planned = planObligations([...index(prior).values(), task], { model, asOf: "2025-10-27", through: "2025-10-27", includeComplete: true }); + assert.ok(planned.calendarItems.some((item) => item.programStatus === "accepted" && item.dueWindowStart <= "2025-10-27" && item.dueWindowEnd >= "2025-10-27")); + const pending = { ...content("policy", "policy-successor"), status: "approved", supersedesId: prior.id }; + assert.equal(obligationProgramStatus({ ...task, policyIds: [pending.id] }, index(prior, pending), "2026-06-15", model), "proposed", + "a pending successor cannot substitute an invalid predecessor"); + const validPrior = content("policy"); + const invalidCutover = { ...content("policy", "policy-successor"), supersedesId: validPrior.id, + approvedOn: "2026-07-02", effectiveOn: "2026-07-01" }; + assert.equal(obligationProgramStatus({ ...task, policyIds: [validPrior.id] }, index(validPrior, invalidCutover), "2026-07-10", model), "proposed", + "an actual invalid successor cutover cannot fall back to standalone eligibility"); +}); diff --git a/packages/filegrc/test/model-migration.test.js b/packages/filegrc/test/model-migration.test.js index 2592dea..9d6b016 100644 --- a/packages/filegrc/test/model-migration.test.js +++ b/packages/filegrc/test/model-migration.test.js @@ -308,7 +308,7 @@ test("previews and atomically migrates every model v1 compatibility field", asyn assert.ok(records.get(id)[field].includes(preview.changes.create[0].id)); assert.ok(!records.get(id)[field].includes("person-example")); } - assert.deepEqual((await validateWorkspace(root)).counts, { resources: 45, errors: 0, warnings: 0 }); + assert.deepEqual((await validateWorkspace(root)).counts, { resources: 45, errors: 0, warnings: 0, information: 0 }); const noop = await migrateModel(root, { targetModelVersion: "2" }); assert.equal(noop.applied, false); @@ -410,7 +410,7 @@ test("classifies and applies the v2-to-v3 migration without inventing decisions" + migrated.model.evidenceSourceFamilies.length + Object.keys(migrated.model.collectionReviews).length, errors: 0, - warnings: 0 + warnings: 0, information: 0 }); assert.equal(result.postMigrationAssessment.contractVersion, 1); }); diff --git a/packages/filegrc/test/reporting-route-sets.test.js b/packages/filegrc/test/reporting-route-sets.test.js index 8982b65..0befc7d 100644 --- a/packages/filegrc/test/reporting-route-sets.test.js +++ b/packages/filegrc/test/reporting-route-sets.test.js @@ -1045,6 +1045,26 @@ test("current readiness accepts repaired legacy reporting coverage while activat assert.equal(early.stages.flatMap(({ items }) => items).find(({ id }) => id === "reporting-channel-cutover").status, "action"); }); +test("historical missing authorization has a bounded evidence interval without inventing channel failure", async (context) => { + const { root, route } = await cloneApprovedRouteWorkspace(context, "filegrc-temporal-authorization-"); + const loaded = await loadWorkspace(root); + loaded.model = loadModel("13"); + const policy = loaded.resources.find(record => record.id === "policy-example"); + const issues = await reportingCutoverIssues(loaded, [policy], { includeHistoryContext: true }); + const missing = issues.find(issue => issue.code === "uncovered-reporting-route-interval"); + assert.ok(missing); + assert.match(missing.message, /authorization-evidence limitation/); + assert.match(missing.message, /not proof.*unavailable/); + assert.equal(missing.temporal.resolved, true); + assert.equal(missing.temporal.evidenceLimitation, true); + assert.equal(missing.temporal.endsAt, new Date(route.approval.effectiveAt).toISOString()); + const absent = { ...loaded, resources: loaded.resources.filter(record => record.id !== route.id) }; + const unresolved = await reportingCutoverIssues(absent, [policy], { includeHistoryContext: true }); + assert.equal(unresolved.find(issue => issue.code === missing.code).temporal, undefined); + const after = await assessReportingRoutePeriod(loaded, { programId: route.programId, start: "2021-09-01", end: "2021-09-02" }); + assert.equal(after.issues.some(issue => issue.code === missing.code), false); +}); + test("rejects unsupported backfilled route decisions", async (context) => { const { root, byId, route } = await cloneApprovedRouteWorkspace(context, "filegrc-route-decisions-"); await git(root, "switch", "-c", "late-route-requirement"); diff --git a/packages/filegrc/test/retention-operating-baseline.test.js b/packages/filegrc/test/retention-operating-baseline.test.js new file mode 100644 index 0000000..3944739 --- /dev/null +++ b/packages/filegrc/test/retention-operating-baseline.test.js @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { makeRetentionApplicationWorkspace } from "./fixtures/retention-applications.js"; +import { commitWorkspaceFiles, writeJson } from "./helpers.js"; +import { loadWorkspace } from "../src/workspace.js"; +import { reviewHistoryContext } from "../src/historical-workspace.js"; +import { currentCalendarDate } from "../src/time.js"; +import { calculateRevision } from "../src/revisions.js"; +import { assessCollectionReview, retentionScheduleIsAuthoritative } from "../src/collection-review.js"; +import { resourceReviewRevisionsSync, retentionReviewResourceIds } from "../src/retention.js"; +import { collectionRevision } from "../src/collection-revision.js"; +import { operatingRetentionScheduleWorkspace } from "../src/retention-schedule-approval.js"; + +async function fixture(context) { + const root = await mkdtemp(join(tmpdir(), "filegrc-retention-baseline-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeRetentionApplicationWorkspace(root); + let loaded = await loadWorkspace(root); + const schedule = loaded.entries.find(({ record }) => record.type === "document" && record.documentKind === "schedule"); + const today = currentCalendarDate(loaded.workspace.timezone); + await writeJson(schedule.path, { ...schedule.record, status: "active", activationBasis: "recorded", activatedOn: today, + effectiveOn: today, activatedByIds: ["person-example"], activatedContentRevisions: schedule.record.approvedContentRevisions }); + await commitWorkspaceFiles(root, "Activate reviewed retention schedule"); + loaded = await loadWorkspace(root); + assert.equal(retentionScheduleIsAuthoritative(loaded, loaded.resources.find(({ type }) => type === "program")), true); + return { root, loaded, today }; +} + +test("pending schedule and row successors preserve the exact operating schedule authority", async (context) => { + const { root, loaded, today } = await fixture(context); + const schedule = loaded.entries.find(({ record }) => record.type === "document" && record.documentKind === "schedule"); + const row = loaded.entries.find(({ record }) => record.type === "retention-schedule-item"); + const successorPath = join(root, "data/documents/document-retention-successor.json"); + const proposed = { ...schedule.record, id: "document-retention-successor", title: "Next retention schedule", status: "draft", supersedesId: schedule.record.id }; + await writeJson(successorPath, proposed); + const rowPath = join(dirname(row.path), "retention-proposal.json"); + await writeJson(rowPath, { ...row.record, id: "retention-proposal", status: "planned", scheduleDocumentId: proposed.id }); + for (const status of ["draft", "approved"]) { + await writeJson(successorPath, { ...proposed, status }); + const current = await loadWorkspace(root); + const program = current.resources.find(({ type }) => type === "program"); + assert.equal(assessCollectionReview(current, "retention-schedule-item").complete, false, "successor review work stays visible"); + assert.equal(retentionScheduleIsAuthoritative(current, program), true); + const operational = operatingRetentionScheduleWorkspace(current); + assert.deepEqual(operational.resources.filter(({ type }) => type === "retention-schedule-item").map(({ id }) => id), [row.record.id]); + } + await writeJson(row.path, { ...row.record, retentionPeriod: { basis: "fixed", amount: 9, unit: "year" } }); + let current = await loadWorkspace(root); + assert.equal(retentionScheduleIsAuthoritative(current, current.resources.find(({ type }) => type === "program")), false, "actual changed active row must retain its approval gap"); + await writeJson(row.path, { ...row.record, scheduleDocumentId: proposed.id }); + current = await loadWorkspace(root); + assert.equal(retentionScheduleIsAuthoritative(current, current.resources.find(({ type }) => type === "program")), false, "changing an active row's schedule link cannot reuse its prior approval"); + await writeJson(row.path, row.record); + await writeFile(schedule.path.replace(/\.json$/, ".md"), "# Unapproved retention change\n\nDelete all retained proof after one day without checking holds.\n"); + current = await loadWorkspace(root); + assert.equal(retentionScheduleIsAuthoritative(current, current.resources.find(({ type }) => type === "program")), false, "stale active schedule content cannot support the proposal"); +}); + +test("retention source review uses the proven same-ID operating revision until actual cutover", async (context) => { + const { root, loaded, today } = await fixture(context); + const source = loaded.entries.find(({ record }) => record.type === "policy"); + const before = resourceReviewRevisionsSync(loaded, [source.record.id]).get(source.record.id); + const markdownPath = source.path.replace(/\.json$/, ".md"); + const body = `${await readFile(markdownPath, "utf8")}\nA proposed new retention duty requires management review before adoption.\n`; + await writeFile(markdownPath, body); + const approved = { ...source.record, status: "approved", approvedOn: today, + approvedContentRevisions: { [`policies/${source.record.id}.md`]: calculateRevision("content", body) } }; + await writeJson(source.path, approved); + await commitWorkspaceFiles(root, "Approve proposed policy revision"); + let current = await loadWorkspace(root); + assert.equal(resourceReviewRevisionsSync(current, [source.record.id]).get(source.record.id), before); + const fallback = await loadWorkspace(root); + const history = reviewHistoryContext(root, fallback); + history.index = null; + history.statesByCommit.clear(); history.workspacesByCommit.clear(); + assert.equal(resourceReviewRevisionsSync(fallback, [source.record.id]).get(source.record.id), before, + "history index fallback reads the committed operating Markdown, not the current proposal"); + await writeJson(source.path, { ...approved, status: "active", effectiveOn: today }); + current = await loadWorkspace(root); + const adoptedRevision = resourceReviewRevisionsSync(current, [source.record.id]).get(source.record.id); + assert.notEqual(adoptedRevision, before); + await commitWorkspaceFiles(root, "Activate reviewed policy revision"); + await writeJson(source.path, { ...approved, status: "retired", effectiveOn: today, + statusTransition: { changedOn: today, changedByIds: ["person-example"], reason: "Policy was withdrawn." } }); + await commitWorkspaceFiles(root, "Retire policy revision"); + const laterBody = `${body}\nA later proposed replacement needs its own operational cutover.\n`; + await writeFile(markdownPath, laterBody); + await writeJson(source.path, { ...approved, approvedContentRevisions: { [`policies/${source.record.id}.md`]: calculateRevision("content", laterBody) } }); + current = await loadWorkspace(root); + assert.notEqual(resourceReviewRevisionsSync(current, [source.record.id]).get(source.record.id), adoptedRevision, "a retired baseline cannot be restored by a later approval"); +}); + +test("actual schedule cutover needs its own exact row approval and selects only the effective schedule", async (context) => { + const { root, loaded, today } = await fixture(context); + const schedule = loaded.entries.find(({ record }) => record.type === "document" && record.documentKind === "schedule"); + const row = loaded.entries.find(({ record }) => record.type === "retention-schedule-item"); + const review = loaded.entries.find(({ record }) => record.type === "collection-review" && record.resourceType === "retention-schedule-item" && record.status === "active"); + const next = { ...schedule.record, id: "document-retention-next", supersedesId: schedule.record.id }; + const body = await readFile(schedule.path.replace(/\.json$/, ".md"), "utf8"); + const bindings = { [`documents/${next.id}.md`]: calculateRevision("content", body) }; + next.approvedContentRevisions = bindings; + next.activatedContentRevisions = bindings; + await writeJson(join(dirname(schedule.path), `${next.id}.json`), next); + await writeFile(join(dirname(schedule.path), `${next.id}.md`), body); + await writeJson(schedule.path, { ...schedule.record, status: "superseded", statusTransition: { changedOn: today, changedByIds: ["person-example"], reason: "Successor took effect." } }); + await writeJson(row.path, { ...row.record, status: "retired" }); + const nextRow = { ...row.record, id: "retention-schedule-item-next", scheduleDocumentId: next.id }; + const nextRowPath = join(dirname(row.path), `${nextRow.id}.json`); + await writeJson(nextRowPath, nextRow); + let current = await loadWorkspace(root); + nextRow.reviewedSourceRevisions = Object.fromEntries(resourceReviewRevisionsSync(current, retentionReviewResourceIds(nextRow, current), "legacy", false, null, nextRow)); + await writeJson(nextRowPath, nextRow); + current = await loadWorkspace(root); + const program = current.resources.find(({ type }) => type === "program"); + assert.equal(retentionScheduleIsAuthoritative(current, program), false, "activation alone cannot approve new retention rows"); + await writeJson(review.path, { ...review.record, status: "retired" }); + current = await loadWorkspace(root); + const approved = { ...review.record, id: "collection-review-retention-next", supersedesId: review.record.id, + populationResourceIds: [nextRow.id], collectionRevision: collectionRevision(operatingRetentionScheduleWorkspace(current), "retention-schedule-item", { programId: program.id }) }; + await writeJson(join(dirname(review.path), `${approved.id}.json`), approved); + current = await loadWorkspace(root); + const projected = operatingRetentionScheduleWorkspace(current); + assert.deepEqual(projected.resources.filter((record) => record.type === "document" && record.documentKind === "schedule").map(({ id }) => id), [next.id]); + const assessment = assessCollectionReview(projected, "retention-schedule-item"); + assert.equal(assessment.complete, true, assessment.message); + assert.equal(retentionScheduleIsAuthoritative(current, program), true); +}); diff --git a/packages/filegrc/test/temporal-diagnostics.test.js b/packages/filegrc/test/temporal-diagnostics.test.js new file mode 100644 index 0000000..c73ba64 --- /dev/null +++ b/packages/filegrc/test/temporal-diagnostics.test.js @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { classifyTemporalDiagnostic, resolveAssessmentCoverage, reclassifyTemporalValidation } from "../src/temporal-diagnostics.js"; + +function fixture() { + return { workspace: { timezone: "America/Chicago" }, resources: [ + { type: "program", id: "a", candidateCoverage: { kind: "range", startsOn: "2026-10-01", endsOn: "2026-12-31" } }, + { type: "program", id: "b", candidateCoverage: { kind: "range", startsOn: "2026-08-01", endsOn: "2026-12-31" } }, + { type: "audit", id: "audit-a", programId: "a", coverage: { kind: "range", startsOn: "2026-08-01", endsOn: "2026-09-30" } }, + { type: "audit", id: "type-one", programId: "a", coverage: { kind: "as-of", on: "2026-10-01" } } + ] }; +} +function diagnostic(overrides = {}) { + return { code: "approval-evidence-unproven", severity: "warning", message: "Earlier authorization is unproven.", + temporal: { programId: "a", startsAt: "2026-08-01T00:00:00-05:00", endsAt: "2026-09-01T00:00:00-05:00", resolved: true, evidenceLimitation: true, ...overrides } }; +} + +test("resolved preperiod evidence limitations are retained as information", () => { + const source = diagnostic(), result = classifyTemporalDiagnostic(source, fixture()); + assert.equal(result.severity, "info"); + assert.equal(result.assessmentCoverage.programId, "a"); + assert.equal(result.assessmentCoverage.basis, "candidate"); + assert.match(result.message, /Retained historical context/); + assert.match(result.message, /does not establish an operating violation/); + assert.equal(source.severity, "warning"); +}); + +test("overlapping, unresolved, and non-opted-in errors remain actionable", () => { + for (const source of [diagnostic({ endsAt: "2026-10-02T00:00:00-05:00" }), diagnostic({ resolved: false }), + diagnostic({ evidenceLimitation: false }), { ...diagnostic(), severity: "error" }, { code: "structural-error", severity: "error" }]) { + assert.equal(classifyTemporalDiagnostic(source, fixture()), source); + } +}); + +test("exclusive interval end at local coverage midnight is historical", () => { + assert.equal(classifyTemporalDiagnostic(diagnostic({ endsAt: "2026-10-01T05:00:00Z" }), fixture()).severity, "info"); + const overlap = diagnostic({ endsAt: "2026-10-01T05:00:00.001Z" }); + assert.equal(classifyTemporalDiagnostic(overlap, fixture()), overlap); +}); + +test("selected audit coverage wins over explicit and candidate coverage", () => { + const source = diagnostic(), loaded = fixture(); + const options = { auditId: "audit-a", coverage: { kind: "as-of", on: "2026-12-31" } }; + assert.equal(classifyTemporalDiagnostic(source, loaded, options), source); + assert.deepEqual(resolveAssessmentCoverage(loaded, options), { start: "2026-08-01", end: "2026-09-30", kind: "range", basis: "audit", programId: "a", auditId: "audit-a" }); + assert.equal(classifyTemporalDiagnostic(source, loaded, { auditId: "type-one" }).severity, "info"); + assert.equal(resolveAssessmentCoverage(loaded, { auditId: "type-one" }).end, "2026-10-01"); +}); + +test("Programs stay isolated and invalid selections never fall back", () => { + const loaded = fixture(); + assert.equal(resolveAssessmentCoverage(loaded), null); + assert.equal(resolveAssessmentCoverage(loaded, { programId: "missing" }), null); + assert.equal(resolveAssessmentCoverage(loaded, { auditId: "missing" }), null); + assert.equal(resolveAssessmentCoverage(loaded, { programId: "b", auditId: "audit-a" }), null); + assert.equal(resolveAssessmentCoverage(loaded, { programId: "b" }).start, "2026-08-01"); + const source = diagnostic(); + assert.equal(classifyTemporalDiagnostic(source, loaded, { programId: "b" }), source); + assert.equal(classifyTemporalDiagnostic(diagnostic({ programId: "b" }), loaded).severity, "warning"); +}); + +test("invalid coverage, timezone, and temporal bounds cannot suppress diagnostics", () => { + for (const coverage of [null, {}, { kind: "range", startsOn: "2026-02-30", endsOn: "2026-12-31" }, + { kind: "range", startsOn: "2026-12-31", endsOn: "2026-01-01" }, { kind: "as-of", on: "invalid" }]) { + const loaded = fixture(); loaded.resources[2].coverage = coverage; + assert.equal(resolveAssessmentCoverage(loaded, { auditId: "audit-a" }), null); + } + for (const endsAt of [null, "invalid", "2026-08-01T00:00:00-05:00", "2026-02-30T00:00:00Z", "2026-09-01"]) { + const source = diagnostic({ endsAt }); assert.equal(classifyTemporalDiagnostic(source, fixture()), source); + } + const loaded = fixture(); loaded.workspace.timezone = "Invalid/Zone"; + assert.equal(resolveAssessmentCoverage(loaded, { programId: "a" }), null); +}); + +test("explicit coverage applies only within a valid selected Program", () => { + assert.deepEqual(resolveAssessmentCoverage(fixture(), { programId: "b", coverage: { kind: "as-of", on: "2026-10-10" } }), + { start: "2026-10-10", end: "2026-10-10", kind: "as-of", basis: "explicit", programId: "b", auditId: null }); +}); + +test("normalized explicit range coverage does not require a kind", () => { + assert.deepEqual(resolveAssessmentCoverage(fixture(), { programId: "a", coverage: { start: "2026-10-01", end: "2026-10-02" } }), + { start: "2026-10-01", end: "2026-10-02", kind: "range", basis: "explicit", programId: "a", auditId: null }); +}); + +test("cached information is reversible across coverage selections without repeated context", () => { + const loaded = fixture(), source = diagnostic(); + const candidate = classifyTemporalDiagnostic(source, loaded); + const audit = classifyTemporalDiagnostic(candidate, loaded, { auditId: "audit-a" }); + assert.equal(audit.severity, "warning"); + assert.equal(audit.message, source.message); + assert.equal(audit.assessmentCoverage, undefined); + assert.equal(audit.temporal.originalSeverity, "warning"); + const candidateAgain = classifyTemporalDiagnostic(audit, loaded); + assert.deepEqual(candidateAgain, candidate); + assert.equal(candidateAgain.message.split("Retained historical context").length, 2); + assert.equal(classifyTemporalDiagnostic(candidate, loaded, { auditId: "missing" }).severity, "warning"); +}); + +test("validation reclassification updates counts and preserves other fields and hidden loaded state", () => { + const loaded = fixture(), structural = { code: "structural", severity: "error" }; + const validation = { ok: false, counts: { resources: 4, errors: 1, warnings: 1 }, diagnostics: [diagnostic(), structural], reconciliation: { candidates: [] } }; + Object.defineProperty(validation, "loaded", { value: loaded }); + const candidate = reclassifyTemporalValidation(validation, loaded); + assert.deepEqual(candidate.counts, { resources: 4, errors: 1, warnings: 0, information: 1 }); + assert.equal(candidate.ok, false); + assert.equal(candidate.loaded, loaded); + assert.equal(candidate.reconciliation, validation.reconciliation); + assert.equal(candidate.diagnostics[1], structural); + const audit = reclassifyTemporalValidation(candidate, loaded, { auditId: "audit-a" }); + assert.deepEqual(audit.counts, { resources: 4, errors: 1, warnings: 1, information: 0 }); + assert.equal(validation.diagnostics[0].severity, "warning"); + assert.equal(reclassifyTemporalValidation({ diagnostics: [diagnostic()], counts: {} }, loaded).ok, true); +}); + + +test("closed subsequent evidence context is informational outside inclusive examination coverage", () => { + const loaded = fixture(); + const source = diagnostic({ startsAt:"2026-10-01T00:00:00-05:00", endsAt:"2026-10-02T00:00:00-05:00" }); + const later = classifyTemporalDiagnostic(source, loaded, { auditId:"audit-a" }); + assert.equal(later.severity,"info"); + assert.equal(later.temporal.relation,"after"); + assert.match(later.message,/Retained subsequent context/); + assert.equal(classifyTemporalDiagnostic(later,loaded,{auditId:"type-one"}).severity,"warning"); + const withinLastDay = diagnostic({startsAt:"2026-09-30T23:59:59-05:00",endsAt:"2026-10-02T00:00:00-05:00"}); + assert.equal(classifyTemporalDiagnostic(withinLastDay,loaded,{auditId:"audit-a"}).severity,"warning"); + const actualFailure = diagnostic({startsAt:"2026-10-01T00:00:00-05:00",endsAt:"2026-10-02T00:00:00-05:00",evidenceLimitation:false}); + assert.equal(classifyTemporalDiagnostic(actualFailure,loaded,{auditId:"audit-a"}).severity,"warning"); +}); + + +test("current errors remain immutable even when carrying cached informational context", () => { + const loaded=fixture(); + const cached=classifyTemporalDiagnostic(diagnostic(),loaded); + const error={...cached,severity:"error",message:"Current committed proof is invalid."}; + assert.equal(classifyTemporalDiagnostic(error,loaded),error); + const validation=reclassifyTemporalValidation({ok:false,diagnostics:[error],counts:{}},loaded); + assert.equal(validation.ok,false); + assert.equal(validation.counts.errors,1); + assert.equal(validation.counts.information,0); + assert.equal(validation.diagnostics[0],error); +}); diff --git a/packages/filegrc/test/workflow.test.js b/packages/filegrc/test/workflow.test.js index 629d1f8..26db9f3 100644 --- a/packages/filegrc/test/workflow.test.js +++ b/packages/filegrc/test/workflow.test.js @@ -887,17 +887,40 @@ test("dated review deadlines agree across workflow, CLI and lazy app/API queue s assert.deepEqual(current, record); }); -test("historical channel warnings stay disclosed without inventing a current approval task", async context => { +test("resolved preperiod authorization information stays disclosed and overlapping coverage remains actionable", async context => { const root = await mkdtemp(join(tmpdir(), "filegrc-historical-channel-guidance-")); context.after(() => import("node:fs/promises").then(({rm}) => rm(root, {recursive:true,force:true}))); await makeComprehensiveWorkspace(root, "13"); const loaded = await loadWorkspace(root), record = loaded.resources.find(record => record.type === "policy"); const entry = loaded.entries.find(entry => entry.record.id === record.id); - const options = { programReadiness: { stages: [{id:"controls", items:[{id:"reporting-channel-cutover",status:"complete"}]}], evidenceReady:true }, auditPreparations:{}, obligations:{items:[],triggers:[],counts:{},asOf:"2026-10-01",through:"2026-10-01"}, validation:{ok:true,diagnostics:[{code:"uncovered-reporting-route-interval",severity:"warning",path:`data/${entry.relativePath}`,message:"Historical channel coverage gap."}]},reconciliation:{candidates:[]},git:{},asOf:"2026-10-01" }; + const options = { programReadiness: { stages: [{id:"controls", items:[{id:"reporting-channel-cutover",status:"complete"}]}], evidenceReady:true }, auditPreparations:{}, obligations:{items:[],triggers:[],counts:{},asOf:"2026-10-01",through:"2026-10-01"}, validation:{ok:true,diagnostics:[{code:"uncovered-reporting-route-interval",severity:"warning",path:`data/${entry.relativePath}`,message:"Retained authorization evidence is incomplete.",temporal:{programId:loaded.resources.find(record=>record.type==="program").id,startsAt:"2026-08-01T00:00:00Z",endsAt:"2026-09-01T00:00:00Z",resolved:true,evidenceLimitation:true}}]},reconciliation:{candidates:[]},git:{},asOf:"2026-10-01",coverage:{kind:"range",startsOn:"2026-09-02",endsOn:"2026-10-01"} }; let workflow = await assessWorkflow(loaded, options); const finding = workflow.findings.find(f => f.code.includes("uncovered-reporting-route-interval")); - assert.equal(finding.state,"complete"); assert.equal(finding.severity,"warning"); assert.deepEqual(finding.actions,[]); assert.match(finding.message,/do not backdate/); - options.programReadiness.stages[0].items[0].status="action"; + assert.equal(finding.state,"complete"); assert.equal(finding.severity,"info"); assert.deepEqual(finding.actions,[]); assert.match(finding.message,/Retained historical context/); + options.coverage={kind:"range",startsOn:"2026-08-15",endsOn:"2026-10-01"}; workflow = await assessWorkflow(loaded, options); assert.equal(workflow.findings.find(f=>f.code.includes("uncovered-reporting-route-interval")).state,"ready"); }); + + +test("cached Program authorization context does not become another Program's gap", async context => { + const root = await mkdtemp(join(tmpdir(), "filegrc-temporal-program-scope-")); + context.after(() => import("node:fs/promises").then(({rm}) => rm(root, { recursive:true, force:true }))); + await makeComprehensiveWorkspace(root, "13"); + const loaded = await loadWorkspace(root); + const first = loaded.resources.find(record => record.type === "program"); + const second = { ...first, id:"program-other", title:"Other Program" }; + loaded.resources.push(second); + const policy = loaded.entries.find(entry => entry.record.type === "policy"); + const warning = { code:"uncovered-reporting-route-interval", severity:"warning", programId:first.id, + path:`data/${policy.relativePath}`, message:"First Program lacks earlier authorization evidence.", + temporal:{programId:first.id,startsAt:"2026-08-01T00:00:00Z",endsAt:"2026-09-01T00:00:00Z",resolved:true,evidenceLimitation:true} }; + const globalError = { code:"invalid-global-structure",severity:"error",path:`data/${policy.relativePath}`,message:"A genuine structural error remains global." }; + const options = { programId:second.id, coverage:{kind:"range",startsOn:"2026-08-01",endsOn:"2026-10-01"}, + programReadiness:{program:second,stages:[],evidenceReady:true},auditPreparations:{}, + obligations:{items:[],triggers:[],counts:{},asOf:"2026-10-01",through:"2026-10-01"}, + validation:{ok:false,diagnostics:[warning,globalError]},reconciliation:{candidates:[]},git:{},asOf:"2026-10-01" }; + const workflow = await assessWorkflow(loaded, options); + assert.equal(workflow.findings.some(finding => finding.code.includes(warning.code)), false); + assert.equal(workflow.findings.some(finding => finding.code.includes(globalError.code)), true); +});