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
9 changes: 9 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -887,3 +887,12 @@ Never-effective Obligation Rules may be withdrawn with actual actors, date and r
Raw edit-envelope export reads the target JSON and its companion bytes and revisions only. It does not calculate unrelated workflow or Git history. Actual mutation validation and stale-write protection remain unchanged.

The new optional source shapes require explicit adoption of a dependency and lockfile for FileGRC 0.21.6 or newer. Shared actions and structural validation enforce the same minimum; unchanged older workspaces remain readable. Correcting a mistaken older pin restores current validity without rewriting business decisions. Historical snapshots retain their actual reader limitation and cannot supply a typed review as though the older reader supported it.


### Authorization history and selected coverage

Approval authorizes an exact proposed revision. Activation and effective dates determine when that revision governs operation. A pending or approved successor does not pause a valid, still-effective predecessor. Operating checks resolve the actual lineage and cutovers; an invalid claimed cutover cannot borrow the predecessor to conceal a gap.

A first recorded approval does not establish that a channel or control failed before that date. Reporting diagnostics describe missing authorization evidence with an exact Program and interval. When a valid baseline resolves that limitation wholly before selected coverage, validation retains it as `info`, with no current action. A resolved limitation wholly after an earlier examination is retained as subsequent information. Overlapping or unresolved evidence limitations remain actionable. Actual invalid changes, missing approvals and stale proof retain their normal checks.

