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.16.34",
"version": "0.16.35",
"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.16.34",
"version": "0.16.35",
"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.16.34",
version: "0.16.35",
lockfileVersion: 3,
requires: true,
packages: {
"": {
name,
version: "0.16.34",
version: "0.16.35",
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.16.34",
"version": "0.16.35",
"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.16.34",
"version": "0.16.35",
"description": "Zero-dependency Git-native GRC engine",
"license": "MIT",
"repository": {
Expand Down
54 changes: 21 additions & 33 deletions packages/filegrc/src/obligations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
Expand All @@ -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.");
}
Expand All @@ -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.`);
}
Expand Down Expand Up @@ -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.`);
}
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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"
Expand All @@ -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 });
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
43 changes: 43 additions & 0 deletions packages/filegrc/src/program.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
2 changes: 1 addition & 1 deletion packages/filegrc/src/web.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 9 additions & 4 deletions packages/filegrc/src/workflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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`
Expand Down
4 changes: 3 additions & 1 deletion packages/filegrc/test/notifications.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
Loading
Loading