diff --git a/docs/architecture.md b/docs/architecture.md index 7da5687..84bd2f4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -118,7 +118,7 @@ FileGRC recommends one dedicated private repository per organization. Browser ed New renderer settings use trunk mode with `main` as the authoritative branch and `origin` as the remote. Model v2 requires every renderer setting to choose trunk or manual mode explicitly. Record lifecycle fields represent draft, proposed, approved, and retired states; Git branches do not represent approval. Approved Policies and governed Documents bind their approval to the SHA-256 revision of each companion Markdown file. Editing bound content requires moving the record back through review and recording a new approval. -Calculated review revisions follow the facts being reviewed. A source binding includes an applicability decision but excludes its reviewer, date, rationale, and scope hash. Program scope bindings include selected systems and requirement decisions, so adding an unrelated Control does not invalidate retention rows. Ordinary Collection Reviews record setup completion and retain the original population as history. Later changes use affected records, event work, and scheduled reviews. A Data Retention Schedule approval still binds to exact document and row decisions, while each row's source tokens are checked separately. Governed-content reviewer and activation metadata do not alter downstream source bindings; Policy, Document, and Training approval and activation bindings still use exact Markdown bytes. +Calculated review revisions follow the facts being reviewed. A source binding includes an applicability decision but excludes its reviewer, date, rationale, and scope hash. Program scope bindings include selected systems and requirement decisions, so adding an unrelated Control does not invalidate retention rows. Ordinary Collection Reviews record setup completion and retain the original population as history. Later changes use affected records, event work, and scheduled reviews. A Data Retention Schedule approval still binds to exact document and row decisions, while each row's source tokens are checked separately. Governed-content reviewer and activation metadata, including proposed and actual adoption dates, do not alter downstream source bindings; Policy, Document, and Training approval and activation bindings still use exact Markdown bytes. When an older binding is stale only because its calculation included review bookkeeping, FileGRC checks committed Git history for the original revision and compares the reviewed facts with the current files. This can clear a spurious stale result without changing a record or recording another approval. Run `filegrc validate` and inspect `review-bindings` or `review-collection --scaffold` with the current engine. Keep the full Git history available; a missing historical source or a changed commitment, applicability decision, retention decision, or governed Markdown leaves the review stale and calls for the normal review workflow. diff --git a/package-lock.json b/package-lock.json index ded5355..44bfafa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.16.35", + "version": "0.16.36", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.16.35", + "version": "0.16.36", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.16.35", + "version": "0.16.36", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.16.35", + "version": "0.16.36", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 6a0381f..4ff5042 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.16.35", + "version": "0.16.36", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index 5a168bf..a342c0b 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.16.35", + "version": "0.16.36", "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 fdece4f..a2775c3 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.16.35", + version: "0.16.36", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.16.35", + version: "0.16.36", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index c05a9cc..ab1d88a 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.16.35", + "version": "0.16.36", "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 ac47fc4..61eb3cd 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.16.35", + "version": "0.16.36", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/collection-revision.js b/packages/filegrc/src/collection-revision.js index 4d8cc8e..7660082 100644 --- a/packages/filegrc/src/collection-revision.js +++ b/packages/filegrc/src/collection-revision.js @@ -30,6 +30,11 @@ export function collectionRevisionMatches(loaded, resourceType, storedRevision, // Accept both the pre-0.16 scope: input and the digest-only input used by // 0.16.0, as well as the older collection basis from 0.9.1. if (revisionsMatch("collection", storedRevision, currentRevision)) return true; + // Before adoption dates became neutral, semantic collection hashes included + // them directly or through each retention row's source decision revisions. + if (revisionsMatch("collection", storedRevision, collectionRevision(loaded, resourceType, { + ...options, legacyGovernedLifecycleMetadata: true + }))) return true; if (resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval") && revisionsMatch("collection", storedRevision, calculateCollectionRevision( loaded, resourceType, { ...options, legacyRetentionSourceBasis: true }, false @@ -163,6 +168,11 @@ function historicallyEquivalentCollection(loaded, resourceType, stored, options, // Require the complete reviewed basis to be unchanged under the current // semantic calculation before accepting an older byte-sensitive digest. if (!revisionsMatch("collection", current, collectionRevision(snapshot, resourceType, historicalOptions))) continue; + for (const legacyGovernedLifecycleMetadata of [false, true]) { + if (revisionsMatch("collection", stored, collectionRevision(snapshot, resourceType, { + ...historicalOptions, legacyGovernedLifecycleMetadata + }))) return true; + } for (const legacyRetentionSourceBasis of resourceType === "retention-schedule-item" ? [false, true] : [false]) { for (const historicalReviewMetadata of legacyRetentionSourceBasis ? [false] : [true]) { for (const legacy of [false, true]) { @@ -194,7 +204,8 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy, scop legacy, historicalReviewMetadata: options.historicalReviewMetadata, historicalCommit: options.historicalCommit, - legacyRetentionSourceBasis: options.legacyRetentionSourceBasis + legacyRetentionSourceBasis: options.legacyRetentionSourceBasis, + legacyGovernedLifecycleMetadata: options.legacyGovernedLifecycleMetadata }) .map((input) => [input.record.id, input])); const authoritativeSource = loaded.resources.find(({ id }) => id === options.authoritativeSourceId); @@ -203,7 +214,7 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy, scop record: authoritativeSource, value: legacy ? authoritativeSource - : authoritativeSourceRevisionValue(authoritativeSource), + : authoritativeSourceRevisionValue(authoritativeSource, options), includeContent: true }); } diff --git a/packages/filegrc/src/collection-scope.js b/packages/filegrc/src/collection-scope.js index 153ffc5..99de3cb 100644 --- a/packages/filegrc/src/collection-scope.js +++ b/packages/filegrc/src/collection-scope.js @@ -366,14 +366,14 @@ export function collectionRevisionInputs(loaded, resourceType, program, options value: reviewedIds.has(record.id) ? resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval") ? retentionScheduleRowProposalValue(record, loaded, options) - : options.historicalReviewMetadata ? record : substantiveRevisionValue(record) + : options.historicalReviewMetadata ? record : substantiveRevisionValue(record, options.legacyGovernedLifecycleMetadata) : resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval") && record.type === "document" ? retentionScheduleDocumentProposalValue(record) : options.historicalReviewMetadata ? dependencyRevisionValue(resourceType, record, legacy, true, currentModel) - : substantiveRevisionValue(dependencyRevisionValue(resourceType, record, legacy, false, currentModel, scheduleDocumentIds, options.legacyRetentionSourceBasis)), + : substantiveRevisionValue(dependencyRevisionValue(resourceType, record, legacy, false, currentModel, scheduleDocumentIds, options.legacyRetentionSourceBasis), options.legacyGovernedLifecycleMetadata), includeContent: legacy || reviewedIds.has(record.id) || dependencyContentAffectsRevision(resourceType, record, options.historicalReviewMetadata, currentModel, scheduleDocumentIds, options.legacyRetentionSourceBasis) @@ -388,7 +388,7 @@ function retentionScheduleRowProposalValue(record, loaded, options = {}) { ].includes(field))); if (!options.historicalReviewMetadata && !options.legacyRetentionSourceBasis) { value.sourceDecisionRevisions = Object.fromEntries(resourceReviewRevisionsSync( - loaded, record.sourceResourceIds || [], "legacy", false, options.historicalCommit || null, record + loaded, record.sourceResourceIds || [], "legacy", false, options.historicalCommit || null, record, !options.legacyGovernedLifecycleMetadata )); } return value; @@ -479,13 +479,13 @@ export function collectionScopeRevisionFacts(loaded, resourceType, program, opti return common; } -export function authoritativeSourceRevisionValue(record) { +export function authoritativeSourceRevisionValue(record, options = {}) { return substantiveRevisionValue(Object.fromEntries(Object.entries(record).filter(([field]) => ( !authoritativeSourceBookkeepingFields.has(field) - )))); + ))), options.legacyGovernedLifecycleMetadata); } -function substantiveRevisionValue(record) { +function substantiveRevisionValue(record, legacyGovernedLifecycleMetadata = false) { if (!record) return record; const value = { ...record }; if (value.type === "retention-schedule-item") { @@ -500,6 +500,10 @@ function substantiveRevisionValue(record) { } if (value.applicabilityReview) value.applicabilityReview = { decision: value.applicabilityReview.decision }; if (["policy", "document", "training"].includes(value.type)) { + if (!legacyGovernedLifecycleMetadata) { + delete value.effectiveOn; + delete value.proposedEffectiveOn; + } for (const field of [ "approverIds", "approvedOn", "activationBasis", "activatedByIds", "activatedOn", "activatedContentRevisions", "statusTransition" diff --git a/packages/filegrc/src/retention.js b/packages/filegrc/src/retention.js index cd88ed5..63062a2 100644 --- a/packages/filegrc/src/retention.js +++ b/packages/filegrc/src/retention.js @@ -204,7 +204,7 @@ export async function resourceReviewRevisions(loaded, ids, scopeHashInput = "leg return new Map([...selected].filter(([, revision]) => revision)); } -export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legacy", legacySource = false, historicalCommit = null, reviewer = null) { +export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legacy", legacySource = false, historicalCommit = null, reviewer = null, lifecycleNeutral = true) { const wanted = new Set(ids); const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry])); const revisions = new Map(); @@ -216,7 +216,7 @@ export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legac if (!entry || reviewing.has(key)) return null; reviewing.add(key); const decisionHeading = legacySource ? heading : heading || retentionPolicyHeading(loaded, entry, reviewer); - const parts = [reviewSource(loaded, entry, scopeHashInput, legacySource, decisionHeading, reviewer)]; + const parts = [reviewSource(loaded, entry, scopeHashInput, legacySource, decisionHeading, reviewer, lifecycleNeutral)]; let matchedSection = !decisionHeading; for (const markdown of reviewMarkdownEntries(loaded, entry, legacySource, reviewer)) { try { @@ -257,6 +257,8 @@ export function resourceReviewRevisionMatches(loaded, revisions, id, stored, rev : revisions.get(id); if (!current || !revisionDigest("content", stored)) return false; if (revisionsMatch("content", stored, current)) return true; + const prior = resourceReviewRevisionsSync(loaded, [id], "legacy", false, null, reviewer, false).get(id); + if (revisionsMatch("content", stored, prior)) return true; if (!sourceHeading(reviewer, id)) { const legacy = resourceReviewRevisionsSync(loaded, [id], "legacy", true).get(id); if (revisionsMatch("content", stored, legacy)) return true; @@ -297,8 +299,12 @@ function historicallyEquivalentReviewSource(loaded, id, stored, current, reviewe // A row may narrow a source to its retention facts or a named Markdown // section. Accept its old binding only when those facts still match the // committed source that supplied the binding. - const original = resourceReviewRevisionsSync(snapshot, [id], "legacy", false, commit).get(id); - if (revisionsMatch("content", stored, original)) return true; + const prior = resourceReviewRevisionsSync(snapshot, [id], "legacy", false, commit, reviewer, false).get(id); + if (revisionsMatch("content", stored, prior)) return true; + for (const lifecycleNeutral of [true, false]) { + const original = resourceReviewRevisionsSync(snapshot, [id], "legacy", false, commit, null, lifecycleNeutral).get(id); + if (revisionsMatch("content", stored, original)) return true; + } for (const scopeHashInput of ["legacy", "digest"]) { const legacy = resourceReviewRevisionsSync(snapshot, [id], scopeHashInput, true, commit).get(id); if (revisionsMatch("content", stored, legacy)) return true; @@ -328,7 +334,7 @@ export function retentionUses(loaded, program) { return [...new Map(uses.map((use) => [`${use.resource.id}:${use.informationTypeId}`, use])).values()]; } -function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = false, heading = null, reviewer = null) { +function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = false, heading = null, reviewer = null, lifecycleNeutral = true) { if (heading && !legacySource) { const governingFields = entry.record.type === "policy" ? ["policyKind", "effectiveOn", "supersedesId", "parentPolicyId", "relatedPolicyIds", "relatedDocumentIds", "requirementIds", "audience", "acknowledgementRequired", "programRole", "reportingRouteRequirements"] @@ -338,7 +344,7 @@ function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = f type: entry.record.type, governingStatus: ["approved", "active"].includes(entry.record.status) ? "approved" : entry.record.status || null, - ...Object.fromEntries(["extensions", "externalIds", ...governingFields].filter((field) => entry.record[field] !== undefined) + ...Object.fromEntries(["extensions", "externalIds", ...governingFields].filter((field) => entry.record[field] !== undefined && (!lifecycleNeutral || field !== "effectiveOn")) .map((field) => [field, entry.record[field]])) }); } @@ -366,7 +372,7 @@ function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = f return legacySource ? canonicalCalculatedRevisionJson(entry.source, scopeHashInput) : canonicalCalculatedRevisionJson(JSON.stringify(substantiveReviewSource( - JSON.parse(entry.source), modelSupports(loaded.model, "retention-schedule-approval"), reviewer + JSON.parse(entry.source), modelSupports(loaded.model, "retention-schedule-approval"), reviewer, lifecycleNeutral )), scopeHashInput); } @@ -508,7 +514,15 @@ const reviewDecisionFields = { document: ["documentKind", "workflowScope", "template", "version", "effectiveOn", "supersedesId", "systemIds", "controlIds", "relatedDocumentIds", "audience", "acknowledgementRequired", "trainingIds", "classificationId", "programRole", "componentIds", "reportingRouteRequirements"] }; -function substantiveReviewSource(record, currentModel, reviewer = null) { +function substantiveReviewSource(record, currentModel, reviewer = null, lifecycleNeutral = true) { + // Adoption dates describe when unchanged approved content enters operation. + // Review decisions bind the governed facts and Markdown, while activation + // validates dates and exact approval revisions independently. + if (lifecycleNeutral && ["policy", "document", "training"].includes(record.type)) { + record = { ...record }; + delete record.effectiveOn; + delete record.proposedEffectiveOn; + } if (currentModel && reviewDecisionFields[record.type]) { const fields = ["id", "type", "title", "extensions", "externalIds", ...reviewDecisionFields[record.type]]; const decision = Object.fromEntries(fields.filter((field) => record[field] !== undefined) diff --git a/packages/filegrc/test/activation-retention.test.js b/packages/filegrc/test/activation-retention.test.js new file mode 100644 index 0000000..d9a79fd --- /dev/null +++ b/packages/filegrc/test/activation-retention.test.js @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { activateGovernedContent, activateDocuments, activatePolicies, loadWorkspace, scaffoldGovernedContentActivation, scaffoldDocumentActivation, scaffoldPolicyActivation, validateWorkspace } from "../src/index.js"; +import { resourceReviewRevisions, resourceReviewRevisionsSync, resourceReviewRevisionMatches } from "../src/retention.js"; +import { collectionRevision, collectionRevisionMatches } from "../src/collection-revision.js"; +import { runCli } from "../src/cli.js"; +import { captureCli, initializeGitWorkspace, writeJson } from "./helpers.js"; +import { makeComprehensiveWorkspace } from "./fixtures.js"; +import { makeRetentionActivationWorkspace } from "./fixtures/activation-retention.js"; + +for (const separate of [false, true]) { + for (const priorBindings of [false, true]) { + test(`${separate ? "separate" : "atomic"} activation preserves ${priorBindings ? "prior" : "current"} retention source bindings`, async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-activation-retention-")); + context.after(() => rm(root, { recursive: true, force: true })); + const { ids, rowId } = await makeRetentionActivationWorkspace(root, { priorBindings }); + let loaded = await loadWorkspace(root); + const rowEntry = loaded.entries.find(({ record }) => record.id === rowId); + const rowSource = await readFile(rowEntry.path, "utf8"); + const approved = new Map(ids.map((id) => [id, loaded.resources.find((record) => record.id === id).approvedContentRevisions])); + const original = await resourceReviewRevisions(loaded, ids); + const before = await validateWorkspace(root); + assert.equal(before.ok, true, JSON.stringify(before.diagnostics)); + const scaffold = await scaffoldGovernedContentActivation(root); + assert.equal(scaffold.available, true, scaffold.message); + const payload = { ...scaffold, activatedByIds: ["person-example"], confirmed: true }; + if (separate) { + const documents = await scaffoldDocumentActivation(root); + const documentPayloadPath = join(root, "document-activation.json"); + await writeJson(documentPayloadPath, { ...documents, activatedByIds: payload.activatedByIds }); + await captureCli(runCli, ["activate-documents", documentPayloadPath, "--yes", "--json", "--root", root]); + const training = await scaffoldGovernedContentActivation(root); + await activateGovernedContent(root, { ...training, policyIds: [], activatedByIds: payload.activatedByIds, confirmed: true }); + const policies = await scaffoldPolicyActivation(root); + await activatePolicies(root, { ...policies, confirmed: true }); + } else { + const payloadPath = join(root, "activation.json"); + await writeJson(payloadPath, payload); + const preview = await captureCli(runCli, ["activate-content", payloadPath, "--preview", "--json", "--root", root]); + assert.equal(preview.result.changes.update.length, ids.length); + await captureCli(runCli, ["activate-content", payloadPath, "--yes", "--json", "--root", root]); + } + loaded = await loadWorkspace(root); + assert.deepEqual(await resourceReviewRevisions(loaded, ids), original); + assert.equal(await readFile(rowEntry.path, "utf8"), rowSource); + for (const id of ids) { + const record = loaded.resources.find((record) => record.id === id); + assert.equal(record.status, "active"); + assert.deepEqual(record.approvedContentRevisions, approved.get(id)); + if (record.type !== "policy") assert.deepEqual(record.activatedContentRevisions, approved.get(id)); + } + const after = await validateWorkspace(root); + assert.equal(after.ok, true, JSON.stringify(after.diagnostics)); + const bindings = await captureCli(runCli, ["review-bindings", rowId, "--json", "--root", root]); + assert.deepEqual(bindings.result.missingResourceIds, []); + // Substantive metadata and Markdown still invalidate the source binding. + const documentEntry = loaded.entries.find(({ record }) => record.type === "document" && ids.includes(record.id)); + await writeJson(documentEntry.path, { ...documentEntry.record, componentIds: [] }); + assert.ok((await validateWorkspace(root)).diagnostics.some(({ code }) => code === "stale-retention-review")); + await writeJson(documentEntry.path, documentEntry.record); + const markdownPath = documentEntry.path.replace(/\.json$/, ".md"); + await writeFile(markdownPath, `${await readFile(markdownPath, "utf8")}\nRetain backups for two years.\n`); + const invalid = await validateWorkspace(root); + assert.ok(invalid.diagnostics.some(({ code }) => code === "stale-retention-review")); + assert.equal(invalid.ok, false); + }); + } +} + +for (const separate of [false, true]) { + test(`${separate ? "separate" : "atomic"} activation validation failure rolls back every file without rewriting retention bindings`, async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-activation-rollback-")); + context.after(() => rm(root, { recursive: true, force: true })); + const { ids, rowId } = await makeRetentionActivationWorkspace(root); + const scaffold = await (separate ? scaffoldDocumentActivation : scaffoldGovernedContentActivation)(root); + const loaded = await loadWorkspace(root); + const rowEntry = loaded.entries.find(({ record }) => record.id === rowId); + await writeJson(rowEntry.path, { ...rowEntry.record, dispositionInstructions: "" }); + const entries = loaded.entries.filter(({ record }) => ids.includes(record.id) || record.id === rowId); + const before = await Promise.all(entries.map(({ path }) => readFile(path, "utf8"))); + await assert.rejects((separate ? activateDocuments : activateGovernedContent)(root, { ...scaffold, activatedByIds: ["person-example"], confirmed: true }), /invalid|disposition/i); + assert.deepEqual(await Promise.all(entries.map(({ path }) => readFile(path, "utf8"))), before); + assert.ok((await loadWorkspace(root)).resources.filter(({ id }) => ids.includes(id)).every(({ status }) => status === "approved")); + }); +} + +test("committed source bindings that included adoption dates remain exact over unchanged governed facts", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-activation-source-history-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "11"); + let loaded = await loadWorkspace(root); + const entries = loaded.entries.filter(({ record }) => ["policy", "document", "training"].includes(record.type)); + const ids = entries.map(({ record }) => record.id); + const stored = resourceReviewRevisionsSync(loaded, ids, "legacy", false, null, null, false); + const current = await resourceReviewRevisions(loaded, ids); + for (const id of ids) assert.notEqual(stored.get(id), current.get(id)); + await initializeGitWorkspace(root); + for (const entry of entries) await writeJson(entry.path, { ...entry.record, effectiveOn: "2026-10-12" }); + loaded = await loadWorkspace(root); + assert.deepEqual(await resourceReviewRevisions(loaded, ids), current); + assert.deepEqual(resourceReviewRevisionsSync(loaded, ids), current); + for (const id of ids) assert.equal(resourceReviewRevisionMatches(loaded, current, id, stored.get(id)), true); + for (const entry of entries) { + const markdownPath = entry.path.replace(/\.json$/, ".md"); + const source = await readFile(markdownPath, "utf8"); + await writeFile(markdownPath, `${source}\nSubstantive new requirement.\n`); + const changed = await loadWorkspace(root); + assert.equal(resourceReviewRevisionMatches(changed, await resourceReviewRevisions(changed, ids), entry.record.id, stored.get(entry.record.id)), false); + await writeFile(markdownPath, source); + } + await rm(join(root, ".git"), { recursive: true, force: true }); + loaded = await loadWorkspace(root); + for (const id of ids) assert.equal(resourceReviewRevisionMatches(loaded, await resourceReviewRevisions(loaded, ids), id, stored.get(id)), false); +}); + + +for (const resourceType of ["retention-schedule-item", "complementary-control"]) { + test(`${resourceType} preserves published collection bindings across governed-content lifecycle changes`, async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-activation-collection-history-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "11"); + let loaded = await loadWorkspace(root); + const document = loaded.entries.find(({ record }) => record.type === "document"); + const policy = loaded.entries.find(({ record }) => record.type === "policy"); + const row = loaded.entries.find(({ record }) => record.type === "retention-schedule-item"); + const complementary = loaded.entries.find(({ record }) => record.type === "complementary-control"); + await writeJson(row.path, { ...row.record, sourceResourceIds: [document.record.id, policy.record.id] }); + await writeJson(complementary.path, { ...complementary.record, sourceDocumentIds: [document.record.id] }); + loaded = await loadWorkspace(root); + const previous = collectionRevision(loaded, resourceType, { legacyGovernedLifecycleMetadata: true }); + const neutral = collectionRevision(loaded, resourceType); + assert.notEqual(previous, neutral); + assert.equal(collectionRevisionMatches(loaded, resourceType, previous), true); + await initializeGitWorkspace(root); + for (const entry of [document, policy]) await writeJson(entry.path, { ...entry.record, effectiveOn: "2026-10-12" }); + loaded = await loadWorkspace(root); + assert.equal(collectionRevision(loaded, resourceType), neutral); + assert.equal(collectionRevisionMatches(loaded, resourceType, previous), true); + const currentDocument = loaded.entries.find(({ record }) => record.id === document.record.id); + await writeJson(document.path, { ...currentDocument.record, version: "2.0" }); + assert.equal(collectionRevisionMatches(await loadWorkspace(root), resourceType, previous), false); + await writeJson(document.path, currentDocument.record); + const markdownPath = document.path.replace(/\.json$/, ".md"); + await writeFile(markdownPath, `${await readFile(markdownPath, "utf8")}\nChanged governed requirements.\n`); + assert.equal(collectionRevisionMatches(await loadWorkspace(root), resourceType, previous), false); + }); +} diff --git a/packages/filegrc/test/fixtures/activation-retention.js b/packages/filegrc/test/fixtures/activation-retention.js new file mode 100644 index 0000000..9e9dafb --- /dev/null +++ b/packages/filegrc/test/fixtures/activation-retention.js @@ -0,0 +1,72 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { makeComprehensiveWorkspace } from "../fixtures.js"; +import { commitWorkspaceFiles, initializeGitWorkspace, writeJson } from "../helpers.js"; +import { contentRevision } from "../../src/files.js"; +import { loadWorkspace } from "../../src/workspace.js"; +import { resourceReviewRevisionsSync, retentionReviewResourceIds } from "../../src/retention.js"; +import { planApplicabilityReview, applyApplicabilityReview } from "../../src/batch-review.js"; +import { applyCollectionReview } from "../../src/collection-review.js"; +import { currentCalendarDate } from "../../src/time.js"; + +export async function makeRetentionActivationWorkspace(root, { priorBindings = false } = {}) { + await makeComprehensiveWorkspace(root, "11"); + let loaded = await loadWorkspace(root); + const governed = loaded.entries.filter(({ record }) => ["policy", "document", "training"].includes(record.type)); + const document = governed.find(({ record }) => record.type === "document"); + const scheduleId = "document-retention-schedule"; + const scheduleMarkdown = "# Data Retention Schedule\n\nRetain production backups for one year after creation.\n"; + await writeFile(join(root, "data/documents", `${scheduleId}.md`), scheduleMarkdown); + await writeJson(join(root, "data/documents", `${scheduleId}.json`), { + ...document.record, id: scheduleId, title: "Data Retention Schedule", documentKind: "schedule", + approvedContentRevisions: { [`documents/${scheduleId}.md`]: contentRevision(scheduleMarkdown) }, + activatedContentRevisions: { [`documents/${scheduleId}.md`]: contentRevision(scheduleMarkdown) } + }); + for (const entry of governed) { + const record = { ...entry.record, status: "approved" }; + if (record.type === "document") record.programRole = "required"; + for (const field of ["effectiveOn", "activationBasis", "activatedOn", "activatedByIds", "activatedContentRevisions"]) delete record[field]; + record.proposedEffectiveOn = "2026-12-01"; + await writeJson(entry.path, record); + } + loaded = await loadWorkspace(root); + const row = loaded.entries.find(({ record }) => record.type === "retention-schedule-item"); + row.record = { + ...row.record, status: "active", title: "Production backup retention", scheduleDocumentId: scheduleId, + informationTypeIds: ["information-type-example"], + sourceResourceIds: governed.filter(({ record }) => record.type !== "training").map(({ record }) => record.id), + cutoff: { basis: "creation" }, retentionPeriod: { basis: "fixed", amount: 1, unit: "year" }, + dispositionAction: "delete", dispositionInstructions: "Delete expired backups through the approved backup service." + }; + row.record.reviewedSourceRevisions = Object.fromEntries(resourceReviewRevisionsSync( + loaded, retentionReviewResourceIds(row.record, loaded), "legacy", priorBindings, null, row.record, !priorBindings + )); + await writeJson(row.path, row.record); + const control = loaded.entries.find(({ record }) => record.type === "control"); + await writeJson(control.path, { ...control.record, procedureRevision: "1", procedureEffectiveOn: "2026-06-30" }); + const obligation = loaded.entries.find(({ record }) => record.type === "obligation"); + const trainingObligation = { + ...obligation.record, id: "obligation-training", scheduleMode: "legacy", activityType: "training", scopeResourceIds: ["training-example"], templateResourceId: "training-example", + recurrence: { mode: "calendar", unit: "month", interval: 1, anchorDate: "2026-08-21" } + }; + delete trainingObligation.ruleIds; + delete trainingObligation.activeRuleId; + await writeJson(join(root, "data/obligations/obligation-training.json"), trainingObligation); + await initializeGitWorkspace(root); + loaded = await loadWorkspace(root); + const review = { + decision: "complete", rationale: "Reviewed the implemented controls and operating sources.", + reviewedByIds: ["person-independent-approver-example"], + reviewedOn: currentCalendarDate(loaded.workspace.timezone), confirmed: true + }; + const applicability = { + decisions: [{ id: control.record.id, decision: "applicable", rationale: "The Control applies to the scoped service.", reviewedByIds: review.reviewedByIds, reviewedOn: review.reviewedOn }] + }; + const plan = await planApplicabilityReview(root, applicability); + await applyApplicabilityReview(root, { ...applicability, basis: plan.basis, confirmed: true }); + await commitWorkspaceFiles(root, "Record Control applicability"); + await applyCollectionReview(root, { ...review, resourceType: "complementary-control" }); + await commitWorkspaceFiles(root, "Review complementary responsibilities"); + await applyCollectionReview(root, { ...review, resourceType: "control" }); + return { ids: governed.map(({ record }) => record.id), rowId: row.record.id }; +} diff --git a/packages/filegrc/test/revision-upgrade.test.js b/packages/filegrc/test/revision-upgrade.test.js index a704ac0..a98767e 100644 --- a/packages/filegrc/test/revision-upgrade.test.js +++ b/packages/filegrc/test/revision-upgrade.test.js @@ -503,7 +503,7 @@ test("cross-step source reviews ignore routine metadata for other record types", } }); -test("source reviews retain governing lifecycle and effective-date changes", async (context) => { +test("source reviews retain governing status changes and ignore adoption dates", async (context) => { const root = await mkdtemp(join(tmpdir(), "filegrc-source-lifecycle-")); context.after(() => rm(root, { recursive: true, force: true })); await makeComprehensiveWorkspace(root, "11"); @@ -523,7 +523,7 @@ test("source reviews retain governing lifecycle and effective-date changes", asy entry.record.status = "approved"; entry.record.effectiveOn = "2030-01-01"; entry.source = `${JSON.stringify(entry.record, null, 2)}\n`; - assert.notEqual(await revision(), approved, `${type} effective date`); + assert.equal(await revision(), approved, `${type} adoption date`); } const coverage = loaded.entries.find(({ record }) => record.type === "source-coverage"); coverage.record.status = "planned"; diff --git a/packages/filegrc/test/source-review-sections.test.js b/packages/filegrc/test/source-review-sections.test.js index 9823c73..b2a41ef 100644 --- a/packages/filegrc/test/source-review-sections.test.js +++ b/packages/filegrc/test/source-review-sections.test.js @@ -220,7 +220,8 @@ test("twenty retention rows follow their approved Policy section through proposa policy.effectiveOn = "2027-01-01"; await writeJson(policyRecordPath, policy); - assert.equal((await state()).stale.length, 20); + // The adoption date is lifecycle metadata; the retained authority is unchanged. + assert.deepEqual(await state(), { currentRule: true, scheduleRevision: approvedScheduleRevision, rowReady: true, rowApprovalIssue: false, stale: [] }); delete policy.effectiveOn; await writeJson(policyRecordPath, policy); assert.deepEqual(await state(), { currentRule: true, scheduleRevision: approvedScheduleRevision, rowReady: true, rowApprovalIssue: false, stale: [] });