diff --git a/package-lock.json b/package-lock.json index 52e2a8a..ded5355 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.16.34", + "version": "0.16.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.16.34", + "version": "0.16.35", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.16.34", + "version": "0.16.35", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.16.34", + "version": "0.16.35", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 361badc..6a0381f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.16.34", + "version": "0.16.35", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index 10ab42a..5a168bf 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.16.34", + "version": "0.16.35", "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 f5e7db4..fdece4f 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.34", + version: "0.16.35", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.16.34", + version: "0.16.35", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index 5285640..c05a9cc 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.34", + "version": "0.16.35", "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 f6f9a89..ac47fc4 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.16.34", + "version": "0.16.35", "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 1d7039b..c5227a9 100644 --- a/packages/filegrc/src/obligations.js +++ b/packages/filegrc/src/obligations.js @@ -23,7 +23,7 @@ import { currentCalendarDate, isRfc3339Timestamp, localDateTimeValue, timestampF import { loadWorkspace } from "./workspace.js"; import { obligationRule, obligationRuleRecurrence } from "./obligation-rule.js"; import { governedContentIsOperating, obligationGovernedContent, obligationProgramStatus } from "./program-lifecycle.js"; -import { resolveProgram } from "./program.js"; +import { recordProgramRelationships, recordBelongsToProgram, resolveProgram } from "./program.js"; import { currentPartyPeople } from "./parties.js"; import { serializeWorkspaceMutation } from "./mutation.js"; import { collectionReviewRevision, historicalCollectionReviewSnapshot } from "./collection-review-integrity.js"; @@ -65,7 +65,7 @@ export function planObligations(resources, options = {}) { const legacyProgram = programs.length === 0 && workspace && (!options.programId || options.programId === workspace.id) ? workspace : null; - const program = programs.find(({ id }) => id === options.programId) || (programs.length === 1 ? programs[0] : legacyProgram); + let program = programs.find(({ id }) => id === options.programId) || (programs.length === 1 ? programs[0] : legacyProgram); if ( options.programId && !programs.some(({ id }) => id === options.programId) @@ -81,6 +81,9 @@ export function planObligations(resources, options = {}) { "Obligation planning requires options.model or a Workspace record with dataModelVersion." ); } + if (programs.length && modelSupports(model, "program-scope")) { + program = resolveProgram({ resources: records, workspace, model }, options.programId); + } if (!program && programs.length > 1) { throw new Error("Obligation planning requires programId when more than one Program is active."); } @@ -100,12 +103,11 @@ export function planObligations(resources, options = {}) { const obligations = records.filter((record) => ( record.type === "obligation" && ["active", "proposed"].includes(record.status) - && obligationBelongsToProgram(record, obligationProgram, model) + && obligationBelongsToProgram(record, obligationProgram, byId, model) )); const programStatusByObligationId = new Map(obligations.map((obligation) => [ obligation.id, obligationProgramStatus(obligation, byId, asOf, model, now) ])); - const obligationIds = new Set(obligations.map(({ id }) => id)); if (obligations.length > MAX_PLANNED_ITEMS) { throw new Error(`The obligation query must be narrowed; it includes more than ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} active obligations.`); } @@ -374,7 +376,10 @@ export function planObligations(resources, options = {}) { const actionsBySource = new Map(); let eventActionCount = 0; for (const record of records) { - if (record.type !== "action-item" || !eventIds.has(record.sourceResourceId) || !obligationIds.has(record.obligationId)) continue; + if (record.type !== "action-item" || !eventIds.has(record.sourceResourceId)) continue; + const obligation = byId.get(record.obligationId); + // Retirement stops new work, not a checklist already assigned to an event. + if (obligation?.type !== "obligation" || !obligationBelongsToProgram(obligation, obligationProgram, byId, model)) continue; if (++eventActionCount > MAX_PLANNED_ITEMS) { throw new Error(`The obligation query must be narrowed; it includes more than ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} event actions.`); } @@ -394,7 +399,7 @@ export function planObligations(resources, options = {}) { .flatMap((run) => run.actions) .filter((item) => item.status !== "complete" || options.includeComplete); const standaloneItems = records - .filter((record) => record.type === "action-item" && !eventIds.has(record.sourceResourceId)) + .filter((record) => record.type === "action-item" && !(eventIds.has(record.sourceResourceId) && record.obligationId)) .map((record) => planStandaloneAction(record, byId, asOf, now)) .filter((item) => workItemBelongsToProgram(item, obligationProgram, byId, model)) .filter((item) => item.status !== "complete" || options.includeComplete); @@ -492,7 +497,7 @@ async function createObligationEventUnlocked(input, options) { if ( record.type !== "obligation" || !["active", "proposed"].includes(record.status) - || (program && !obligationBelongsToProgram(record, program, loaded.model)) + || (program && !obligationBelongsToProgram(record, program, byId, loaded.model)) || (Array.isArray(record.eventRiskLevels) && !record.eventRiskLevels.includes(riskLevel)) ) return false; const rule = byId.get(record.activeRuleId); @@ -513,7 +518,7 @@ async function createObligationEventUnlocked(input, options) { if ( record.type !== "obligation" || record.status !== "active" - || (program && !obligationBelongsToProgram(record, program, loaded.model)) + || (program && !obligationBelongsToProgram(record, program, byId, loaded.model)) ) return false; const schedule = obligationRule(record, byId, { now: eventTime }) || record; const matches = schedule.recurrence?.mode === "event" @@ -526,7 +531,7 @@ async function createObligationEventUnlocked(input, options) { if ( record.type !== "obligation" || !["active", "proposed"].includes(record.status) - || (program && !obligationBelongsToProgram(record, program, loaded.model)) + || (program && !obligationBelongsToProgram(record, program, byId, loaded.model)) || (Array.isArray(record.eventRiskLevels) && !record.eventRiskLevels.includes(riskLevel)) ) return false; const rule = obligationRule(record, byId, { now: eventTime, includeProposed: true }); @@ -1773,7 +1778,7 @@ function preferredCompletionType(activity, item, byId) { } function planStandaloneAction(record, byId, asOf, now) { - const source = byId.get(record.sourceResourceId); + const relationships = recordProgramRelationships(byId.get(record.sourceResourceId), byId); const window = plannedCompletionWindow(record.completionWindow); const complete = ["done", "canceled"].includes(record.status); const timingStatus = complete @@ -1795,9 +1800,9 @@ function planStandaloneAction(record, byId, asOf, now) { sourceResourceId: record.sourceResourceId, title: record.title, ownerIds: record.assigneeIds || [], - policyIds: source?.policyIds || [], - controlIds: source?.controlIds || [], - systemIds: source?.systemIds || [], + policyIds: relationships.policyIds, + controlIds: relationships.controlIds, + systemIds: relationships.systemIds, completionResourceIds: record.completionResourceIds || [], evidenceIds: record.evidenceIds || [], recordedStatus: record.status, @@ -1996,29 +2001,12 @@ function membershipIsFinal(selector, window, asOf) { return asOf > window.dueWindowEnd; } -function obligationBelongsToProgram(obligation, program, model) { - if (!program || !modelSupports(model, "program-scope") || program.type !== "program") return true; - const controlIds = new Set(program.controlIds || []); - const policyIds = new Set(program.policyIds || []); - const scopedIds = new Set([program.id, ...(program.systemIds || [])]); - const linkedControls = obligation.controlIds || []; - const linkedPolicies = obligation.policyIds || []; - const linkedScope = obligation.scopeResourceIds || []; - if (linkedControls.some((id) => controlIds.has(id))) return true; - if (linkedPolicies.some((id) => policyIds.has(id))) return true; - if (linkedScope.some((id) => scopedIds.has(id))) return true; - return linkedControls.length === 0 && linkedPolicies.length === 0 && linkedScope.length === 0; +function obligationBelongsToProgram(obligation, program, byId, model) { + return recordBelongsToProgram(obligation, program, byId, model); } function workItemBelongsToProgram(item, program, byId, model) { - if (!program || !modelSupports(model, "program-scope") || program.type !== "program") return true; - const source = byId.get(item.sourceResourceId); - if (source?.programId) return source.programId === program.id; - const controlIds = item.controlIds || []; - const policyIds = item.policyIds || []; - if (controlIds.length) return controlIds.some((id) => (program.controlIds || []).includes(id)); - if (policyIds.length) return policyIds.some((id) => (program.policyIds || []).includes(id)); - return true; + return recordBelongsToProgram(byId.get(item.sourceResourceId), program, byId, model); } function requireDate(value, label) { diff --git a/packages/filegrc/src/program.js b/packages/filegrc/src/program.js index 2898705..e530a72 100644 --- a/packages/filegrc/src/program.js +++ b/packages/filegrc/src/program.js @@ -52,3 +52,46 @@ export function programComponents(loaded, program) { && (record.systemUses || []).some(({ systemId }) => systemIds.has(systemId)) )); } + +// Follow authority/source relationships, not owners, evidence, or completion +// records: those links must not make unrelated work enter a Program. +export function recordProgramRelationships(record, byId) { + const result = { programIds: new Set(), controlIds: new Set(), policyIds: new Set(), systemIds: new Set(), scopeResourceIds: new Set() }; + const seen = new Set(); + const pending = [record]; + while (pending.length) { + const source = pending.pop(); + if (!source || seen.has(source.id)) continue; + seen.add(source.id); + for (const [type, field] of [["program", "programIds"], ["control", "controlIds"], ["policy", "policyIds"], ["system", "systemIds"]]) { + if (source.type === type) result[field].add(source.id); + for (const id of source[field] || []) result[field].add(id); + const singular = field.slice(0, -1); + if (source[singular]) result[field].add(source[singular]); + } + for (const id of source.scopeResourceIds || []) result.scopeResourceIds.add(id); + for (const { systemId } of source.systemUses || []) result.systemIds.add(systemId); + if (source.programId || source.type === "program") continue; + for (const id of [source.sourceResourceId, source.auditId, source.obligationId, source.componentId, source.sourceComponentId, ...(source.obligationIds || []), ...(source.auditIds || []), ...(source.componentIds || []), ...(source.sourceResourceIds || []), ...(source.scopeResourceIds || []), ...(source.type === "requirement-mapping" ? source.targetResourceIds || [] : [])].filter(Boolean)) { + pending.push(byId.get(id)); + } + } + return Object.fromEntries(Object.entries(result).map(([field, ids]) => [field, [...ids]])); +} + +export function recordBelongsToProgram(record, program, byId, model) { + if (!program || !modelSupports(model, "program-scope") || program.type !== "program") return true; + if (!record) return false; + const relationships = recordProgramRelationships(record, byId); + const selectedPolicies = new Set([ + ...(program.policyIds || []), + ...(program.controlIds || []).flatMap((id) => byId.get(id)?.policyIds || []) + ]); + // Narrower authority wins: a shared Policy or System cannot pull a Control + // or an engagement owned by another Program into this queue. + if (relationships.programIds.length) return relationships.programIds.includes(program.id); + if (relationships.controlIds.length) return relationships.controlIds.some((id) => (program.controlIds || []).includes(id)); + if (relationships.systemIds.length) return relationships.systemIds.some((id) => (program.systemIds || []).includes(id)); + if (relationships.policyIds.length) return relationships.policyIds.some((id) => selectedPolicies.has(id)); + return relationships.scopeResourceIds.length === 0; +} diff --git a/packages/filegrc/src/web.js b/packages/filegrc/src/web.js index 9d10ea3..62d859f 100644 --- a/packages/filegrc/src/web.js +++ b/packages/filegrc/src/web.js @@ -2480,7 +2480,7 @@ function actionCompletionPlan(item) { href: "#/resource/action-item/" + encodeURIComponent(action.record.id) }; } - if (!action.record.obligationId) { + if (item.kind === "action" || !action.record.obligationId) { return { blocked: "Open task", href: "#/resource/action-item/" + encodeURIComponent(action.record.id) diff --git a/packages/filegrc/src/workflow.js b/packages/filegrc/src/workflow.js index fee7949..f892415 100644 --- a/packages/filegrc/src/workflow.js +++ b/packages/filegrc/src/workflow.js @@ -24,7 +24,7 @@ import { } from "./obligations.js"; import { assessAuditPreparation } from "./audit-preparation.js"; import { assessProgramReadiness } from "./program-readiness.js"; -import { resolveProgram } from "./program.js"; +import { recordBelongsToProgram, resolveProgram } from "./program.js"; import { resourceReviewRevisions, retentionReviewResourceIds, retentionRuleIsCurrent } from "./retention.js"; import { displayRevision } from "./revisions.js"; import { signatoryAppointmentIssue, soc2ReportEvidenceIssue, subsequentEventsReviewIssue } from "./soc2.js"; @@ -168,7 +168,9 @@ async function assessWorkflowUnmeasured(input, options = {}) { const workItems = buildWorkItems(loaded.resources, obligationPlan, { asOf, includeComplete: Boolean(options.includeComplete), - programId: programRecord.id + programId: programRecord.id, + program: programRecord, + model: loaded.model }).map((item) => ({ ...item, notificationContacts: notificationContacts(item.ownerIds, loaded.resources) @@ -1233,7 +1235,10 @@ function buildWorkItems(records, obligationPlan, options) { .map((item) => item.source?.id) .filter(Boolean)); for (const record of records) { - if (record.type === "obligation" || obligationSources.has(record.id)) continue; + // The queue planner owns all Action Items, including filtered and terminal + // work. Recreating them from deadlines would undo its scope and lifecycle. + if (["obligation", "action-item"].includes(record.type) || obligationSources.has(record.id)) continue; + if (!recordBelongsToProgram(record, options.program, byId, options.model)) continue; const due = firstDate(record); if (!due) continue; const state = sourceWorkState(record, due, options.asOf); @@ -1297,7 +1302,7 @@ function obligationWorkItem(item, byId, asOf, programId) { function obligationNextAction(item, programId) { const programOption = programId ? ` --program ${shellArgument(programId)}` : ""; if (item.actionItemId) { - if (item.status === "blocked") { + if (item.kind === "action" || item.status === "blocked") { return { kind: "command", command: `npx filegrc get ${shellArgument(item.actionItemId)} --mutation` diff --git a/packages/filegrc/test/notifications.test.js b/packages/filegrc/test/notifications.test.js index ae431b5..714770a 100644 --- a/packages/filegrc/test/notifications.test.js +++ b/packages/filegrc/test/notifications.test.js @@ -45,7 +45,9 @@ test("domain and CLI work items preserve owner IDs and Person email contacts", a owner.email = "owner@example.com"; await writeJson(loaded.entries.find(({ record }) => record.id === owner.id).path, owner); await writeJson(join(root, "data/action-items/action-notify.json"), { - id: "action-notify", type: "action-item", title: "Review source", status: "open", assigneeIds: [owner.id], dueOn: "2026-10-01" + id: "action-notify", type: "action-item", title: "Review source", status: "open", assigneeIds: [owner.id], + sourceResourceId: loaded.resources.find(({ type }) => type === "control").id, + completionWindow: { precision: "date", startsOn: "2026-09-30", dueOn: "2026-10-01", overdueOn: "2026-10-02" } }); const item = (await core.getWorkItems(root, { asOf: "2026-10-07" })).find(({ source }) => source.id === "action-notify"); assert.deepEqual(item.ownerIds, [owner.id]); diff --git a/packages/filegrc/test/obligations.test.js b/packages/filegrc/test/obligations.test.js index 1df457d..2e27d3e 100644 --- a/packages/filegrc/test/obligations.test.js +++ b/packages/filegrc/test/obligations.test.js @@ -11,6 +11,7 @@ import { completeObligationOccurrence, completeObligationAction, assessProgramReadiness, + assessWorkflow, createAppState, createObligationEvent, createResource, @@ -1750,3 +1751,184 @@ test("binds activation-relative occurrences to stable rule cutover dates", async const rejected = await validateWorkspace(root); assert.equal(rejected.diagnostics.some(({ code }) => code === "invalid-obligation-occurrence-schedule-binding"), true); }); + +test("standalone source relationships isolate Programs and preserve overdue counts", () => { + const model = loadModel("11"); + const program = { id: "program-selected", type: "program", controlIds: ["control-selected"], systemIds: ["system-selected"] }; + const other = { ...program, id: "program-other", controlIds: ["control-other"], systemIds: ["system-other"] }; + const records = [program, other, + { id: "control-selected", type: "control", policyIds: ["policy-shared"] }, + { id: "control-other", type: "control", policyIds: ["policy-shared"] }, + { id: "policy-shared", type: "policy" }, + { id: "system-selected", type: "system" }, + { id: "system-other", type: "system" }, + { id: "component-selected", type: "component", systemUses: [{ systemId: "system-selected" }] }, + { id: "component-other", type: "component", systemUses: [{ systemId: "system-other" }] }, + { id: "audit-other", type: "audit", programId: other.id, controlIds: program.controlIds } + ]; + const cases = [ + [records[2], true], [records[3], false], [records[4], true], + [records[5], true], [records[6], false], + [{ id: "test-selected", type: "control-test", controlId: "control-selected" }, true], + [{ id: "test-other", type: "control-test", controlId: "control-other" }, false], + [{ id: "finding-chain", type: "finding", sourceResourceId: "test-selected" }, true], + [{ id: "finding-other", type: "finding", sourceResourceId: "test-other" }, false], + [{ id: "incident-selected", type: "incident", systemIds: program.systemIds }, true], + [{ id: "incident-other", type: "incident", systemIds: other.systemIds }, false], + [{ id: "occurrence-other", type: "obligation-occurrence", programId: other.id, obligationId: "obligation-linked-selected" }, false], + [{ id: "obligation-linked-selected", type: "obligation", scopeResourceIds: [program.id] }, true], + [{ id: "request-other", type: "audit-request", auditId: "audit-other", controlIds: program.controlIds }, false], + [{ id: "policy-review-selected", type: "policy-review", scopeResourceIds: ["policy-shared"] }, true], + [{ id: "component-review-selected", type: "access-review", componentIds: ["component-selected"] }, true], + [{ id: "component-review-other", type: "access-review", componentIds: ["component-other"] }, false], + [{ id: "coverage-other", type: "source-coverage", componentId: "component-other" }, false], + [{ id: "event-other", type: "obligation-event", obligationIds: ["control-linked-obligation"] }, false], + [{ id: "event-finding-other", type: "finding", sourceResourceId: "event-other" }, false], + [{ id: "mapping-selected", type: "requirement-mapping", sourceResourceIds: ["control-selected"] }, true], + [{ id: "unscoped", type: "meeting" }, true], + [{ id: "cycle-a", type: "finding", sourceResourceId: "cycle-b", controlIds: other.controlIds }, false], + [{ id: "cycle-b", type: "finding", sourceResourceId: "cycle-a" }, false] + ]; + records.push(...cases.map(([source]) => source).filter(({ type }) => type !== "obligation")); + records.push({ id: "control-linked-obligation", type: "obligation", controlIds: other.controlIds }); + records.push({ id: "obligation-linked-selected", type: "obligation", scopeResourceIds: [program.id] }); + const actions = cases.map(([source], index) => ({ + id: `action-${index}`, type: "action-item", title: `Follow up ${source.id}`, status: "open", + sourceResourceId: source.id, + completionWindow: { precision: "date", startsOn: "2026-03-01", dueOn: "2026-03-01", overdueOn: "2026-03-02" } + })); + records.push(...actions, { ...actions[0], id: "action-missing", sourceResourceId: "missing" }); + const options = { model, programId: program.id, asOf: "2026-03-15", through: "2026-03-15" }; + const plan = planObligations(records, options); + assert.deepEqual(new Set(plan.items.map(({ actionItemId }) => actionItemId)), new Set(cases.flatMap(([, selected], index) => selected ? [actions[index].id] : []))); + assert.equal(plan.counts.overdue, cases.filter(([, selected]) => selected).length); + assert.deepEqual(plan.items.find(({ actionItemId }) => actionItemId === "action-0").controlIds, ["control-selected"]); + const complete = records.map((record) => record.id === "action-0" ? { ...record, status: "done" } : record); + assert.equal(planObligations(complete, options).counts.overdue, plan.counts.overdue - 1); + assert.equal(planObligations(complete, { ...options, includeComplete: true }).counts.complete, 1); + assert(planObligations(records, { ...options, programId: other.id }).items.some(({ actionItemId }) => actionItemId === "action-1")); +}); + +test("CLI and browser section expose the same scoped standalone actions", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-standalone-parity-")); + context.after(() => import("node:fs/promises").then(({ rm }) => rm(root, { recursive: true, force: true }))); + const filegrcVersion = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")).version; + await createFilegrc({ target: root, yes: true, install: false, filegrcVersion, policyOwnerEmail: "security@example.com", timezone: "UTC" }); + const lockPath = join(root, "package-lock.json"); + const lock = JSON.parse(await readFile(lockPath, "utf8")); + lock.packages["node_modules/filegrc"] = { version: filegrcVersion }; + await writeFile(lockPath, JSON.stringify(lock)); + const loaded = await loadWorkspace(root); + const program = loaded.resources.find(({ type }) => type === "program"); + const controls = loaded.resources.filter(({ type }) => type === "control"); + await writeFile(join(root, "data", "programs", `${program.id}.json`), JSON.stringify({ ...program, controlIds: [controls[0].id] })); + for (const [index, control] of controls.slice(0, 2).entries()) { + await createResource(root, { + id: `action-item-parity-${index}`, type: "action-item", title: `Control follow-up ${index}`, status: "open", + assigneeIds: ["person-program-lead"], sourceResourceId: control.id, + completionWindow: { precision: "date", startsOn: "2026-01-01", dueOn: "2026-01-02", overdueOn: "2026-01-03" } + }); + } + const { createAppStateSection } = await import("../src/state.js"); + const state = await createAppStateSection(await loadWorkspace(root), "obligations", { asOf: "2026-03-15", now: "2026-03-15T12:00:00Z" }); + const cli = await execute(process.execPath, [fileURLToPath(new URL("../bin/filegrc.js", import.meta.url)), "obligations", "--root", root, "--as-of", "2026-03-15", "--json"]); + const plan = JSON.parse(cli.stdout); + assert.deepEqual(plan.standaloneItems, state.obligations.standaloneItems); + assert.deepEqual(plan.counts, state.obligations.counts); + assert.deepEqual(plan.standaloneItems.map(({ actionItemId }) => actionItemId), ["action-item-parity-0"]); + assert.equal(plan.counts.overdue, 1); + const workflow = await assessWorkflow(root, { asOf: "2026-03-15", evaluatedAt: "2026-03-15T12:00:00Z" }); + const actionWork = workflow.workItems.filter(({ source }) => source.type === "action-item"); + assert.deepEqual(actionWork.map(({ source }) => source.id), ["action-item-parity-0"]); + assert.equal(actionWork[0].nextAction.command, "npx filegrc get action-item-parity-0 --mutation"); + await updateResource(root, "action-item", "action-item-parity-0", { + ...(await loadWorkspace(root)).resources.find(({ id }) => id === "action-item-parity-0"), + status: "canceled", + cancellation: { canceledByIds: ["person-program-lead"], canceledOn: "2026-03-15", reason: "Duplicate follow-up." } + }); + const canceled = await assessWorkflow(root, { asOf: "2026-03-15", evaluatedAt: "2026-03-15T12:00:00Z" }); + assert.equal(canceled.workItems.filter(({ source }) => source.type === "action-item").length, 0); + const history = await assessWorkflow(root, { asOf: "2026-03-15", evaluatedAt: "2026-03-15T12:00:00Z", includeComplete: true }); + assert.deepEqual(history.workItems.filter(({ source }) => source.type === "action-item").map(({ state }) => state), ["complete"]); + const validation = await validateWorkspace(root); + assert.equal(validation.ok, true, JSON.stringify(validation.diagnostics)); +}); + +test("Obligations and standalone actions share derived Policy and explicit scope membership", () => { + const model = loadModel("11"); + const program = { id: "program-selected", type: "program", controlIds: ["control-selected"], systemIds: [] }; + const control = { id: "control-selected", type: "control", policyIds: ["policy-selected"] }; + const policy = { id: "policy-selected", type: "policy", status: "draft" }; + const sources = [ + { id: "obligation-selected", policyIds: [policy.id] }, + { id: "obligation-other", policyIds: ["policy-other"] }, + { id: "obligation-scoped", scopeResourceIds: [program.id] }, + { id: "obligation-unknown-scope", scopeResourceIds: ["person-other"] } + ].map((fields) => ({ ...fields, type: "obligation", title: fields.id, status: "proposed", activityType: "custom", recurrence: { mode: "event", eventType: "custom" } })); + const records = [program, control, policy, ...sources]; + const plan = planObligations(records, { model, programId: program.id, asOf: "2026-03-15" }); + assert.deepEqual(Object.keys(plan.programStatuses).sort(), ["obligation-scoped", "obligation-selected"]); +}); + +test("retired Obligations keep assigned event tasks in their selected Program", () => { + const program = { id: "program-selected", type: "program", status: "active", controlIds: ["control-selected"] }; + const other = { ...program, id: "program-other", status: "planned", controlIds: ["control-other"] }; + const event = { id: "event-assigned", type: "obligation-event", title: "Assigned work", status: "open", eventType: "custom", occurredOn: "2026-03-01" }; + const obligations = [program, other].map((scope, index) => ({ + id: `obligation-retired-${index}`, type: "obligation", status: "retired", title: "Retired work rule", activityType: "custom", controlIds: scope.controlIds + })); + const actions = obligations.map((obligation, index) => ({ + id: `action-persisted-${index}`, type: "action-item", status: "open", title: "Finish assigned work", sourceResourceId: event.id, obligationId: obligation.id, + completionWindow: { precision: "date", startsOn: "2026-03-01", dueOn: "2026-03-02", overdueOn: "2026-03-03" } + })); + const records = [program, other, event, ...obligations, ...actions]; + const options = { model: loadModel("11"), asOf: "2026-03-15" }; + const plan = planObligations(records, options); + assert.deepEqual(plan.eventItems.map(({ actionItemId }) => actionItemId), [actions[0].id]); + assert.equal(plan.counts.overdue, 1); + assert.equal(plan.triggers.length, 0); + assert.equal(plan.calendarItems.length, 0); + assert.deepEqual(planObligations(records, { ...options, programId: other.id }).eventItems.map(({ actionItemId }) => actionItemId), [actions[1].id]); + assert.throws(() => planObligations(records.map((record) => record.id === other.id ? { ...record, status: "active" } : record), options), /More than one active Program/); + const canceled = records.map((record) => record.id === event.id ? { ...record, status: "canceled" } : record); + assert.equal(planObligations(canceled, options).counts.overdue, 0); +}); + +test("independent follow-ups to Policy Events remain standalone across CLI, browser, and workflow", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-event-follow-up-")); + context.after(() => import("node:fs/promises").then(({ rm }) => rm(root, { recursive: true, force: true }))); + await makeComprehensiveWorkspace(root, "11"); + const loaded = await loadWorkspace(root); + const event = loaded.resources.find(({ type }) => type === "obligation-event"); + const obligation = loaded.resources.find(({ type }) => type === "obligation"); + await writeFile(join(root, "data", "obligations", "obligation-unrelated.json"), JSON.stringify({ ...obligation, id: "obligation-unrelated", status: "proposed", controlIds: ["control-unrelated"] })); + await writeFile(join(root, "data", "obligation-events", "event-unrelated.json"), JSON.stringify({ ...event, id: "event-unrelated", obligationIds: ["obligation-unrelated"] })); + const control = loaded.resources.find(({ type }) => type === "control"); + await writeFile(join(root, "data", "controls", "control-unrelated.json"), JSON.stringify({ ...control, id: "control-unrelated", status: "planned" })); + for (const [index, sourceResourceId] of [event.id, "event-unrelated"].entries()) { + await createResource(root, { + id: `action-event-follow-up-${index}`, type: "action-item", title: "Independent event follow-up", status: "open", + assigneeIds: ["person-example"], sourceResourceId, + completionWindow: { precision: "date", startsOn: "2026-01-01", dueOn: "2026-01-02", overdueOn: "2026-01-03" } + }); + } + await initializeGitWorkspace(root, { message: "Record generic event follow-ups" }); + const { createAppStateSection } = await import("../src/state.js"); + const options = { asOf: "2026-10-10", now: "2026-10-10T12:00:00Z" }; + const state = await createAppStateSection(await loadWorkspace(root), "obligations", options); + const cli = JSON.parse((await execute(process.execPath, [fileURLToPath(new URL("../bin/filegrc.js", import.meta.url)), "obligations", "--root", root, "--as-of", options.asOf, "--json"])).stdout); + assert.deepEqual(cli.standaloneItems, state.obligations.standaloneItems); + assert.deepEqual(cli.counts, state.obligations.counts); + assert.equal(cli.standaloneItems.find(({ actionItemId }) => actionItemId === "action-event-follow-up-0")?.status, "overdue"); + assert.equal(cli.standaloneItems.some(({ actionItemId }) => actionItemId === "action-event-follow-up-1"), false); + const workflow = await assessWorkflow(root, { asOf: options.asOf, evaluatedAt: options.now }); + assert.equal(workflow.workItems.find(({ source }) => source.id === "action-event-follow-up-0")?.nextAction.command, "npx filegrc get action-event-follow-up-0 --mutation"); + assert.equal(workflow.workItems.some(({ source }) => source.id === "action-event-follow-up-1"), false); + // Canceling a trigger cancels its generated checklist, not a separately assigned follow-up. + await updateResource(root, "obligation-event", event.id, { ...event, status: "canceled", cancellation: { canceledByIds: ["person-example"], canceledOn: options.asOf, reason: "Duplicate trigger." } }); + const canceled = planObligations((await loadWorkspace(root)).resources, { model: loaded.model, ...options }); + assert.equal(canceled.standaloneItems.find(({ actionItemId }) => actionItemId === "action-event-follow-up-0")?.status, "overdue"); + assert.equal(canceled.eventItems.some(({ sourceResourceId }) => sourceResourceId === event.id), false); + const validation = await validateWorkspace(root); + assert.equal(validation.ok, true, JSON.stringify(validation.diagnostics)); +}); diff --git a/packages/filegrc/test/workflow.test.js b/packages/filegrc/test/workflow.test.js index cec1b4b..60fde80 100644 --- a/packages/filegrc/test/workflow.test.js +++ b/packages/filegrc/test/workflow.test.js @@ -734,3 +734,57 @@ test("keeps preliminary audit planning separate from an accepted engagement", as assert.equal(findingKeys.has("audit.audit-planned.lifecycle.management-acknowledgement"), false); assert.equal(workflow.findings.find(({ key }) => key === "audit.audit-planned.lifecycle.advance")?.title, "Record the agreed engagement and start audit preparation"); }); + +test("Program workflow excludes unrelated source deadlines and does not recreate canceled event tasks", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-scoped-workflow-deadlines-")); + context.after(() => import("node:fs/promises").then(({ rm }) => rm(root, { recursive: true, force: true }))); + await makeComprehensiveWorkspace(root, "11"); + const loaded = await loadWorkspace(root); + const risk = loaded.resources.find(({ type }) => type === "risk"); + const control = loaded.resources.find(({ type }) => type === "control"); + await writeJson(join(root, "data", "controls", "control-unrelated.json"), { ...control, id: "control-unrelated", status: "planned" }); + await writeJson(join(root, "data", "risks", "risk-unrelated-deadline.json"), { + ...risk, id: "risk-unrelated-deadline", controlIds: ["control-unrelated"], reviewDueOn: "2026-01-01" + }); + const action = loaded.resources.find(({ type }) => type === "action-item"); + const event = loaded.resources.find(({ type }) => type === "obligation-event"); + const system = loaded.resources.find(({ type }) => type === "system"); + const component = loaded.resources.find(({ type }) => type === "component"); + const evidence = loaded.resources.find(({ type }) => type === "evidence"); + await writeJson(join(root, "data", "systems", "system-unrelated.json"), { ...system, id: "system-unrelated" }); + await writeJson(join(root, "data", "components", "component-unrelated.json"), { + ...component, id: "component-unrelated", systemUses: component.systemUses.map((use) => ({ ...use, systemId: "system-unrelated" })) + }); + for (const [suffix, sourceComponentId] of [["selected", component.id], ["unrelated", "component-unrelated"]]) { + const record = { ...evidence, id: `evidence-deadline-${suffix}`, status: "collected", expiresOn: "2026-01-01", sourceComponentId }; + for (const field of ["controlIds", "systemIds", "componentIds", "sourceResourceIds", "auditIds", "verifierIds", "verifiedOn"]) delete record[field]; + await mkdir(join(root, "data", "evidence", record.id), { recursive: true }); + await writeJson(join(root, "data", "evidence", record.id, "evidence.json"), record); + } + const program = loaded.resources.find(({ type }) => type === "program"); + const audit = loaded.resources.find(({ type }) => type === "audit"); + await writeJson(join(root, "data", "programs", "program-unrelated.json"), { ...program, id: "program-unrelated", status: "planned" }); + for (const [suffix, programId] of [["selected", program.id], ["unrelated", "program-unrelated"]]) { + const auditId = `audit-deadline-${suffix}`; + await writeJson(join(root, "data", "audits", `${auditId}.json`), { ...audit, id: auditId, programId }); + const record = { ...evidence, id: `evidence-audit-${suffix}`, status: "collected", expiresOn: "2026-01-01", auditIds: [auditId] }; + delete record.verifierIds; + delete record.verifiedOn; + await mkdir(join(root, "data", "evidence", record.id), { recursive: true }); + await writeJson(join(root, "data", "evidence", record.id, "evidence.json"), record); + } + await writeJson(join(root, "data", "obligation-events", `${event.id}.json`), { + ...event, status: "canceled", cancellation: { canceledByIds: ["person-example"], canceledOn: "2026-07-01", reason: "Duplicate event." } + }); + await writeJson(join(root, "data", "action-items", `${action.id}.json`), { + ...action, status: "open", sourceResourceId: event.id, obligationId: loaded.resources.find(({ type }) => type === "obligation").id, + completionWindow: { precision: "date", startsOn: "2026-01-01", dueOn: "2026-01-02", overdueOn: "2026-01-03" } + }); + const workflow = await assessWorkflow(root, { asOf: "2026-10-10", evaluatedAt: "2026-10-10T12:00:00Z" }); + assert.equal(workflow.workItems.some(({ source }) => source.id === "risk-unrelated-deadline"), false); + assert.equal(workflow.workItems.some(({ source }) => source.id === action.id), false); + assert.equal(workflow.workItems.find(({ source }) => source.id === "evidence-deadline-selected")?.state, "overdue"); + assert.equal(workflow.workItems.some(({ source }) => source.id === "evidence-deadline-unrelated"), false); + assert.equal(workflow.workItems.find(({ source }) => source.id === "evidence-audit-selected")?.state, "overdue"); + assert.equal(workflow.workItems.some(({ source }) => source.id === "evidence-audit-unrelated"), false); +});