From 49ceacd733b27632a023867160523947e2762da0 Mon Sep 17 00:00:00 2001 From: Abe Wheeler Date: Mon, 12 Oct 2026 13:34:57 -0500 Subject: [PATCH] Preserve equivalent model 11 applicability reviews and release 0.17.1 --- docs/upgrading-to-model-v12.md | 2 ++ package-lock.json | 8 ++--- package.json | 2 +- packages/create-filegrc/package.json | 2 +- packages/create-filegrc/src/index.js | 4 +-- packages/create-filegrc/template/package.json | 2 +- packages/filegrc/package.json | 2 +- packages/filegrc/src/applicability-scope.js | 9 +++++ .../filegrc/test/applicability-scope.test.js | 28 +++++++++++++++ .../filegrc/test/revision-upgrade.test.js | 34 ++++++++++++++++++ packages/filegrc/test/schedule-set.test.js | 36 +++++++++++++++++++ 11 files changed, 119 insertions(+), 10 deletions(-) diff --git a/docs/upgrading-to-model-v12.md b/docs/upgrading-to-model-v12.md index d6ebca6..fa74821 100644 --- a/docs/upgrading-to-model-v12.md +++ b/docs/upgrading-to-model-v12.md @@ -11,6 +11,8 @@ npx filegrc migrate --to-model 12 --yes --json The migration changes only the Workspace model version. It preserves content, approvals, operating rules, historical occurrences, and prior Collection Reviews. It does not infer adoption or exclusion decisions. Existing operation remains recorded, while readiness exposes missing reviews. Older workspaces migrate one version at a time. +Applicability reviews for Requirements, Controls, Commitments, and Complementary Controls remain current when the reviewed scope facts still match model v11's decision semantics, including existing Git-backed compatibility checks. The version change alone does not require another applicability decision. Changed criteria, linked scope, statements, or requirement decisions still require review. Stored review revisions and reviewer dates remain unchanged. This compatibility does not satisfy the new Obligation schedule-set review. + For each Program, inspect its Controls, governing content, linked Obligations, and all active and proposed rules. Commit the reviewed source records, then scaffold the review: ```sh diff --git a/package-lock.json b/package-lock.json index 074bf1b..6296501 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.17.0", + "version": "0.17.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.17.0", + "version": "0.17.1", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.17.0", + "version": "0.17.1", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.17.0", + "version": "0.17.1", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 32a56c4..902cc8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.17.0", + "version": "0.17.1", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index 0cd2e6a..90345b9 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.17.0", + "version": "0.17.1", "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 e4c99f1..092c526 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.17.0", + version: "0.17.1", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.17.0", + version: "0.17.1", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index 3785149..0036a2c 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.17.0", + "version": "0.17.1", "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 08fcccf..955db98 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.17.0", + "version": "0.17.1", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/applicability-scope.js b/packages/filegrc/src/applicability-scope.js index 65eef18..6cd966b 100644 --- a/packages/filegrc/src/applicability-scope.js +++ b/packages/filegrc/src/applicability-scope.js @@ -1,5 +1,6 @@ import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionsMatch } from "./revisions.js"; import { reviewCommitsChangingTypes, reviewHistoricalWorkspace, reviewHistoryCommits, reviewHistoryContext } from "./historical-workspace.js"; +import { loadModel } from "../model/index.js"; const scopeSourceTypes = new Set([ "workspace", "program", "commitment", "control", "complementary-control", @@ -68,6 +69,7 @@ function indexedValues(index, ids) { } const historicalScopeIndexes = new WeakMap(); +let compatibleModel11; export function applicabilityScopeRevision(record, program, resources, model, options = {}) { if (Number(model.modelVersion) >= 11 && !options.legacyDecisionScope) { @@ -302,6 +304,13 @@ export function applicabilityReviewIsCurrent(review, record, program, resources, const current = revision(); const matches = (revision) => revisionsMatch("applicability-scope", review.scopeRevision, revision); if (matches(current)) return true; + // Model 12 adds schedule-set approval, but leaves applicability facts and + // decision semantics unchanged. Re-evaluate the current facts with the + // published v11 calculation, including its historical recovery paths. + // Keep this bridge explicit: future models must establish equivalence before + // accepting reviews from another version. Never rewrite the stored review. + if (String(model.modelVersion) === "12" && applicabilityDecisionFields[record?.type] + && applicabilityReviewIsCurrent(review, record, program, resources, compatibleModel11 ||= loadModel("11"), root, scopeIndex)) return true; if (Number(model.modelVersion) >= 11 && matches(revision({ legacyDecisionScope: true }))) return true; diff --git a/packages/filegrc/test/applicability-scope.test.js b/packages/filegrc/test/applicability-scope.test.js index 6f011c5..8e51611 100644 --- a/packages/filegrc/test/applicability-scope.test.js +++ b/packages/filegrc/test/applicability-scope.test.js @@ -13,6 +13,34 @@ import { createAppStateSection } from "../src/state.js"; import { loadWorkspace } from "../src/workspace.js"; import { makeComprehensiveWorkspace } from "./fixtures.js"; +test("model 12 preserves model 11 applicability semantics without accepting material changes", () => { + const requirement = { id: "requirement-one", type: "requirement", frameworkId: "framework-one", description: "Criterion", reference: "CC1.1" }; + const framework = { id: "framework-one", type: "framework", version: "1", status: "active" }; + const program = { id: "program-one", type: "program", assuranceGoal: "soc-2-type-2", systemIds: ["system-one"], frameworkIds: [framework.id], requirementIds: [requirement.id], requirementApplicability: [{ requirementId: requirement.id, decision: "applicable" }] }; + for (const type of ["requirement", "control", "commitment", "complementary-control"]) { + const record = type === "requirement" ? requirement : { + id: `${type}-one`, type, status: "active", statement: "Required work", systemIds: ["system-one"], requirementIds: [requirement.id], responsibleParty: "customer" + }; + const resources = [requirement, framework, record]; + const review = { decision: "applicable", reviewedOn: "2026-09-01", scopeRevision: applicabilityScopeRevision(record, program, resources, loadModel("11")) }; + const unchanged = structuredClone(review); + const current = (candidate = record, selected = program, facts = resources) => applicabilityReviewIsCurrent(review, candidate, selected, facts, loadModel("12")); + assert.equal(current(), true, type); + assert.deepEqual(review, unchanged); + assert.equal(current({ ...record, [type === "requirement" ? "description" : "statement"]: "Changed" }), false, type); + assert.equal(current(record, { ...program, assuranceGoal: "soc-2-type-1" }), false, type); + assert.equal(current(record, program, resources.map((r) => r.type === "framework" ? { ...r, version: "2" } : r)), false, type); + assert.equal(current({ ...record, status: "retired" }), false, type); + if (type !== "requirement") { + assert.equal(current({ ...record, systemIds: ["system-other"] }), false, type); + assert.equal(current(record, { ...program, requirementApplicability: [{ requirementId: requirement.id, decision: "not-applicable" }] }), false, type); + assert.equal(current(record, program, resources.map((r) => r.type === "requirement" ? { ...r, description: "Changed" } : r)), false, type); + } + if (type === "control") assert.equal(current({ ...record, status: "not-applicable" }), false); + assert.equal(applicabilityReviewIsCurrent(review, record, program, resources, { ...loadModel("12"), modelVersion: "13" }), false, "future versions need their own compatibility decision"); + } +}); + test("model v7 rejects legacy applicability revisions and binds dependent reviews to requirement decisions", () => { const requirement = { id: "requirement-one", type: "requirement", frameworkId: "framework-one", reference: "CC1.1" }; const control = { id: "control-one", type: "control", title: "Control one", policyIds: ["policy-one"] }; diff --git a/packages/filegrc/test/revision-upgrade.test.js b/packages/filegrc/test/revision-upgrade.test.js index a98767e..1e29dc8 100644 --- a/packages/filegrc/test/revision-upgrade.test.js +++ b/packages/filegrc/test/revision-upgrade.test.js @@ -25,6 +25,7 @@ import { validateWorkspace } from "../src/validate.js"; import { loadWorkspace } from "../src/workspace.js"; import { serveWorkspace } from "../src/index.js"; import { makeComprehensiveWorkspace } from "./fixtures.js"; +import { loadModel } from "../model/index.js"; import { commitWorkspaceFiles, initializeGitWorkspace } from "./helpers.js"; test("revision labels shorten known hashes without changing stored values", () => { @@ -1431,6 +1432,39 @@ test("Git proves an older broad commitment review survives unrelated scope addit assert.equal(applicabilityReviewIsCurrent(review, commitment, program, current.resources, current.model, root), false); }); +test("model 12 recovers model 11 legacy reviews through Git without hiding changed scope", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-applicability-migration-history-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "11"); + const before = await loadWorkspace(root); + const program = before.resources.find(({ type }) => type === "program"); + const records = before.resources.filter(({ type }) => ["requirement", "control", "commitment", "complementary-control"].includes(type)); + const reviews = records.map((record) => ({ decision: "applicable", scopeRevision: applicabilityScopeRevision(record, program, before.resources, before.model, { + legacyDecisionScope: true, legacyBroadScope: true, legacyResourceProjection: true + }) })); + await initializeGitWorkspace(root); + program.systemIds.push("system-unrelated"); + await writeFile(before.entries.find(({ record }) => record.id === program.id).path, `${JSON.stringify(program, null, 2)}\n`); + const workspaceEntry = before.entries.find(({ record }) => record.type === "workspace"); + workspaceEntry.record.dataModelVersion = "12"; + await writeFile(workspaceEntry.path, `${JSON.stringify(workspaceEntry.record, null, 2)}\n`); + await commitWorkspaceFiles(root, "Upgrade unchanged applicability semantics"); + const current = await loadWorkspace(root); + const selected = current.resources.find(({ id }) => id === program.id); + for (let index = 0; index < records.length; index += 1) { + const record = current.resources.find(({ id }) => id === records[index].id); + const review = reviews[index]; + assert.equal(applicabilityReviewIsCurrent(review, record, selected, current.resources, loadModel("11"), root), true, record.type); + assert.equal(applicabilityReviewIsCurrent(review, record, selected, current.resources, current.model, root), true, record.type); + const field = record.type === "requirement" ? "description" : "statement"; + const original = record[field]; + record[field] = "Materially changed scope"; + assert.equal(applicabilityReviewIsCurrent(review, record, selected, current.resources, current.model, root), false, record.type); + record[field] = original; + assert.equal(applicabilityReviewIsCurrent(review, record, selected, current.resources, current.model, root), true, "cached failure must not hide restored facts"); + } +}); + test("a source rebind alone does not require another whole-schedule approval", async (context) => { const root = await mkdtemp(join(tmpdir(), "filegrc-schedule-rebind-")); context.after(() => rm(root, { recursive: true, force: true })); diff --git a/packages/filegrc/test/schedule-set.test.js b/packages/filegrc/test/schedule-set.test.js index db985e2..93df00b 100644 --- a/packages/filegrc/test/schedule-set.test.js +++ b/packages/filegrc/test/schedule-set.test.js @@ -9,6 +9,7 @@ import test from "node:test"; import { loadModel } from "../model/index.js"; import { scheduleSetIssues, scheduleSetRecords } from "../src/schedule-set.js"; import { collectionRevision } from "../src/collection-revision.js"; +import { applicabilityReviewIsCurrent, applicabilityScopeRevision } from "../src/applicability-scope.js"; const scheduleSetRevision = (loaded, program) => collectionRevision(loaded, "obligation", { program }); import { obligationRule } from "../src/obligation-rule.js"; import { assessCollectionReview, scaffoldCollectionReview, planCollectionReview, applyCollectionReview } from "../src/collection-review.js"; @@ -16,6 +17,7 @@ import { loadWorkspace } from "../src/workspace.js"; import { makeComprehensiveWorkspace } from "./fixtures.js"; import { initializeGitWorkspace, initializeExistingModel12Workspace, commitWorkspaceFiles, writeJson, executeCli } from "./helpers.js"; import { runCli } from "../src/cli.js"; +import { createAppStateSection } from "../src/state.js"; import { currentCalendarDate } from "../src/time.js"; import { fileURLToPath } from "node:url"; @@ -120,6 +122,24 @@ test("v12 migration preserves operating records and leaves schedule decisions pe const root = await mkdtemp(join(tmpdir(), "filegrc-v12-migration-")); context.after(() => rm(root, { recursive: true, force: true })); await makeComprehensiveWorkspace(root, "11"); + const reviewed = await loadWorkspace(root); + const reviewedProgram = reviewed.resources.find(({ type }) => type === "program"); + const reviewBindings = new Map(); + for (const entry of reviewed.entries.filter(({ record }) => ["control", "commitment", "complementary-control"].includes(record.type))) { + entry.record.applicabilityReview = { + decision: "applicable", rationale: "Required in the selected scope.", + reviewedByIds: ["person-independent-approver-example"], reviewedOn: currentCalendarDate("UTC"), + scopeRevision: applicabilityScopeRevision(entry.record, reviewedProgram, reviewed.resources, reviewed.model) + }; + reviewBindings.set(entry.record.id, entry.record.applicabilityReview); + await writeJson(entry.path, entry.record); + } + for (const decision of reviewedProgram.requirementApplicability || []) { + const requirement = reviewed.resources.find(({ id }) => id === decision.requirementId); + decision.scopeRevision = applicabilityScopeRevision(requirement, reviewedProgram, reviewed.resources, reviewed.model); + reviewBindings.set(requirement.id, decision); + } + await writeJson(reviewed.entries.find(({ record }) => record.id === reviewedProgram.id).path, reviewedProgram); await initializeGitWorkspace(root); const before = await loadWorkspace(root); const preview = await planModelMigration(root, { targetModelVersion: "12" }); @@ -131,6 +151,22 @@ test("v12 migration preserves operating records and leaves schedule decisions pe const after = await loadWorkspace(root); assert.equal(after.workspace.dataModelVersion, "12"); assert.deepEqual(after.resources.filter(({ type }) => type !== "workspace"), before.resources.filter(({ type }) => type !== "workspace")); + const program = after.resources.find(({ id }) => id === reviewedProgram.id); + for (const [id, review] of reviewBindings) { + assert.equal(applicabilityReviewIsCurrent(review, after.resources.find((r) => r.id === id), program, after.resources, after.model, root), true, id); + } + const browser = await createAppStateSection(after, "program", { programId: program.id }); + const cli = fileURLToPath(new URL("../bin/filegrc.js", import.meta.url)); + const output = await executeCli(runCli, process.execPath, [cli, "program-readiness", "--program", program.id, "--json", "--root", root]); + const { generatedAt: cliGeneratedAt, ...cliReadiness } = JSON.parse(output.stdout); + const { generatedAt: browserGeneratedAt, ...browserReadiness } = browser.programReadiness; + assert.ok(cliGeneratedAt && browserGeneratedAt); + assert.deepEqual(cliReadiness, browserReadiness); + for (const id of reviewBindings.keys()) assert.equal(browser.applicabilityReviewStatuses[id], "current", id); + for (const resourceType of Object.keys(before.model.collectionReviews)) { + assert.equal(collectionRevision(after, resourceType, { program }), collectionRevision(before, resourceType, { program: reviewedProgram }), resourceType); + assert.equal(assessCollectionReview(after, resourceType).complete, assessCollectionReview(before, resourceType).complete, resourceType); + } assert.equal(assessCollectionReview(after, "obligation").complete, false); assert.equal((await planModelMigration(root, { targetModelVersion: "12" })).summary.update, 0); });