Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/upgrading-to-model-v12.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "filegrc-monorepo",
"version": "0.17.0",
"version": "0.17.1",
"private": true,
"workspaces": [
"packages/filegrc",
Expand Down
2 changes: 1 addition & 1 deletion packages/create-filegrc/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions packages/create-filegrc/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/create-filegrc/template/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/filegrc/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "filegrc",
"version": "0.17.0",
"version": "0.17.1",
"description": "Zero-dependency Git-native GRC engine",
"license": "MIT",
"repository": {
Expand Down
9 changes: 9 additions & 0 deletions packages/filegrc/src/applicability-scope.js
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
28 changes: 28 additions & 0 deletions packages/filegrc/test/applicability-scope.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"] };
Expand Down
34 changes: 34 additions & 0 deletions packages/filegrc/test/revision-upgrade.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 }));
Expand Down
36 changes: 36 additions & 0 deletions packages/filegrc/test/schedule-set.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ 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";
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";

Expand Down Expand Up @@ -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" });
Expand All @@ -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);
});
Expand Down
Loading