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 ' These are the approved values. Pending schedule edits do not change the rule applied here.Approved rule being applied