diff --git a/package-lock.json b/package-lock.json index 6580a3a..7f5e9d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.20.7", + "version": "0.20.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.20.7", + "version": "0.20.8", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.20.7", + "version": "0.20.8", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.20.7", + "version": "0.20.8", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 002c2e8..b2100b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.20.7", + "version": "0.20.8", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index d3110dd..122ae96 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -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": { diff --git a/packages/create-filegrc/src/index.js b/packages/create-filegrc/src/index.js index afda92c..d96e5fc 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.20.7", + version: "0.20.8", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.20.7", + version: "0.20.8", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index d58db38..924869b 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -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", diff --git a/packages/filegrc/model/v13.json b/packages/filegrc/model/v13.json index 9920b42..1d7b863 100644 --- a/packages/filegrc/model/v13.json +++ b/packages/filegrc/model/v13.json @@ -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": [ diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index 3f1a3d4..30e9b26 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.20.7", + "version": "0.20.8", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/obligations.js b/packages/filegrc/src/obligations.js index e55b694..9e51066 100644 --- a/packages/filegrc/src/obligations.js +++ b/packages/filegrc/src/obligations.js @@ -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; diff --git a/packages/filegrc/test/completion-scope.test.js b/packages/filegrc/test/completion-scope.test.js index b3f325c..8037b92 100644 --- a/packages/filegrc/test/completion-scope.test.js +++ b/packages/filegrc/test/completion-scope.test.js @@ -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}`); + } +}); diff --git a/packages/filegrc/test/fixtures/native-review-occurrence.js b/packages/filegrc/test/fixtures/native-review-occurrence.js new file mode 100644 index 0000000..cdab173 --- /dev/null +++ b/packages/filegrc/test/fixtures/native-review-occurrence.js @@ -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") }; +} diff --git a/packages/filegrc/test/native-review-occurrence.test.js b/packages/filegrc/test/native-review-occurrence.test.js new file mode 100644 index 0000000..2b84716 --- /dev/null +++ b/packages/filegrc/test/native-review-occurrence.test.js @@ -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); + } +});