diff --git a/docs/architecture.md b/docs/architecture.md index fcf557a..56b6655 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -896,3 +896,11 @@ Approval authorizes an exact proposed revision. Activation and effective dates d 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. + +Same-ID revisions follow the same rule. Returning governed content to draft or approving its next revision leaves the last valid committed operating edition in force until its actual cutover. Shared scheduling, readiness, workforce acknowledgments and examination coverage read that edition's own JSON, approval, activation and Markdown from Git. They do not attach current proposed text or expanded scope to an older approval. Actual retirement and invalid implemented revisions stop fallback. Historical queries cannot borrow descendants of their selected commit. + +Evidence packets retain current source proposals in the normal record and content export. When a different committed edition still operates at the selected period end, `operatingBaselines` identifies its commit, lifecycle dates and exported files under `operating-baselines/`. Those exact JSON and Markdown files join the packet checksum manifest; the existing history export retains intervening revisions. + +A content revision does not restart an unchanged adopted schedule. Its Obligation Rule keeps its anchor, recorded completions and still-open earlier windows. Shared planning and occurrence validation use the first actual relevant governing operation together with the rule's own cutover. Initial content activation and real rule amendments retain their boundaries; this does not waive gaps in period authorization or evidence. + +Owner retention applicability uses the same operating schedule edition. Source Coverage, existing applications and new application previews use its exact rule and procedure, while present service and source fingerprints remain current. A proposed period or disposal change does not stale an application before cutover. The browser displays the approved values being applied; actual activation of a changed rule requires reassessing an old application when its binding no longer matches. diff --git a/package-lock.json b/package-lock.json index f4933ec..9a9eee0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.21.8", + "version": "0.21.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.21.8", + "version": "0.21.9", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.21.8", + "version": "0.21.9", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.21.8", + "version": "0.21.9", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 6035dc1..d46ae36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.21.8", + "version": "0.21.9", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index 163a3cf..be5833b 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.21.8", + "version": "0.21.9", "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 1b81360..940f291 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.8", + version: "0.21.9", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.21.8", + version: "0.21.9", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index 29ef301..1e809e9 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.8", + "version": "0.21.9", "private": true, "description": "filegrc workspace for a SOC 2 program", "type": "module", diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index c1d5a1f..22fc11c 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.21.8", + "version": "0.21.9", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/audit-preparation.js b/packages/filegrc/src/audit-preparation.js index 08c6aab..ec25eaa 100644 --- a/packages/filegrc/src/audit-preparation.js +++ b/packages/filegrc/src/audit-preparation.js @@ -98,7 +98,7 @@ export async function assessAuditPreparation(input, options = {}) { await documentsStage(loaded, audit, byId, calendarAsOf), evidenceStage(audit, records, byId, loaded.model), populationsStage(audit, records, byId, loaded.model), - occurrenceContinuityStage(audit, records, loaded.model, programReadiness.asOf, loaded.root) + occurrenceContinuityStage(audit, records, loaded.model, programReadiness.asOf, loaded.root, loaded) ] : []; stages.push(fieldworkStage(audit, fieldworkSections, programReadiness)); @@ -956,7 +956,7 @@ function fieldworkStage(audit, sections, programReadiness) { ); } -function occurrenceContinuityStage(audit, records, model, asOf, root) { +function occurrenceContinuityStage(audit, records, model, asOf, root, loaded) { if (audit.auditKind !== "soc-2-type-2") { return stage( "occurrences", @@ -991,7 +991,7 @@ function occurrenceContinuityStage(audit, records, model, asOf, root) { || record.controlIds.some((id) => selectedControlIds.has(id)) )); const plan = planObligations(periodResources, { - root, + root, loaded, programId: audit.programId, from: periodStart, asOf: periodThrough, diff --git a/packages/filegrc/src/cli.js b/packages/filegrc/src/cli.js index dc52589..ed0cc65 100644 --- a/packages/filegrc/src/cli.js +++ b/packages/filegrc/src/cli.js @@ -450,7 +450,7 @@ export async function runCli(argv = process.argv.slice(2)) { } if (command === "obligations") { const loaded = await loadWorkspace(root); - const result = planObligations(loaded.resources, { root: loaded.root, + const result = planObligations(loaded.resources, { root: loaded.root, loaded, programId: flags.program, asOf: flags["as-of"] ?? currentCalendarDate(loaded.workspace.timezone), from: flags.from, diff --git a/packages/filegrc/src/collection-revision.js b/packages/filegrc/src/collection-revision.js index b12dabc..7e87437 100644 --- a/packages/filegrc/src/collection-revision.js +++ b/packages/filegrc/src/collection-revision.js @@ -1,8 +1,8 @@ +import { governedMarkdownSource } from "./governed-content-baseline.js"; import { scheduleSetWithoutExcludedWithdrawals } from "./schedule-set.js"; import { resourceIndex } from "./resource-index.js"; import { calculationMemo } from "./calculation-cache.js"; import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; import { modelSupports } from "../model/index.js"; import { authoritativeSourceRevisionValue, @@ -10,7 +10,6 @@ import { collectionScopeRevisionFacts, scopedCollectionRecords } from "./collection-scope.js"; -import { resolveDataPath } from "./paths.js"; import { getFileAtRevision } from "./git.js"; import { reviewHistoricalWorkspace, reviewHistoryCommits, reviewHistoryContext } from "./historical-workspace.js"; import { resolveProgram } from "./program.js"; @@ -267,7 +266,12 @@ function calculateCollectionRevisionUncached(loaded, resourceType, options, lega ? loaded.historicalFiles ? loaded.historicalFiles.get(`data/${path}`) ?? null : getFileAtRevision(loaded.root, options.historicalCommit, `data/${path}`) - : loaded.historicalFiles ? loaded.historicalFiles.get(`data/${path}`) ?? null : loaded.historicalCommit ? getFileAtRevision(loaded.root, loaded.historicalCommit, `data/${path}`) : readFileSync(resolveDataPath(loaded.root, path), "utf8"); + : (() => { + const entry = loaded.entries?.find((item) => item.record === record); + if (entry?.reviewFiles instanceof Map) return entry.reviewFiles.get(`data/${path}`) ?? null; + if (entry?.reviewCommit) return getFileAtRevision(loaded.root, entry.reviewCommit, `data/${path}`); + return governedMarkdownSource(loaded, record, path); + })(); if (content === null) return []; return [{ path, revision: createHash("sha256").update(content).digest("hex") }]; } catch (error) { diff --git a/packages/filegrc/src/evidence-packet.js b/packages/filegrc/src/evidence-packet.js index 38e3126..da940af 100644 --- a/packages/filegrc/src/evidence-packet.js +++ b/packages/filegrc/src/evidence-packet.js @@ -1,4 +1,4 @@ -import { governedContentCoversPeriod } from "./governed-content-baseline.js"; +import { governedContentBaseline, governedContentSource, governedMarkdownSource, registerGovernedLookup, 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"; @@ -69,7 +69,7 @@ export async function prepareEvidencePacket(input, options = {}) { 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; - const byId = new Map(records.map((record) => [record.id, record])); + const byId = registerGovernedLookup(new Map(records.map((record) => [record.id, record])), loaded); const entriesById = new Map(loaded.entries.map((entry) => [entry.record.id, entry])); const audit = options.auditId ? byId.get(options.auditId) : null; if (options.auditId && audit?.type !== "audit") throw new Error(`Audit "${options.auditId}" was not found.`); @@ -100,7 +100,7 @@ export async function prepareEvidencePacket(input, options = {}) { byId.get(id)?.type === "evidence" ? byId.get(id).sourceResourceIds || [] : [] )) ); - const plan = planObligations(records, { root: loaded.root, + const plan = planObligations(records, { root: loaded.root, loaded, programId: program?.id || options.programId || audit?.programId, asOf: end, from: start, @@ -344,6 +344,32 @@ export async function prepareEvidencePacket(input, options = {}) { }; }) .sort((a, b) => a.type.localeCompare(b.type) || a.title.localeCompare(b.title)); + // Keep editable proposals in the source export and identify the exact + // committed edition still operating at the selected coverage end. + const operatingBaselines = [...selectedIds].flatMap((id) => { + const current = byId.get(id); + const baseline = governedContentBaseline(current, byId, end, loaded.model); + if (!baseline || baseline === current) return []; + const source = governedContentSource(loaded, baseline); + if (!source.commit || !source.entry) return []; + return [{ resourceId: id, baselineId: baseline.id, type: baseline.type, + commit: source.commit, approvedOn: baseline.approvedOn, effectiveOn: baseline.effectiveOn, + ...(baseline.activatedOn ? { activatedOn: baseline.activatedOn } : {}), + recordPath: `operating-baselines/${id}/${source.commit}/record.json`, + relatedRecords: baseline.type === "document" && baseline.documentKind === "schedule" + ? source.loaded.resources.filter((record) => record.type === "retention-schedule-item" && record.scheduleDocumentId === baseline.id + || record.type === "collection-review" && record.resourceType === "retention-schedule-item" + && (record.populationResourceIds || []).some((rowId) => source.loaded.resources.some((row) => row.id === rowId && row.scheduleDocumentId === baseline.id))) + .map((record) => ({ id: record.id, type: record.type, + recordPath: `operating-baselines/${id}/${source.commit}/records/${record.type}/${record.id}.json`, + contentPaths: markdownEntries(source.loaded.model, record) + .filter(({ path, required }) => required || governedMarkdownSource(source.loaded, record, path) !== null) + .map(({ path }) => ({ sourcePath: path, + exportedPath: `operating-baselines/${id}/${source.commit}/content/${path}` })) })) : [], + contentPaths: markdownEntries(source.loaded.model, baseline).map(({ path }) => ({ + sourcePath: path, exportedPath: `operating-baselines/${id}/${source.commit}/content/${path}` + })) }]; + }); await assertLoadedEntriesCurrent(loaded); const dataDigest = await dataTreeDigest(loaded.root); await assertLoadedEntriesCurrent(loaded); @@ -526,7 +552,8 @@ export async function prepareEvidencePacket(input, options = {}) { managementPreparation, controlCoverage, gaps, - records: packetRecords + records: packetRecords, + operatingBaselines }; preparedPacketValidations.set(packet, { validation, @@ -767,7 +794,7 @@ export async function writeEvidencePacket(input, packet, options = {}) { if (!validation.ok) throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before writing evidence.`); await assertPacketSourceState(packet, validation.loaded); const entriesById = new Map(validation.loaded.entries.map((entry) => [entry.record.id, entry])); - const byId = new Map(validation.loaded.resources.map((record) => [record.id, record])); + const byId = registerGovernedLookup(new Map(validation.loaded.resources.map((record) => [record.id, record])), validation.loaded); const files = []; let outputCreated = false; try { @@ -818,6 +845,27 @@ export async function writeEvidencePacket(input, packet, options = {}) { } } } + for (const item of packet.operatingBaselines || []) { + const baseline = governedContentBaseline(byId.get(item.resourceId), byId, packet.period.end, validation.loaded.model); + const source = governedContentSource(validation.loaded, baseline); + if (!baseline || source.commit !== item.commit || !source.entry) throw new Error(`Operating baseline for ${item.resourceId} changed after packet preparation.`); + await writePacketFile(output, item.recordPath, source.entry.source || `${JSON.stringify(baseline, null, 2)}\n`, files); + for (const related of item.relatedRecords || []) { + const entry = source.loaded.entries.find(({ record }) => record.id === related.id && record.type === related.type); + if (!entry) throw new Error(`Operating schedule record ${related.id} is unavailable.`); + await writePacketFile(output, related.recordPath, entry.source || `${JSON.stringify(entry.record, null, 2)}\n`, files); + for (const content of related.contentPaths) { + const text = governedMarkdownSource(source.loaded, entry.record, content.sourcePath); + if (text === null) throw new Error(`Operating schedule content for ${related.id} is unavailable.`); + await writePacketFile(output, content.exportedPath, text, files); + } + } + for (const content of item.contentPaths) { + const text = governedMarkdownSource(validation.loaded, baseline, content.sourcePath); + if (text === null) throw new Error(`Operating content for ${item.resourceId} is unavailable.`); + await writePacketFile(output, content.exportedPath, text, files); + } + } for (const item of packet.evidence) { const record = byId.get(item.id); for (const path of record?.filePaths || []) { @@ -972,6 +1020,7 @@ function packetHandlingMarkdown(packet) { "", "Transfer this directory through the auditor's approved encrypted channel. Do not email an unencrypted packet. Give access only to the engagement team and retain or remove exported copies under the organization's evidence-retention rules.", "", + ...((packet.operatingBaselines || []).length ? ["Current source records can include pending proposals. `manifest.json` identifies the exact committed editions still operating at period end in `operatingBaselines`; their JSON and Markdown are in `operating-baselines/`. Use those editions with the full history when assessing operation.", ""] : []), "After transfer, enter the packet directory and run `shasum -a 256 -c SHA256SUMS` or `sha256sum -c SHA256SUMS`. filegrc does not sign or encrypt the packet because those operations require organization-controlled keys and transfer-system choices.", "" ].join("\n"); @@ -2221,6 +2270,7 @@ function packetMarkdown(packet) { ? `Open \`index.html\` for the auditor-oriented index. \`control-matrix.csv\` cross-references criteria, Controls, filegrc Evidence, Evidence Artifacts, and tests.${modelSupports(packet.dataModelVersion, "governed-document-activation") ? " `document-lifecycle-index.csv` lists each engagement Document's approval and activation facts and exact Markdown revisions." : ""} \`source-component-index.csv\` identifies the Components used to produce Evidence. \`evidence-artifact-index.csv\` lists material that must be delivered or accessed outside this packet. For Type 2, \`population-index.csv\` records management's population reconciliation and fixed source exports. FileGRC records, governed Markdown, fixed attachments, and committed historical versions are included in their respective directories.` : "Open `index.html` for the auditor-oriented index. `control-matrix.csv` cross-references criteria, controls, filegrc Evidence, External Evidence, and tests. `source-system-index.csv` identifies the systems of record used to produce External Evidence. `external-evidence-index.csv` lists material that must be delivered or accessed outside this packet. For Type 2, `population-index.csv` records management's population reconciliation and fixed source exports. filegrc records, governed Markdown, fixed attachments, and committed historical versions are included in their respective directories.", "", + ...((packet.operatingBaselines || []).length ? ["Current source records can include pending proposals. `manifest.json` identifies the exact committed editions still operating at period end in `operatingBaselines`; their JSON and Markdown are in `operating-baselines/`. Use those editions with the full history when assessing operation.", ""] : []), "After transfer, enter the packet directory and run `shasum -a 256 -c SHA256SUMS` or `sha256sum -c SHA256SUMS`. The checksum file covers every other packet file.", "" ]; diff --git a/packages/filegrc/src/governed-content-baseline.js b/packages/filegrc/src/governed-content-baseline.js index b71b02e..cc1adc6 100644 --- a/packages/filegrc/src/governed-content-baseline.js +++ b/packages/filegrc/src/governed-content-baseline.js @@ -1,6 +1,9 @@ -import { getFileAtRevision } from "./git.js"; +import { retentionScheduleOperatingProofMatches, retentionScheduleOperatingHistoryIds } from "./retention-schedule-approval.js"; +import { getFileAtRevision, getFileHistoryWithPaths, getFilesAtRevisions, isGitAncestor } from "./git.js"; +import { reviewHistoryContext, reviewHistoryCommits, reviewHistoricalWorkspace } from "./historical-workspace.js"; +import { calculationMemo } from "./calculation-cache.js"; import { readFileSync } from "node:fs"; -import { markdownEntries } from "./resource-markdown.js"; +import { markdownEntries, resourceDataPath } from "./resource-markdown.js"; import { resolveDataPath } from "./paths.js"; import { modelSupports } from "../model/index.js"; import { calculateRevision, revisionDigest, revisionsMatch } from "./revisions.js"; @@ -8,6 +11,36 @@ import { partiesIndependent, partyPeople } from "./parties.js"; import { personWasActiveOn } from "./soc2.js"; import { parseCalendarDate } from "./recurrence.js"; +const lookupSources = new WeakMap(); +const recordSources = new WeakMap(); + +// Context is derived and request-local. Source records never hold Git metadata. +export function registerGovernedLookup(byId, loaded) { + if (loaded) lookupSources.set(byId, loaded); + return byId; +} + +export function governedContentSource(loaded, record) { + const historical = recordSources.get(record); + if (historical) return historical; + return { loaded, record, commit: loaded?.historicalCommit || null, + entry: loaded?.entries?.find((entry) => entry.record === record || entry.record.id === record?.id) || null }; +} + +export function governedMarkdownSource(loaded, record, path) { + const source = governedContentSource(loaded, record); + const workspace = source.loaded; + if (!workspace) return null; + return calculationMemo(workspace.resources || workspace, "governed-markdown-source", [workspace.root || null, + source.commit || null, workspace.historicalFiles || null, path], () => { + if (workspace.historicalFiles instanceof Map) return workspace.historicalFiles.get(`data/${path}`) ?? null; + if (source.commit) return getFileAtRevision(workspace.root, source.commit, `data/${path}`); + if (!workspace.root) return null; + try { return readFileSync(resolveDataPath(workspace.root, path), "utf8"); } + catch (error) { if (error.code === "ENOENT") return null; throw error; } + }); +} + 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; @@ -66,6 +99,96 @@ function predecessorCoversScope(proposed, predecessor) { return true; } +function historySnapshots(loaded, ids) { + if (!loaded?.root || !loaded.resources) return []; + const context = reviewHistoryContext(loaded.root, loaded); + let commits = reviewHistoryCommits(context); + if (context.index?.available && ids?.size) { + // Only these source changes can alter a governed edition. Avoid building + // whole historical workspaces for unrelated evidence and operating work. + const paths = new Set(); + const relevantIds = new Set(ids); + for (const id of ids) { + const record = loaded.resources.find((item) => item.id === id); + if (record) for (const relatedId of retentionScheduleOperatingHistoryIds(loaded, record)) relevantIds.add(relatedId); + } + for (const id of relevantIds) { + for (const item of context.index.historiesById.get(id) || []) paths.add(item.path); + const record = loaded.resources.find((item) => item.id === id); + if (record) for (const { path } of markdownEntries(loaded.model, record)) paths.add(`data/${path}`); + } + commits = commits.filter((commit) => [...(context.index.fileChangesByCommit.get(commit)?.keys() || [])] + .some((path) => paths.has(path))); + } + if (loaded.historicalCommit) { + // Indexed and fallback histories must obey the same ancestry boundary. + const ancestors = context.index?.available ? new Set() : null; + if (ancestors) { + const pending = [loaded.historicalCommit]; + while (pending.length) { + const commit = pending.pop(); + if (ancestors.has(commit)) continue; + ancestors.add(commit); + pending.push(...(context.index.parentsByCommit.get(commit) || [])); + } + } + commits = commits.filter((commit) => ancestors ? ancestors.has(commit) + : isGitAncestor(loaded.root, commit, loaded.historicalCommit)); + } + return [...commits].reverse().map((commit) => ({ commit, snapshot: () => reviewHistoricalWorkspace(context, commit) })); +} + +// Distinguish an initial approval from a proposal whose actual operating +// edition ended or failed. This is derived from the same bounded Git lineage. +export function governedContentHasOperatingHistory(loaded, record) { + if (!governedTypes.has(record?.type)) return false; + for (const { snapshot: readSnapshot } of historySnapshots(loaded, new Set([record.id]))) { + const snapshot = readSnapshot(); + const candidate = snapshot?.resources.find((item) => item.id === record.id && item.type === record.type); + if (!candidate) continue; + if (["retired", "superseded"].includes(candidate.status)) return true; + if (candidate.status !== "active") continue; + const byId = snapshot.byId || new Map(snapshot.resources.map((item) => [item.id, item])); + if (operatingStart(candidate, loaded.model) && authorized(candidate, byId, loaded.model) + && governedApprovalContentMatches(snapshot, candidate) + && retentionScheduleOperatingProofMatches(snapshot, candidate, operatingStart(candidate, loaded.model))) return true; + } + return false; +} + +function awaitsCutover(record, asOf, model) { + return pendingStatuses.has(record.status) || ["active", "retired", "superseded"].includes(record.status) + && (date(record.effectiveOn) > asOf || operatingStart(record, model) > asOf); +} + +function historicalEdition(record, byId, asOf, model, options) { + const loaded = lookupSources.get(byId); + if (!loaded || !modelSupports(model, "operating-preparation")) return undefined; + for (const { commit, snapshot: readSnapshot } of historySnapshots(loaded, new Set([record.id]))) { + const snapshot = readSnapshot(); + const candidate = snapshot?.resources.find((item) => item.id === record.id); + if (!candidate || candidate.type !== record.type) continue; + if (awaitsCutover(candidate, asOf, snapshot.model)) continue; + if (!["active", "retired", "superseded"].includes(candidate.status)) continue; + const end = operatingEnd(candidate); + // The latest actual retirement or broken cutover cannot revive old duties. + if (["retired", "superseded"].includes(candidate.status) && (!end || end <= asOf)) return null; + const sourceById = snapshot.byId || new Map(snapshot.resources.map((item) => [item.id, item])); + const start = operatingStart(candidate, snapshot.model); + const entry = snapshot.entries?.find((item) => item.record.id === candidate.id) || null; + // Historical workspaces intern identical JSON across commits. Provenance + // belongs to this selected edition, never to the shared snapshot record. + const edition = { ...candidate }; + recordSources.set(edition, { loaded: snapshot, record: edition, entry, commit }); + if (!start || start > asOf || !authorized(edition, sourceById, snapshot.model) + || !governedApprovalContentMatches(snapshot, edition) + || !retentionScheduleOperatingProofMatches(snapshot, edition, asOf)) return null; + if (options.contentMatches && !options.contentMatches(edition)) return null; + return edition; + } + return undefined; +} + // 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 = {}) { @@ -74,15 +197,21 @@ export function governedContentBaseline(record, byId, asOf, model, options = {}) 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 (awaitsCutover(candidate, asOf, model)) { + const historical = historicalEdition(candidate, byId, asOf, model, options); + if (historical === null) return null; + if (historical) candidate = historical; + else { + 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); + && ["active", "superseded", "retired"].includes(next.status) && date(next.effectiveOn) && next.effectiveOn <= asOf + && !(operatingStart(next, model) > asOf)); if (successors.length > 1) return null; if (successors.length === 1) { if (seen.has(successors[0].id)) return null; @@ -90,8 +219,13 @@ export function governedContentBaseline(record, byId, asOf, model, options = {}) } const start = operatingStart(candidate, model); const end = operatingEnd(candidate); + const source = recordSources.get(candidate)?.loaded || lookupSources.get(byId); + const authorizationById = recordSources.has(candidate) + ? source.byId || new Map(source.resources.map((item) => [item.id, item])) : byId; if (!start || start > asOf || (["superseded", "retired"].includes(candidate.status) && !end) - || end && end <= asOf || !authorized(candidate, byId, model) + || end && end <= asOf || !authorized(candidate, authorizationById, model) + || source?.root && modelSupports(model, "operating-preparation") + && (!governedApprovalContentMatches(source, candidate) || !retentionScheduleOperatingProofMatches(source, candidate, asOf)) || options.contentMatches && !options.contentMatches(candidate)) return null; return candidate; } @@ -99,9 +233,81 @@ export function governedContentBaseline(record, byId, asOf, model, options = {}) } export function pendingGovernedSuccessor(record, byId, asOf, model, options = {}) { - if (!pendingStatuses.has(record?.status) && !(record?.status === "active" && date(record.effectiveOn) > asOf)) return null; + if (!record || !(pendingStatuses.has(record.status) || record.status === "active" && awaitsCutover(record, asOf, model))) return null; const baseline = governedContentBaseline(record, byId, asOf, model, options); - return baseline && baseline.id !== record.id ? baseline : null; + return baseline && (baseline.id !== record.id || recordSources.has(baseline)) + && predecessorCoversScope(record, baseline) ? baseline : null; +} + +// Date discovery needs only governed JSON versions, never full workspace states. +// Exact record/Markdown approval and activation checks remain in the resolver. +function originHistoryRecords(loaded, byId, record, dependencyIds) { + const owner = loaded?.resources || byId; + return calculationMemo(owner, "governed-origin-history-records", [loaded?.root || null, loaded?.historicalCommit || null, + loaded?.model?.modelVersion || null, record.id, [...dependencyIds].sort().join("\0")], () => { + const ids = new Set([record.id, ...dependencyIds]); + const records = []; + const context = loaded?.root ? reviewHistoryContext(loaded.root, loaded) : null; + const allowed = (commit) => !loaded?.historicalCommit || commit === loaded.historicalCommit + || isGitAncestor(loaded.root, commit, loaded.historicalCommit); + const seen = new Set(); + while ([...ids].some((id) => !seen.has(id))) { + const id = [...ids].find((candidate) => !seen.has(candidate)); + seen.add(id); + const current = byId.get(id); + if (current && governedTypes.has(current.type)) records.push(current); + if (context?.index?.available) { + for (const item of context.index.historiesById.get(id) || []) { + if (!allowed(item.commit)) continue; + const entry = context.index.recordsByCommit.get(item.commit)?.get(id); + if (entry && governedTypes.has(entry.record.type)) records.push(entry.record); + } + } else if (context && context.index?.error?.code !== "FILEGRC_INVALID_HISTORY_RECORD") { + const entry = loaded.entries?.find((item) => item.record.id === id); + const path = `data/${entry?.relativePath || resourceDataPath(loaded.model, current || { ...record, id })}`; + const history = (getFileHistoryWithPaths(loaded.root, path, Number.MAX_SAFE_INTEGER) || []).filter((item) => allowed(item.commit)); + const sources = getFilesAtRevisions(loaded.root, history.map((item) => ({ revision: item.commit, relativePath: item.path }))); + for (const source of sources) { + try { + const candidate = JSON.parse(source); + if (candidate?.id === id && governedTypes.has(candidate.type)) records.push(candidate); + } catch { /* The normal historical resolver reports unreadable proof. */ } + } + } + for (const candidate of records) { + if (candidate.supersedesId && !ids.has(candidate.supersedesId)) ids.add(candidate.supersedesId); + } + for (const candidate of byId.values()) { + if (governedTypes.has(candidate.type) && ids.has(candidate.supersedesId)) ids.add(candidate.id); + } + } + return records; + }); +} + +// Calendar debt starts with actual governing operation, not each later content +// revision. This origin makes no claim that the intervening period is covered. +export function governedContentOperatingOrigin(record, byId, asOf, model, options = {}) { + const loaded = lookupSources.get(byId); + const owner = loaded?.resources || byId; + const policy = record?.type === "policy" && !options.appliesOn; + return calculationMemo(owner, "governed-operating-origin", [loaded?.root || null, loaded?.historicalCommit || null, + model.modelVersion, record?.id, asOf, policy ? null : options.scopeKey || null], () => { + const source = byId.get(record.id) || record; + const current = governedContentBaseline(source, byId, asOf, model); + if (!current) return null; + const currentStart = operatingStart(current, model); + const records = originHistoryRecords(loaded, byId, source, policy ? [] : options.dependencyIds || []); + const dates = [...new Set([...records.filter((candidate) => ["active", "retired", "superseded"].includes(candidate.status)) + .map((candidate) => operatingStart(candidate, model) || date(candidate.effectiveOn)), currentStart, asOf] + .filter((value) => date(value) && value <= asOf))].sort(); + if (!dates.some((on) => on < currentStart) && (!options.appliesOn || options.appliesOn(currentStart, current))) return currentStart; + for (const on of dates) { + const edition = governedContentBaseline(source, byId, on, model); + if (edition && (!options.appliesOn || options.appliesOn(on, edition))) return on; + } + return null; + }); } export function governedContentCoversPeriod(record, start, end, byId, model, options = {}) { @@ -115,10 +321,16 @@ export function governedContentCoversPeriod(record, start, end, byId, model, opt if (lineage.has(candidate.supersedesId) && !lineage.has(candidate.id)) { lineage.add(candidate.id); changed = true; } } } + const candidates = [...lineage].map((id) => byId.get(id)).filter(Boolean); + if (modelSupports(model, "operating-preparation")) { + for (const { snapshot } of historySnapshots(lookupSources.get(byId), lineage)) { + for (const candidate of snapshot()?.resources || []) { + if (lineage.has(candidate.id) && candidate.type === record?.type) candidates.push(candidate); + } + } + } const boundaries = new Set([start, end]); - for (const id of lineage) { - const candidate = byId.get(id); - if (!candidate) continue; + for (const candidate of candidates) { for (const value of [candidate.effectiveOn, candidate.activatedOn, operatingEnd(candidate)]) { if (date(value) && value >= start && value <= end) boundaries.add(value); } @@ -127,15 +339,12 @@ export function governedContentCoversPeriod(record, start, end, byId, model, opt } export function governedApprovalContentMatches(loaded, record) { - const entries = markdownEntries(loaded.model, record); + const source = governedContentSource(loaded, record); + const entries = markdownEntries(source.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; } + const text = governedMarkdownSource(loaded, record, path); + return typeof text === "string" + && revisionsMatch("content", record.approvedContentRevisions?.[path], calculateRevision("content", text)); }); } diff --git a/packages/filegrc/src/obligations.js b/packages/filegrc/src/obligations.js index 9d931e5..c5e189d 100644 --- a/packages/filegrc/src/obligations.js +++ b/packages/filegrc/src/obligations.js @@ -1,4 +1,4 @@ -import { governedContentBaseline } from "./governed-content-baseline.js"; +import { registerGovernedLookup, governedContentBaseline, governedContentOperatingOrigin } 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"; @@ -110,7 +110,7 @@ export function planObligations(resources, options = {}) { const requestedFrom = options.from ? requireDate(options.from, "from date") : null; if (through < asOf && !requestedFrom) throw new Error("The through date must not be before the as-of date."); if (requestedFrom && through < requestedFrom) throw new Error("The through date must not be before the from date."); - const byId = new Map(records.map((record) => [record.id, record])); + const byId = registerGovernedLookup(new Map(records.map((record) => [record.id, record])), options.loaded || { root: options.root, resources: records, model, workspace }); const obligationProgram = program && options.additionalControlIds?.length ? { ...program, controlIds: [...new Set([...(program.controlIds || []), ...options.additionalControlIds])] } : program; @@ -563,7 +563,7 @@ export async function createObligationEvent(input, options) { async function createObligationEventUnlocked(input, options) { const loaded = await loadWorkspace(input); const records = loaded.resources; - const byId = new Map(records.map((record) => [record.id, record])); + const byId = registerGovernedLookup(new Map(records.map((record) => [record.id, record])), loaded); const program = options?.allPrograms === true ? null : resolveProgram(loaded, options?.programId); const eventType = String(options?.eventType || "").trim(); const occurredAt = options?.occurredAt ? requireTimestamp(options.occurredAt, "event timestamp") : null; @@ -718,7 +718,7 @@ async function completeObligationOccurrenceUnlocked(input, options) { const program = resolveProgram(loaded, options.programId); const completedOn = executionDate(completionRecord, loaded.model, loaded.workspace.timezone); if (!completedOn) throw new Error("An actual completion date is required."); - const plan = planObligations(loaded.resources, { + const plan = planObligations(loaded.resources, { loaded, root: loaded.root, programId: program.id, asOf: completedOn, through: completedOn, includeComplete: true, model: loaded.model @@ -870,7 +870,7 @@ export async function scaffoldObligationOccurrence(input, options = {}) { const program = resolveProgram(loaded, options.programId); const windowStart = requireDate(options.windowStart, "occurrence window start"); const asOf = requireDate(options.asOf || currentCalendarDate(loaded.workspace.timezone), "as-of date"); - const plan = planObligations(loaded.resources, { + const plan = planObligations(loaded.resources, { loaded, root: loaded.root, programId: program.id, from: windowStart, @@ -1577,7 +1577,7 @@ function obligationCompletionContext(loaded, item) { function plannedOccurrenceForScaffold(loaded, obligation, windowStart, completedOn, programId) { const start = requireDate(windowStart, "occurrence window start"); - const plan = planObligations(loaded.resources, { + const plan = planObligations(loaded.resources, { loaded, root: loaded.root, programId, from: start, @@ -1596,7 +1596,7 @@ function plannedOccurrenceForScaffold(loaded, obligation, windowStart, completed } function plannedActionForScaffold(loaded, action, completedOn, programId) { - const plan = planObligations(loaded.resources, { + const plan = planObligations(loaded.resources, { loaded, root: loaded.root, programId, asOf: completedOn, @@ -2193,7 +2193,7 @@ function completionFallsInWindow(record, window, timezone = "UTC", model) { export function assessCompletionForObligation(record, obligation, loaded, options = {}) { if (loaded.historicalCommit) obligation = loaded.resources.find(({ id }) => id === obligation.id) || obligation; const activity = obligationActivity(loaded.model, obligation); - const byId = new Map(loaded.resources.map((current) => [current.id, current])); + const byId = registerGovernedLookup(new Map(loaded.resources.map((current) => [current.id, current])), loaded); const date = executionDate(record, loaded.model, loaded.workspace?.timezone || "UTC"); const issues = []; if (!completionMatchesObligation(record, obligation, loaded.model)) issues.push("Use the completion record type, activity profile, subtype, and Obligation linkage required by the adopted activity."); @@ -2275,7 +2275,7 @@ export function obligationScheduleRecurrence(obligation, rule, byId, model, time const activationDate = !modelSupports(model, "operating-preparation") ? 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); + obligationActivationDate(obligation, byId, model, timezone, asOf, { rule })].filter(Boolean).sort().at(-1); return { activationDate, recurrence: { @@ -2291,22 +2291,41 @@ export function obligationScheduleRecurrence(obligation, rule, byId, model, time }; } -export function obligationActivationDate(obligation, byId, ruleOrModel, timezone = "UTC", asOf = currentCalendarDate(timezone)) { +export function obligationActivationDate(obligation, byId, ruleOrModel, timezone = "UTC", asOf = currentCalendarDate(timezone), options = {}) { if (ruleOrModel?.type === "obligation-rule") { return ruleOrModel.effectiveAt ? currentCalendarDate(ruleOrModel.timezone || timezone, new Date(ruleOrModel.effectiveAt)) : null; } const model = ruleOrModel; + const ruleStart = options.rule?.effectiveAt + ? currentCalendarDate(options.rule.timezone || timezone, new Date(options.rule.effectiveAt)) : null; + const operatingOrigin = (record) => { + if (!options.rule || !modelSupports(model, "operating-preparation")) return null; + const currentStart = [record.effectiveOn, record.activatedOn].filter(Boolean).sort().at(-1); + // When the explicit rule began after current content, an older content + // origin cannot change max(rule start, content start). Keep this common + // path free of historical workspace materialization. + if (ruleStart && currentStart && ruleStart >= currentStart) return null; + return governedContentOperatingOrigin(record, byId, asOf, model, { + scopeKey: options.rule.id, + dependencyIds: obligation.policyIds || [], + appliesOn: record.type === "policy" ? null : (on) => obligationGovernedContent(obligation, byId, model, on) + .some((candidate) => candidate.id === record.id) + }); + }; 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) + .map((policy) => operatingOrigin(policy) || policy.effectiveOn) .filter(Boolean); const governedContentDates = obligationGovernedContent(obligation, byId, model, asOf) - .flatMap((record) => modelSupports(model, "operating-preparation") ? [record.effectiveOn, record.activatedOn] : [record.effectiveOn]) + .flatMap((record) => { + const origin = operatingOrigin(record); + return origin ? [origin] : 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 8bedd59..11cf455 100644 --- a/packages/filegrc/src/operating-baseline.js +++ b/packages/filegrc/src/operating-baseline.js @@ -1,8 +1,6 @@ -import { governedContentBaseline, governedApprovalContentMatches } from "./governed-content-baseline.js"; +import { governedMarkdownSource, registerGovernedLookup, 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"; -import { resolveDataPath } from "./paths.js"; import { contentRevision } from "./files.js"; import { revisionsMatch } from "./revisions.js"; import { substantiveMarkdown, openPlaceholderCount } from "./content-readiness.js"; @@ -23,7 +21,7 @@ export async function currentProcedureContent(loaded, record, revisions = null) const entries = markdownEntries(loaded.model, record); const matches = await Promise.all(entries.map(async ({ path }) => { try { - const text = await readFile(resolveDataPath(loaded.root, path), "utf8"); + const text = governedMarkdownSource(loaded, record, path) || ""; return substantiveMarkdown(text) && !openPlaceholderCount(text) && (!revisions || revisionsMatch("content", contentRevision(text), revisions[path])); } catch (error) { if (error.code === "ENOENT") return false; throw error; } @@ -35,7 +33,7 @@ export async function currentProcedureContent(loaded, record, revisions = null) // recurring occurrence. The selected Controls and their schedules choose the // work; actual actors, dates, populations and results establish completion. export async function assessProgramBaseline(loaded, program, controls, asOf, makeCheck) { - const byId = new Map(loaded.resources.map((record) => [record.id, record])); + const byId = registerGovernedLookup(new Map(loaded.resources.map((record) => [record.id, record])), loaded); const obligations = loaded.resources.filter((record) => record.type === "obligation" && record.status === "active" && recordBelongsToProgram(record, program, byId, loaded.model)); const ruleFor = (record) => byId.get(record.activeRuleId) || record; @@ -55,7 +53,7 @@ export async function assessProgramBaseline(loaded, program, controls, asOf, mak }; const timezone = loaded.workspace?.timezone || "UTC"; const assessedAt = asOf === currentCalendarDate(timezone) ? new Date().toISOString() : timestampFromLocalDateTime(`${asOf}T23:59:59`, timezone); - const calendar = planObligations(loaded.resources, { root: loaded.root, model: loaded.model, programId: program.id, asOf, from: asOf, through: asOf, includeComplete: true }).calendarItems; + const calendar = planObligations(loaded.resources, { root: loaded.root, loaded, model: loaded.model, programId: program.id, asOf, from: asOf, through: asOf, includeComplete: true }).calendarItems; const checks = []; for (const profile of loaded.model.operatingPreparation?.baselineProfiles || []) { const schedules = obligations.filter((record) => profile.activities.includes(record.activityType)); @@ -127,18 +125,19 @@ export async function assessProgramBaseline(loaded, program, controls, asOf, mak 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 + const sourceSubjects = loaded.resources.filter((subject) => ["policy", "document", "training"].includes(subject.type)); + const subjects = modelSupports(loaded.model, "operating-preparation") + ? [...new Map(sourceSubjects.map((subject) => { + const baseline = governedContentBaseline(subject, byId, asOf, loaded.model, + { contentMatches: (candidate) => governedApprovalContentMatches(loaded, candidate) }) || subject; + return [baseline.id, baseline]; + })).values()] : sourceSubjects; + const operatingSubjects = subjects.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. diff --git a/packages/filegrc/src/operating-preparation.js b/packages/filegrc/src/operating-preparation.js index 3f5e2d8..5fcd615 100644 --- a/packages/filegrc/src/operating-preparation.js +++ b/packages/filegrc/src/operating-preparation.js @@ -233,7 +233,7 @@ export function firstOperatingTasks(loaded, program, options = {}) { const futureDates = records.filter((record) => record.type === "obligation-rule" || record.type === "obligation") .flatMap((record) => [record.effectiveAt?.slice(0, 10), record.recurrence?.anchorDate, record.startsOn]).filter(Boolean); const horizon = [through.toISOString().slice(0, 10), ...futureDates].sort().at(-1); - const plan = planObligations(records, { root: loaded.root, model: loaded.model, programId: program.id, asOf, + const plan = planObligations(records, { root: loaded.root, loaded, model: loaded.model, programId: program.id, asOf, through: horizon, includeComplete: true, firstOnly: true }); const byId = new Map(loaded.resources.map((record) => [record.id, record])); const first = new Map(); diff --git a/packages/filegrc/src/program-lifecycle.js b/packages/filegrc/src/program-lifecycle.js index 1434ffa..08b62c9 100644 --- a/packages/filegrc/src/program-lifecycle.js +++ b/packages/filegrc/src/program-lifecycle.js @@ -89,7 +89,7 @@ export function obligationGovernedDocuments(obligation, byId, model, asOf = null ...(obligation.scopeResourceIds || []), ...(obligation.templateResourceId ? [obligation.templateResourceId] : []) ]; - const requiredDocumentsByControl = indexRequiredDocumentsByControl(byId, model); + const requiredDocumentsByControl = indexRequiredDocumentsByControl(byId, model, asOf); const controlDocumentIds = (obligation.controlIds || []) .flatMap((id) => requiredDocumentsByControl.get(id) || []); return [...new Set([...policyDocumentIds, ...directDocumentIds, ...controlDocumentIds])] @@ -119,12 +119,17 @@ export function obligationGovernedContent(obligation, byId, model, asOf = null) return [...documents, ...training]; } -function indexRequiredDocumentsByControl(byId, model) { +function indexRequiredDocumentsByControl(byId, model, asOf) { const modelVersion = String(model?.modelVersion || ""); - const cached = requiredDocumentsByControlCache.get(byId); - if (cached?.modelVersion === modelVersion) return cached.index; + let cached = requiredDocumentsByControlCache.get(byId); + if (!cached) { cached = new Map(); requiredDocumentsByControlCache.set(byId, cached); } + const key = `${modelVersion}:${asOf || "current"}`; + if (cached.has(key)) return cached.get(key); const index = new Map(); - for (const record of byId.values()) { + for (const source of byId.values()) { + if (source.type !== "document") continue; + const record = asOf && modelSupports(model, "operating-preparation") + ? governedContentBaseline(source, byId, asOf, model) || source : source; if ( record.type !== "document" || record.programRole !== "required" @@ -136,7 +141,8 @@ function indexRequiredDocumentsByControl(byId, model) { index.get(controlId).push(record.id); } } - requiredDocumentsByControlCache.set(byId, { modelVersion, index }); + cached.set(key, index); + if (cached.size > 64) cached.delete(cached.keys().next().value); return index; } diff --git a/packages/filegrc/src/program-readiness.js b/packages/filegrc/src/program-readiness.js index 14814df..e52171c 100644 --- a/packages/filegrc/src/program-readiness.js +++ b/packages/filegrc/src/program-readiness.js @@ -1,10 +1,9 @@ -import { governedContentBaseline, pendingGovernedSuccessor, governedApprovalContentMatches } from "./governed-content-baseline.js"; +import { governedMarkdownSource, registerGovernedLookup, 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"; import { reportingCutoverIssues } from "./activation-readiness.js"; import { recordBelongsToProgram } from "./program.js"; -import { readFile } from "node:fs/promises"; import { modelSupports } from "../model/index.js"; import { applicabilityReviewIsCurrent, createApplicabilityScopeIndex } from "./applicability-scope.js"; import { assessRequiredAppointments } from "./appointments.js"; @@ -13,7 +12,6 @@ import { collectionMembershipSourceTypes, collectionRevisionInputs, collectionSc import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js"; import { coverageEnd, coverageStart } from "./coverage.js"; import { planObligations } from "./obligations.js"; -import { resolveDataPath } from "./paths.js"; import { contentRevisionBindingsMatch, documentIsAuditSpecific, @@ -145,7 +143,7 @@ async function assessProgramReadinessUncached(input, options = {}) { ? input : await loadWorkspace(input); const records = loaded.resources; - const byId = new Map(records.map((record) => [record.id, record])); + const byId = registerGovernedLookup(new Map(records.map((record) => [record.id, record])), loaded); const workspace = loaded.workspace || records.find((record) => record.type === "workspace"); const program = resolveProgram(loaded, options.programId); const asOf = options.asOf || currentCalendarDate(workspace?.timezone || "UTC"); @@ -167,8 +165,8 @@ async function assessProgramReadinessUncached(input, options = {}) { const markdown = new Map(); const readMarkdown = async (record) => { if (!record) return ""; - if (!markdown.has(record.id)) markdown.set(record.id, await primaryMarkdown(loaded, record)); - return markdown.get(record.id); + if (!markdown.has(record)) markdown.set(record, await primaryMarkdown(loaded, record)); + return markdown.get(record); }; const policyStage = await policiesStage(scope, records, byId, readMarkdown, loaded.model); @@ -314,7 +312,7 @@ async function assessProgramReadinessUncached(input, options = {}) { 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}`; + entry.message = `${baseline.title || baseline.id} remains the effective operating baseline while the proposed revision awaits cutover. ${entry.message}`; } } } @@ -355,7 +353,7 @@ async function assessProgramReadinessUncached(input, options = {}) { program?.assuranceGoal === "soc-2-type-2" && progressWindow?.start <= asOf ); - const obligations = planObligations(records, { root: loaded.root, programId: program.id, asOf, through: asOf, now: asOfTime, model: loaded.model }); + const obligations = planObligations(records, { root: loaded.root, loaded, programId: program.id, asOf, through: asOf, now: asOfTime, model: loaded.model }); const policyLibrary = await assessPolicyLibraryUpgrades(loaded); const operating = Boolean( evidenceReady @@ -2036,7 +2034,7 @@ async function operationStage(loaded, workspace, scope, records, byId, asOf, evi ]); } - const obligations = planObligations(records, { root: loaded.root, programId: workspace.id, asOf, through: asOf, model }); + const obligations = planObligations(records, { root: loaded.root, loaded, programId: workspace.id, asOf, through: asOf, model }); const workQueue = planWorkQueue(records, obligations, { validationOk: await sourceDeadlineValidation(loaded), program: workspace, model }); const start = progressWindow?.start || null; const end = progressWindow?.end || null; @@ -2187,7 +2185,7 @@ async function primaryMarkdown(loaded, record) { )); if (!selected) return ""; try { - return await readFile(resolveDataPath(loaded.root, selected.path), "utf8"); + return governedMarkdownSource(loaded, record, selected.path) || ""; } catch { return ""; } diff --git a/packages/filegrc/src/retention-applications.js b/packages/filegrc/src/retention-applications.js index 8e5bb2f..6a53f36 100644 --- a/packages/filegrc/src/retention-applications.js +++ b/packages/filegrc/src/retention-applications.js @@ -1,5 +1,5 @@ import { recordBelongsToProgram, recordProgramRelationships } from "./program.js"; -import { calculationMemo } from "./calculation-cache.js"; +import { calculationMemo, withAssessmentCache } from "./calculation-cache.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { createHash } from "node:crypto"; @@ -9,7 +9,7 @@ import { previewWorkflowMutation } from "./workflow.js"; import { loadWorkspace } from "./workspace.js"; import { collectionRevisionMatches } from "./collection-revision.js"; import { reviewHistoricalWorkspace, reviewHistoryContext } from "./historical-workspace.js"; -import { resourceReviewRevisionsSync, retentionReviewResourceIds, retentionRuleIsCurrent, retentionSystemUses, retentionUses } from "./retention.js"; +import { operatingRetentionReadWorkspace, resourceReviewRevisionsSync, retentionReviewResourceIds, retentionRuleIsCurrent, retentionSystemUses, retentionUses } from "./retention.js"; import { appointmentWasAuthorizedOn, personWasActiveOn } from "./soc2.js"; import { currentPartyPeople } from "./parties.js"; import { createResource } from "./files.js"; @@ -45,7 +45,11 @@ export function retentionApplicationRuntimeSupported(loaded, commit = null, targ } function ruleMarkdown(loaded, rule) { return markdownEntries(loaded.model, rule).map(({ path }) => { - try { return { path, source: loaded.historicalFiles ? loaded.historicalFiles.get(`data/${path}`) || null + try { + const entry = loaded.entries?.find((item) => item.record === rule); + if (entry?.reviewFiles instanceof Map) return { path, source: entry.reviewFiles.get(`data/${path}`) ?? null }; + if (entry?.reviewCommit) return { path, source: getFileAtRevision(loaded.root, entry.reviewCommit, `data/${path}`) }; + return { path, source: loaded.historicalFiles ? loaded.historicalFiles.get(`data/${path}`) || null : loaded.historicalCommit ? getFileAtRevision(loaded.root, loaded.historicalCommit, `data/${path}`) : readFileSync(resolveDataPath(loaded.root, path), "utf8") }; } catch (error) { if (error.code === "ENOENT") return { path, source: null }; throw error; } }); @@ -91,7 +95,16 @@ export function retentionApplicationScopeRevision(record, loaded = null) { // These fields can change the service's legal or operational fit. agreementDocumentId: record.agreementDocumentId || null, standardAgreement: record.standardAgreement || null }); } -export function retentionApplicationIssues(loaded, record, { includeStale = true } = {}) { +function retentionAssessmentCommit(loaded) { + return calculationMemo(loaded.resources, "retention-assessment-commit", [loaded.root, loaded.historicalCommit || null], () => + loaded.historicalCommit || runGitCommandSync(loaded.root, ["rev-parse", "HEAD"], { timeout: 5000, maxBuffer: 1024 }).trim()); +} + +export function retentionApplicationIssues(loaded, record, options = {}) { + return withAssessmentCache(() => retentionApplicationIssuesUncached(loaded, record, options)); +} + +function retentionApplicationIssuesUncached(loaded, record, { includeStale = true } = {}) { const issues = []; const fail = (message) => issues.push({ severity: "error", code: "invalid-retention-application", resourceId: record.id, message }); if (!retentionApplicationRuntimeSupported(loaded, null, record)) { @@ -124,7 +137,7 @@ export function retentionApplicationIssues(loaded, record, { includeStale = true if (retiredRecord && JSON.stringify(retiredRecord.statusTransition) !== JSON.stringify(record.statusTransition)) fail("Preserve the committed retirement decision."); } const rule = byId.get(record.ruleId); - const approvalSnapshot = loaded.root && /^[a-f0-9]{40}$/.test(record.approvalCommit || "") && isDataHistoryAncestor(loaded, record.approvalCommit, getGitSummary(loaded.root).commit) + const approvalSnapshot = loaded.root && /^[a-f0-9]{40}$/.test(record.approvalCommit || "") && isDataHistoryAncestor(loaded, record.approvalCommit, retentionAssessmentCommit(loaded)) ? reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), record.approvalCommit) : null; const review = approvalSnapshot?.resources.find(({ id }) => id === record.approvalId); const historical = loaded.root && /^[a-f0-9]{40}$/.test(review?.scopeRevision || "") @@ -181,8 +194,11 @@ export function retentionApplicationIssues(loaded, record, { includeStale = true if (CONFIRMATIONS.some((key) => record.confirmations?.[key] !== true) || ["purpose", "rationale", "disposalImplementation"].some((key) => !String(record[key] || "").trim())) fail("Review purpose, cutoff, complete disposition, holds and conflicting duties, and explain actual disposal before applying an approved rule. Use normal independent review when any condition does not fit."); if (!firstCommit || includeStale && record.status !== "retired") { - const revisions = resourceReviewRevisionsSync(loaded, retentionReviewResourceIds(rule, loaded)); - if (rule.status !== "active" || retentionRuleRevision(rule, loaded) !== record.ruleRevision || !retentionRuleIsCurrent(rule, revisions, byId, loaded)) fail("The approved rule or a governing source changed. Review the material change independently before relying on this application."); + const operating = operatingRetentionReadWorkspace(loaded); + const operatingById = new Map(operating.resources.map((item) => [item.id, item])); + const operatingRule = operatingById.get(record.ruleId); + const revisions = operatingRule ? resourceReviewRevisionsSync(operating, retentionReviewResourceIds(operatingRule, operating)) : new Map(); + if (!operatingRule || operatingRule.status !== "active" || retentionRuleRevision(operatingRule, operating) !== record.ruleRevision || !retentionRuleIsCurrent(operatingRule, revisions, operatingById, operating)) fail("The approved rule or a governing source changed. Review the material change independently before relying on this application."); if (Object.keys(record.reviewedScopeRevisions || {}).sort().join() !== [...(record.scopeResourceIds || [])].sort().join() || record.scopeResourceIds.some((id) => retentionApplicationScopeRevision(byId.get(id), loaded) !== record.reviewedScopeRevisions?.[id])) fail("Service data use, agreement or classification changed after the owner review. Retire and replace this applicability decision or request material rule review."); } @@ -206,31 +222,43 @@ export function applicationCoversUse(loaded, use, programId, ruleId = null) { return currentRetentionApplications(loaded).some((record) => record.programId === programId && (!ruleId || record.ruleId === ruleId) && record.scopeResourceIds.includes(use.resource.id) && record.informationTypeIds.includes(use.informationTypeId)); } -export async function scaffoldRetentionApplication(input, ruleId) { +export function scaffoldRetentionApplication(input, ruleId) { + return withAssessmentCache(() => scaffoldRetentionApplicationUncached(input, ruleId)); +} + +async function scaffoldRetentionApplicationUncached(input, ruleId) { const loaded = await loadWorkspace(input); if (!loaded.model.resources["retention-application"]) throw applicationError("Apply approved rules requires model v13. Preview and complete the supported model migration first."); if (!retentionApplicationRuntimeSupported(loaded)) throw applicationError("Upgrade the repository dependency and lockfile to FileGRC 0.19.2 before applying an approved rule."); - const rule = loaded.resources.find(({ id }) => id === ruleId); + const operating = operatingRetentionReadWorkspace(loaded); + const rule = operating.resources.find(({ id }) => id === ruleId); if (rule?.type !== "retention-schedule-item" || rule.status !== "active") throw applicationError("Choose an active independently approved retention rule."); - const review = loaded.resources.find((item) => item.type === "collection-review" && item.resourceType === "retention-schedule-item" && item.status === "active" && item.populationResourceIds?.includes(rule.id)); + const review = operating.resources.find((item) => item.type === "collection-review" && item.resourceType === "retention-schedule-item" && item.status === "active" && item.populationResourceIds?.includes(rule.id)); if (!review) throw applicationError("Approve this rule independently before applying it to another service."); const head = getGitSummary(loaded.root).commit; - const committed = head ? reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), head) : null; + const reviewEntry = operating.entries.find((item) => item.record === review); + const approvalCommit = reviewEntry?.reviewCommit || head; + const committed = approvalCommit ? reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), approvalCommit) : null; const committedReview = committed?.resources.find(({ id }) => id === review.id); const originalWorkspace = review.scopeRevision ? reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), review.scopeRevision) : null; const original = originalWorkspace?.resources.find(({ id }) => id === rule.id); - if (!original || retentionRuleRevision(original, originalWorkspace) !== retentionRuleRevision(rule, loaded) + if (!original || retentionRuleRevision(original, originalWorkspace) !== retentionRuleRevision(rule, operating) || JSON.stringify(committedReview) !== JSON.stringify(review)) throw applicationError("Commit the exact independent schedule approval before applying its unchanged rule. Material rule changes need independent review."); return { record: { type: "retention-application", id: "retention-application-" + rule.id.replace(/^retention-schedule-item-/, "") + "-service", title: "Apply " + rule.title, - status: "active", ruleId, ruleRevision: retentionRuleRevision(rule, loaded), approvalId: review.id, approvalRevision: review.collectionRevision, approvalCommit: getGitSummary(loaded.root).commit, + status: "active", ruleId, ruleRevision: retentionRuleRevision(rule, operating), approvalId: review.id, approvalRevision: review.collectionRevision, approvalCommit, programId: review.scopeResourceIds?.length === 1 ? review.scopeResourceIds[0] : null, scopeResourceIds: [], informationTypeIds: [], reviewedScopeRevisions: {}, reviewedById: null, authorityAppointmentId: null, reviewedOn: currentCalendarDate(loaded.workspace.timezone || "UTC"), purpose: null, rationale: null, disposalImplementation: null, confirmations: Object.fromEntries(CONFIRMATIONS.map((key) => [key, false])) }, + approvedRule: structuredClone(rule), scopeCandidates: loaded.resources.filter((item) => ["system", "component", "vendor", "source-coverage"].includes(item.type) && !["retired", "terminated"].includes(item.status)).map((item) => ({ id: item.id, title: item.title, revision: retentionApplicationScopeRevision(item, loaded) })), guidance: "Choose actual service scope and existing rule Information Types. Complete every fit confirmation. Keep any proposed new semantic rule in independent review. apply-retention-rule preview computes exact service bindings; it never changes the original approval." }; } -export async function saveRetentionApplication(input, payload, { preview = false } = {}) { +export function saveRetentionApplication(input, payload, options = {}) { + return withAssessmentCache(() => saveRetentionApplicationUncached(input, payload, options)); +} + +async function saveRetentionApplicationUncached(input, payload, { preview = false } = {}) { const loaded = await loadWorkspace(input); if (!payload?.record || typeof payload.record !== "object" || Array.isArray(payload.record) || payload.record.type !== "retention-application" || !Array.isArray(payload.record.scopeResourceIds)) throw applicationError("A retention application record and service scope array are required."); @@ -249,7 +277,8 @@ export async function saveRetentionApplication(input, payload, { preview = false if (issues.length) throw applicationError(issues.map(({ message }) => message).join("\n")); if (preview) { const projected = await previewWorkflowMutation(input, { operation: "create", record, content: payload.content }); - return { operation: "apply-retention-rule", record, issues, preservesIndependentApproval: true, workflowDelta: projected.workflowDelta }; + const operating = operatingRetentionReadWorkspace(loaded); + return { operation: "apply-retention-rule", record, approvedRule: structuredClone(operating.resources.find((item) => item.id === record.ruleId)), issues, preservesIndependentApproval: true, workflowDelta: projected.workflowDelta }; } return createResource(input, record, { content: payload.content }); } diff --git a/packages/filegrc/src/retention-schedule-approval.js b/packages/filegrc/src/retention-schedule-approval.js index 903208f..ec9dd98 100644 --- a/packages/filegrc/src/retention-schedule-approval.js +++ b/packages/filegrc/src/retention-schedule-approval.js @@ -1,11 +1,14 @@ -import { governedContentBaseline, governedApprovalContentMatches } from "./governed-content-baseline.js"; +import { governedContentBaseline, governedApprovalContentMatches, governedContentSource, governedMarkdownSource, governedContentHasOperatingHistory, registerGovernedLookup } from "./governed-content-baseline.js"; +import { recordBelongsToProgram, recordProgramRelationships } from "./program.js"; +import { assessCollectionReview } from "./collection-review.js"; +import { reviewHistoryContext } from "./historical-workspace.js"; +import { resourceDataPath } from "./resource-markdown.js"; +import { calculationMemo } from "./calculation-cache.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"; import { markdownEntries } from "./resource-markdown.js"; -import { resolveDataPath } from "./paths.js"; import { currentPartyPeople } from "./parties.js"; import { nearDuplicateInformationTypes, @@ -16,8 +19,82 @@ import { } from "./retention.js"; import { calculateRevision, revisionsMatch } from "./revisions.js"; +function isProgramRetentionSchedule(record) { + return record?.type === "document" && record.documentKind === "schedule" && record.workflowScope === "program"; +} + +// The whole-schedule assessor hashes the parent as a source. During that one +// synchronous proof calculation, nested parent lookup verifies its own content +// binding without starting the same whole-schedule calculation again. +const operatingProofCalculations = new WeakSet(); +export function retentionScheduleOperatingProofMatches(loaded, record, asOf = currentCalendarDate(loaded.workspace?.timezone || "UTC")) { + if (!isProgramRetentionSchedule(record) || !modelSupports(loaded.model, "retention-schedule-approval")) return true; + if (operatingProofCalculations.has(loaded.resources)) return true; + const schedules = calculationMemo(loaded.resources, "operating-proof-schedules", [], () => loaded.resources.filter(isProgramRetentionSchedule)); + const pending = new Set(); + const lineage = new Set([record.id]); + for (let changed = true; changed;) { + changed = false; + for (const item of schedules) { + if (!lineage.has(item.supersedesId) || lineage.has(item.id)) continue; + if (!["draft", "in-review", "proposed", "approved"].includes(item.status) + && !(item.effectiveOn > asOf) && !(item.activatedOn > asOf)) continue; + lineage.add(item.id); pending.add(item.id); changed = true; + } + } + const source = calculationMemo(loaded.entries || loaded.resources, "operating-proof-source-context", [], () => ({ + revision: calculateRevision("content", JSON.stringify(loaded.entries?.map((entry) => [entry.record.id, entry.source || null, entry.reviewCommit || null]) + || loaded.resources)), + files: loaded.entries?.filter((entry) => entry.reviewFiles).map((entry) => entry.reviewFiles) || [] + })); + return calculationMemo(loaded.resources, "operating-schedule-proof", [record.id, loaded.root, loaded.model.modelVersion, + loaded.historicalCommit || null, loaded.historicalFiles || null, source.revision, ...source.files, [...pending].sort().join("\0")], () => + calculateOperatingScheduleProof(loaded, record, pending)); +} + +function calculateOperatingScheduleProof(loaded, record, pending) { + operatingProofCalculations.add(loaded.resources); + try { + const include = (item) => !pending.has(item.id) + && !(item.type === "retention-schedule-item" && pending.has(item.scheduleDocumentId)); + const resources = pending.size ? loaded.resources.filter(include) : loaded.resources; + const entries = loaded.entries ? loaded.entries.filter(({ record: item }) => include(item)) + : resources.map((item) => ({ record: item, source: JSON.stringify(item), relativePath: resourceDataPath(loaded.model, item) })); + const input = pending.size || !loaded.entries ? { ...loaded, resources, entries } : loaded; + if (input !== loaded) operatingProofCalculations.add(input.resources); + try { + const byId = new Map(resources.map((item) => [item.id, item])); + const programs = resources.filter((item) => item.type === "program" && item.status !== "retired"); + const scope = recordProgramRelationships(record, byId); + const owners = programs.filter((program) => recordBelongsToProgram(record, program, byId, loaded.model)); + if (!owners.length || owners.length > 1 && ![scope.programIds, scope.controlIds, scope.systemIds, scope.policyIds].some((ids) => ids.length)) return false; + if (scope.programIds.some((id) => !owners.some((program) => program.id === id)) + || scope.controlIds.some((id) => !owners.some((program) => (program.controlIds || []).includes(id)))) return false; + return owners.every((program) => Boolean(assessCollectionReview(input, "retention-schedule-item", { programId: program.id })?.complete)); + } finally { if (input !== loaded) operatingProofCalculations.delete(input.resources); } + } finally { operatingProofCalculations.delete(loaded.resources); } +} + +export function retentionScheduleOperatingHistoryIds(loaded, record) { + if (!isProgramRetentionSchedule(record) || !loaded.root) return []; + return calculationMemo(loaded, "operating-schedule-history-ids", [record.id], () => { + const context = reviewHistoryContext(loaded.root, loaded); + const records = [...loaded.resources]; + if (context.index?.available) { + for (const changed of context.index.recordsByCommit.values()) { + for (const item of changed.values()) records.push(item.record); + } + } + const rows = new Set(records.filter((item) => item.type === "retention-schedule-item" + && item.scheduleDocumentId === record.id).map(({ id }) => id)); + const reviews = records.filter((item) => item.type === "collection-review" && item.resourceType === "retention-schedule-item" + && (item.populationResourceIds || []).some((id) => rows.has(id))); + return [...rows, ...reviews.map(({ id }) => id)]; + }); +} + export function retentionScheduleApprovalIssues(loaded, program, rows, options = {}) { - const byId = new Map(loaded.resources.map((record) => [record.id, record])); + const byId = registerGovernedLookup(new Map(loaded.resources.map((record) => [record.id, record])), loaded); const issues = []; const planned = rows.filter(({ status }) => status === "planned"); if (planned.length) { @@ -111,7 +188,7 @@ function primaryMarkdown(loaded, record) { )); if (!selected) return ""; try { - return readFileSync(resolveDataPath(loaded.root, selected.path), "utf8"); + return governedMarkdownSource(loaded, record, selected.path) || ""; } catch { return ""; } @@ -129,10 +206,8 @@ function approvalBindingsMatch(loaded, record) { const actual = {}; for (const entry of markdownEntries(loaded.model, record)) { try { - actual[entry.path] = calculateRevision( - "content", - readFileSync(resolveDataPath(loaded.root, entry.path), "utf8") - ); + const source = governedMarkdownSource(loaded, record, entry.path); + if (typeof source === "string") actual[entry.path] = calculateRevision("content", source); } catch (error) { if (error.code !== "ENOENT") throw error; } @@ -148,19 +223,53 @@ function approvalBindingsMatch(loaded, record) { // 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. +// inventory and source 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 byId = registerGovernedLookup(new Map(loaded.resources.map((record) => [record.id, record])), loaded); const excluded = new Set(); + const replacements = new Map(); + const proposedRowSchedules = new Set(); + const affectedRowIds = 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 (!baseline && (schedule.status === "active" || governedContentHasOperatingHistory(loaded, schedule))) { + // An actual claimed cutover with invalid activation cannot use an old + // whole-schedule approval merely because the proposed bytes match it. + excluded.add(schedule.id); + continue; + } + if (baseline?.documentKind !== "schedule" || baseline.workflowScope !== "program") continue; + if (baseline.id !== schedule.id) { excluded.add(schedule.id); continue; } + if (baseline === schedule) continue; + const source = governedContentSource(loaded, baseline); + if (!source.commit || !source.loaded) continue; + const approvedRows = source.loaded.resources.filter((record) => record.type === "retention-schedule-item" + && record.scheduleDocumentId === baseline.id); + const rowIds = new Set(approvedRows.map(({ id }) => id)); + for (const row of loaded.resources.filter((record) => record.type === "retention-schedule-item" + && record.scheduleDocumentId === schedule.id)) rowIds.add(row.id); + for (const id of rowIds) affectedRowIds.add(id); + const approvedReviews = source.loaded.resources.filter((record) => record.type === "collection-review" + && record.resourceType === "retention-schedule-item" + && (record.populationResourceIds || []).some((id) => rowIds.has(id))); + for (const record of [baseline, ...approvedRows, ...approvedReviews]) { + const entry = source.loaded.entries?.find((item) => item.record.id === record.id); + replacements.set(record.id, { ...(entry || {}), record, source: entry?.source || JSON.stringify(record), + reviewCommit: source.commit, reviewFiles: source.loaded.historicalFiles }); + } + proposedRowSchedules.add(schedule.id); } - if (!excluded.size) return loaded; + if (!excluded.size && !replacements.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)) }; + && !(record.type === "retention-schedule-item" && (excluded.has(record.scheduleDocumentId) + || proposedRowSchedules.has(record.scheduleDocumentId))) + && !(record.type === "collection-review" && record.resourceType === "retention-schedule-item" + && (record.populationResourceIds || []).some((id) => affectedRowIds.has(id))) + && !replacements.has(record.id); + return { ...loaded, + resources: [...loaded.resources.filter(include), ...[...replacements.values()].map(({ record }) => record)], + entries: [...loaded.entries.filter(({ record }) => include(record)), ...replacements.values()] }; } diff --git a/packages/filegrc/src/retention.js b/packages/filegrc/src/retention.js index 924838c..c757fdd 100644 --- a/packages/filegrc/src/retention.js +++ b/packages/filegrc/src/retention.js @@ -1,7 +1,8 @@ -import { governedContentBaseline, governedApprovalContentMatches } from "./governed-content-baseline.js"; +import { operatingRetentionScheduleWorkspace } from "./retention-schedule-approval.js"; +import { governedContentBaseline, governedContentSource, registerGovernedLookup } 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 { calculationMemo, hasCalculationCache, withAssessmentCache } from "./calculation-cache.js"; import { readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { contentRevision } from "./files.js"; @@ -13,7 +14,15 @@ import { canonicalCalculatedRevisionJson, revisionDigest, revisionsMatch } from import { getFileAtRevision } from "./git.js"; import { reviewHistoricalWorkspace, reviewHistoryCommits, reviewHistoryContext } from "./historical-workspace.js"; -export async function assessRetentionReadiness(loaded, program, options = {}) { +export function operatingRetentionReadWorkspace(loaded) { + return calculationMemo(loaded, "operating-retention-read-workspace", [], () => operatingRetentionScheduleWorkspace(loaded)); +} + +export function assessRetentionReadiness(loaded, program, options = {}) { + return withAssessmentCache(() => assessRetentionReadinessUncached(loaded, program, options)); +} + +async function assessRetentionReadinessUncached(loaded, program, options = {}) { if (!loaded.model.resources["retention-schedule-item"]) return []; const byId = new Map(loaded.resources.map((record) => [record.id, record])); const rules = loaded.resources.filter((record) => ( @@ -24,6 +33,11 @@ export async function assessRetentionReadiness(loaded, program, options = {}) { const revisions = await resourceReviewRevisions(loaded, activeRules.flatMap((rule) => retentionReviewResourceIds(rule, loaded))); const usableRules = activeRules.filter((rule) => retentionRuleIsCurrent(rule, revisions, byId, loaded)); const proposedRules = rules.filter(retentionRuleHasCoverageProposal); + const operating = operatingRetentionReadWorkspace(loaded); + const operatingById = new Map(operating.resources.map((record) => [record.id, record])); + const operatingRules = operating.resources.filter((record) => record.type === "retention-schedule-item" && record.status === "active"); + const operatingRevisions = await resourceReviewRevisions(operating, operatingRules.flatMap((rule) => retentionReviewResourceIds(rule, operating))); + const operatingUsableRules = operatingRules.filter((rule) => retentionRuleIsCurrent(rule, operatingRevisions, operatingById, operating)); const uses = retentionUses(loaded, program); const priorApproval = loaded.model.resources["retention-application"] && loaded.resources.some((item) => item.type === "collection-review" && item.resourceType === "retention-schedule-item" && item.status === "active"); const items = rules.map((rule) => readinessItem( @@ -56,13 +70,13 @@ export async function assessRetentionReadiness(loaded, program, options = {}) { } items.push(...[...usesByInformationType].map(([informationTypeId, informationTypeUses]) => { const absentUses = informationTypeUses.filter((use) => ( - (!proposedRules.some((rule) => ruleCoversUse(rule, use, program)) || priorApproval && !retentionUseWasIndependentlyReviewed(loaded, use, program.id)) && !applicationCoversUse(loaded, use, program.id) + (!proposedRules.some((rule) => ruleCoversUse(rule, use, program)) || priorApproval && !retentionUseWasIndependentlyReviewed(operating, use, program.id)) && !applicationCoversUse(loaded, use, program.id) )); const pendingUses = informationTypeUses.filter((use) => ( - !usableRules.some((rule) => ruleCoversUse(rule, use, program)) && !applicationCoversUse(loaded, use, program.id) + !operatingUsableRules.some((rule) => ruleCoversUse(rule, use, program)) && !applicationCoversUse(loaded, use, program.id) )); const approvalPending = !options.scheduleApproved; - const matches = usableRules.filter((rule) => ( + const matches = operatingUsableRules.filter((rule) => ( informationTypeUses.some((use) => ruleCoversUse(rule, use, program)) )); const informationTypeTitle = byId.get(informationTypeId)?.title || informationTypeId; @@ -158,16 +172,23 @@ export async function assessRetentionReadiness(loaded, program, options = {}) { // Both source assessments and schedule readiness share this exact decision. // An application extends an unchanged approved rule to one explicit population; // it never supplies a new category or changes the rule's preservation semantics. -export async function resolveSourceCoverageRetention(loaded, coverage, program) { +export function resolveSourceCoverageRetention(loaded, coverage, program) { + return withAssessmentCache(() => resolveSourceCoverageRetentionUncached(loaded, coverage, program)); +} + +async function resolveSourceCoverageRetentionUncached(loaded, coverage, program) { const byId = new Map(loaded.resources.map((record) => [record.id, record])); if (!coverage || !program || !recordBelongsToProgram(coverage, program, byId, loaded.model)) return { rules: [], proposedRules: [] }; - const linked = (coverage.retentionScheduleItemIds || []).map((id) => byId.get(id)).filter((rule) => rule?.type === "retention-schedule-item"); - const revisions = await resourceReviewRevisions(loaded, linked.flatMap((rule) => retentionReviewResourceIds(rule, loaded))); + const operating = operatingRetentionReadWorkspace(loaded); + const operatingById = new Map(operating.resources.map((record) => [record.id, record])); + const linked = (coverage.retentionScheduleItemIds || []).map((id) => operatingById.get(id)).filter((rule) => rule?.type === "retention-schedule-item"); + const proposed = (coverage.retentionScheduleItemIds || []).map((id) => byId.get(id)).filter((rule) => rule?.type === "retention-schedule-item"); + const revisions = await resourceReviewRevisions(operating, linked.flatMap((rule) => retentionReviewResourceIds(rule, operating))); const direct = (rule) => (rule.scopeResourceIds || []).includes(coverage.id); const applied = (rule) => (rule.informationTypeIds || []).some((informationTypeId) => applicationCoversUse(loaded, { resource: coverage, informationTypeId }, program.id, rule.id)); return { - proposedRules: linked.filter((rule) => retentionRuleHasCoverageProposal(rule) && direct(rule)), - rules: linked.filter((rule) => rule.status === "active" && retentionRuleIsCurrent(rule, revisions, byId, loaded) && (direct(rule) || applied(rule))) + proposedRules: proposed.filter((rule) => retentionRuleHasCoverageProposal(rule) && direct(rule)), + rules: linked.filter((rule) => rule.status === "active" && retentionRuleIsCurrent(rule, revisions, operatingById, operating) && (direct(rule) || applied(rule))) }; } @@ -194,9 +215,8 @@ export async function resourceReviewRevisions(loaded, ids, scopeHashInput = "leg let matchedSection = !decisionHeading; for (const markdown of reviewMarkdownEntries(loaded, entry, false, reviewer)) { try { - const source = entry.reviewCommit - ? entry.reviewFiles?.get(`data/${markdown.path}`) - ?? getFileAtRevision(loaded.root, entry.reviewCommit, `data/${markdown.path}`) + const source = entry.reviewCommit || loaded.historicalCommit || loaded.historicalFiles + ? reviewMarkdownSource(loaded, markdown.path, entry.reviewCommit || (loaded.historicalFiles ? historicalMarkdownSnapshot : loaded.historicalCommit), entry.reviewFiles) : await readFile(resolveDataPath(loaded.root, markdown.path), "utf8"); if (source !== null) { const section = reviewedMarkdownSection(source, decisionHeading); @@ -237,8 +257,9 @@ const historicalMarkdownSnapshot = Symbol("historical Markdown snapshot"); function reviewMarkdownSource(loaded, path, commit = null, files = null) { return calculationMemo(loaded, "review-markdown-source", [path, commit], () => { if (commit === historicalMarkdownSnapshot) return loaded.historicalFiles?.get(`data/${path}`) ?? null; - if (commit) return loaded.historicalFiles?.get(`data/${path}`) - ?? files?.get(`data/${path}`) ?? getFileAtRevision(loaded.root, commit, `data/${path}`); + if (commit) return files?.get(`data/${path}`) + ?? (commit === loaded.historicalCommit ? loaded.historicalFiles?.get(`data/${path}`) : null) + ?? getFileAtRevision(loaded.root, commit, `data/${path}`); try { return readFileSync(resolveDataPath(loaded.root, path), "utf8"); } catch (error) { if (error.code === "ENOENT") return null; throw error; } }); @@ -433,10 +454,23 @@ function reviewMarkdownEntries(loaded, entry, legacySource, reviewer = null) { const approvedReviewEntries = new WeakMap(); function effectiveReviewEntry(loaded, entry) { + if (!entry || !loaded.root || !["policy", "document", "training"].includes(entry.record.type)) return entry; + if (!modelSupports(loaded.model, "operating-preparation")) return legacyEffectiveReviewEntry(loaded, entry); + const byId = calculationMemo(loaded, "governed-review-lookup", [], () => + registerGovernedLookup(new Map(loaded.resources.map((record) => [record.id, record])), loaded)); + const baseline = governedContentBaseline(entry.record, byId, + currentCalendarDate(loaded.workspace?.timezone || "UTC"), loaded.model); + if (!baseline) return entry; + const source = governedContentSource(loaded, baseline); + if (!source.commit) return source.entry || entry; + return { ...(source.entry || entry), record: baseline, source: JSON.stringify(baseline), + reviewCommit: source.commit, reviewFiles: source.loaded.historicalFiles }; +} + +function legacyEffectiveReviewEntry(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; - const approvedAwaitingCutover = modelSupports(loaded.model, "operating-preparation") && entry.record.status === "approved"; - if (approvedReviewContentMatches(loaded, entry) && !approvedAwaitingCutover) return entry; + if (approvedReviewContentMatches(loaded, entry)) return entry; if (entry.record.status === "active") return entry; let cache = approvedReviewEntries.get(loaded.resources); if (!cache) { @@ -448,20 +482,7 @@ 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); - 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; - } + if (!historical || !approvedReviewContentMatches(snapshot, historical, commit)) continue; const approved = { ...historical, reviewCommit: commit, reviewFiles: snapshot.historicalFiles }; cache.set(entry.record.id, approved); return approved; diff --git a/packages/filegrc/src/server.js b/packages/filegrc/src/server.js index 98c60ea..7fa3cba 100644 --- a/packages/filegrc/src/server.js +++ b/packages/filegrc/src/server.js @@ -270,7 +270,7 @@ export function createFilegrcServer(input = process.cwd(), options = {}) { } if (request.method === "GET" && url.pathname === "/api/obligations") { const loaded = await loadWorkspace(input); - const obligations = planObligations(loaded.resources, { root: loaded.root, + const obligations = planObligations(loaded.resources, { root: loaded.root, loaded, programId: url.searchParams.get("programId") || undefined, asOf: url.searchParams.get("asOf") || undefined, from: url.searchParams.get("from") || undefined, diff --git a/packages/filegrc/src/state.js b/packages/filegrc/src/state.js index 8c27c52..5f7861c 100644 --- a/packages/filegrc/src/state.js +++ b/packages/filegrc/src/state.js @@ -16,6 +16,7 @@ import { serializeWorkspaceMutation } from "./mutation.js"; import { fingerprintWorkspace, validateWorkspace } from "./validate.js"; import { assessWorkflow } from "./workflow.js"; import { measureTiming } from "./timing.js"; +import { withAssessmentCache } from "./calculation-cache.js"; import { soc2RequirementApplicabilityConstraint } from "./soc2.js"; import { modelSupports } from "../model/index.js"; import { calculateRevision } from "./revisions.js"; @@ -39,7 +40,7 @@ export async function createAppState(input = process.cwd(), options = {}) { options.programId ?? null ]); if (!options.validationProof && appStatePromises.has(key)) return appStatePromises.get(key); - const promise = serializeWorkspaceMutation(input, (root) => createAppStateUnlocked(root, options), { allowModelDiagnostics: true }).finally(() => { + const promise = serializeWorkspaceMutation(input, (root) => withAssessmentCache(() => createAppStateUnlocked(root, options)), { allowModelDiagnostics: true }).finally(() => { if (appStatePromises.get(key) === promise) appStatePromises.delete(key); }); if (!options.validationProof) appStatePromises.set(key, promise); @@ -117,6 +118,10 @@ export async function createAppBootstrap(input = process.cwd(), options = {}) { } export async function createAppStateSection(input, section, options = {}) { + return withAssessmentCache(() => createAppStateSectionUnlocked(input, section, options)); +} + +async function createAppStateSectionUnlocked(input, section, options) { const loaded = input?.entries && input?.root ? input : await loadWorkspace(input); const workspace = loaded.workspace; const asOf = options.asOf ?? currentCalendarDate(workspace?.timezone || "UTC"); @@ -173,7 +178,7 @@ export async function createAppStateSection(input, section, options = {}) { } if (section === "obligations") { const activeProgram = resolveProgram(loaded, options.programId); - const obligations = planObligations(loaded.resources, { root: loaded.root, + const obligations = planObligations(loaded.resources, { root: loaded.root, loaded, programId: activeProgram.id, asOf, now: options.now ?? generatedAt, model: loaded.model }); return { generatedAt, asOf, obligations, workQueue: planWorkQueue(loaded.resources, obligations, { validationOk: options.validation?.ok ?? await sourceDeadlineValidation(loaded), program: activeProgram, model: loaded.model }) }; @@ -291,7 +296,7 @@ async function createAppStateUnlocked(input, options) { return [audit?.id || "none", preparation]; }) ))); - const obligations = planObligations(entries, { root: loaded.root, + const obligations = planObligations(entries, { root: loaded.root, loaded, programId: activeProgram.id, asOf, now: options.now ?? generatedAt, diff --git a/packages/filegrc/src/validate.js b/packages/filegrc/src/validate.js index ea368ea..4e2efa7 100644 --- a/packages/filegrc/src/validate.js +++ b/packages/filegrc/src/validate.js @@ -1,3 +1,5 @@ +import { registerGovernedLookup } from "./governed-content-baseline.js"; +import { reviewHistoricalWorkspace, reviewHistoryContext } from "./historical-workspace.js"; import { reclassifyTemporalValidation } from "./temporal-diagnostics.js"; import { workspaceEngineVersion, runtimeCompatibilityLine } from "./runtime-version.js"; import { workflowSourceRuntimeIssues } from "./workflow-source-runtime.js"; @@ -116,6 +118,7 @@ async function validateWorkspaceUnmeasured(input, options) { } let reconciliation = null; const { byId } = indexResources(loaded.resources); + registerGovernedLookup(byId, loaded); const seen = new Map(); const pathById = new Map(loaded.entries.map((entry) => [ entry.record?.id, @@ -200,7 +203,7 @@ async function validateWorkspaceUnmeasured(input, options) { if (record.type === "obligation") validateObligation(record, loaded.model, byId, displayPath, diagnostics); if (record.type === "obligation-rule") validateObligationRule(record, loaded.model, byId, displayPath, diagnostics); if (record.type === "obligation-occurrence") { - validateObligationOccurrence(record, loaded.model, loaded.resources, loaded.entries, loaded.root, loaded.workspace.timezone, byId, asOf, displayPath, diagnostics); + validateObligationOccurrence(record, loaded.model, loaded.resources, loaded.entries, loaded.root, loaded.workspace.timezone, byId, asOf, displayPath, diagnostics, loaded); } if (record.type === "retention-schedule-item") validateRetentionScheduleItem(record, loaded, byId, currentReviewRevisions, displayPath, diagnostics); if (record.type === "requirement-mapping") validateRequirementMapping(record, loaded, currentReviewRevisions, displayPath, diagnostics); @@ -634,7 +637,7 @@ function validateObligationRule(record, model, byId, path, diagnostics) { } } -function validateObligationOccurrence(record, model, resources, entries, root, workspaceTimezone, byId, asOf, path, diagnostics) { +function validateObligationOccurrence(record, model, resources, entries, root, workspaceTimezone, byId, asOf, path, diagnostics, sourceContext) { const members = record.members || []; const memberIds = members.map(({ resourceId }) => resourceId); if (new Set(memberIds).size !== memberIds.length) { @@ -760,7 +763,7 @@ function validateObligationOccurrence(record, model, resources, entries, root, w if (rule?.type === "obligation-rule" && rule.obligationId !== record.obligationId) { diagnostics.push(error("wrong-obligation-rule", path, "The occurrence rule must belong to the same Obligation.")); } - validateOccurrenceScheduleBinding(record, obligation, rule, resources, model, root, path, diagnostics); + validateOccurrenceScheduleBinding(record, obligation, rule, resources, model, root, path, diagnostics, true, sourceContext); for (const member of members) { if (!byId.has(member.resourceId)) continue; const validCompletions = []; @@ -873,10 +876,11 @@ function validateObligationOccurrence(record, model, resources, entries, root, w } } -function validateOccurrenceScheduleBinding(record, obligation, rule, resources, model, root, path, diagnostics, allowHistorical = true) { +function validateOccurrenceScheduleBinding(record, obligation, rule, resources, model, root, path, diagnostics, allowHistorical = true, sourceContext = null) { if (obligation?.type !== "obligation" || rule?.type !== "obligation-rule") return; const start = record.coverage?.kind === "range" ? record.coverage.startsOn : null; - const byId = new Map(resources.map((candidate) => [candidate.id, candidate])); + const byId = registerGovernedLookup(new Map(resources.map((candidate) => [candidate.id, candidate])), sourceContext + || { root, resources, model, workspace: resources.find(({ type }) => type === "workspace") }); const timezone = resources.find(({ type }) => type === "workspace")?.timezone || "UTC"; const { recurrence, activationDate: cutover } = obligationScheduleRecurrence(obligation, rule, byId, model, timezone); if (!start || !validCalendarRecurrence(recurrence)) return; @@ -900,7 +904,9 @@ function validateOccurrenceScheduleBinding(record, obligation, rule, resources, const priorObligation = correction.resources.find(({ id }) => id === prior.obligationId); const priorRule = correction.resources.find(({ id }) => id === prior.ruleId); const issues = []; - validateOccurrenceScheduleBinding(prior, priorObligation, priorRule, correction.resources, model, root, path, issues); + const correctionContext = { root, resources: correction.resources, model, + workspace: correction.resources.find(({ type }) => type === "workspace"), historicalCommit: record.populationSourceRevision }; + validateOccurrenceScheduleBinding(prior, priorObligation, priorRule, correction.resources, model, root, path, issues, true, correctionContext); if (issues.length) diagnostics.push(...issues); if (!parseCalendarDate(record.membershipCutoffAt) || record.membershipCutoffAt < start || record.membershipCutoffAt > record.coverage.endsOn || record.membershipCutoffAt > currentCalendarDate(timezone)) { @@ -938,7 +944,8 @@ function validateOccurrenceScheduleBinding(record, obligation, rule, resources, // governing context. Merely committing arbitrary dates grants no authority. const original = history?.at(-1); if (original) { - const snapshot = recordsAtRevision({ root }, original.commit); + const historicalContext = reviewHistoricalWorkspace(reviewHistoryContext(root, resources), original.commit); + const snapshot = historicalContext?.resources || []; const historical = snapshot.find(({ id }) => id === basis.id); if (historical && ["programId", "obligationId", "ruleId", "occurrenceKey", "membershipCutoffAt"] .every((key) => historical[key] === record[key]) @@ -947,10 +954,10 @@ function validateOccurrenceScheduleBinding(record, obligation, rule, resources, const originalRule = snapshot.find(({ id }) => id === record.ruleId); if (originalObligation?.type === "obligation" && originalRule?.type === "obligation-rule" && validCalendarRecurrence(obligationScheduleRecurrence(originalObligation, originalRule, - new Map(snapshot.map((candidate) => [candidate.id, candidate])), model, timezone).recurrence)) { + registerGovernedLookup(new Map(snapshot.map((candidate) => [candidate.id, candidate])), historicalContext), model, timezone).recurrence)) { const issues = []; validateOccurrenceScheduleBinding(record, originalObligation, - originalRule, snapshot, model, root, path, issues, false); + originalRule, snapshot, model, root, path, issues, false, historicalContext); if (issues.length === 0) return; } } diff --git a/packages/filegrc/src/web.js b/packages/filegrc/src/web.js index 35eb3a6..f5f7104 100644 --- a/packages/filegrc/src/web.js +++ b/packages/filegrc/src/web.js @@ -3849,7 +3849,7 @@ function renderDetail(main, type, id, params = new URLSearchParams()) { try { const scaffold = await fetchJson("/api/retention-applications/scaffold", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ruleId: entry.record.id }) }); scaffold.record.reviewedScopeRevisions = Object.fromEntries(scaffold.scopeCandidates.map(({ id, revision }) => [id, revision])); - openEditor("retention-application", null, { seed: scaffold.record, retentionApplication: true, saveLabel: "Apply approved rule", description: "Apply the complete approved rule “" + entry.record.title + "”. Its independent approval stays unchanged. Select the actual service or Source Coverage population, then confirm purpose, cutoff, disposition, holds and conflicting duties. A retained proof population does not extend the retention of raw logs or deleted content. If any condition does not fit, cancel and request a material rule review." }); + openEditor("retention-application", null, { seed: scaffold.record, retentionApplication: true, approvedRule: scaffold.approvedRule, saveLabel: "Apply approved rule", description: "Apply the complete approved rule “" + (scaffold.approvedRule?.title || entry.record.title) + "”. Its independent approval stays unchanged. Select the actual service or Source Coverage population, then confirm purpose, cutoff, disposition, holds and conflicting duties. A retained proof population does not extend the retention of raw logs or deleted content. If any condition does not fit, cancel and request a material rule review." }); } catch (error) { showError(error.message); } }); main.querySelector("[data-amend-nonmaterial]")?.addEventListener("click", () => openNonmaterialAmendmentDialog(amendmentTarget, amendmentTarget !== entry ? entry : null)); @@ -5375,6 +5375,13 @@ function programGoalFromKind(kind) { return "none"; } +function approvedRetentionRulePanel(rule) { + if (!rule) return ""; + const hidden = new Set(["id", "type", "title", "tags", "reviewedSourceRevisions", "approvedByIds", "approvedOn", "statusTransition", "supersedesId"]); + const values = Object.entries(rule).filter(([name, value]) => !hidden.has(name) && value !== undefined && value !== null && value !== "" && (!Array.isArray(value) || value.length)); + return '
Approved rule being applied

These are the approved values. Pending schedule edits do not change the rule applied here.

' + values.map(([name, value]) => '
' + esc(fieldLabel("retention-schedule-item", name)) + '
' + (name === "retentionPeriod" ? esc(retentionPeriodLabel(value)) : name === "cutoff" ? esc(retentionCutoffLabel(value)) : formatValue(value, name, "retention-schedule-item", false, rule)) + '
').join("") + '
'; +} + function openEditor(type, entry = null, options = {}) { const definition = state.model.resources[type]; const fields = { ...state.model.commonFields, ...definition.fields }; @@ -5424,7 +5431,7 @@ function openEditor(type, entry = null, options = {}) { || implementationEditorDescription(type) || conciseResourceDescription(definition) || "Fill the core fields below. Git will record the author, time, reason, and diff when you commit this file."; - dialog.innerHTML = '

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

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

' + esc(editorDescription) + '

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

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

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

' + esc(editorDescription) + '

' + approvedRetentionRulePanel(options.approvedRule) + resourceReviewCriteria(type, true) + (options.occurrenceReview ? occurrenceMemberReview(record, options.membershipFinal, options.periodFinal) : "") + '
' + names.map((name) => editorField(type, name, fields[name], record[name], required.has(name) || conditionMatches(record, fields[name].requiredWhen), Boolean(entry), oneOf.has(name), activeOneOf.has(name), record.id)).join("") + '
' + activeMarkdown.map((markdown) => { const generated = !entry?.content?.[markdown.name]; const source = entry?.content?.[markdown.name]?.source diff --git a/packages/filegrc/src/workflow.js b/packages/filegrc/src/workflow.js index fec2e2b..ee23413 100644 --- a/packages/filegrc/src/workflow.js +++ b/packages/filegrc/src/workflow.js @@ -1,5 +1,5 @@ import { reclassifyTemporalValidation } from "./temporal-diagnostics.js"; -import { governedContentCoversPeriod, governedApprovalContentMatches } from "./governed-content-baseline.js"; +import { governedContentBaseline, governedContentSource, registerGovernedLookup, 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"; @@ -26,7 +26,7 @@ import { deleteResource, updateResource } from "./files.js"; -import { getGitSummary, getWorkspaceHistories, runGitCommandSync } from "./git.js"; +import { isGitAncestor, getGitSummary, getWorkspaceHistories, runGitCommandSync } from "./git.js"; import { completeObligationAction, completeObligationEvent, @@ -97,7 +97,7 @@ async function assessWorkflowUnmeasured(input, options = {}) { const auditPreparations = Object.fromEntries( Object.entries(suppliedAuditPreparations).filter(([id]) => auditIds.has(id)) ); - const obligationPlan = options.obligations || planObligations(loaded.resources, { root: loaded.root, + const obligationPlan = options.obligations || planObligations(loaded.resources, { root: loaded.root, loaded, programId: programRecord.id, asOf, through: options.through || asOf, @@ -804,7 +804,7 @@ async function assessPeriodHealth(loaded, options) { } const findings = []; - const recordById = resourceIndex(loaded.resources).byId; + const recordById = registerGovernedLookup(resourceIndex(loaded.resources).byId, loaded); const relevantEntries = loaded.entries.filter(({ record }) => [ "appointment", "control", @@ -922,7 +922,7 @@ async function assessPeriodHealth(loaded, options) { || !(record.controlIds || []).length || record.controlIds.some((id) => selectedControlIds.has(id)) )); - const occurrences = planObligations(periodResources, { root: loaded.root, + const occurrences = planObligations(periodResources, { root: loaded.root, loaded, programId: options.programId, from: coverage.start, asOf: periodThrough, @@ -966,7 +966,11 @@ async function assessPeriodHealth(loaded, options) { for (const entry of relevantEntries) { const record = entry.record; const history = histories.get(`data/${entry.relativePath}`) || []; - const impact = await assessPeriodImpact(loaded, record, histories, coverage, options.asOf); + const baseline = governedContentBaseline(record, recordById, options.asOf, loaded.model); + const source = baseline && baseline !== record ? governedContentSource(loaded, baseline) : null; + const operatingHistories = source?.commit ? new Map([...histories].map(([path, changes]) => [path, + changes.filter(({ commit }) => isGitAncestor(loaded.root, commit, source.commit))])) : histories; + const impact = await assessPeriodImpact(source?.loaded || loaded, baseline || record, operatingHistories, coverage, options.asOf); if (impact) { findings.push(periodFinding( `period.change.${record.type}.${record.id}`, diff --git a/packages/filegrc/test/calculation-cache.test.js b/packages/filegrc/test/calculation-cache.test.js index 7181c88..8108829 100644 --- a/packages/filegrc/test/calculation-cache.test.js +++ b/packages/filegrc/test/calculation-cache.test.js @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import test from "node:test"; import { loadModel } from "../model/index.js"; import { applicabilityScopeRevision } from "../src/applicability-scope.js"; @@ -11,7 +11,7 @@ import { assessCollectionReview } from "../src/collection-review.js"; import { getRepositoryStateSignature, setGitCommandInterceptorForTests } from "../src/git.js"; import { resourceReviewRevisions, resourceReviewRevisionsSync } from "../src/retention.js"; import { createStateCalculator } from "../src/state-calculator.js"; -import { createAppStateSection } from "../src/state.js"; +import { createAppState, createAppStateSection } from "../src/state.js"; import { loadWorkspace } from "../src/workspace.js"; import { makeComprehensiveWorkspace } from "./fixtures.js"; @@ -265,3 +265,79 @@ test("public workflow bounds review Markdown reads and observes edits on its nex assert.equal(reads, firstReads*2, "the next assessment must re-read mutable source content"); } finally { fs.readFileSync=originalRead;syncBuiltinESMExports(); } }); + + +test("public app-state calculations bound shared source reads and refresh after each invocation", async (context) => { + const { createRequire, syncBuiltinESMExports } = await import("node:module"); + const { hasCalculationCache } = await import("../src/calculation-cache.js"); + const root = await workspace(context); + const initial = await loadWorkspace(root); + const policy = initial.entries.find(({ record }) => record.type === "policy"); + const rule = initial.entries.find(({ record }) => record.type === "retention-schedule-item"); + for (let index = 0; index < 20; index++) { + const record = { ...rule.record, id: `shared-source-row-${index}`, status: "active", + sourceResourceIds: [policy.record.id], reviewedSourceRevisions: { [policy.record.id]: "stale" } }; + await writeFile(join(root, "data", dirname(rule.relativePath), `${record.id}.json`), JSON.stringify(record)); + } + const markdownPath = join(initial.root, "data", policy.relativePath.replace(/\.json$/, ".md")); + const fs = createRequire(import.meta.url)("node:fs"), originalRead = fs.readFileSync; + let reads = 0; + fs.readFileSync = function (path, ...args) { + if (String(path) === markdownPath) { + reads++; + assert.equal(hasCalculationCache(), true, "source graph calculation must belong to this public state invocation"); + } + return originalRead.call(this, path, ...args); + }; + syncBuiltinESMExports(); + try { + const options = { readOnly: true, includeDetails: false, asOf: "2026-10-14", now: "2026-10-14T20:00:00.000Z" }; + const first = await createAppState(root, options); + const firstReads = reads; + assert.ok(firstReads > 0 && firstReads <= 2, `full state shared source reads: ${firstReads}`); + assert.equal(hasCalculationCache(), false); + const original = originalRead(markdownPath, "utf8"); + await writeFile(markdownPath, `${original}\nChanged source for the next state request.\n`); + const next = await createAppState(root, options); + assert.equal(reads, firstReads * 2, "a new full state invocation must reread source content"); + const content = next.resources.find(({ record }) => record.id === policy.record.id).content; + assert.ok(Object.values(content).some(({ source }) => source.includes("Changed source for the next state request."))); + assert.notDeepEqual(content, first.resources.find(({ record }) => record.id === policy.record.id).content); + for (const section of ["program", "obligations"]) { + const loaded = await loadWorkspace(root); + const before = reads; + await createAppStateSection(loaded, section, options); + assert.ok(reads - before <= 2, `${section} source reads stay bounded`); + assert.equal(hasCalculationCache(), false); + } + await assert.rejects(createAppStateSection(await loadWorkspace(root), "missing", options), /Unknown app-state section/); + assert.equal(hasCalculationCache(), false, "a failed state request must not leak its read snapshot"); + await assert.rejects(createAppState(root, { ...options, programId: "missing-program" }), /Program/); + assert.equal(hasCalculationCache(), false, "a failed serialized state request must release its read snapshot"); + } finally { + fs.readFileSync = originalRead; + syncBuiltinESMExports(); + } +}); + + +test("a completed app-state read cannot authorize a stale Markdown mutation", async (context) => { + const { readFile } = await import("node:fs/promises"); + const { updateResource } = await import("../src/files.js"); + const { readResourceMutation } = await import("../src/state.js"); + const { hasCalculationCache } = await import("../src/calculation-cache.js"); + const root = await workspace(context); + const loaded = await loadWorkspace(root); + const entry = loaded.entries.find(({ record }) => record.type === "policy"); + const mutation = await readResourceMutation(root, entry.record.type, entry.record.id); + await createAppState(root, { readOnly: true, includeDetails: false }); + assert.equal(hasCalculationCache(), false); + const path = join(loaded.root, "data", entry.relativePath.replace(/\.json$/, ".md")); + const changed = `${await readFile(path, "utf8")}\nActual later source edit.\n`; + await writeFile(path, changed); + await assert.rejects(updateResource(root, mutation.record.type, mutation.record.id, mutation.record, { + content: mutation.content, expectedRevision: mutation.revision, + expectedContentRevisions: mutation.contentRevisions, requireExpectedContentRevisions: true + }), /Content at data\/.* changed after you opened it/); + assert.equal(await readFile(path, "utf8"), changed); +}); diff --git a/packages/filegrc/test/retention-operating-baseline.test.js b/packages/filegrc/test/retention-operating-baseline.test.js index 3944739..59cfab6 100644 --- a/packages/filegrc/test/retention-operating-baseline.test.js +++ b/packages/filegrc/test/retention-operating-baseline.test.js @@ -119,7 +119,7 @@ test("actual schedule cutover needs its own exact row approval and selects only 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 }) }; + populationResourceIds: [nextRow.id], collectionRevision: collectionRevision(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); diff --git a/packages/filegrc/test/same-id-governed-baseline.test.js b/packages/filegrc/test/same-id-governed-baseline.test.js new file mode 100644 index 0000000..0195201 --- /dev/null +++ b/packages/filegrc/test/same-id-governed-baseline.test.js @@ -0,0 +1,683 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { loadWorkspace } from "../src/workspace.js"; +import { reviewHistoryContext, reviewHistoricalWorkspace } from "../src/historical-workspace.js"; +import { calculateRevision } from "../src/revisions.js"; +import { markdownEntries } from "../src/resource-markdown.js"; +import { loadModel } from "../model/index.js"; +import { writeJson, initializeGitWorkspace, commitWorkspaceFiles } from "./helpers.js"; +import { governedContentBaseline, governedContentCoversPeriod, governedContentSource, + governedMarkdownSource, governedApprovalContentMatches, pendingGovernedSuccessor, + registerGovernedLookup } from "../src/governed-content-baseline.js"; + +const oldBody = "# Operating procedure\n\nReview access every month and retain the completed review.\n"; +const newBody = "# Proposed procedure\n\nReview access every week and retain the completed review.\n"; +async function fixture(t, type = "policy") { + const root = await mkdtemp(join(tmpdir(), "filegrc-same-id-baseline-")); + t.after(() => rm(root, { recursive: true, force: true })); + const model = loadModel("13"); + const record = { id: `${type}-example`, type, title: "Operating content", status: "active", + ownerIds: ["person-owner"], approverIds: ["person-reviewer"], approvedOn: "2026-01-01", + effectiveOn: "2026-01-02", controlIds: ["control-example"], + ...(type === "document" ? { workflowScope: "program", documentKind: "procedure" } : {}) }; + const path = markdownEntries(model, record)[0].path; + const bindings = { [path]: calculateRevision("content", oldBody) }; + record.approvedContentRevisions = bindings; + if (type !== "policy") Object.assign(record, { activationBasis: "recorded", activatedOn: "2026-01-02", + activatedByIds: ["person-owner"], activatedContentRevisions: bindings }); + const jsonPath = join(root, "data", path.replace(/\.md$/, ".json")); + await mkdir(join(root, "data", path.split("/")[0]), { recursive: true }); + await mkdir(join(root, "data/people")); + await writeJson(join(root, "data/workspace.json"), { id: "workspace", type: "workspace", dataModelVersion: "13", timezone: "UTC" }); + for (const id of ["person-owner", "person-reviewer"]) await writeJson(join(root, `data/people/${id}.json`), { id, type: "person", status: "active" }); + await writeJson(jsonPath, record); + await writeFile(join(root, "data", path), oldBody); + await initializeGitWorkspace(root); + const originalCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); + async function current(fallback = false) { + const loaded = await loadWorkspace(root); + if (fallback) reviewHistoryContext(loaded.root, loaded).index = null; + const byId = registerGovernedLookup(new Map(loaded.resources.map((item) => [item.id, item])), loaded); + return { loaded, byId, source: byId.get(record.id) }; + } + async function change(next, body = newBody, commit = false) { + await writeJson(jsonPath, next); + await writeFile(join(root, "data", path), body); + if (commit) await commitWorkspaceFiles(root, "Record content decision"); + } + return { root, record, path, current, change, originalCommit, + approved: { ...record, status: "approved", approvedOn: "2026-06-01", + approvedContentRevisions: { [path]: calculateRevision("content", newBody) } } }; +} + +for (const type of ["policy", "document", "training"]) { + test(`${type} same-ID drafts and new approvals retain exact committed operating content`, async (t) => { + const f = await fixture(t, type); + for (const status of ["draft", "in-review", "approved"]) { + await f.change({ ...f.approved, status }, newBody, status === "approved"); + for (const fallback of [false, true]) { + const { loaded, byId, source } = await f.current(fallback); + const baseline = governedContentBaseline(source, byId, "2026-06-15", loaded.model); + assert.equal(baseline?.approvedOn, "2026-01-01"); + assert.equal(baseline?.id, source.id); + assert.notEqual(baseline, source); + assert.equal(governedMarkdownSource(loaded, baseline, f.path), oldBody); + assert.equal(governedMarkdownSource(loaded, source, f.path), newBody); + assert.equal(governedApprovalContentMatches(loaded, baseline), true); + assert.equal(governedContentSource(loaded, baseline).commit, f.originalCommit); + const pending = pendingGovernedSuccessor(source, byId, "2026-06-15", loaded.model); + assert.deepEqual(pending, baseline); + assert.equal(governedContentSource(loaded, pending).commit, governedContentSource(loaded, baseline).commit); + assert.equal(governedContentCoversPeriod(source, "2026-01-02", "2026-06-15", byId, loaded.model), true); + } + } + const expanded = { ...f.approved, controlIds: ["control-example", "control-new"] }; + await f.change(expanded); + const { loaded, byId, source } = await f.current(); + const baseline = governedContentBaseline(source, byId, "2026-06-15", loaded.model); + assert.deepEqual(baseline.controlIds, ["control-example"], "new proposal duties never enter the operating edition"); + assert.equal(pendingGovernedSuccessor(source, byId, "2026-06-15", loaded.model), null, + "old scope cannot make new proposal obligations informational"); + }); + + test(`${type} actual cutover selects its own proof while future activation preserves prior proof`, async (t) => { + const f = await fixture(t, type); + const active = { ...f.approved, status: "active", effectiveOn: "2026-07-01", + ...(type === "policy" ? {} : { activatedOn: "2026-07-01", activatedContentRevisions: f.approved.approvedContentRevisions }) }; + await f.change(active, newBody, true); + const { loaded, byId, source } = await f.current(); + assert.equal(governedContentBaseline(source, byId, "2026-06-15", loaded.model)?.approvedOn, "2026-01-01"); + assert.equal(governedContentBaseline(source, byId, "2026-07-01", loaded.model), source); + assert.equal(governedContentCoversPeriod(source, "2026-01-02", "2026-07-15", byId, loaded.model), true); + if (type !== "policy") { + await f.change({ ...active, effectiveOn: "2026-06-01" }); + const awaitingActivation = await f.current(); + assert.equal(governedContentBaseline(awaitingActivation.source, awaitingActivation.byId, "2026-06-15", awaitingActivation.loaded.model)?.approvedOn, "2026-01-01", + "actual activation governs cutover when effective date is earlier"); + } + await f.change({ ...active, status: "retired", statusTransition: { changedOn: "2026-08-01" } }, newBody, true); + const retired = await f.current(); + assert.equal(governedContentBaseline(retired.source, retired.byId, "2026-06-15", retired.loaded.model)?.approvedOn, "2026-01-01"); + assert.equal(governedContentBaseline(retired.source, retired.byId, "2026-08-01", retired.loaded.model), null); + await f.change({ ...active, approvedOn: "2026-07-02" }); + const invalid = await f.current(); + assert.equal(governedContentBaseline(invalid.source, invalid.byId, "2026-07-15", invalid.loaded.model), null); + await f.change(active, `${newBody}\nUnapproved operating change.\n`); + const stale = await f.current(); + assert.equal(governedContentBaseline(stale.source, stale.byId, "2026-07-15", stale.loaded.model), null); + }); +} + +test("same-ID historical actual cutovers and retirement are barriers, including draft reverts", async (t) => { + const f = await fixture(t); + const invalid = { ...f.approved, status: "active", effectiveOn: "2026-03-01", approvedOn: "2026-03-02" }; + await f.change(invalid, newBody, true); + await f.change({ ...f.approved, status: "draft" }, newBody, true); + for (const fallback of [false, true]) { + const { loaded, byId, source } = await f.current(fallback); + assert.equal(governedContentBaseline(source, byId, "2026-04-01", loaded.model), null); + assert.equal(governedContentBaseline(source, byId, "2026-02-01", loaded.model)?.approvedOn, "2026-01-01"); + } + await f.change({ ...f.approved, status: "active", effectiveOn: "2026-05-01", approvedOn: "2026-04-01" }, newBody, true); + await f.change({ ...f.approved, status: "draft" }, newBody, true); + let state = await f.current(); + assert.equal(governedContentCoversPeriod(state.source, "2026-01-02", "2026-06-01", state.byId, state.loaded.model), false, + "a valid later edition does not erase an earlier authorization gap"); + await f.change({ ...f.record, status: "retired", statusTransition: { changedOn: "2026-06-01" } }, oldBody, true); + await f.change({ ...f.record, status: "draft" }, oldBody, true); + state = await f.current(); + assert.equal(governedContentBaseline(state.source, state.byId, "2026-06-15", state.loaded.model), null, + "reverting proposed bytes does not undo actual retirement"); +}); + +test("historical lookup stays within its own commit ancestry with and without the history index", async (t) => { + const f = await fixture(t); + await f.change({ ...f.record, status: "draft" }, newBody, true); + const draftCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: f.root, encoding: "utf8" }).trim(); + await f.change({ ...f.approved, status: "retired", statusTransition: { changedOn: "2026-02-01" } }, newBody, true); + for (const fallback of [false, true]) { + const { loaded } = await f.current(fallback); + const context = reviewHistoryContext(loaded.root, loaded); + const historical = reviewHistoricalWorkspace(context, draftCommit); + const byId = registerGovernedLookup(new Map(historical.resources.map((item) => [item.id, item])), historical); + const baseline = governedContentBaseline(byId.get(f.record.id), byId, "2026-06-15", historical.model); + assert.equal(baseline?.approvedOn, "2026-01-01", "future retirement cannot leak into an earlier source snapshot"); + assert.equal(governedMarkdownSource(historical, baseline, f.path), oldBody); + } +}); + +test("minimal registered contexts and source lookup retain exact historical Markdown", async (t) => { + const f = await fixture(t); + await f.change({ ...f.record, status: "draft" }); + const { loaded, source } = await f.current(); + const minimal = { root: loaded.root, resources: loaded.resources, model: loaded.model, workspace: loaded.workspace }; + const byId = registerGovernedLookup(new Map(loaded.resources.map((item) => [item.id, item])), minimal); + const baseline = governedContentBaseline(source, byId, "2026-06-15", loaded.model); + assert.equal(governedMarkdownSource(minimal, baseline, f.path), oldBody); + assert.equal(governedContentSource(minimal, source).entry, null); + assert.equal(await readFile(join(f.root, "data", f.path), "utf8"), newBody); +}); + +test("indexed governed lookup materializes only commits changing the target edition", async (t) => { + const f = await fixture(t); + for (let index = 0; index < 8; index += 1) { + await writeJson(join(f.root, "data/unrelated.json"), { id: "unrelated-work", type: "action-item", title: `Operating work ${index}` }); + await commitWorkspaceFiles(f.root, "Record unrelated operating work"); + } + await f.change({ ...f.record, status: "draft" }); + const { loaded, byId, source } = await f.current(); + const history = reviewHistoryContext(loaded.root, loaded); + assert.equal(history.index.available, true); + assert.equal(governedContentCoversPeriod(source, "2026-01-02", "2026-06-15", byId, loaded.model), true); + assert.equal(history.workspacesByCommit.size, 1, + "unrelated commits must not materialize full historical record populations"); +}); + +test("pending same-ID retention schedule keeps its exact old rows and retirement cannot restore authority", async (t) => { + const { makeRetentionApplicationWorkspace } = await import("./fixtures/retention-applications.js"); + const { assessCollectionReview, retentionScheduleIsAuthoritative } = await import("../src/collection-review.js"); + const { operatingRetentionScheduleWorkspace } = await import("../src/retention-schedule-approval.js"); + const { currentCalendarDate } = await import("../src/time.js"); + const root = await mkdtemp(join(tmpdir(), "filegrc-same-id-schedule-")); + t.after(() => rm(root, { recursive: true, force: true })); + await makeRetentionApplicationWorkspace(root); + let loaded = await loadWorkspace(root); + const entry = loaded.entries.find(({ record }) => record.type === "document" && record.documentKind === "schedule"); + const row = loaded.entries.find(({ record }) => record.type === "retention-schedule-item"); + const today = currentCalendarDate(loaded.workspace.timezone); + const active = { ...entry.record, status: "active", activationBasis: "recorded", effectiveOn: today, + activatedOn: today, activatedByIds: ["person-example"], activatedContentRevisions: entry.record.approvedContentRevisions }; + await writeJson(entry.path, active); + await commitWorkspaceFiles(root, "Activate approved schedule"); + const body = await readFile(entry.path.replace(/\.json$/, ".md"), "utf8"); + const proposedBody = `${body}\nA proposed retention decision awaits adoption.\n`; + await writeFile(entry.path.replace(/\.json$/, ".md"), proposedBody); + await writeJson(entry.path, { ...active, status: "approved", approvedContentRevisions: { + [`documents/${active.id}.md`]: calculateRevision("content", proposedBody) } }); + await writeJson(row.path, { ...row.record, retentionPeriod: { basis: "fixed", amount: 9, unit: "year" } }); + await commitWorkspaceFiles(root, "Approve proposed schedule revision"); + for (const fallback of [false, true]) { + loaded = await loadWorkspace(root); + if (fallback) reviewHistoryContext(loaded.root, loaded).index = null; + const projection = operatingRetentionScheduleWorkspace(loaded); + assert.deepEqual(projection.resources.find(({ id }) => id === row.record.id).retentionPeriod, row.record.retentionPeriod); + assert.equal(assessCollectionReview(loaded, "retention-schedule-item").complete, false); + assert.equal(retentionScheduleIsAuthoritative(loaded, loaded.resources.find(({ type }) => type === "program")), true); + } + await writeFile(entry.path.replace(/\.json$/, ".md"), body); + await writeJson(row.path, row.record); + await writeJson(entry.path, active); + await commitWorkspaceFiles(root, "Restore the reviewed active schedule"); + await writeJson(row.path, { ...row.record, retentionPeriod: { basis: "fixed", amount: 9, unit: "year" } }); + await commitWorkspaceFiles(root, "Change a row without fresh schedule approval"); + loaded = await loadWorkspace(root); + assert.equal(retentionScheduleIsAuthoritative(loaded, loaded.resources.find(({ type }) => type === "program")), false); + await writeJson(entry.path, { ...active, status: "draft" }); + await commitWorkspaceFiles(root, "Draft the next schedule proposal"); + for (const fallback of [false, true]) { + loaded = await loadWorkspace(root); + if (fallback) reviewHistoryContext(loaded.root, loaded).index = null; + const byId = registerGovernedLookup(new Map(loaded.resources.map((item) => [item.id, item])), loaded); + assert.equal(governedContentBaseline(byId.get(active.id), byId, today, loaded.model), null, + "all baseline consumers retain the last active edition's missing whole-schedule authorization"); + assert.equal(retentionScheduleIsAuthoritative(loaded, loaded.resources.find(({ type }) => type === "program")), false, + "a draft cannot restore authority lost by an unreviewed active row change"); + } + await writeJson(row.path, row.record); + await writeJson(entry.path, active); + await commitWorkspaceFiles(root, "Restore the exact reviewed schedule before retirement"); + await writeJson(entry.path, { ...active, status: "retired", statusTransition: { changedOn: today } }); + await commitWorkspaceFiles(root, "Retire operating schedule"); + await writeJson(entry.path, { ...active, status: "approved" }); + await commitWorkspaceFiles(root, "Propose copied schedule content"); + loaded = await loadWorkspace(root); + assert.equal(retentionScheduleIsAuthoritative(loaded, loaded.resources.find(({ type }) => type === "program")), false, + "copying the exact former bytes and approval facts cannot reverse an actual retirement"); +}); + +test("a pending retention edition preserves unrelated Program review records", async (t) => { + const { makeRetentionApplicationWorkspace } = await import("./fixtures/retention-applications.js"); + const { applyCollectionReview, scaffoldCollectionReview, retentionScheduleIsAuthoritative } = await import("../src/collection-review.js"); + const { operatingRetentionScheduleWorkspace } = await import("../src/retention-schedule-approval.js"); + const { currentCalendarDate } = await import("../src/time.js"); + const root = await mkdtemp(join(tmpdir(), "filegrc-retention-program-reviews-")); + t.after(() => rm(root, { recursive: true, force: true })); + await makeRetentionApplicationWorkspace(root); + let loaded = await loadWorkspace(root); + const firstProgram = loaded.resources.find(({ type }) => type === "program"); + const otherProgram = { ...firstProgram, id: "program-other", title: "Second program" }; + await writeJson(join(root, "data/programs/program-other.json"), otherProgram); + await commitWorkspaceFiles(root, "Add the second program"); + const scaffold = await scaffoldCollectionReview(root, { resourceType: "retention-schedule-item", programId: firstProgram.id }); + await applyCollectionReview(root, { ...scaffold, programId: firstProgram.id, decision: "complete", rationale: "Reviewed the shared schedule for both programs.", + reviewedByIds: ["person-independent-approver-example"], reviewedOn: currentCalendarDate(loaded.workspace.timezone), confirmed: true }); + loaded = await loadWorkspace(root); + const entry = loaded.entries.find(({ record }) => record.type === "document" && record.documentKind === "schedule"); + const today = currentCalendarDate(loaded.workspace.timezone); + const active = { ...entry.record, status: "active", activationBasis: "recorded", effectiveOn: today, + activatedOn: today, activatedByIds: ["person-example"], activatedContentRevisions: entry.record.approvedContentRevisions }; + await writeJson(entry.path, active); + await commitWorkspaceFiles(root, "Activate the shared approved schedule"); + loaded = await loadWorkspace(root); + const sharedReview = loaded.resources.find((item) => item.type === "collection-review" && item.resourceType === "retention-schedule-item"); + const otherReview = { ...sharedReview, id: "review-other-program-history", scopeResourceIds: [otherProgram.id], populationResourceIds: ["retention-rule-other-period"] }; + await writeJson(join(root, "data/collection-reviews/review-other-program-history.json"), otherReview); + await commitWorkspaceFiles(root, "Retain the second program's separate historical review"); + await writeJson(entry.path, { ...active, status: "draft" }); + await commitWorkspaceFiles(root, "Draft the next shared schedule edition"); + loaded = await loadWorkspace(root); + const projection = operatingRetentionScheduleWorkspace(loaded); + assert.deepEqual(projection.resources.find(({ id }) => id === otherReview.id), otherReview, + "replacing one schedule population must not remove another program's review history"); + assert.deepEqual(projection.entries.find(({ record }) => record.id === otherReview.id).record, otherReview); + assert.equal(retentionScheduleIsAuthoritative(loaded, firstProgram), true); + assert.equal(retentionScheduleIsAuthoritative(loaded, otherProgram), true); + const byId = registerGovernedLookup(new Map(loaded.resources.map((item) => [item.id, item])), loaded); + assert.equal(governedContentBaseline(byId.get(active.id), byId, today, loaded.model)?.status, "active", + "the shared baseline requires whole-schedule proof for both owning Programs"); + const otherSchedule = { ...active, id: "document-other-schedule", title: "Second program's proposed independent schedule" }; + const otherBindings = { [`documents/${otherSchedule.id}.md`]: calculateRevision("content", await readFile(entry.path.replace(/\.json$/, ".md"), "utf8")) }; + Object.assign(otherSchedule, { approvedContentRevisions: otherBindings, activatedContentRevisions: otherBindings }); + await writeJson(join(root, `data/documents/${otherSchedule.id}.json`), otherSchedule); + await writeFile(join(root, `data/documents/${otherSchedule.id}.md`), await readFile(entry.path.replace(/\.json$/, ".md"), "utf8")); + loaded = await loadWorkspace(root); + const independent = registerGovernedLookup(new Map(loaded.resources.map((item) => [item.id, item])), loaded); + assert.equal(governedContentBaseline(independent.get(otherSchedule.id), independent, today, loaded.model), null, + "a second active schedule cannot borrow the shared schedule's whole-workspace approval while the first awaits review"); +}); + +test("resolved edition provenance never changes interned records in another snapshot", async (t) => { + const f = await fixture(t); + await f.change({ ...f.record, status: "draft" }, newBody, true); + const draftCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: f.root, encoding: "utf8" }).trim(); + await f.change(f.record, newBody, true); + const invalidCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: f.root, encoding: "utf8" }).trim(); + const { loaded } = await f.current(); + const history = reviewHistoryContext(loaded.root, loaded); + const original = reviewHistoricalWorkspace(history, f.originalCommit); + const draft = reviewHistoricalWorkspace(history, draftCommit); + const invalid = reviewHistoricalWorkspace(history, invalidCommit); + const originalRecord = original.resources.find(({ id }) => id === f.record.id); + const invalidRecord = invalid.resources.find(({ id }) => id === f.record.id); + assert.equal(originalRecord, invalidRecord, "fixture exercises history's JSON interning"); + const byId = registerGovernedLookup(new Map(draft.resources.map((item) => [item.id, item])), draft); + const edition = governedContentBaseline(byId.get(f.record.id), byId, "2026-06-15", draft.model); + assert.notEqual(edition, originalRecord); + assert.equal(governedMarkdownSource(draft, edition, f.path), oldBody); + assert.equal(governedContentSource(invalid, invalidRecord).loaded, invalid); + assert.equal(governedMarkdownSource(invalid, invalidRecord, f.path), newBody); + assert.equal(governedApprovalContentMatches(invalid, invalidRecord), false); +}); + +test("a starter active parent without whole-schedule proof does not block first approved schedule setup", async (t) => { + const { makeSourceCoverageRetentionWorkspace } = await import("./fixtures/retention-applications.js"); + const { assessCollectionReview, retentionScheduleIsAuthoritative } = await import("../src/collection-review.js"); + const { governedContentHasOperatingHistory } = await import("../src/governed-content-baseline.js"); + const root = await mkdtemp(join(tmpdir(), "filegrc-first-schedule-approval-")); + t.after(() => rm(root, { recursive: true, force: true })); + await makeSourceCoverageRetentionWorkspace(root); + for (const fallback of [false, true]) { + const loaded = await loadWorkspace(root); + if (fallback) reviewHistoryContext(loaded.root, loaded).index = null; + const schedule = loaded.resources.find((record) => record.type === "document" && record.documentKind === "schedule"); + assert.equal(schedule.status, "approved"); + assert.equal(assessCollectionReview(loaded, "retention-schedule-item").complete, true); + assert.equal(governedContentHasOperatingHistory(loaded, schedule), false, + "a parent label alone cannot establish prior operation without exact whole-schedule proof"); + assert.equal(retentionScheduleIsAuthoritative(loaded, loaded.resources.find(({ type }) => type === "program")), true, + "the first valid independent approval remains available for setup and owner rule applications"); + } +}); + +test("Source Coverage and owner applications retain effective rows through a same-ID proposal", async (t) => { + const { makeSourceCoverageRetentionWorkspace, sourceCoverageRetentionDecision } = await import("./fixtures/retention-applications.js"); + const { retentionApplicationIssues, scaffoldRetentionApplication, saveRetentionApplication } = await import("../src/retention-applications.js"); + const { assessRetentionReadiness, resolveSourceCoverageRetention, resourceReviewRevisionsSync, retentionReviewResourceIds } = await import("../src/retention.js"); + const { sourceCoverageComplete, sourcePreparationFacts } = await import("../src/source-coverage.js"); + const { retentionScheduleIsAuthoritative, applyCollectionReview, scaffoldCollectionReview } = await import("../src/collection-review.js"); + const { currentCalendarDate } = await import("../src/time.js"); + const { updateResource } = await import("../src/files.js"); + const { readResourceMutation } = await import("../src/resource-mutation.js"); + const { validateWorkspace } = await import("../src/validate.js"); + const root = await mkdtemp(join(tmpdir(), "filegrc-effective-source-retention-")); + t.after(() => rm(root, { recursive: true, force: true })); + await makeSourceCoverageRetentionWorkspace(root); + await saveRetentionApplication(root, await saveRetentionApplication(root, await sourceCoverageRetentionDecision(root), { preview: true })); + await commitWorkspaceFiles(root, "Apply the approved three-year rule to selected proof"); + let loaded = await loadWorkspace(root); + const entry = loaded.entries.find(({ record }) => record.type === "document" && record.documentKind === "schedule"); + const rowEntry = loaded.entries.find(({ record }) => record.type === "retention-schedule-item"); + const today = currentCalendarDate(loaded.workspace.timezone); + const active = { ...entry.record, status: "active", activationBasis: "recorded", effectiveOn: today, + activatedOn: today, activatedByIds: ["person-example"], activatedContentRevisions: entry.record.approvedContentRevisions }; + await writeJson(entry.path, active); + await commitWorkspaceFiles(root, "Activate the exact independently approved schedule"); + const unchanged = await scaffoldCollectionReview(root, { resourceType: "retention-schedule-item" }); + await applyCollectionReview(root, { ...unchanged, decision: "complete", rationale: "Independently reviewed the unchanged active schedule before proposing its successor edition.", + reviewedByIds: ["person-independent-approver-example"], reviewedOn: today, confirmed: true }); + await commitWorkspaceFiles(root, "Review the complete unchanged schedule"); + const mutation = await readResourceMutation(root, "retention-schedule-item", rowEntry.record.id); + await updateResource(root, "retention-schedule-item", rowEntry.record.id, { ...mutation.record, retentionPeriod: { basis: "fixed", amount: 9, unit: "year" } }, + { expectedRevision: mutation.revision, content: { record: "# Proposed row procedure\n\nA proposed new row procedure awaits adoption.\n" }, expectedContentRevisions: mutation.contentRevisions }); + await commitWorkspaceFiles(root, "Propose a nine-year schedule revision"); + loaded = await loadWorkspace(root); + const program = loaded.resources.find(({ type }) => type === "program"); + const coverage = loaded.resources.find(({ id }) => id === "source-coverage-selected-proof"); + const app = loaded.resources.find(({ type }) => type === "retention-application"); + assert.equal(retentionScheduleIsAuthoritative(loaded, program), true); + assert.deepEqual(retentionApplicationIssues(loaded, app), []); + assert.equal((await resolveSourceCoverageRetention(loaded, coverage, program)).rules[0].retentionPeriod.amount, 3); + assert.equal(await sourceCoverageComplete(coverage, loaded, program), true); + assert.equal((await sourcePreparationFacts(loaded, coverage, program, { asOf: "2026-10-24", sourceId: coverage.componentId, controlIds: ["control-example"] })).complete, true); + const readiness = await assessRetentionReadiness(loaded, program, { scheduleApproved: true }); + assert.equal(readiness.find(({ id }) => id === "retention-source-coverage-source-coverage-selected-proof").status, "complete"); + const scaffold = await scaffoldRetentionApplication(root, rowEntry.record.id); + assert.equal(scaffold.approvedRule.retentionPeriod.amount, 3); + assert.equal(scaffold.record.ruleRevision, app.ruleRevision); + const pendingPayload = { ...await sourceCoverageRetentionDecision(root) }; + pendingPayload.record.id = "retention-application-another-current-three-year-decision"; + const preview = await saveRetentionApplication(root, pendingPayload, { preview: true }); + assert.equal(preview.approvedRule.retentionPeriod.amount, 3); + assert.equal(preview.record.ruleRevision, app.ruleRevision); + const coverageEntry = loaded.entries.find(({ record }) => record.id === coverage.id); + await writeJson(coverageEntry.path, { ...coverage, excludedPopulation: "Changed material source population." }); + let changed = await loadWorkspace(root); + assert.ok(retentionApplicationIssues(changed, app).some(({ message }) => /Service data use/.test(message))); + assert.equal(await sourceCoverageComplete(changed.resources.find(({ id }) => id === coverage.id), changed, program), false); + await writeJson(coverageEntry.path, coverage); + const componentEntry = loaded.entries.find(({ record }) => record.id === coverage.componentId); + await writeJson(componentEntry.path, { ...componentEntry.record, description: "A materially different business purpose and service data use." }); + changed = await loadWorkspace(root); + assert.ok(retentionApplicationIssues(changed, app).length > 0, "current inventory facts must never be replaced by old schedule scope facts"); + await writeJson(componentEntry.path, componentEntry.record); + loaded = await loadWorkspace(root); + const proposedRow = loaded.resources.find(({ id }) => id === rowEntry.record.id); + await writeJson(rowEntry.path, { ...proposedRow, reviewedSourceRevisions: Object.fromEntries(resourceReviewRevisionsSync(loaded, retentionReviewResourceIds(proposedRow, loaded), "legacy", false, null, proposedRow)) }); + const review = await scaffoldCollectionReview(root, { resourceType: "retention-schedule-item" }); + await applyCollectionReview(root, { ...review, decision: "complete", rationale: "Reviewed the changed retention period and procedure.", reviewedByIds: ["person-independent-approver-example"], reviewedOn: today, confirmed: true }); + await commitWorkspaceFiles(root, "Approve the proposed nine-year schedule"); + loaded = await loadWorkspace(root); + const approvedValidation = await validateWorkspace(root); + assert.equal(approvedValidation.ok, true, JSON.stringify(approvedValidation.diagnostics.filter(({ severity }) => severity === "error"))); + assert.deepEqual(retentionApplicationIssues(loaded, app), [], "independent approval alone does not cut over the operating rule"); + assert.equal((await resolveSourceCoverageRetention(loaded, loaded.resources.find(({ id }) => id === coverage.id), program)).rules[0].retentionPeriod.amount, 3); + const approved = loaded.resources.find(({ id }) => id === active.id); + await writeJson(entry.path, { ...approved, status: "active", activationBasis: "recorded", effectiveOn: today, activatedOn: today, + activatedByIds: ["person-example"], activatedContentRevisions: approved.approvedContentRevisions }); + await commitWorkspaceFiles(root, "Activate the independently approved nine-year schedule"); + loaded = await loadWorkspace(root); + assert.ok(retentionApplicationIssues(loaded, app).length > 0, "actual changed-rule cutover invalidates the old three-year applicability decision"); + assert.equal((await resolveSourceCoverageRetention(loaded, loaded.resources.find(({ id }) => id === coverage.id), program)).rules.length, 0); + assert.deepEqual(retentionApplicationIssues(loaded, { ...app, status: "retired", statusTransition: { + changedOn: today, changedByIds: ["person-example"], reason: "The owner retired the prior decision after changed-rule cutover." } }), [], + "retiring the stale application preserves its original independent proof and owner decision"); +}); + +test("an unchanged calendar rule retains its anchor, open windows and completion links after same-ID policy activation", async (t) => { + const { makeComprehensiveWorkspace } = await import("./fixtures.js"); + const { planObligations, obligationScheduleRecurrence, scaffoldObligationOccurrence } = await import("../src/obligations.js"); + const { validateWorkspace } = await import("../src/validate.js"); + const { scaffoldCollectionReview, applyCollectionReview } = await import("../src/collection-review.js"); + const root = await mkdtemp(join(tmpdir(), "filegrc-calendar-content-cutover-")); + t.after(() => rm(root, { recursive: true, force: true })); + const originalAuthorDate = process.env.GIT_AUTHOR_DATE; + const originalCommitterDate = process.env.GIT_COMMITTER_DATE; + t.after(() => { + if (originalAuthorDate === undefined) delete process.env.GIT_AUTHOR_DATE; else process.env.GIT_AUTHOR_DATE = originalAuthorDate; + if (originalCommitterDate === undefined) delete process.env.GIT_COMMITTER_DATE; else process.env.GIT_COMMITTER_DATE = originalCommitterDate; + }); + t.mock.timers.enable({ apis: ["Date"], now: Date.parse("2026-07-14T18:00:00Z") }); + const at = (timestamp) => { + t.mock.timers.setTime(Date.parse(timestamp)); + process.env.GIT_AUTHOR_DATE = timestamp; + process.env.GIT_COMMITTER_DATE = timestamp; + }; + at("2026-07-14T18:00:00Z"); + await makeComprehensiveWorkspace(root, "11"); + let loaded = await loadWorkspace(root); + const obligationEntry = loaded.entries.find(({ record }) => record.type === "obligation"); + const ruleEntry = loaded.entries.find(({ record }) => record.type === "obligation-rule"); + const occurrenceEntry = loaded.entries.find(({ record }) => record.type === "obligation-occurrence"); + const reviewEntry = loaded.entries.find(({ record }) => record.type === "vendor-review"); + const policyEntry = loaded.entries.find(({ record }) => record.type === "policy"); + const obligation = { ...obligationEntry.record, activityType: "vendor-review", scopeResourceIds: [] }; + const rule = { ...ruleEntry.record, recurrence: { mode: "calendar", unit: "month", interval: 1, anchorDate: "2026-06-30" }, + window: { precision: "date", startsAfter: 0, dueAfter: 14 }, + selector: { resourceType: "vendor", membershipMode: "as-of", cutoff: "window-end", statuses: ["active"] } }; + const occurrence = { ...occurrenceEntry.record, occurrenceKey: `program-example:${obligation.id}:2026-06-30`, + coverage: { kind: "range", startsOn: "2026-06-30", endsOn: "2026-07-14" }, membershipCutoffAt: "2026-07-14", + members: [{ resourceId: "vendor-example", disposition: "expected", result: "passed", completionResourceIds: [reviewEntry.record.id] }], + reconciledAt: "2026-07-15T15:30:00Z" }; + await writeJson(obligationEntry.path, obligation); + await writeJson(ruleEntry.path, rule); + await rm(occurrenceEntry.path); + await writeJson(reviewEntry.path, { ...reviewEntry.record, completedOn: "2026-07-10", coverage: { kind: "as-of", on: "2026-07-10" } }); + const componentEntry = loaded.entries.find(({ record }) => record.type === "component"); + await writeJson(componentEntry.path, { ...componentEntry.record, vendorId: "vendor-example" }); + await initializeGitWorkspace(root); + const populationReview = await scaffoldCollectionReview(root, { resourceType: "vendor", now: "2026-07-14T18:00:00Z" }); + await applyCollectionReview(root, { ...populationReview, decision: "complete", rationale: "Reviewed the generic vendor population at the June cycle cutoff.", + reviewedByIds: ["person-independent-approver-example"], reviewedOn: "2026-07-14", now: "2026-07-14T18:00:00Z", confirmed: true }); + await commitWorkspaceFiles(root, "Review the exact June vendor population at its cutoff"); + at("2026-07-15T18:00:00Z"); + const cutoffOccurrence = await scaffoldObligationOccurrence(root, { obligationId: obligation.id, windowStart: "2026-06-30", asOf: "2026-07-15" }); + await writeJson(occurrenceEntry.path, { ...cutoffOccurrence.record, ...occurrence }); + await commitWorkspaceFiles(root, "Retain the June vendor review and its exact population proof"); + at("2026-08-01T18:00:00Z"); + await writeJson(join(root, "data/workspace.json"), { ...loaded.workspace, dataModelVersion: "13" }); + await writeJson(join(root, "package.json"), { private: true, dependencies: { filegrc: "^0.21.0" } }); + await writeJson(join(root, "package-lock.json"), { lockfileVersion: 3, packages: { "": { dependencies: { filegrc: "^0.21.0" } }, "node_modules/filegrc": { version: "0.21.0" } } }); + await commitWorkspaceFiles(root, "Adopt the current model without changing the operating rule"); + const unchangedPaths = [obligationEntry.path, ruleEntry.path, occurrenceEntry.path, reviewEntry.path]; + const originalBytes = await Promise.all(unchangedPaths.map((path) => readFile(path, "utf8"))); + const options = { asOf: "2026-10-10", from: "2026-06-30", through: "2026-10-29", includeComplete: true }; + const assess = async () => { + const current = await loadWorkspace(root); + const byId = registerGovernedLookup(new Map(current.resources.map((record) => [record.id, record])), current); + return { schedule: obligationScheduleRecurrence(obligation, rule, byId, current.model, "America/Chicago", new Set(), options.asOf), + items: planObligations(current.resources, { ...options, loaded: current, root: current.root, model: current.model }).calendarItems + .filter((item) => item.obligationId === obligation.id) + .map(({ ruleId, occurrenceId, dueWindowStart, dueWindowEnd, status, completionResourceIds }) => ({ ruleId, occurrenceId, dueWindowStart, dueWindowEnd, status, completionResourceIds })) + .sort((left, right) => left.dueWindowStart.localeCompare(right.dueWindowStart)) }; + }; + const before = await assess(); + assert.equal(before.schedule.recurrence.anchorDate, "2026-06-30"); + assert.equal(before.schedule.activationDate, "2026-06-30", "a rule effective in January must wait for the first actual June policy operation"); + assert.ok(before.items.some((item) => item.dueWindowStart === "2026-07-30" && item.status === "overdue")); + assert.ok(before.items.some((item) => item.dueWindowStart === "2026-09-30" && !["complete", "overdue"].includes(item.status))); + assert.deepEqual(before.items.find((item) => item.occurrenceId === occurrence.id)?.completionResourceIds, [reviewEntry.record.id]); + const body = `${await readFile(policyEntry.path.replace(/\.json$/, ".md"), "utf8")}\nThe reviewed policy revision takes effect on August 15.\n`; + const proposed = { ...policyEntry.record, status: "approved", approvedOn: "2026-08-01", + approvedContentRevisions: { [`policies/${policyEntry.record.id}.md`]: calculateRevision("content", body) } }; + delete proposed.effectiveOn; + await writeJson(policyEntry.path, proposed); + await writeFile(policyEntry.path.replace(/\.json$/, ".md"), body); + await commitWorkspaceFiles(root, "Approve the next policy revision"); + const scheduleReview = await scaffoldCollectionReview(root, { resourceType: "obligation", now: "2026-08-01T18:00:00Z" }); + await applyCollectionReview(root, { ...scheduleReview, decision: "complete", rationale: "Reviewed the unchanged monthly vendor duty against the new policy edition.", + scheduleDecisions: scheduleReview.scheduleDecisions.map((decision) => ({ ...decision, disposition: "adopted", rationale: "Keep the existing approved monthly rule." })), + reviewedByIds: ["person-independent-approver-example"], reviewedOn: "2026-08-01", now: "2026-08-01T18:00:00Z", confirmed: true }); + await commitWorkspaceFiles(root, "Approve the exact unchanged operating schedule set"); + assert.deepEqual((await assess()).items, before.items); + at("2026-08-15T18:00:00Z"); + await writeJson(policyEntry.path, { ...proposed, status: "active", effectiveOn: "2026-08-15" }); + await commitWorkspaceFiles(root, "Activate the approved policy revision"); + const after = await assess(); + const validation = await validateWorkspace(root); + assert.equal(validation.ok, true, JSON.stringify(validation.diagnostics.filter(({ severity }) => severity === "error"))); + assert.equal(after.schedule.recurrence.anchorDate, before.schedule.recurrence.anchorDate); + assert.deepEqual(await Promise.all(unchangedPaths.map((path) => readFile(path, "utf8"))), originalBytes, + "content activation must not modify the rule, its approval and anchor, or the existing proof records"); + assert.deepEqual(after.items, before.items, + "an unchanged approved rule keeps prior unrecorded open and overdue windows as well as frozen completion links"); + await assert.rejects(scaffoldObligationOccurrence(root, { obligationId: obligation.id, windowStart: "2026-07-30", asOf: options.asOf }), + /population can no longer be inferred/, "the owed July window exists, but backfilling its population still requires actual temporal proof"); + const retained = await scaffoldObligationOccurrence(root, { obligationId: obligation.id, windowStart: "2026-06-30", asOf: options.asOf, correctFinalized: true }); + assert.deepEqual(retained.record.coverage, occurrence.coverage); + assert.equal(retained.record.ruleId, rule.id); + assert.deepEqual(retained.record.members.flatMap((member) => member.completionResourceIds || []), [reviewEntry.record.id]); + at("2026-09-15T18:00:00Z"); + const successor = { ...rule, id: "obligation-rule-real-cutover", supersedesId: rule.id, + approvedOn: "2026-09-01", effectiveAt: "2026-09-15T00:00:00-05:00", cutoverDecision: "supersede-open-window", + recurrence: { ...rule.recurrence, anchorDate: "2026-09-15" } }; + await writeJson(join(root, "data/obligation-rules/obligation-rule-real-cutover.json"), successor); + await writeJson(obligationEntry.path, { ...obligation, activeRuleId: successor.id, ruleIds: [rule.id, successor.id] }); + loaded = await loadWorkspace(root); + const nextById = registerGovernedLookup(new Map(loaded.resources.map((record) => [record.id, record])), loaded); + const amended = obligationScheduleRecurrence(nextById.get(obligation.id), successor, nextById, loaded.model, "America/Chicago", new Set(), options.asOf); + assert.equal(amended.activationDate, "2026-09-15", "a real new rule still starts at its actual effective cutover"); + assert.equal(amended.recurrence.anchorDate, "2026-09-15"); +}); + +for (const type of ["document", "training"]) { + test(`a calendar rule waits for first ${type} activation without restarting on later editions`, async (t) => { + const { makeComprehensiveWorkspace } = await import("./fixtures.js"); + const { planObligations, obligationScheduleRecurrence } = await import("../src/obligations.js"); + const root = await mkdtemp(join(tmpdir(), `filegrc-initial-${type}-activation-`)); + t.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "13"); + let loaded = await loadWorkspace(root); + const oe = loaded.entries.find(({ record }) => record.type === "obligation"); + const re = loaded.entries.find(({ record }) => record.type === "obligation-rule"); + const entry = loaded.entries.find(({ record }) => record.type === type); + const obligation = { ...oe.record, activityType: "vendor-review", scopeResourceIds: type === "document" ? [entry.record.id] : [], + ...(type === "training" ? { templateResourceId: entry.record.id } : {}) }; + const rule = { ...re.record, recurrence: { mode: "calendar", unit: "month", interval: 1, anchorDate: "2026-06-30" }, + window: { precision: "date", startsAfter: 0, dueAfter: 30 }, + selector: { resourceType: "vendor", membershipMode: "as-of", cutoff: "window-end", statuses: ["active"] } }; + const initial = { ...entry.record, effectiveOn: "2026-07-10", activatedOn: "2026-07-10" }; + await writeJson(oe.path, obligation); await writeJson(re.path, rule); await writeJson(entry.path, initial); + const controlEntry = loaded.entries.find(({ record }) => record.type === "control"); + await writeJson(join(root, "data/controls/control-unrelated.json"), { ...controlEntry.record, id: "control-unrelated", title: "A separate control outside the unchanged obligation" }); + await writeFile(join(root, "data/controls/control-unrelated.md"), await readFile(controlEntry.path.replace(/\.json$/, ".md"), "utf8")); + for (const item of loaded.entries.filter(({ record }) => record.type === "obligation-occurrence")) await rm(item.path); + await initializeGitWorkspace(root); + const inspect = async () => { + const current = await loadWorkspace(root); + const byId = registerGovernedLookup(new Map(current.resources.map((record) => [record.id, record])), current); + return { schedule: obligationScheduleRecurrence(obligation, rule, byId, current.model, "America/Chicago", new Set(), "2026-10-10"), + windows: planObligations(current.resources, { loaded: current, root: current.root, model: current.model, asOf: "2026-10-10", from: "2026-06-30", through: "2026-10-29", includeComplete: true }) + .calendarItems.map(({ dueWindowStart, dueWindowEnd }) => ({ dueWindowStart, dueWindowEnd })) }; + }; + const before = await inspect(); + assert.equal(before.schedule.activationDate, "2026-07-10"); + assert.equal(before.windows[0].dueWindowStart, "2026-07-10"); + const body = `${await readFile(entry.path.replace(/\.json$/, ".md"), "utf8")}\nA later independently reviewed edition.\n`; + const bindings = { [markdownEntries(loaded.model, initial)[0].path]: calculateRevision("content", body) }; + await writeFile(entry.path.replace(/\.json$/, ".md"), body); + await writeJson(entry.path, { ...initial, controlIds: [...new Set([...(initial.controlIds || []), "control-unrelated"])], approvedOn: "2026-08-01", approvedContentRevisions: bindings, + effectiveOn: "2026-08-15", activatedOn: "2026-08-15", activatedContentRevisions: bindings }); + await commitWorkspaceFiles(root, "Activate the later governed content edition"); + const after = await inspect(); + assert.equal(after.schedule.activationDate, "2026-07-10"); + assert.deepEqual(after.windows, before.windows, "an unrelated added Control must not reset the unchanged rule's existing duty"); + }); +} + +test("rules starting after current governed activation do not materialize historical workspaces", async (t) => { + const { obligationScheduleRecurrence } = await import("../src/obligations.js"); + const f = await fixture(t); + const { loaded, byId } = await f.current(); + const history = reviewHistoryContext(loaded.root, loaded); + const obligation = { id: "obligation-later-rule", type: "obligation", policyIds: [f.record.id] }; + const rule = { id: "obligation-rule-later", type: "obligation-rule", effectiveAt: "2026-06-01T00:00:00Z", timezone: "UTC", + recurrence: { mode: "calendar", unit: "month", interval: 1, anchorMode: "activation" } }; + for (let index = 0; index < 5; index += 1) { + const schedule = obligationScheduleRecurrence(obligation, rule, byId, loaded.model, "UTC", new Set(), "2026-07-01"); + assert.equal(schedule.activationDate, "2026-06-01"); + assert.equal(schedule.recurrence.anchorDate, "2026-06-01"); + } + assert.equal(history.workspacesByCommit.size, 0); +}); + +test("unchanged operating dates across metadata commits avoid full origin snapshots for every rule", async (t) => { + const { governedContentOperatingOrigin } = await import("../src/governed-content-baseline.js"); + const { withAssessmentCache } = await import("../src/calculation-cache.js"); + const f = await fixture(t); + for (let index = 0; index < 8; index += 1) await f.change({ ...f.record, title: `Reviewed administrative label ${index}` }, oldBody, true); + for (const fallback of [false, true]) { + const { loaded, byId, source } = await f.current(fallback); + const history = reviewHistoryContext(loaded.root, loaded); + withAssessmentCache(() => { + for (let index = 0; index < 20; index += 1) { + assert.equal(governedContentOperatingOrigin(source, byId, "2026-07-01", loaded.model, { scopeKey: `rule-${index}` }), "2026-01-02"); + } + }); + assert.equal(history.workspacesByCommit.size, 0, "date discovery reads target JSON versions instead of materializing whole program snapshots"); + } +}); + +test("whole-schedule proof reuses exact source cohorts but preserves cutovers and fresh Markdown checks", async (t) => { + const { makeRetentionApplicationWorkspace } = await import("./fixtures/retention-applications.js"); + const { retentionScheduleOperatingProofMatches } = await import("../src/retention-schedule-approval.js"); + const { withAssessmentCache } = await import("../src/calculation-cache.js"); + const { currentCalendarDate } = await import("../src/time.js"); + const { addCalendarDays } = await import("../src/recurrence.js"); + const root = await mkdtemp(join(tmpdir(), "filegrc-proof-cohort-")); + t.after(() => rm(root, { recursive: true, force: true })); + await makeRetentionApplicationWorkspace(root); + let loaded = await loadWorkspace(root); + const entry = loaded.entries.find(({ record }) => record.type === "document" && record.documentKind === "schedule"); + const row = loaded.entries.find(({ record }) => record.type === "retention-schedule-item"); + const today = currentCalendarDate(loaded.workspace.timezone); + const active = { ...entry.record, status: "active", activationBasis: "recorded", effectiveOn: today, activatedOn: today, + activatedByIds: ["person-example"], activatedContentRevisions: entry.record.approvedContentRevisions }; + await writeJson(entry.path, active); + await commitWorkspaceFiles(root, "Activate the independently approved schedule"); + const cutover = addCalendarDays(today, 2); + const next = { ...active, id: "document-future-schedule", supersedesId: active.id, effectiveOn: cutover, activatedOn: cutover }; + const body = await readFile(entry.path.replace(/\.json$/, ".md"), "utf8"); + const bindings = { [`documents/${next.id}.md`]: calculateRevision("content", body) }; + Object.assign(next, { approvedContentRevisions: bindings, activatedContentRevisions: bindings }); + await writeJson(join(root, `data/documents/${next.id}.json`), next); + await writeFile(join(root, `data/documents/${next.id}.md`), body); + await writeJson(join(root, "data/retention-schedule-items/retention-future.json"), { ...row.record, id: "retention-future", status: "planned", scheduleDocumentId: next.id }); + loaded = await loadWorkspace(root); + const parent = loaded.resources.find(({ id }) => id === active.id); + const program = loaded.resources.find(({ type }) => type === "program"); + const controls = program.controlIds; + let scopeReads = 0; + Object.defineProperty(program, "controlIds", { configurable: true, enumerable: true, get() { scopeReads += 1; return controls; } }); + withAssessmentCache(() => { + assert.equal(retentionScheduleOperatingProofMatches(loaded, parent, today), true); + const firstReads = scopeReads; + assert.ok(firstReads > 0); + assert.equal(retentionScheduleOperatingProofMatches({ ...loaded, entries: [...loaded.entries] }, parent, addCalendarDays(today, 1)), true); + assert.equal(scopeReads, firstReads, "identical proof and successor exclusions reuse one assessment across wrappers and query dates"); + assert.equal(retentionScheduleOperatingProofMatches(loaded, parent, cutover), false, + "the changed successor cohort must recheck its missing independent approval"); + }); + await writeFile(entry.path.replace(/\.json$/, ".md"), `${body}\nUnapproved changed retention terms.\n`); + withAssessmentCache(() => assert.equal(retentionScheduleOperatingProofMatches(loaded, parent, today), false, + "a new request observes current Markdown rather than retaining the earlier proof result")); +}); + +test("application ancestry refreshes HEAD per request and respects historical commit boundaries", async (t) => { + const { makeSourceCoverageRetentionWorkspace, sourceCoverageRetentionDecision } = await import("./fixtures/retention-applications.js"); + const { retentionApplicationIssues, retentionApplicationScopeRevision } = await import("../src/retention-applications.js"); + const { withAssessmentCache } = await import("../src/calculation-cache.js"); + const root = await mkdtemp(join(tmpdir(), "filegrc-application-head-boundary-")); + t.after(() => rm(root, { recursive: true, force: true })); + await makeSourceCoverageRetentionWorkspace(root); + const loaded = await loadWorkspace(root); + const payload = await sourceCoverageRetentionDecision(root); + payload.record.reviewedScopeRevisions = Object.fromEntries(payload.record.scopeResourceIds.map((id) => [id, + retentionApplicationScopeRevision(loaded.resources.find((item) => item.id === id), loaded)])); + const originalCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); + withAssessmentCache(() => assert.deepEqual(retentionApplicationIssues(loaded, payload.record), [])); + const coverage = loaded.entries.find(({ record }) => record.id === "source-coverage-selected-proof"); + await writeJson(coverage.path, { ...coverage.record, title: "Administrative label after the approval snapshot" }); + await commitWorkspaceFiles(root, "Update a nonmaterial source label"); + const futureCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); + const laterApproval = { ...payload.record, approvalCommit: futureCommit }; + const current = await loadWorkspace(root); + withAssessmentCache(() => assert.deepEqual(retentionApplicationIssues(current, laterApproval), [], + "a later request receives the new exact HEAD rather than a cross-request Git cache")); + const historical = reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), originalCommit); + withAssessmentCache(() => assert.ok(retentionApplicationIssues(historical, laterApproval).some(({ message }) => /exact committed independent schedule approval/.test(message)), + "a later approval commit cannot enter an earlier historical assessment through current HEAD")); +}); diff --git a/packages/filegrc/test/same-id-operating-consumers.test.js b/packages/filegrc/test/same-id-operating-consumers.test.js new file mode 100644 index 0000000..a86321e --- /dev/null +++ b/packages/filegrc/test/same-id-operating-consumers.test.js @@ -0,0 +1,308 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { makeComprehensiveWorkspace } from "./fixtures.js"; +import { writeJson, initializeGitWorkspace, commitWorkspaceFiles } from "./helpers.js"; +import { loadWorkspace } from "../src/workspace.js"; +import { validateWorkspace } from "../src/validate.js"; +import { updateResource } from "../src/files.js"; +import { readResourceMutation } from "../src/resource-mutation.js"; +import { resourceReviewRevisionsSync, retentionReviewResourceIds } from "../src/retention.js"; +import { scaffoldCollectionReview, applyCollectionReview } from "../src/collection-review.js"; +import { currentCalendarDate } from "../src/time.js"; +import { governedContentBaseline, governedContentCoversPeriod, governedMarkdownSource, registerGovernedLookup } from "../src/governed-content-baseline.js"; +import { createObligationEvent, planObligations } from "../src/obligations.js"; +import { assessProgramReadiness } from "../src/program-readiness.js"; +import { assessProgramBaseline } from "../src/operating-baseline.js"; +import { prepareEvidencePacket, writeEvidencePacket } from "../src/evidence-packet.js"; +import { assessWorkflow } from "../src/workflow.js"; + +// The public generic fixture represents existing v11 operation. Only fixture +// construction writes initial facts directly; proposal and approval writes below +// use the same validated domain operations as the CLI and browser. +async function makeActiveWorkspace(root) { + // Historical approvals remain valid when a workspace adopts the new action. + await makeComprehensiveWorkspace(root, "11"); + let loaded = await loadWorkspace(root); + const document = loaded.entries.find(({ record }) => record.id === "document-example"); + await writeJson(document.path, { ...document.record, documentKind: "schedule", reportingRouteRequirements: undefined }); + const rowEntry = loaded.entries.find(({ record }) => record.type === "retention-schedule-item"); + const row = { + ...rowEntry.record, status: "active", + scopeResourceIds: ["system-example", "component-example", "vendor-example"], + scheduleDocumentId: document.record.id, informationTypeIds: ["information-type-example"], + cutoff: { basis: "creation" }, retentionPeriod: { basis: "fixed", amount: 3, unit: "year" }, + dispositionAction: "delete", dispositionInstructions: "Delete after the approved cutoff and period unless a hold applies." + }; + loaded = await loadWorkspace(root); + row.reviewedSourceRevisions = Object.fromEntries(resourceReviewRevisionsSync( + loaded, retentionReviewResourceIds(row, loaded), "legacy", false, null, row, true + )); + await writeJson(rowEntry.path, row); + const attestation = loaded.entries.find(({ record }) => record.type === "attestation"); + await writeJson(attestation.path, { ...attestation.record, programId: "program-example" }); + const training = loaded.resources.find(({ type }) => type === "training"); + await writeJson(join(root, "data/attestations/attestation-training-example.json"), { + ...attestation.record, id: "attestation-training-example", title: "Security training completion", + programId: "program-example", subjectResourceIds: [training.id], + contentRevisions: training.approvedContentRevisions + }); + const policy = loaded.entries.find(({ record }) => record.type === "policy"); + await writeJson(policy.path, { ...policy.record, programRole: "required" }); + await initializeGitWorkspace(root); + const scaffold = await scaffoldCollectionReview(root, { resourceType: "retention-schedule-item" }); + await applyCollectionReview(root, { + ...scaffold, decision: "complete", rationale: "Reviewed the complete schedule and all approved rule requirements.", + reviewedByIds: ["person-independent-approver-example"], reviewedOn: currentCalendarDate(loaded.workspace.timezone), confirmed: true + }); + await commitWorkspaceFiles(root, "Approve retention schedule"); + loaded = await loadWorkspace(root); + const schedule = loaded.entries.find(({record}) => record.type === "document"); + await writeJson(schedule.path, {...schedule.record,status:"active",activationBasis:"recorded",activatedOn:currentCalendarDate(loaded.workspace.timezone),effectiveOn:currentCalendarDate(loaded.workspace.timezone),activatedByIds:["person-example"],activatedContentRevisions:schedule.record.approvedContentRevisions}); + await commitWorkspaceFiles(root,"Record model v11 active generic retention fixture"); + await writeJson(loaded.entries.find(({ record }) => record.type === "workspace").path, { ...loaded.workspace, dataModelVersion: "13" }); + await writeJson(`${root}/package.json`, { private: true, dependencies: { filegrc: "^0.19.2" } }); + await writeJson(`${root}/package-lock.json`, { lockfileVersion: 3, packages: { "": { dependencies: { filegrc: "^0.19.2" } }, "node_modules/filegrc": { version: "0.19.2" } } }); + await commitWorkspaceFiles(root, "Upgrade workspace model"); + return root; +} + +async function fixture(t, keep = false) { + const root = await mkdtemp(join(tmpdir(), "filegrc-same-id-consumers-")); + if (!keep) t.after(() => rm(root, { recursive: true, force: true })); + await makeActiveWorkspace(root); + assert.equal((await validateWorkspace(root)).ok, true); + const loaded = await loadWorkspace(root); + return { root, today: currentCalendarDate(loaded.workspace.timezone) }; +} + +async function mutate(root, type, change) { + const mutation = await readResourceMutation(root, type, `${type}-example`); + change(mutation); + return updateResource(root, type, mutation.record.id, mutation.record, { + expectedRevision: mutation.revision, + content: mutation.content, + expectedContentRevisions: mutation.contentRevisions, + requireExpectedContentRevisions: true + }); +} + +async function propose(root, type, today, onStage = async () => {}) { + await mutate(root, type, ({ record }) => { + record.status = "draft"; + for (const field of ["approvedOn", "approvedContentRevisions", "effectiveOn", "activationBasis", "activatedOn", "activatedByIds", "activatedContentRevisions"]) delete record[field]; + }); + await commitWorkspaceFiles(root, `Return ${type} to draft for review`); + await onStage("draft"); + await mutate(root, type, ({ content }) => { + for (const slot of Object.keys(content)) content[slot] += "\nProposed clarification: retain the completed review and its population reconciliation.\n"; + }); + await commitWorkspaceFiles(root, `Propose revised ${type} content`); + await mutate(root, type, ({ record }) => { record.status = "in-review"; }); + await commitWorkspaceFiles(root, `Review revised ${type} proposal`); + await onStage("in-review"); + await mutate(root, type, ({ record }) => { + record.status = "approved"; + record.approvedOn = today; + record.approverIds = ["person-independent-approver-example"]; + }); + await commitWorkspaceFiles(root, `Approve revised ${type} pending activation`); +} + +async function consumers(root, today, type) { + const loaded = await loadWorkspace(root); + const byId = registerGovernedLookup(new Map(loaded.resources.map((record) => [record.id, record])), loaded); + const source = byId.get(`${type}-example`); + const baseline = governedContentBaseline(source, byId, today, loaded.model); + const path = Object.keys(baseline?.approvedContentRevisions || source.approvedContentRevisions)[0]; + const plan = planObligations(loaded.resources, { root, model: loaded.model, asOf: today, from: today, through: today, includeComplete: true }); + const readiness = await assessProgramReadiness(loaded, { asOf: today }); + // Select the existing generic Control for the real workforce consumer. No + // program fact or completion record is invented to make its result pass. + const program = byId.get("program-example"); + const control = byId.get("control-example"); + const profiles = loaded.model.operatingPreparation.baselineProfiles; + loaded.model.operatingPreparation.baselineProfiles = [{ id: type === "training" ? "workforce-training" : "workforce-policy", resourceType: "attestation", activities: [], controlCodes: [control.code] }]; + const workforce = await assessProgramBaseline(loaded, program, [control], today, + (_id, _title, complete, _work, _acceptance, _record, _type, details) => ({ complete, ...details })); + loaded.model.operatingPreparation.baselineProfiles = profiles; + const workflow = await assessWorkflow(loaded, { asOf: today, includeComplete: true, + coverage: { kind: "range", startsOn: today, endsOn: today } }); + const packet = await prepareEvidencePacket(root, { start: "2026-06-30", end: today, programId: program.id }); + return { packet, loaded, source, baseline, path, markdown: governedMarkdownSource(loaded, baseline, path), + covered: governedContentCoversPeriod(source, today, today, byId, loaded.model), plan, readiness, workforce, + policyGaps: workflow.findings.filter((finding) => finding.key.includes("policy-example") && finding.key.startsWith("period.")) }; +} + +for (const type of ["policy", "training"]) { + test(`normal same-ID ${type} draft, proposal and approval retain operating consumers`, async (t) => { + const keep = process.env.FILEGRC_KEEP_SAME_ID_FIXTURE === type; + const { root, today } = await fixture(t, keep); + const before = await consumers(root, today, type); + assert.equal(before.covered, true); + assert.equal(before.plan.programStatuses["obligation-example"], "accepted"); + if (type === "training") assert.ok(before.workforce[0].resultIds.includes("attestation-training-example"), "fixture completion proves the actual old Training revision"); + if (type === "policy") { + assert.ok(before.workforce[0].resultIds.includes("attestation-example"), "fixture acknowledgement proves the actual old Policy revision"); + assert.ok(before.packet.datedRecords.some(({ id }) => id === "attestation-example"), "the packet selects actual dated acknowledgement evidence"); + } + await propose(root, type, today, async (status) => { + const loaded = await loadWorkspace(root); + const program = loaded.resources.find(({ id }) => id === "program-example"); + const control = loaded.resources.find(({ id }) => id === "control-example"); + const profiles = loaded.model.operatingPreparation.baselineProfiles; + loaded.model.operatingPreparation.baselineProfiles = [{ id: type === "training" ? "workforce-training" : "workforce-policy", resourceType: "attestation", activities: [], controlCodes: [control.code] }]; + const workforce = await assessProgramBaseline(loaded, program, [control], today, + (_id, _title, complete, _work, _acceptance, _record, _type, details) => ({ complete, ...details })); + loaded.model.operatingPreparation.baselineProfiles = profiles; + assert.equal(loaded.resources.find(({ id }) => id === `${type}-example`).status, status); + assert.deepEqual(workforce[0].subjectIds, before.workforce[0].subjectIds, `${status} retains operating subjects`); + assert.deepEqual(workforce[0].resultIds, before.workforce[0].resultIds, `${status} retains exact old acknowledgments`); + assert.deepEqual(workforce[0].missingMembers, before.workforce[0].missingMembers); + }); + if (keep) console.log(`Retained public pending ${type} fixture: ${root}`); + const after = await consumers(root, today, type); + assert.equal((await validateWorkspace(root)).ok, true); + assert.equal(after.source.status, "approved", "the editable proposal remains visible"); + assert.equal(after.source.approvedOn, today); + assert.notDeepEqual(after.source.approvedContentRevisions, before.source.approvedContentRevisions); + assert.equal(after.baseline?.status, "active"); + assert.deepEqual(after.baseline.approvedContentRevisions, before.source.approvedContentRevisions); + assert.equal(after.markdown, before.markdown, "operating consumers read exact committed old Markdown"); + assert.notEqual(await readFile(join(root, "data", after.path), "utf8"), before.markdown, "the source still contains proposed Markdown"); + assert.equal(after.covered, true, "approval is not an operating cutover"); + assert.equal(after.plan.programStatuses["obligation-example"], "accepted"); + assert.deepEqual(after.plan.items, before.plan.items, "existing recurring occurrences remain recordable"); + assert.deepEqual(after.workforce[0].resultIds, before.workforce[0].resultIds); + assert.deepEqual(after.workforce[0].missingMembers, before.workforce[0].missingMembers); + assert.deepEqual(after.packet.gaps.filter(({ code, resourceId }) => code === "policy-not-effective-for-period" && resourceId === "policy-example"), + before.packet.gaps.filter(({ code, resourceId }) => code === "policy-not-effective-for-period" && resourceId === "policy-example"), + "the current-period packet retains prior Policy coverage"); + assert.deepEqual(after.packet.filegrcRecords.filter(({ type }) => type === "attestation"), before.packet.filegrcRecords.filter(({ type }) => type === "attestation"), + "the packet retains existing dated acknowledgement evidence"); + const delivery = await writeEvidencePacket(root, after.packet, { output: `.filegrc/same-id-${type}-delivery` }); + const output = delivery.output || delivery.path; + const exported = JSON.parse(await readFile(join(output, "manifest.json"), "utf8")); + const operating = exported.operatingBaselines.find(({ resourceId }) => resourceId === after.source.id); + assert.ok(operating, "the delivery explicitly identifies the prior operating edition"); + assert.equal(JSON.parse(await readFile(join(output, operating.recordPath), "utf8")).status, "active"); + const content = operating.contentPaths.find(({ sourcePath }) => sourcePath === after.path || sourcePath === `data/${after.path}`); + assert.ok(content); + assert.equal(await readFile(join(output, content.exportedPath), "utf8"), before.markdown); + assert.equal(JSON.parse(await readFile(join(output, "records", type, `${after.source.id}.json`), "utf8")).status, "approved"); + assert.equal(await readFile(join(output, "content", after.path), "utf8"), await readFile(join(root, "data", after.path), "utf8")); + assert.deepEqual(after.policyGaps, before.policyGaps, "a pending revision creates no additional Policy coverage gap"); + const activation = type === "policy" ? after.readiness.policyActivations : after.readiness.trainingActivations; + assert.ok(activation.some((item) => (item.policyId || item.resourceId || item.trainingId) === after.source.id), "new approval work remains discoverable"); + }); +} + +test("whole-schedule same-ID reapproval retains the operating retention schedule until activation", async (t) => { + const { root, today } = await fixture(t); + const before = await consumers(root, today, "document"); + assert.equal(before.source.status, "active"); + const scaffold = await scaffoldCollectionReview(root, { resourceType: "retention-schedule-item" }); + await applyCollectionReview(root, { ...scaffold, decision: "complete", + rationale: "Independently reviewed the complete unchanged schedule and all retained rules.", + reviewedByIds: ["person-independent-approver-example"], reviewedOn: today, confirmed: true }); + await commitWorkspaceFiles(root, "Independently review existing complete retention schedule"); + const originalRow = before.loaded.resources.find(({ type }) => type === "retention-schedule-item"); + const proposedRow = await readResourceMutation(root, "retention-schedule-item", originalRow.id); + proposedRow.record.retentionPeriod = { ...originalRow.retentionPeriod, amount: originalRow.retentionPeriod.amount + 1 }; + await updateResource(root, "retention-schedule-item", originalRow.id, proposedRow.record, { + expectedRevision: proposedRow.revision, + content: proposedRow.content, + expectedContentRevisions: proposedRow.contentRevisions + }); + await commitWorkspaceFiles(root, "Propose a revised retention period pending schedule activation"); + const revisedReview = await scaffoldCollectionReview(root, { resourceType: "retention-schedule-item" }); + await applyCollectionReview(root, { ...revisedReview, decision: "complete", + rationale: "Independently reviewed the proposed retention period and the complete schedule.", + reviewedByIds: ["person-independent-approver-example"], reviewedOn: today, confirmed: true }); + await commitWorkspaceFiles(root, "Approve proposed complete retention schedule pending activation"); + const after = await consumers(root, today, "document"); + assert.equal((await validateWorkspace(root)).ok, true); + assert.equal(after.source.status, "approved"); + assert.equal(after.source.activatedOn, undefined, "new review does not activate its decision"); + assert.equal(after.baseline?.status, "active"); + assert.equal(after.baseline.activatedOn, before.source.activatedOn); + assert.equal(after.markdown, before.markdown); + assert.equal(after.covered, true); + const target = (state) => state.readiness.stages.flatMap(({ items }) => items).find(({ id }) => id === "document-document-example"); + assert.equal(target(before).status, "complete"); + assert.ok(["complete", "info"].includes(target(after).status), "review work does not reopen operating activation prerequisites"); + assert.deepEqual(after.plan.items, before.plan.items); + assert.deepEqual(after.workforce[0].resultIds, before.workforce[0].resultIds); + const delivery = await writeEvidencePacket(root, after.packet, { output: ".filegrc/same-id-retention-delivery" }); + const output = delivery.output || delivery.path; + const exported = JSON.parse(await readFile(join(output, "manifest.json"), "utf8")); + const operating = exported.operatingBaselines.find(({ resourceId }) => resourceId === after.source.id); + const row = operating.relatedRecords.find(({ id }) => id === originalRow.id); + assert.ok(row, "the delivered operating schedule includes its exact historical structured row"); + const rowSource = await readFile(join(output, row.recordPath), "utf8"); + assert.equal(rowSource, before.loaded.entries.find(({ record }) => record.id === originalRow.id).source, "the delivered row preserves exact committed old bytes"); + assert.deepEqual(JSON.parse(rowSource).retentionPeriod, originalRow.retentionPeriod); + const originalReview = before.loaded.resources.find((record) => record.type === "collection-review" && record.resourceType === "retention-schedule-item" && record.status === "active"); + const proof = operating.relatedRecords.find(({ id }) => id === originalReview.id); + assert.ok(proof, "the operating rows retain their original whole-schedule approval proof"); + assert.deepEqual(JSON.parse(await readFile(join(output, proof.recordPath), "utf8")), originalReview); + const rawRow = (await readResourceMutation(root, "retention-schedule-item", originalRow.id)).record; + assert.deepEqual(rawRow.retentionPeriod, proposedRow.record.retentionPeriod, "raw workspace row retains its proposed decision"); + assert.notDeepEqual(rawRow.retentionPeriod, JSON.parse(rowSource).retentionPeriod); + const checksums = await readFile(join(output, "SHA256SUMS"), "utf8"); + const { createHash } = await import("node:crypto"); + assert.ok(checksums.split("\n").includes(`${createHash("sha256").update(rowSource).digest("hex")} ${row.recordPath}`)); +}); + +test("normal Policy retirement remains an operating barrier", async (t) => { + const { root, today } = await fixture(t); + await mutate(root, "policy", ({ record }) => { + record.status = "retired"; + record.statusTransition = { changedOn: today, changedByIds: ["person-example"], reason: "Management withdrew the operating Policy." }; + }); + await commitWorkspaceFiles(root, "Withdraw the existing Policy"); + const state = await consumers(root, today, "policy"); + assert.equal(state.baseline, null, "actual withdrawal cannot reuse prior operating authority"); + assert.equal(state.covered, false); + assert.equal(state.plan.programStatuses["obligation-example"], "proposed"); +}); + +test("a stale committed operating edition cannot authorize a later same-ID proposal", async (t) => { + const { root, today } = await fixture(t); + const path = join(root, "data/policies/policy-example.md"); + // Deliberately invalid negative fixture: an actual active edition has changed + // without its approval. This historical barrier must survive later valid CRUD. + await writeFile(path, `${await readFile(path, "utf8")}\nAn unapproved operating duty was introduced.\n`); + await commitWorkspaceFiles(root, "Record an invalid operating edition for negative coverage"); + await propose(root, "policy", today); + const state = await consumers(root, today, "policy"); + assert.equal(state.source.status, "approved"); + assert.equal(state.baseline, null); + assert.equal(state.covered, false); + assert.equal(state.plan.programStatuses["obligation-example"], "proposed"); +}); + +// Event writes use the same operating eligibility as the planner while a +// governed revision is still being authored or awaiting activation. +test("actual Policy Event writes retain the baseline through draft, review and approval", async (t) => { + const { root, today } = await fixture(t); + const trigger = async (status) => { + const created = await createObligationEvent(root, { programId: "program-example", + eventType: "person-role-changed", occurredOn: today, subjectResourceIds: ["person-example"], + title: `Generic role change during ${status}` }); + assert.equal(created.actions.length, 1); + assert.equal(created.actions[0].obligationId, "obligation-example"); + assert.equal(created.actions[0].sourceResourceId, created.event.id); + const loaded = await loadWorkspace(root); + const plan = planObligations(loaded.resources, { root, loaded, model: loaded.model, + programId: "program-example", asOf: today, through: today, includeComplete: true }); + assert.equal(plan.programStatuses["obligation-example"], "accepted"); + assert.ok(loaded.resources.some(({ id }) => id === created.actions[0].id)); + await commitWorkspaceFiles(root, `Record generic role change during ${status}`); + }; + await propose(root, "policy", today, trigger); + await trigger("approved"); + assert.equal((await validateWorkspace(root)).ok, true); +}); diff --git a/scripts/test-process-cleanup.mjs b/scripts/test-process-cleanup.mjs new file mode 100644 index 0000000..75dd3e1 --- /dev/null +++ b/scripts/test-process-cleanup.mjs @@ -0,0 +1,151 @@ +import { execFile } from "node:child_process"; +import { readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +export const processOwnerVariable = "FILEGRC_TEST_PROCESS_OWNER"; + +export async function processTable() { + const { stdout } = await execFileAsync("ps", ["-axo", "pid=,ppid=,pgid=,uid=,stat=,lstart="]); + return stdout.trim().split("\n") + .map((line) => line.trim().split(/\s+/)) + .filter((parts) => parts.length === 10 && !parts[4].startsWith("Z") && parts.slice(0, 4).every((part) => Number.isInteger(Number(part)))) + .map(([pid, parentPid, groupId, uid, , ...started]) => ({ pid: Number(pid), parentPid: Number(parentPid), groupId: Number(groupId), uid: Number(uid), started: started.join(" ") })); +} + +export async function hasOwner(pid, owner, { platform = process.platform, readEnvironment = (pid) => readFile(`/proc/${pid}/environ`, "utf8"), liveProcesses = processTable } = {}) { + try { + if (platform === "linux") { + const environment = await readEnvironment(pid); + return environment.split("\0").includes(`${processOwnerVariable}=${owner}`); + } + // Never expose process environments in logs or return values. + const { stdout } = await execFileAsync("ps", ["eww", "-p", String(pid), "-o", "command="], { maxBuffer: 8 * 1024 * 1024 }); + return stdout.split(/\s+/).includes(`${processOwnerVariable}=${owner}`); + } catch (error) { + // ps exits 1 when the process has already gone away. + if (["ENOENT", "ESRCH"].includes(error.code) || (error.code === 1 && !error.stdout?.trim() && !error.stderr?.trim())) return false; + if (platform === "linux" && ["EACCES", "EPERM"].includes(error.code)) { + // Linux may deny environ while an exiting process becomes a zombie. + // Recheck the live table, which excludes zombies, before treating this as + // a permission failure. A still-live selected process must fail closed. + if (!(await liveProcesses()).some((entry) => entry.pid === pid)) return false; + } + throw new Error("Could not inspect test child process ownership.", { cause: { code: error.code } }); + } +} + +export async function discoverLinuxOwners(owners, procRoot = "/proc") { + const marked = new Map(); + try { + for (const entry of await readdir(procRoot)) { + if (!/^[1-9]\d*$/.test(entry) || Number(entry) <= 1) continue; + let environment; + try { environment = await readFile(join(procRoot, entry, "environ"), "utf8"); } + catch (error) { + // Unrelated processes may disappear or belong to another user. A + // selected target is separately verified, and its permission errors fail. + if (["ENOENT", "ESRCH", "EACCES", "EPERM"].includes(error.code)) continue; + throw error; + } + const owner = environment.split("\0").find((value) => value.startsWith(`${processOwnerVariable}=`))?.slice(processOwnerVariable.length + 1); + if (owners.has(owner)) marked.set(Number(entry), owner); + } + return marked; + } catch (error) { + throw new Error("Could not discover test child process ownership.", { cause: { code: error.code } }); + } +} + +async function discoverOwners(owners) { + if (process.platform === "linux") return discoverLinuxOwners(owners); + try { + // A descendant can create its own session and outlive its original group. + // Inspect environments internally; never expose command text or environments. + const { stdout } = await execFileAsync("ps", ["eww", "-axo", "pid=,command="], { maxBuffer: 64 * 1024 * 1024 }); + const marked = new Map(); + for (const line of stdout.split("\n")) { + const pid = Number(line.trim().match(/^\d+/)?.[0]); + const owner = line.match(/(?:^|\s)FILEGRC_TEST_PROCESS_OWNER=([a-f0-9-]{36})(?=\s|$)/)?.[1]; + if (Number.isInteger(pid) && pid > 1 && owners.has(owner)) marked.set(pid, owner); + } + return marked; + } catch (error) { + throw new Error("Could not discover test child process ownership.", { cause: { code: error.code } }); + } +} + +export async function ownedProcessIds(groups, { table = processTable, owns = hasOwner, discover = discoverOwners, excludePid = process.pid, identities } = {}) { + const processes = await table(); + const targets = new Set(); + const owners = new Set([...groups].filter(([pid, owner]) => Number.isInteger(pid) && pid > 1 && typeof owner === "string" && /^[a-f0-9-]{36}$/.test(owner)).map(([, owner]) => owner)); + const marked = await discover(owners); + for (const { pid } of processes) { + if (pid !== excludePid && marked.has(pid) && await owns(pid, marked.get(pid))) targets.add(pid); + } + for (const [groupId, owner] of groups) { + if (!Number.isInteger(groupId) || groupId <= 1 || typeof owner !== "string" || !/^[a-f0-9-]{36}$/.test(owner)) continue; + const members = processes.filter((entry) => entry.groupId === groupId && entry.pid > 1); + // A historical PID is not authority over a group with the same reused ID. + // No marker means fail closed, including an orphan that cleared its environment. + let verified = false; + for (const member of members) { + if (marked.get(member.pid) === owner && await owns(member.pid, owner)) { verified = true; break; } + } + if (verified) for (const { pid } of members) if (pid !== excludePid) targets.add(pid); + } + // Also retain still-attached descendants that deliberately supplied a clean env. + let changed = true; + while (changed) { + changed = false; + for (const entry of processes) { + if (entry.pid > 1 && entry.pid !== excludePid && targets.has(entry.parentPid) && !targets.has(entry.pid)) { + targets.add(entry.pid); + changed = true; + } + } + } + for (const entry of processes) if (targets.has(entry.pid)) identities?.set(entry.pid, processIdentity(entry)); + return targets; +} + +function processIdentity({ pid, groupId, uid, started }) { + return JSON.stringify([pid, groupId, uid, started]); +} + +export async function cleanupOwnedProcesses(groups, options = {}) { + const { kill = process.kill, delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), ...selection } = options; + const retained = new Map(); + const targetsNow = async () => { + const snapshot = await (selection.table || processTable)(); + const targets = await ownedProcessIds(groups, { ...selection, table: async () => snapshot, identities: retained }); + for (const [pid, identity] of retained) { + const entry = snapshot.find((entry) => entry.pid === pid); + // Retain ownership after its marked ancestor exits, but never across a + // PID reuse, group change, or process start change. Reparenting is allowed. + if (!entry) retained.delete(pid); + else if (processIdentity(entry) === identity) targets.add(pid); + else throw new Error("A selected test child process changed identity during cleanup; it was not signalled."); + } + return targets; + }; + let found = false; + for (const signal of ["SIGTERM", "SIGKILL"]) { + // Revalidate ownership after TERM, never signal a remembered numeric group. + const targets = await targetsNow(); + found ||= targets.size > 0; + for (const pid of targets) { + if (!(await targetsNow()).has(pid)) continue; + try { kill(pid, signal); } + catch (error) { if (error.code !== "ESRCH") throw error; } + } + if (signal === "SIGTERM" && targets.size) await delay(100); + } + const deadline = Date.now() + 2_000; + while ((await targetsNow()).size) { + if (Date.now() >= deadline) throw new Error("A test-file child process did not exit after cleanup."); + await delay(25); + } + return found; +} diff --git a/scripts/test-process-cleanup.test.mjs b/scripts/test-process-cleanup.test.mjs new file mode 100644 index 0000000..2069735 --- /dev/null +++ b/scripts/test-process-cleanup.test.mjs @@ -0,0 +1,218 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { fork, spawn } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { once } from "node:events"; +import { test } from "node:test"; +import { cleanupOwnedProcesses, discoverLinuxOwners, hasOwner, ownedProcessIds, processOwnerVariable } from "./test-process-cleanup.mjs"; + +const owner = randomUUID(); +test("cleanup skips absent, reused and invalid groups without probing or signalling them", async () => { + const groups = new Map([[0, owner], [-3, owner], [1, owner], [200, owner], [300, owner]]); + const reads = []; + assert.equal(await cleanupOwnedProcesses(groups, { + table: async () => [{ pid: 300, groupId: 300 }], + owns: async (pid) => { reads.push(pid); return false; }, + kill: () => assert.fail("unowned process signalled"), + }), false); + assert.deepEqual(reads, []); +}); + +test("cleanup rechecks ownership before KILL and preserves genuine permission failures", async () => { + let owned = true; + const signals = []; + await assert.rejects(cleanupOwnedProcesses(new Map([[200, owner]]), { + table: async () => [{ pid: 201, groupId: 200, started: owned ? "old" : "new" }], + discover: async () => new Map([[201, owner]]), + owns: async () => owned, + kill: (pid, signal) => { signals.push([pid, signal]); owned = false; }, + delay: async () => {}, + }), /changed identity/); + assert.deepEqual(signals, [[201, "SIGTERM"]]); + await assert.rejects(cleanupOwnedProcesses(new Map([[200, owner]]), { + table: async () => [{ pid: 201, groupId: 200 }], owns: async () => true, + discover: async () => new Map([[201, owner]]), + kill: () => { throw Object.assign(new Error("denied"), { code: "EPERM" }); }, + }), { code: "EPERM" }); +}); + +test("surviving detached descendants are cleaned after their leader exits", { skip: process.platform === "win32" }, async (t) => { + const directory = await mkdtemp(join(tmpdir(), "filegrc-cleanup-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const pidPath = join(directory, "child.pid"); + const childCode = `require('node:fs').writeFileSync(${JSON.stringify(pidPath)}, String(process.pid));setInterval(()=>{},1000)`; + const leader = spawn(process.execPath, ["-e", `const c=require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(childCode)}],{stdio:'ignore'});c.unref();`], { + detached: true, stdio: "ignore", env: { ...process.env, [processOwnerVariable]: owner }, + }); + await once(leader, "exit"); + let pid; + for (let count = 0; count < 100 && !pid; count++) { + pid = await readFile(pidPath, "utf8").then(Number).catch(() => null); + if (!pid) await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.ok(pid); + const groups = new Map([[leader.pid, owner]]); + t.after(() => cleanupOwnedProcesses(groups)); + assert.ok((await ownedProcessIds(groups)).has(pid)); + assert.equal(await cleanupOwnedProcesses(groups), true); + assert.equal((await ownedProcessIds(groups)).size, 0); +}); + +function standaloneEnvironment(overrides = {}) { + const environment = { ...process.env, ...overrides }; + // These fixtures start the standalone CLI/worker, outside this node:test run. + delete environment.NODE_TEST_CONTEXT; + return environment; +} + +async function schedulerFixture(t, body, timeoutMs = 5_000, nested = false, customEnv = false) { + const directory = await mkdtemp(join(tmpdir(), "filegrc-scheduler-cleanup-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const pidPath = join(directory, "survivor.pid"); + const testPath = join(directory, "fixture.test.mjs"); + const survivorCode = `require('node:fs').writeFileSync(${JSON.stringify(pidPath)},String(process.pid));setInterval(()=>{},1000)`; + const childCode = nested ? `const c=require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(survivorCode)}],{detached:true,stdio:'ignore'});c.unref();` : survivorCode; + await writeFile(testPath, `import {test} from 'node:test';import {spawn} from 'node:child_process';import {existsSync} from 'node:fs'; + test('fixture', async()=>{const c=spawn(process.execPath,['-e',${JSON.stringify(childCode)}],{detached:true,stdio:'ignore',env:${customEnv ? '{PATH:process.env.PATH}' : 'process.env'}});c.unref();while(!existsSync(${JSON.stringify(pidPath)}))await new Promise(r=>setTimeout(r,10));${body}});`); + const schedulerUrl = new URL("./test-scheduler.mjs", import.meta.url).href; + const source = `import {runTestSuite} from ${JSON.stringify(schedulerUrl)};await runTestSuite({name:'cleanup-regression',cwd:${JSON.stringify(directory)},files:[${JSON.stringify(testPath)}],inputPaths:[${JSON.stringify(testPath)}],cache:false});`; + const child = spawn(process.execPath, ["--input-type=module", "-e", source], { env: standaloneEnvironment({ FILEGRC_TEST_CONCURRENCY: "1", FILEGRC_TEST_FILE_TIMEOUT_MS: String(timeoutMs) }), stdio: ["ignore", "pipe", "pipe"] }); + let output = ""; + child.stdout.on("data", (chunk) => output += chunk); + child.stderr.on("data", (chunk) => output += chunk); + const [code] = await once(child, "exit"); + const pid = Number(await readFile(pidPath, "utf8")); + const { processTable } = await import("./test-process-cleanup.mjs"); + assert.equal((await processTable()).some((entry) => entry.pid === pid), false, output); + return { code, output }; +} + +test("scheduler cleans detached children while retaining passing and failing test results", { skip: process.platform === "win32" }, async (t) => { + const passing = await schedulerFixture(t, ""); + assert.equal(passing.code, 0, passing.output); + const failing = await schedulerFixture(t, "throw new Error('real fixture failure');"); + assert.equal(failing.code, 1, failing.output); + assert.match(failing.output, /✖ fixture/); +}); + +test("scheduler timeout cleans live detached children and remains a failure", { skip: process.platform === "win32" }, async (t) => { + const result = await schedulerFixture(t, "setInterval(()=>{},1000);await new Promise(()=>{});", 1_000); + assert.equal(result.code, 1, result.output); + assert.match(result.output, /test-file timeout/); +}); + +test("worker disconnect cleans nested detached grandchildren across groups", { skip: process.platform === "win32" }, async (t) => { + const directory = await mkdtemp(join(tmpdir(), "filegrc-disconnect-cleanup-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const pidPath = join(directory, "child.pid"); + const testPath = join(directory, "fixture.test.mjs"); + await writeFile(testPath, `import {test} from 'node:test';import {spawn} from 'node:child_process';import {existsSync} from 'node:fs';test('fixture',async()=>{const c=spawn(process.execPath,['-e',${JSON.stringify(`const c=require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(`require('node:fs').writeFileSync(${JSON.stringify(pidPath)},String(process.pid));setInterval(()=>{},1000)`)}],{detached:true,stdio:'ignore'});c.unref();`)}],{detached:true,stdio:'ignore'});c.unref();while(!existsSync(${JSON.stringify(pidPath)}))await new Promise(r=>setTimeout(r,10));});`); + const child = fork(new URL("./test-shard-worker.mjs", import.meta.url), [], { detached: true, env: standaloneEnvironment({ [processOwnerVariable]: randomUUID() }), stdio: ["ignore", "ignore", "ignore", "ipc"] }); + t.after(() => { try { child.kill("SIGKILL"); } catch {} }); + const result = new Promise((resolve) => child.on("message", (message) => { if (message.type === "filegrc-test-result") resolve(message); })); + child.send({ type: "run", token: randomUUID(), file: testPath, cwd: directory }); + assert.equal((await result).code, 0); + const exited = once(child, "exit"); + child.disconnect(); + await exited; + const pid = Number(await readFile(pidPath, "utf8")); + const { processTable } = await import("./test-process-cleanup.mjs"); + assert.equal((await processTable()).some((entry) => entry.pid === pid), false); +}); + + +test("scheduler cleans nested detached grandchildren after success and timeout", { skip: process.platform === "win32" }, async (t) => { + const passing = await schedulerFixture(t, "", 5_000, true); + assert.equal(passing.code, 0, passing.output); + const timedOut = await schedulerFixture(t, "setInterval(()=>{},1000);await new Promise(()=>{});", 1_000, true); + assert.equal(timedOut.code, 1, timedOut.output); + assert.match(timedOut.output, /test-file timeout/); +}); + + +test("cleanup retains an unmarked TERM-resistant child after its marked leader exits", { skip: process.platform === "win32" }, async (t) => { + const directory = await mkdtemp(join(tmpdir(), "filegrc-unmarked-cleanup-")); + t.after(() => rm(directory, { recursive: true, force: true })); + for (const detached of [false, true]) { + const pidPath = join(directory, `child-${detached}.pid`); + const childCode = `process.on('SIGTERM',()=>{});require('node:fs').writeFileSync(${JSON.stringify(pidPath)},String(process.pid));setInterval(()=>{},1000)`; + const leader = spawn(process.execPath, ["-e", `require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(childCode)}],{detached:${detached},stdio:'ignore',env:{PATH:process.env.PATH}});setInterval(()=>{},1000);`], { detached: true, stdio: "ignore", env: { ...process.env, [processOwnerVariable]: owner } }); + const groups = new Map([[leader.pid, owner]]); + t.after(() => cleanupOwnedProcesses(groups)); + let pid; + for (let count = 0; count < 100 && !pid; count++) { + pid = await readFile(pidPath, "utf8").then(Number).catch(() => null); + if (!pid) await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.ok(pid); + assert.ok((await ownedProcessIds(groups)).has(pid)); + assert.equal(await cleanupOwnedProcesses(groups), true); + const { processTable } = await import("./test-process-cleanup.mjs"); + assert.equal((await processTable()).some((entry) => entry.pid === pid), false); + } +}); + + +test("Linux ownership discovery uses exact NUL-delimited proc environments", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "filegrc-proc-owners-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const anotherOwner = randomUUID(); + for (const [pid, environment] of [["200", `PATH=/bin\0${processOwnerVariable}=${owner}\0`], ["201", `OTHER=${processOwnerVariable}=${owner}\0`], ["202", `${processOwnerVariable}=${anotherOwner}\0`], ["203", `${processOwnerVariable}=${owner}-suffix\0`], ["1", `${processOwnerVariable}=${owner}\0`]]) { + await mkdir(join(directory, pid)); + await writeFile(join(directory, pid, "environ"), environment); + } + await mkdir(join(directory, "204")); // Process disappeared before its env read. + assert.deepEqual(await discoverLinuxOwners(new Set([owner]), directory), new Map([[200, owner]])); +}); + + +test("unknown inaccessible members of a reused group are never probed as owned", async () => { + assert.equal(await cleanupOwnedProcesses(new Map([[300, owner]]), { + table: async () => [{ pid: 300, groupId: 300, uid: 0, started: "unrelated" }], + discover: async () => new Map(), + owns: async () => { throw Object.assign(new Error("must not inspect unknown environment"), { code: "EACCES" }); }, + kill: () => assert.fail("unrelated process signalled"), + }), false); +}); + + +test("shared-isolation worker injects ownership into an explicit detached child environment", { + skip: process.platform === "win32" || Number(process.versions.node.split(".")[0]) < 22, +}, async (t) => { + // Node 20 uses a separate test process and cannot run the worker's wrappers. + const result = await schedulerFixture(t, "", 5_000, false, true); + assert.equal(result.code, 0, result.output); +}); + + +test("Linux selected process dying before its environment read is not a permission failure", async () => { + let live = [{ pid: 300, groupId: 300, uid: 1000, started: "original" }]; + let permissionReads = 0; + const owns = (pid, owner) => hasOwner(pid, owner, { + platform: "linux", + readEnvironment: async () => { + permissionReads++; + live = []; // The selected process exits or becomes an excluded zombie. + throw Object.assign(new Error("private environment must never appear"), { code: "EACCES" }); + }, + liveProcesses: async () => live, + }); + assert.equal(await cleanupOwnedProcesses(new Map([[300, owner]]), { + table: async () => live, + discover: async () => new Map([[300, owner]]), owns, + kill: () => assert.fail("an exited process must not be signalled"), + }), false); + assert.ok(permissionReads > 0, "the process must die between selection and its environment read"); +}); + +test("Linux selected live process permission failures remain fatal and sanitized", async () => { + for (const code of ["EACCES", "EPERM"]) { + await assert.rejects(hasOwner(300, owner, { + platform: "linux", + readEnvironment: async () => { throw Object.assign(new Error("private environment must never appear"), { code }); }, + liveProcesses: async () => [{ pid: 300, groupId: 300, uid: 1000, started: "original" }], + }), (error) => error.cause?.code === code && !String(error).includes("private environment") && !JSON.stringify(error).includes("private environment")); + } +}); diff --git a/scripts/test-scheduler.mjs b/scripts/test-scheduler.mjs index e5305e5..df5bb9e 100644 --- a/scripts/test-scheduler.mjs +++ b/scripts/test-scheduler.mjs @@ -1,3 +1,4 @@ +import { cleanupOwnedProcesses, processOwnerVariable } from "./test-process-cleanup.mjs"; import { createHash, randomUUID } from "node:crypto"; import { execFile, fork } from "node:child_process"; import { constants } from "node:fs"; @@ -215,9 +216,10 @@ export async function runTestSuite({ } function createTestSupervisor({ runRoot }) { + const owner = randomUUID(); const child = fork(workerPath, [], { detached: process.platform !== "win32", - env: { ...process.env, FILEGRC_TEST_RUN_ROOT: runRoot }, + env: { ...process.env, FILEGRC_TEST_RUN_ROOT: runRoot, [processOwnerVariable]: owner }, execArgv: [], stdio: ["ignore", "pipe", "pipe", "ipc"], }); @@ -227,7 +229,7 @@ function createTestSupervisor({ runRoot }) { child.once("exit", () => { running = false; }); - return { child, processGroups: new Set(), alive: () => running }; + return { child, owner, processGroups: new Map(), alive: () => running }; } async function runSupervisorFile({ supervisor, name, cwd, file, timeoutMs }) { @@ -243,7 +245,7 @@ async function runSupervisorFile({ supervisor, name, cwd, file, timeoutMs }) { Number.isInteger(message.pid) && message.pid > 1 ) { - supervisor.processGroups.add(message.pid); + supervisor.processGroups.set(message.pid, token); return; } if ( @@ -330,62 +332,25 @@ async function terminateSupervisorProcess(supervisor) { }); return; } - const targets = await descendantProcessTargets(child.pid); - for (const groupId of supervisor.processGroups) targets.add(-groupId); + const groups = new Map(supervisor.processGroups); + groups.set(child.pid, supervisor.owner); + await cleanupOwnedProcesses(groups); supervisor.processGroups.clear(); - for (const signal of ["SIGTERM", "SIGKILL"]) { - for (const target of targets) { - try { - process.kill(target, signal); - } catch (error) { - if (error.code !== "ESRCH") throw error; - } - } - if (signal === "SIGTERM") await delay(100); - } - await waitForProcessTargets(targets); } async function cleanupSupervisorChildGroups(supervisor) { - const trackedGroups = [...supervisor.processGroups]; - supervisor.processGroups.clear(); + const trackedGroups = [...supervisor.processGroups.keys()]; if (process.platform === "win32") { + supervisor.processGroups.clear(); await acknowledgeSupervisorCleanup(supervisor, trackedGroups); return false; } - const processes = await processTable(); - const targets = new Set( - processes - .filter( - ({ pid, groupId }) => - pid !== supervisor.child.pid && groupId === supervisor.child.pid, - ) - .map(({ pid }) => pid), - ); - for (const groupId of trackedGroups) { - try { - process.kill(-groupId, 0); - targets.add(-groupId); - } catch (error) { - if (error.code !== "ESRCH") throw error; - } - } - if (!targets.size) { - await acknowledgeSupervisorCleanup(supervisor, trackedGroups); - return false; - } - for (const signal of ["SIGTERM", "SIGKILL"]) { - for (const target of targets) { - try { - process.kill(target, signal); - } catch (error) { - if (error.code !== "ESRCH") throw error; - } - } - if (signal === "SIGTERM") await delay(100); - } + const groups = new Map(supervisor.processGroups); + groups.set(supervisor.child.pid, supervisor.owner); + const hadChildren = await cleanupOwnedProcesses(groups, { excludePid: supervisor.child.pid }); + supervisor.processGroups.clear(); await acknowledgeSupervisorCleanup(supervisor, trackedGroups); - return true; + return hadChildren; } async function acknowledgeSupervisorCleanup(supervisor, groupIds) { @@ -425,59 +390,6 @@ async function acknowledgeSupervisorCleanup(supervisor, groupIds) { }); } -async function waitForProcessTargets(targets) { - const deadline = Date.now() + 2_000; - while (targets.size && Date.now() < deadline) { - for (const target of targets) { - try { - process.kill(target, 0); - } catch (error) { - if (error.code === "ESRCH") targets.delete(target); - else throw error; - } - } - if (targets.size) await delay(25); - } - if (targets.size) { - throw new Error("A test-file child process did not exit after cleanup."); - } -} - -async function descendantProcessTargets(rootPid) { - const processes = await processTable(); - const ownGroup = processes.find(({ pid }) => pid === process.pid)?.groupId; - const descendants = new Set([rootPid]); - let changed = true; - while (changed) { - changed = false; - for (const entry of processes) { - if (descendants.has(entry.parentPid) && !descendants.has(entry.pid)) { - descendants.add(entry.pid); - changed = true; - } - } - } - const groups = new Set( - processes - .filter( - ({ pid, groupId }) => descendants.has(pid) && groupId !== ownGroup, - ) - .map(({ groupId }) => -groupId), - ); - for (const pid of descendants) groups.add(pid); - return groups; -} - -async function processTable() { - const { stdout } = await execFileAsync("ps", ["-axo", "pid=,ppid=,pgid="]); - return stdout - .trim() - .split("\n") - .map((line) => line.trim().split(/\s+/).map(Number)) - .filter((parts) => parts.length === 3 && parts.every(Number.isInteger)) - .map(([pid, parentPid, groupId]) => ({ pid, parentPid, groupId })); -} - async function anotherProcessIsWaiting() { const tickets = await activeTickets(); return tickets.some(({ pid }) => pid !== process.pid); @@ -611,7 +523,7 @@ async function contentKey({ name, files, inputPaths, toolchainCommands }) { ); const paths = [ ...new Set( - [...inputPaths, workerPath, fileURLToPath(import.meta.url)].map((path) => + [...inputPaths, workerPath, join(scriptsRoot, "test-process-cleanup.mjs"), fileURLToPath(import.meta.url)].map((path) => resolve(path), ), ), diff --git a/scripts/test-shard-worker.mjs b/scripts/test-shard-worker.mjs index 9064816..a03494c 100644 --- a/scripts/test-shard-worker.mjs +++ b/scripts/test-shard-worker.mjs @@ -1,3 +1,4 @@ +import { cleanupOwnedProcesses, processOwnerVariable } from "./test-process-cleanup.mjs"; import childProcess from "node:child_process"; import { syncBuiltinESMExports } from "node:module"; import { resolve, relative, sep } from "node:path"; @@ -12,9 +13,9 @@ import { if (isMainThread) { let activeWorker = null; - const detachedGroups = new Set(); + const detachedGroups = new Map(); - process.once("disconnect", () => { + process.once("disconnect", async () => { if (process.platform === "win32") { const cleanup = childProcess.spawn( "taskkill.exe", @@ -24,14 +25,9 @@ if (isMainThread) { cleanup.unref(); return; } - for (const groupId of detachedGroups) { - try { - process.kill(-groupId, "SIGKILL"); - } catch (error) { - if (error.code !== "ESRCH") throw error; - } - } - process.kill(-process.pid, "SIGKILL"); + detachedGroups.set(process.pid, process.env[processOwnerVariable]); + await cleanupOwnedProcesses(detachedGroups); + process.exit(1); }); process.on("message", async (message) => { @@ -81,7 +77,7 @@ if (isMainThread) { }; worker.on("message", (result) => { if (result?.type === "filegrc-child-process") { - detachedGroups.add(result.pid); + detachedGroups.set(result.pid, result.token); sendResult(result); return; } @@ -112,14 +108,11 @@ function trackSpawnedProcesses(token) { for (const method of ["spawn", "fork"]) { const original = childProcess[method]; childProcess[method] = function trackedChildProcess(...args) { - const child = original.apply(this, args); const options = args.at(-1); - if ( - Number.isInteger(child.pid) && - options && - typeof options === "object" && - options.detached === true - ) { + const detached = options && typeof options === "object" && options.detached === true; + if (detached) args[args.length - 1] = { ...options, env: { ...(options.env || process.env), [processOwnerVariable]: token } }; + const child = original.apply(this, args); + if (Number.isInteger(child.pid) && detached) { parentPort.postMessage({ type: "filegrc-child-process", token,