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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -775,3 +775,5 @@ At the Step 3 cutover, `program-path --next` names FileGRC Autopilot as `step.ne
Reporting Channel Set proposals require committed independent approval during Step 1. Step 3 checks the approved revision, live support, and exact effective interval at content cutover. The same derived reporting check runs in activation planners and validation.

Each Program has an exact-revision Obligation Collection Review. Its complete population includes linked active schedules, unresolved proposals, and retired duplicates. Per-schedule decisions distinguish adopted rules, adopted replacements, retired schedules, and intentional inapplicability. Pending alternative rule proposals need explicit exclusion. Changes to schedules, rules, selected scope, or governing content invalidate this approval; the approved-to-active content transition preserves its design binding. Ordinary setup Collection Reviews retain their existing historical confirmation semantics.

Schedule-set and Control collection reviewer eligibility distinguish Obligation queue ownership from personal work assignments. Team membership and chairing a Team-owned review queue do not establish that a Person operates the Controls. Control ownership still resolves Team members and Appointment holders, and personal or Appointment assignments on Obligations remain conflicts. Governed content approval keeps its full owner separation checks, including Team membership. A reviewer who also owns implementation work remains ineligible.
8 changes: 4 additions & 4 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "filegrc-monorepo",
"version": "0.17.1",
"version": "0.17.2",
"private": true,
"workspaces": [
"packages/filegrc",
Expand Down
2 changes: 1 addition & 1 deletion packages/create-filegrc/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "create-filegrc",
"version": "0.17.1",
"version": "0.17.2",
"description": "Create a filegrc workspace for a SOC 2 program",
"license": "MIT",
"repository": {
Expand Down
4 changes: 2 additions & 2 deletions packages/create-filegrc/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -524,13 +524,13 @@ async function runCombinedSetup(target, input) {
async function writeMinimalLockfile(target, name, versionRange) {
const lock = {
name,
version: "0.17.1",
version: "0.17.2",
lockfileVersion: 3,
requires: true,
packages: {
"": {
name,
version: "0.17.1",
version: "0.17.2",
dependencies: { filegrc: versionRange }
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/create-filegrc/template/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "{{project_name}}",
"version": "0.17.1",
"version": "0.17.2",
"private": true,
"description": "filegrc workspace for a SOC 2 program",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/filegrc/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "filegrc",
"version": "0.17.1",
"version": "0.17.2",
"description": "Zero-dependency Git-native GRC engine",
"license": "MIT",
"repository": {
Expand Down
5 changes: 2 additions & 3 deletions packages/filegrc/src/collection-revision.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { resolveDataPath } from "./paths.js";
import { getFileAtRevision } from "./git.js";
import { reviewHistoricalWorkspace, reviewHistoryCommits, reviewHistoryContext } from "./historical-workspace.js";
import { resolveProgram } from "./program.js";
import { currentPartyPeople } from "./parties.js";
import { currentPartyPeople, queueOwnerPeople } from "./parties.js";
import { markdownEntries } from "./resource-markdown.js";
import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, calculateRevision, canonicalCalculatedRevision, revisionDigest, revisionsMatch } from "./revisions.js";

Expand Down Expand Up @@ -156,8 +156,7 @@ export function controlReviewerConflictIds(loaded, controls, byId = new Map(load
&& (record.controlIds || []).some((id) => controlIds.has(id)))) {
// A team owns the work queue, but membership alone does not mean each member
// performed its work. Named people and appointment holders remain conflicts.
const namedOwners = (obligation.ownerIds || []).filter((id) => byId.get(id)?.type !== "team");
for (const id of currentPartyPeople(namedOwners, byId)) conflicts.add(id);
for (const id of queueOwnerPeople(obligation.ownerIds || [], byId)) conflicts.add(id);
}
return [...conflicts].sort();
}
Expand Down
8 changes: 8 additions & 0 deletions packages/filegrc/src/parties.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ export function currentPartyPeople(ids = [], byId) {
});
}

// A Team owns a work queue, but membership and chairing do not prove that a
// person performs its work. Personal and Appointment assignments do name them.
// Use this only for Obligation queue ownership, never for governed content or
// Control implementation ownership.
export function queueOwnerPeople(ids = [], byId) {
return currentPartyPeople(ids.filter((id) => byId.get(id)?.type !== "team"), byId);
}

export function partiesIndependent(ownerIds = [], approverIds = [], byId) {
const owners = partyPeople(ownerIds, byId);
const approvers = partyPeople(approverIds, byId);
Expand Down
11 changes: 8 additions & 3 deletions packages/filegrc/src/schedule-set.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { personWasActiveOn } from "./soc2.js";
import { currentCalendarDate } from "./time.js";
import { modelSupports } from "../model/index.js";
import { recordBelongsToProgram } from "./program.js";
import { currentPartyPeople } from "./parties.js";
import { currentPartyPeople, queueOwnerPeople } from "./parties.js";
import { obligationRule } from "./obligation-rule.js";

export function scheduleSetRecords(loaded, program) {
Expand Down Expand Up @@ -47,8 +47,13 @@ export function scheduleSetInputs(loaded, program) {
export function scheduleReviewerConflicts(loaded, program) {
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
const controls = loaded.resources.filter((record) => record.type === "control" && program?.controlIds?.includes(record.id));
return [...new Set([...controls, ...scheduleSetRecords(loaded, program)]
.flatMap((record) => [...currentPartyPeople(record.ownerIds || [], byId)]))].sort();
// Review the whole schedule population, including proposals. Queue Team
// membership identifies notification recipients, not individual operators.
// Control ownership and named schedule assignments still establish conflicts.
return [...new Set([
...controls.flatMap((record) => [...currentPartyPeople(record.ownerIds || [], byId)]),
...scheduleSetRecords(loaded, program).flatMap((record) => [...queueOwnerPeople(record.ownerIds || [], byId)])
])].sort();
}

export function scheduleSetIssues(loaded, program, review, options = {}) {
Expand Down
50 changes: 49 additions & 1 deletion packages/filegrc/test/schedule-set.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { loadModel } from "../model/index.js";
import { scheduleSetIssues, scheduleSetRecords } from "../src/schedule-set.js";
import { scheduleSetIssues, scheduleSetRecords, scheduleReviewerConflicts } from "../src/schedule-set.js";
import { controlReviewerConflictIds } from "../src/collection-revision.js";
import { partiesIndependent } from "../src/parties.js";
import { collectionRevision } from "../src/collection-revision.js";
import { applicabilityReviewIsCurrent, applicabilityScopeRevision } from "../src/applicability-scope.js";
const scheduleSetRevision = (loaded, program) => collectionRevision(loaded, "obligation", { program });
Expand All @@ -31,6 +33,36 @@ function fixture() {
];
return { loaded: { resources: records, model: loadModel("12"), root: null }, program };
}

test("queue ownership separates independent oversight from operational assignments", () => {
const owner = { id: "person-owner", type: "person", status: "active" };
const reviewer = { id: "person-reviewer", type: "person", status: "active" };
const operator = { id: "person-operator", type: "person", status: "active" };
const chair = { id: "appointment-chair", type: "appointment", status: "active", holderId: reviewer.id };
const team = { id: "team-oversight", type: "team", status: "active", memberIds: [owner.id, reviewer.id, operator.id], chairIds: [chair.id] };
const control = { id: "control-one", type: "control", status: "implemented", ownerIds: [owner.id] };
const obligation = { id: "obligation-review", type: "obligation", status: "active", ownerIds: [team.id], controlIds: [control.id] };
const program = { id: "program-one", type: "program", controlIds: [control.id] };
const loaded = { model: loadModel("12"), resources: [owner, reviewer, operator, chair, team, control, obligation] };
assert.deepEqual(scheduleReviewerConflicts(loaded, program), [owner.id]);
assert.deepEqual(controlReviewerConflictIds(loaded, [control]), [owner.id]);
// A mixed-role Team can contain an operator without making its chair one.
obligation.ownerIds.push(operator.id);
assert.deepEqual(scheduleReviewerConflicts(loaded, program), [operator.id, owner.id].sort());
obligation.ownerIds = [team.id, chair.id];
assert.deepEqual(scheduleReviewerConflicts(loaded, program), [owner.id, reviewer.id].sort());
obligation.ownerIds = [team.id];
control.ownerIds.push(reviewer.id);
assert.deepEqual(scheduleReviewerConflicts(loaded, program), [owner.id, reviewer.id].sort());
control.ownerIds = [team.id];
assert.deepEqual(scheduleReviewerConflicts(loaded, program), [owner.id, reviewer.id, operator.id].sort());
assert.equal(partiesIndependent([team.id], [reviewer.id], new Map(loaded.resources.map((r) => [r.id, r]))), false,
"governed content owned by a Team still cannot be approved by its member");
control.ownerIds = [owner.id];
obligation.status = "proposed";
obligation.ownerIds = [reviewer.id];
assert.ok(scheduleReviewerConflicts(loaded, program).includes(reviewer.id), "personal proposal assignments still conflict");
});
const adopted = { obligationId: "calendar", disposition: "adopted", ruleId: "rule-calendar", rationale: "Required periodic review." };
const exclusion = { obligationId: "event", disposition: "inapplicable", rationale: "This proposal duplicates the separately reviewed incident checklist." };

Expand Down Expand Up @@ -78,6 +110,14 @@ test("schedule-set review is exact, independent, and available through the CLI",
const root = await mkdtemp(join(tmpdir(), "filegrc-schedule-review-"));
context.after(() => rm(root, { recursive: true, force: true }));
await makeComprehensiveWorkspace(root, "12");
const initial = await loadWorkspace(root);
const team = initial.entries.find(({ record }) => record.type === "team");
team.record.memberIds = ["person-example", "person-independent-approver-example"];
team.record.chairIds = ["person-independent-approver-example"];
await writeJson(team.path, team.record);
const ownedSchedule = initial.entries.find(({ record }) => record.type === "obligation");
ownedSchedule.record.ownerIds = [team.record.id];
await writeJson(ownedSchedule.path, ownedSchedule.record);
await initializeExistingModel12Workspace(root);
let loaded = await loadWorkspace(root);
const program = loaded.resources.find(({ type }) => type === "program");
Expand All @@ -90,6 +130,14 @@ test("schedule-set review is exact, independent, and available through the CLI",
await assert.rejects(planCollectionReview(root, { ...payload, reviewedByIds: ["person-example"] }), /independent/);
const plan = await planCollectionReview(root, payload);
assert.deepEqual(plan.changes.create[0].scheduleDecisions, payload.scheduleDecisions);
const browser = await createAppStateSection(loaded, "program", { programId: program.id });
assert.ok(browser.collectionReviews.obligation.eligibleReviewerIds.includes("person-independent-approver-example"));
const payloadPath = `${root}-schedule-review.json`;
context.after(() => rm(payloadPath, { force: true }));
await writeJson(payloadPath, payload);
const preview = await executeCli(runCli, process.execPath, [cli, "review-collection", "obligation", payloadPath, "--program", program.id, "--root", root, "--preview", "--json"]);
const stableChanges = (changes) => JSON.parse(JSON.stringify(changes, (key, value) => key === "knowledgeCutoffAt" ? undefined : value));
assert.deepEqual(stableChanges(JSON.parse(preview.stdout).changes), stableChanges(plan.changes));
await applyCollectionReview(root, { ...payload, confirmed: true });
loaded = await loadWorkspace(root);
assert.equal(assessCollectionReview(loaded, "obligation", { programId: program.id }).complete, true);
Expand Down
Loading