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
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.20.7",
"version": "0.20.8",
"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.20.7",
"version": "0.20.8",
"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.20.7",
version: "0.20.8",
lockfileVersion: 3,
requires: true,
packages: {
"": {
name,
version: "0.20.7",
version: "0.20.8",
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.20.7",
"version": "0.20.8",
"private": true,
"description": "filegrc workspace for a SOC 2 program",
"type": "module",
Expand Down
68 changes: 68 additions & 0 deletions packages/filegrc/model/v13.json
Original file line number Diff line number Diff line change
Expand Up @@ -13050,6 +13050,74 @@
"allowProofScope": true,
"reviewDateField": "reviewedOn",
"executionDateField": "completedOn"
},
"policy-review": {
"statuses": [
"complete"
],
"resultField": "outcome",
"passingResults": [
"passed",
"passed-with-exceptions"
],
"scopeFields": [
"scopeResourceIds"
],
"executionDateField": "completedOn"
},
"meeting": {
"statuses": [
"complete"
],
"scopeFields": [
"teamId"
],
"executionDateField": "endedAt"
},
"risk-assessment": {
"statuses": [
"complete"
],
"scopeFields": [
"systemIds",
"componentIds",
"vendorIds",
"commitmentIds",
"riskIds",
"newRiskIds",
"changedRiskIds"
],
"reviewDateField": "approvedOn",
"executionDateField": "completedOn"
},
"access-review": {
"statuses": [
"complete"
],
"resultField": "outcome",
"passingResults": [
"passed",
"passed-with-exceptions"
],
"scopeFields": [
"componentIds"
],
"reviewDateField": "approvedOn",
"executionDateField": "completedOn"
},
"vendor-review": {
"statuses": [
"complete"
],
"resultField": "decision",
"passingResults": [
"approved",
"conditional"
],
"scopeFields": [
"vendorId"
],
"executionDateField": "completedOn"
}
},
"baselineProfiles": [
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.20.7",
"version": "0.20.8",
"description": "Zero-dependency Git-native GRC engine",
"license": "MIT",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion packages/filegrc/src/obligations.js
Original file line number Diff line number Diff line change
Expand Up @@ -708,7 +708,7 @@ async function completeObligationOccurrenceUnlocked(input, options) {
if (!assessment.complete) throw new Error(assessment.issues.join(" "));
const scaffold = await scaffoldObligationOccurrence(loaded.root, {
obligationId: obligation.id, programId: program.id,
windowStart: item.dueWindowStart, asOf: completedOn,
windowStart: item.dueWindowStart, asOf: currentCalendarDate(loaded.workspace.timezone),
completionRecord
});
const occurrence = scaffold.record;
Expand Down
18 changes: 18 additions & 0 deletions packages/filegrc/test/completion-scope.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,21 @@ test("unrelated source and Control links do not turn results into System work",
assert.equal(matches({ type: "exercise", componentIds: ["component-b"], evidenceIds: ["proof-a"] }, "system-a"), false);
assert.equal(matches({ type: "exercise", evidenceIds: ["proof-a"] }, "system-a"), true, "Proof-only scope remains supported where the model allows it");
});

test("native review membership uses declared scope fields without counting actors or proof links", () => {
const records = ["policy", "document", "team", "person", "vendor", "risk", "commitment"].map((type) => ({ id: `${type}-a`, type }));
const { byId } = fixture();
for (const record of records) byId.set(record.id, record);
const cases = [
["policy-review", { scopeResourceIds: ["policy-a", "document-a"] }, ["policy-a", "document-a"]],
["meeting", { teamId: "team-a" }, ["team-a"]],
["risk-assessment", { systemIds: ["system-a"], componentIds: ["component-a"], vendorIds: ["vendor-a"], riskIds: ["risk-a"], commitmentIds: ["commitment-a"] }, ["system-a", "component-a", "vendor-a", "risk-a", "commitment-a"]],
["access-review", { componentIds: ["component-a"] }, ["component-a", "system-a"]],
["vendor-review", { vendorId: "vendor-a" }, ["vendor-a"]]
];
for (const [type, scope, members] of cases) {
const record = { type, ...scope, reviewerIds: ["person-a"], approverIds: ["person-a"], assessorIds: ["person-a"], chairIds: ["person-a"], attendeeIds: ["person-a"], evidenceIds: ["proof-a"] };
for (const id of members) assert.equal(completionMatchesMember(record, null, id, model, byId), true, `${type} ${id}`);
for (const id of ["person-a", "system-b", "component-b", "proof-a"]) assert.equal(completionMatchesMember(record, null, id, model, byId), false, `${type} ${id}`);
}
});
36 changes: 36 additions & 0 deletions packages/filegrc/test/fixtures/native-review-occurrence.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { rm } from "node:fs/promises";
import { makeComprehensiveWorkspace } from "../fixtures.js";
import { writeJson } from "../helpers.js";
import { loadWorkspace } from "../../src/index.js";

export async function nativeReviewFixture(root, activityType = "policy-review") {
await makeComprehensiveWorkspace(root, "13");
const loaded = await loadWorkspace(root);
const entry = (type) => loaded.entries.find(({ record }) => record.type === type);
const scopes = {
"policy-review": ["policy-example", "document-example"],
meeting: ["team-example"],
"oversight-meeting": ["team-example"],
"risk-assessment": ["system-example", "component-example", "risk-example"],
"vendor-review": ["vendor-example"],
"access-review": ["component-example"]
};
const obligation = { ...entry("obligation").record, activityType, scopeResourceIds: scopes[activityType] };
const rule = { ...entry("obligation-rule").record,
recurrence: { mode: "calendar", unit: "month", interval: 1, anchorDate: "2026-01-01" },
window: { precision: "date", startsAfter: 0, dueAfter: 20 }, effectiveAt: "2026-07-05T00:00:00-05:00" };
delete rule.selector;
await writeJson(entry("obligation").path, obligation);
await writeJson(entry("obligation-rule").path, rule);
for (const current of loaded.entries.filter(({ record }) => record.type === "obligation-occurrence")) await rm(current.path);
const type = activityType === "oversight-meeting" ? "meeting" : activityType;
const reviewEntry = entry(type);
const review = { ...reviewEntry.record };
if (type === "meeting") Object.assign(review, { scheduledFor: "2026-07-07", startedAt: "2026-07-07T12:00:00Z", endedAt: "2026-07-07T13:00:00Z" });
else Object.assign(review, { completedOn: "2026-07-07" });
if (review.approvedOn) review.approvedOn = "2026-07-07";
if (review.coverage) review.coverage = { kind: "as-of", on: "2026-07-07" };
if (type === "policy-review") Object.assign(review, { scopeResourceIds: scopes[activityType], reviewerIds: ["person-example"], changesRequired: false });
await writeJson(reviewEntry.path, review);
return { root, obligation, rule, review, reviewEntry, obligationEntry: entry("obligation"), ruleEntry: entry("obligation-rule") };
}
140 changes: 140 additions & 0 deletions packages/filegrc/test/native-review-occurrence.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { nativeReviewFixture } from "./fixtures/native-review-occurrence.js";
import { executeCli, initializeGitWorkspace, writeJson } from "./helpers.js";
import { runCli } from "../src/cli.js";
import { contentRevision } from "../src/files.js";
import { completeObligationOccurrence, loadWorkspace, planObligations, scaffoldObligationOccurrence, saveObligationOccurrence, validateWorkspace } from "../src/index.js";
import { assessCompletionForObligation } from "../src/obligations.js";

async function fixture(t, activity) {
const root = await mkdtemp(join(tmpdir(), "filegrc-native-review-"));
t.after(() => rm(root, { recursive: true, force: true }));
return nativeReviewFixture(root, activity);
}
const scaffold = (f) => scaffoldObligationOccurrence(f.root, { obligationId: f.obligation.id, windowStart: "2026-07-05", asOf: "2026-07-07" });
const complete = async (f, record = f.review) => completeObligationOccurrence(f.root, { obligationId: f.obligation.id, windowStart: "2026-07-05", record,
expectedRevision: contentRevision(await readFile(f.obligationEntry.path, "utf8")), now: "2026-07-07T18:00:00Z" });

for (const activity of ["policy-review", "meeting", "oversight-meeting", "risk-assessment", "vendor-review", "access-review"]) {
test(`${activity} links its native typed population through CLI and reuses unchanged proof`, async (t) => {
const f = await fixture(t, activity);
const before = await readFile(f.reviewEntry.path, "utf8");
const policyBefore = await readFile(join(f.root, "data/policies/policy-example.json"), "utf8");
const payload = await scaffold(f);
assert.deepEqual(payload.record.members.map(({ resourceId }) => resourceId).sort(), [...f.obligation.scopeResourceIds].sort());
assert.ok(payload.record.members.every(({ result, completionResourceIds }) => result === "passed" && completionResourceIds.includes(f.review.id)));
const path = join(f.root, "completion.json");
await writeJson(path, { record: f.review, revision: contentRevision(await readFile(f.obligationEntry.path, "utf8")) });
await executeCli(runCli, process.execPath, [new URL("../bin/filegrc.js", import.meta.url).pathname, "complete", f.obligation.id, path, "--root", f.root, "--json"]);
const first = await complete(f);
const second = await complete(f);
assert.deepEqual(second.linked, first.linked);
assert.equal(await readFile(f.reviewEntry.path, "utf8"), before);
assert.equal(await readFile(join(f.root, "data/policies/policy-example.json"), "utf8"), policyBefore);
const loaded = await loadWorkspace(f.root);
const item = planObligations(loaded.resources, { root: f.root, model: loaded.model, asOf: "2026-07-07", through: "2026-07-07", includeComplete: true }).calendarItems.find(({ obligationId }) => obligationId === f.obligation.id);
// Meetings have chairs, rather than an occurrence reviewer, so reconciliation remains explicit.
if (activity.includes("meeting")) assert.equal(first.linked.status, "open");
else assert.equal(item.status, "complete");
assert.deepEqual((await validateWorkspace(f.root)).diagnostics.filter(({ code }) => /occurrence|member|population|policy-review/.test(code)), []);
});
}

test("Policy review rejects failed, unfinished, unproved, unrelated and ineligible execution without partial writes", async (t) => {
const f = await fixture(t);
const loaded = await loadWorkspace(f.root);
const window = { dueWindowStart: "2026-07-05", dueWindowEnd: "2026-07-21" };
const variants = [
{ ...f.review, outcome: "failed" },
{ ...f.review, status: "in-progress", outcome: undefined, completedOn: undefined },
{ ...f.review, reviewerIds: [] },
{ ...f.review, evidenceIds: [] },
{ ...f.review, evidenceIds: ["missing-proof"] },
{ ...f.review, scopeResourceIds: ["policy-other"] },
{ ...f.review, completedOn: "2026-07-04" },
{ ...f.review, completedOn: "2027-07-08" },
{ ...f.review, completedOn: "2026-07-22" }
];
for (const [index, record] of variants.entries()) {
assert.equal(assessCompletionForObligation(record, f.obligation, loaded, { memberIds: f.obligation.scopeResourceIds, window, asOf: "2026-07-07" }).complete, false, String(index));
// New proof exercises the atomic create-and-link path, not the existing-record mismatch guard.
await assert.rejects(complete(f, { ...record, id: `policy-review-invalid-${index}` }));
const current = await loadWorkspace(f.root);
assert.equal(current.resources.some(({ id }) => id === `policy-review-invalid-${index}`), false);
assert.equal(current.resources.some(({ type }) => type === "obligation-occurrence"), false);
}
});

test("completion windows use actual execution rather than coverage and preserve later-period work", async (t) => {
const f = await fixture(t);
await writeJson(f.reviewEntry.path, { ...f.review, completedOn: "2026-06-30", coverage: { kind: "as-of", on: "2026-07-07" } });
assert.ok((await scaffold(f)).record.members.every(({ result }) => result === "pending"));
await writeJson(f.reviewEntry.path, { ...f.review, coverage: { kind: "as-of", on: "2026-06-30" } });
assert.ok((await scaffold(f)).record.members.every(({ result }) => result === "passed"));
const loaded = await loadWorkspace(f.root);
const later = planObligations(loaded.resources, { model: loaded.model, asOf: "2026-08-01", from: "2026-08-01", through: "2026-08-01", includeComplete: true }).calendarItems.find(({ obligationId }) => obligationId === f.obligation.id);
assert.equal(later.completedCount, 0);
});

test("finalized Policy review retains its frozen authored population and approval separation", async (t) => {
const f = await fixture(t);
await complete(f);
await initializeGitWorkspace(f.root);
await writeJson(f.obligationEntry.path, { ...f.obligation, scopeResourceIds: [...f.obligation.scopeResourceIds, "document-later"] });
const loaded = await loadWorkspace(f.root);
const doc = loaded.resources.find(({ type }) => type === "document");
await writeJson(join(f.root, "data/documents/document-later.json"), { ...doc, id: "document-later", status: "draft", approverIds: [], approvedOn: undefined, approvedRevision: undefined, effectiveOn: undefined, activatedOn: undefined, activatedByIds: undefined, activationRevision: undefined, activationBasis: undefined });
const current = await loadWorkspace(f.root);
const occurrence = current.resources.find(({ type }) => type === "obligation-occurrence");
assert.deepEqual(occurrence.members.map(({ resourceId }) => resourceId).sort(), [...f.obligation.scopeResourceIds].sort());
assert.deepEqual((await validateWorkspace(f.root)).diagnostics.filter(({ code }) => /occurrence|member|population/.test(code)), []);
});

test("native scoped completions reject missing actors, incomplete work and future review or execution", async (t) => {
for (const activity of ["policy-review", "meeting", "risk-assessment", "vendor-review", "access-review"]) {
const f = await fixture(t, activity), loaded = await loadWorkspace(f.root);
const spec = loaded.model.operatingPreparation.completionRecords[f.review.type];
const variants = [{ ...f.review, status: "planned" }, { ...f.review, evidenceIds: ["missing-proof"] }];
variants.push({ ...f.review, [spec.executionDateField]: spec.executionDateField === "endedAt" ? "2026-07-08T13:00:00Z" : "2026-07-08" });
if (spec.reviewDateField) variants.push({ ...f.review, [spec.reviewDateField]: "2026-07-08" });
if (spec.resultField) variants.push({ ...f.review, [spec.resultField]: activity === "vendor-review" ? "rejected" : "failed" });
for (const record of variants) assert.equal(assessCompletionForObligation(record, f.obligation, loaded, { memberIds: f.obligation.scopeResourceIds, asOf: "2026-07-07" }).complete, false, activity);
}
});

test("an unchanged existing Policy review links atomically while altered content and false passing links are rejected", async (t) => {
const f = await fixture(t), before = await readFile(f.reviewEntry.path, "utf8");
await assert.rejects(complete(f, { ...f.review, changesRequired: true }), /different content/);
await writeJson(f.reviewEntry.path, { ...f.review, outcome: "failed" });
const payload = await scaffold(f);
assert.ok(payload.record.members.every(({ result }) => result === "failed"));
const record = { ...payload.record, status: "reconciled", conclusion: "complete", completedCount: 2,
reviewedByIds: ["person-example"], reconciledAt: "2026-07-07T18:00:00Z",
members: payload.record.members.map((member) => ({ ...member, result: "passed" })) };
await assert.rejects(saveObligationOccurrence(f.root, { record }), /completion|passed|outcome/i);
assert.equal((await loadWorkspace(f.root)).resources.some(({ type }) => type === "obligation-occurrence"), false);
await writeJson(f.reviewEntry.path, JSON.parse(before));
await complete(f);
assert.equal(await readFile(f.reviewEntry.path, "utf8"), before);
});

test("review completed after execution can link the original allowed window and retry unchanged", async (t) => {
for (const activity of ["risk-assessment", "access-review"]) {
const f = await fixture(t, activity);
f.review.approvedOn = "2026-07-08";
await writeJson(f.reviewEntry.path, f.review);
const before = await readFile(f.reviewEntry.path, "utf8");
const options = { obligationId: f.obligation.id, windowStart: "2026-07-05", record: f.review,
expectedRevision: contentRevision(await readFile(f.obligationEntry.path, "utf8")), now: "2026-07-09T18:00:00Z" };
const first = await completeObligationOccurrence(f.root, options);
assert.equal(first.linked.status, "reconciled", activity);
assert.equal(first.linked.completedCount, f.obligation.scopeResourceIds.length);
assert.ok(first.linked.members.every(({ result }) => result === "passed"));
assert.deepEqual((await completeObligationOccurrence(f.root, options)).linked, first.linked);
assert.equal(await readFile(f.reviewEntry.path, "utf8"), before);
}
});
Loading