The selected Audit's agreed range or as-of date takes precedence over a management candidate period. `validateWorkspace` accepts optional `programId`, `auditId` and `coverage` context; shared workflow and packet checks use the same classification. Cached information is restored to its original diagnostic before assessing a different Program or examination interval. Global structural errors remain visible. Validation counts report `information` separately from errors and warnings.
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.21.7",
"version": "0.21.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.21.7",
"version": "0.21.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.21.7",
version: "0.21.8",
lockfileVersion: 3,
requires: true,
packages: {
"": {
name,
version: "0.21.7",
version: "0.21.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.21.7",
"version": "0.21.8",
"private": true,
"description": "filegrc workspace for a SOC 2 program",
"type": "module",
Expand Down
4 changes: 2 additions & 2 deletions packages/create-filegrc/test/create.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ test("creates a complete generic repository with one dependency", async (context
installedLock.packages["node_modules/filegrc"] = { version: currentEngineVersion };
await writeFile(installedLockPath, JSON.stringify(installedLock));
const validation = await validateWorkspace(target);
assert.deepEqual(validation.counts, { resources: 224, errors: 0, warnings: 3 });
assert.deepEqual(validation.counts, { resources: 224, errors: 0, warnings: 3, information: 0 });
assert.equal(
validation.diagnostics.filter(({ code }) => code === "past-proposed-effective-date").length,
3
Expand Down Expand Up @@ -1315,7 +1315,7 @@ test("creates and configures a service from one JSON config", async (context) =>
assert.match(output, /npx filegrc program-path --next --json/);
assert.match(output, /Confirm the selected assurance goal with management: SOC 2 Type 2/);
const validation = await validateWorkspace(target);
assert.deepEqual(validation.counts, { resources: 226, errors: 0, warnings: 0 });
assert.deepEqual(validation.counts, { resources: 226, errors: 0, warnings: 0, information: 0 });
const workspace = JSON.parse(await readFile(join(target, "data", "workspace.json"), "utf8"));
assert.equal(workspace.systemIds, undefined);
const program = JSON.parse(await readFile(join(target, "data", "programs", "program-soc-2.json"), "utf8"));
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.21.7",
"version": "0.21.8",
"description": "Zero-dependency Git-native GRC engine",
"license": "MIT",
"repository": {
Expand Down
36 changes: 33 additions & 3 deletions packages/filegrc/src/activation-readiness.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { assessReportingRouteSets } from "./reporting-route-sets.js";
import { reportingRouteSourceAppliesToProgram } from "./reporting-route-integrity.js";
import { timestampFromLocalDateTime } from "./time.js";

export async function reportingCutoverIssues(loaded, updates, { operatingAsOf } = {}) {
export async function reportingCutoverIssues(loaded, updates, { operatingAsOf, includeHistoryContext = false } = {}) {
if (!modelSupports(loaded.model, "schedule-set-review")) return [];
const sources = updates.filter((record) => ["policy", "document", "commitment", "risk"].includes(record.type)
&& Array.isArray(record.reportingRouteRequirements) && record.reportingRouteRequirements.length);
Expand All @@ -29,10 +29,14 @@ export async function reportingCutoverIssues(loaded, updates, { operatingAsOf }
if (requirement.programScope === "selected-programs" && !requirement.programIds?.includes(program.id)) continue;
const at = new Date(requirement.effectiveAt) > new Date(effectiveAt) ? requirement.effectiveAt : effectiveAt;
if (requirement.endsAt && new Date(requirement.endsAt) <= new Date(at)) continue;
const assessment = await assessReportingRouteSets(future, { programId: program.id, at });
const assessment = await assessReportingRouteSets(future, { programId: program.id, at, operatingOnly: true });
const candidateIds = new Set(assessment.routeSets.filter(({ record }) => record.purposeKey === requirement.purposeKey).map(({ record }) => record.id));
const relevant = assessment.issues.filter((issue) => issue.resourceId === source.id || candidateIds.has(issue.resourceId));
issues.push(...relevant.map((issue) => ({ ...issue, sourceId: source.id, programId: program.id })));
for (const issue of relevant) {
const temporal = includeHistoryContext && issue.code === "uncovered-reporting-route-interval"
? await resolvedReportingAuthorizationInterval(future, issue, { programId: program.id, purposeKey: requirement.purposeKey, at }) : null;
issues.push({ ...issue, sourceId: source.id, programId: program.id, ...(temporal ? { temporal } : {}) });
}
}
}
}
Expand Down Expand Up @@ -93,3 +97,29 @@ export function historicalContentCutover(loaded, record) {
const prior = snapshot.resources.find(({ id }) => id === record.id);
return prior ? { snapshot, record: prior } : null;
}

// Missing earlier authorization records do not prove that channels were absent.
// A valid current baseline and a dated valid effective cutover can bound the
// retained evidence limitation. Proposal and approval review dates alone cannot.
async function resolvedReportingAuthorizationInterval(loaded, issue, { programId, purposeKey, at }) {
const startsAt = new Date(at), now = new Date();
if (!Number.isFinite(startsAt.getTime()) || startsAt >= now) return null;
const current = await assessReportingRouteSets(loaded, { programId, at: now.toISOString(), operatingOnly: true });
const covers = assessment => {
const baseline = assessment.routeSets.filter(item => item.record.purposeKey === purposeKey && item.effective && !item.canceled);
if (baseline.length !== 1 || baseline[0].state !== "complete") return false;
const ids = new Set([issue.resourceId, baseline[0].record.id]);
return !assessment.issues.some(item => ids.has(item.resourceId));
};
if (!covers(current)) return null;
const boundaries = [...new Set(loaded.resources.filter(record => record.type === "reporting-route-set"
&& record.programId === programId && record.purposeKey === purposeKey)
.map(record => record.approval?.effectiveAt).filter(value => typeof value === "string"
&& Number.isFinite(Date.parse(value)) && new Date(value) > startsAt && new Date(value) <= now))].sort((a, b) => Date.parse(a) - Date.parse(b));
for (const boundary of boundaries) {
const assessment = await assessReportingRouteSets(loaded, { programId, at: boundary, operatingOnly: true });
if (covers(assessment)) return { programId, startsAt: startsAt.toISOString(), endsAt: new Date(boundary).toISOString(),
resolved: true, evidenceLimitation: true, purposeKey };
}
return null;
}
6 changes: 3 additions & 3 deletions packages/filegrc/src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ export async function runCli(argv = process.argv.slice(2)) {
return;
}
if (command === "validate") {
const result = await validateWorkspace(positionals[0] ?? root);
const result = await validateWorkspace(positionals[0] ?? root, { programId: flags.program, auditId: flags.audit });
if (flags.json) console.log(JSON.stringify({ ok: result.ok, counts: result.counts, diagnostics: result.diagnostics }, null, 2));
else printValidation(result);
if (!result.ok) process.exitCode = 1;
Expand Down Expand Up @@ -1468,7 +1468,7 @@ async function readStdin() {

function printValidation(result) {
for (const item of result.diagnostics) console.log(`${item.severity.toUpperCase()} ${item.path}: ${item.message}`);
console.log(`${result.counts.resources} resources, ${result.counts.errors} errors, ${result.counts.warnings} warnings`);
console.log(`${result.counts.resources} resources, ${result.counts.errors} errors, ${result.counts.warnings} warnings${result.counts.information ? `, ${result.counts.information} information` : ""}`);
}

async function printVersion() {
Expand All @@ -1483,7 +1483,7 @@ Usage:
filegrc serve [root] [--host 127.0.0.1] [--port 8787] [--allow-non-authoritative-writes]
filegrc setup [setup.json|-] [setup options] [--draft] [--preview] [--summary] [--json]
filegrc build [root] [--output .filegrc/site]
filegrc validate [root] [--json]
filegrc validate [root] [--program PROGRAM_ID] [--audit AUDIT_ID] [--json]
filegrc model [--json|--write-docs|--check-docs]
filegrc migrate --to-model <${SUPPORTED_MODEL_VERSIONS.join("|")}> [--preview] [--decisions path] [--job-title text] [--starts-on YYYY-MM-DD] [--yes] [--json]
filegrc describe <resource-type>
Expand Down
4 changes: 2 additions & 2 deletions packages/filegrc/src/collection-review.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { loadWorkspace } from "./workspace.js";
import { resolveProgram } from "./program.js";
import { currentCalendarDate } from "./time.js";
import { currentPartyPeople } from "./parties.js";
import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js";
import { retentionScheduleApprovalIssues, operatingRetentionScheduleWorkspace } from "./retention-schedule-approval.js";
import { markdownEntries } from "./resource-markdown.js";
import { resolveDataPath } from "./paths.js";
import { calculateRevision, calculatedRevisionDiagnostic, displayRevision, revisionsMatch } from "./revisions.js";
Expand Down Expand Up @@ -188,7 +188,7 @@ function assessCollectionReviewUncached(loaded, resourceType, options) {

export function retentionScheduleIsAuthoritative(loaded, program) {
return !modelSupports(loaded.model, "retention-schedule-approval")
|| assessCollectionReview(loaded, "retention-schedule-item", { programId: program?.id }).complete;
|| assessCollectionReview(operatingRetentionScheduleWorkspace(loaded), "retention-schedule-item", { programId: program?.id }).complete;
}

export async function scaffoldCollectionReview(input = process.cwd(), options = {}) {
Expand Down
12 changes: 7 additions & 5 deletions packages/filegrc/src/evidence-packet.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { governedContentCoversPeriod } from "./governed-content-baseline.js";
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { copyFile, mkdir, readFile, readdir, readlink, rm, writeFile } from "node:fs/promises";
Expand Down Expand Up @@ -64,7 +65,7 @@ const NON_EVIDENCE_RECORD_TYPES = new Set([
"workspace"
]);
export async function prepareEvidencePacket(input, options = {}) {
const validation = await validateWorkspace(input);
const validation = await validateWorkspace(input, { programId: options.programId, auditId: options.auditId, ...(options.start && options.end ? { coverage: { kind: "range", startsOn: options.start, endsOn: options.end } } : {}) });
if (!validation.ok) throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before generating evidence.`);
const { loaded } = validation;
const records = loaded.resources;
Expand Down Expand Up @@ -1400,7 +1401,7 @@ function packetGaps({
for (const policyId of coverage.policyIds) {
const policy = byId.get(policyId);
if (!policy) continue;
if (!policyCoversPeriod(policy, start, end, byId)) {
if (!policyCoversPeriod(policy, start, end, byId, model)) {
gaps.push(gap("error", "policy-not-effective-for-period", `${policy.title} does not show approved policy coverage for the full packet period.`, policy.id));
}
}
Expand Down Expand Up @@ -1919,16 +1920,17 @@ function auditGaps(gaps, audit, byId, records, start, end, model) {
}
}

function policyCoversPeriod(policy, start, end, byId, seen = new Set()) {
function policyCoversPeriod(policy, start, end, byId, model, seen = new Set()) {
if (modelSupports(model, "operating-preparation")) return governedContentCoversPeriod(policy, start, end, byId, model);
if (seen.has(policy.id)) return false;
seen.add(policy.id);
if (!["approved", "active", "superseded"].includes(policy.status) || !policy.approvedOn || !policy.effectiveOn) return false;
const predecessor = policy.supersedesId ? byId.get(policy.supersedesId) : null;
if (policy.effectiveOn > end) {
return predecessor?.type === "policy" ? policyCoversPeriod(predecessor, start, end, byId, seen) : false;
return predecessor?.type === "policy" ? policyCoversPeriod(predecessor, start, end, byId, model, seen) : false;
}
if (policy.effectiveOn <= start) return true;
return predecessor?.type === "policy" ? policyCoversPeriod(predecessor, start, policy.effectiveOn, byId, seen) : false;
return predecessor?.type === "policy" ? policyCoversPeriod(predecessor, start, policy.effectiveOn, byId, model, seen) : false;
}

function isDescriptionRequirement(requirement) {
Expand Down
Loading
Loading