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: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ FileGRC recommends one dedicated private repository per organization. Browser ed

New renderer settings use trunk mode with `main` as the authoritative branch and `origin` as the remote. Model v2 requires every renderer setting to choose trunk or manual mode explicitly. Record lifecycle fields represent draft, proposed, approved, and retired states; Git branches do not represent approval. Approved Policies and governed Documents bind their approval to the SHA-256 revision of each companion Markdown file. Editing bound content requires moving the record back through review and recording a new approval.

Calculated review revisions follow the facts being reviewed. A source binding includes an applicability decision but excludes its reviewer, date, rationale, and scope hash. Program scope bindings include selected systems and requirement decisions, so adding an unrelated Control does not invalidate retention rows. Ordinary Collection Reviews record setup completion and retain the original population as history. Later changes use affected records, event work, and scheduled reviews. A Data Retention Schedule approval still binds to exact document and row decisions, while each row's source tokens are checked separately. Governed-content reviewer and activation metadata do not alter downstream source bindings; Policy, Document, and Training approval and activation bindings still use exact Markdown bytes.
Calculated review revisions follow the facts being reviewed. A source binding includes an applicability decision but excludes its reviewer, date, rationale, and scope hash. Program scope bindings include selected systems and requirement decisions, so adding an unrelated Control does not invalidate retention rows. Ordinary Collection Reviews record setup completion and retain the original population as history. Later changes use affected records, event work, and scheduled reviews. A Data Retention Schedule approval still binds to exact document and row decisions, while each row's source tokens are checked separately. Governed-content reviewer and activation metadata, including proposed and actual adoption dates, do not alter downstream source bindings; Policy, Document, and Training approval and activation bindings still use exact Markdown bytes.

When an older binding is stale only because its calculation included review bookkeeping, FileGRC checks committed Git history for the original revision and compares the reviewed facts with the current files. This can clear a spurious stale result without changing a record or recording another approval. Run `filegrc validate` and inspect `review-bindings` or `review-collection --scaffold` with the current engine. Keep the full Git history available; a missing historical source or a changed commitment, applicability decision, retention decision, or governed Markdown leaves the review stale and calls for the normal review workflow.

Expand Down
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.35",
"version": "0.16.36",
"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.35",
"version": "0.16.36",
"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.35",
version: "0.16.36",
lockfileVersion: 3,
requires: true,
packages: {
"": {
name,
version: "0.16.35",
version: "0.16.36",
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.35",
"version": "0.16.36",
"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.35",
"version": "0.16.36",
"description": "Zero-dependency Git-native GRC engine",
"license": "MIT",
"repository": {
Expand Down
15 changes: 13 additions & 2 deletions packages/filegrc/src/collection-revision.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export function collectionRevisionMatches(loaded, resourceType, storedRevision,
// Accept both the pre-0.16 scope: input and the digest-only input used by
// 0.16.0, as well as the older collection basis from 0.9.1.
if (revisionsMatch("collection", storedRevision, currentRevision)) return true;
// Before adoption dates became neutral, semantic collection hashes included
// them directly or through each retention row's source decision revisions.
if (revisionsMatch("collection", storedRevision, collectionRevision(loaded, resourceType, {
...options, legacyGovernedLifecycleMetadata: true
}))) return true;
if (resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
&& revisionsMatch("collection", storedRevision, calculateCollectionRevision(
loaded, resourceType, { ...options, legacyRetentionSourceBasis: true }, false
Expand Down Expand Up @@ -163,6 +168,11 @@ function historicallyEquivalentCollection(loaded, resourceType, stored, options,
// Require the complete reviewed basis to be unchanged under the current
// semantic calculation before accepting an older byte-sensitive digest.
if (!revisionsMatch("collection", current, collectionRevision(snapshot, resourceType, historicalOptions))) continue;
for (const legacyGovernedLifecycleMetadata of [false, true]) {
if (revisionsMatch("collection", stored, collectionRevision(snapshot, resourceType, {
...historicalOptions, legacyGovernedLifecycleMetadata
}))) return true;
}
for (const legacyRetentionSourceBasis of resourceType === "retention-schedule-item" ? [false, true] : [false]) {
for (const historicalReviewMetadata of legacyRetentionSourceBasis ? [false] : [true]) {
for (const legacy of [false, true]) {
Expand Down Expand Up @@ -194,7 +204,8 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy, scop
legacy,
historicalReviewMetadata: options.historicalReviewMetadata,
historicalCommit: options.historicalCommit,
legacyRetentionSourceBasis: options.legacyRetentionSourceBasis
legacyRetentionSourceBasis: options.legacyRetentionSourceBasis,
legacyGovernedLifecycleMetadata: options.legacyGovernedLifecycleMetadata
})
.map((input) => [input.record.id, input]));
const authoritativeSource = loaded.resources.find(({ id }) => id === options.authoritativeSourceId);
Expand All @@ -203,7 +214,7 @@ function calculateCollectionRevision(loaded, resourceType, options, legacy, scop
record: authoritativeSource,
value: legacy
? authoritativeSource
: authoritativeSourceRevisionValue(authoritativeSource),
: authoritativeSourceRevisionValue(authoritativeSource, options),
includeContent: true
});
}
Expand Down
16 changes: 10 additions & 6 deletions packages/filegrc/src/collection-scope.js
Original file line number Diff line number Diff line change
Expand Up @@ -366,14 +366,14 @@ export function collectionRevisionInputs(loaded, resourceType, program, options
value: reviewedIds.has(record.id)
? resourceType === "retention-schedule-item" && modelSupports(loaded.model, "retention-schedule-approval")
? retentionScheduleRowProposalValue(record, loaded, options)
: options.historicalReviewMetadata ? record : substantiveRevisionValue(record)
: options.historicalReviewMetadata ? record : substantiveRevisionValue(record, options.legacyGovernedLifecycleMetadata)
: resourceType === "retention-schedule-item"
&& modelSupports(loaded.model, "retention-schedule-approval")
&& record.type === "document"
? retentionScheduleDocumentProposalValue(record)
: options.historicalReviewMetadata
? dependencyRevisionValue(resourceType, record, legacy, true, currentModel)
: substantiveRevisionValue(dependencyRevisionValue(resourceType, record, legacy, false, currentModel, scheduleDocumentIds, options.legacyRetentionSourceBasis)),
: substantiveRevisionValue(dependencyRevisionValue(resourceType, record, legacy, false, currentModel, scheduleDocumentIds, options.legacyRetentionSourceBasis), options.legacyGovernedLifecycleMetadata),
includeContent: legacy
|| reviewedIds.has(record.id)
|| dependencyContentAffectsRevision(resourceType, record, options.historicalReviewMetadata, currentModel, scheduleDocumentIds, options.legacyRetentionSourceBasis)
Expand All @@ -388,7 +388,7 @@ function retentionScheduleRowProposalValue(record, loaded, options = {}) {
].includes(field)));
if (!options.historicalReviewMetadata && !options.legacyRetentionSourceBasis) {
value.sourceDecisionRevisions = Object.fromEntries(resourceReviewRevisionsSync(
loaded, record.sourceResourceIds || [], "legacy", false, options.historicalCommit || null, record
loaded, record.sourceResourceIds || [], "legacy", false, options.historicalCommit || null, record, !options.legacyGovernedLifecycleMetadata
));
}
return value;
Expand Down Expand Up @@ -479,13 +479,13 @@ export function collectionScopeRevisionFacts(loaded, resourceType, program, opti
return common;
}

export function authoritativeSourceRevisionValue(record) {
export function authoritativeSourceRevisionValue(record, options = {}) {
return substantiveRevisionValue(Object.fromEntries(Object.entries(record).filter(([field]) => (
!authoritativeSourceBookkeepingFields.has(field)
))));
))), options.legacyGovernedLifecycleMetadata);
}

function substantiveRevisionValue(record) {
function substantiveRevisionValue(record, legacyGovernedLifecycleMetadata = false) {
if (!record) return record;
const value = { ...record };
if (value.type === "retention-schedule-item") {
Expand All @@ -500,6 +500,10 @@ function substantiveRevisionValue(record) {
}
if (value.applicabilityReview) value.applicabilityReview = { decision: value.applicabilityReview.decision };
if (["policy", "document", "training"].includes(value.type)) {
if (!legacyGovernedLifecycleMetadata) {
delete value.effectiveOn;
delete value.proposedEffectiveOn;
}
for (const field of [
"approverIds", "approvedOn", "activationBasis", "activatedByIds",
"activatedOn", "activatedContentRevisions", "statusTransition"
Expand Down
30 changes: 22 additions & 8 deletions packages/filegrc/src/retention.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ export async function resourceReviewRevisions(loaded, ids, scopeHashInput = "leg
return new Map([...selected].filter(([, revision]) => revision));
}

export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legacy", legacySource = false, historicalCommit = null, reviewer = null) {
export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legacy", legacySource = false, historicalCommit = null, reviewer = null, lifecycleNeutral = true) {
const wanted = new Set(ids);
const entries = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
const revisions = new Map();
Expand All @@ -216,7 +216,7 @@ export function resourceReviewRevisionsSync(loaded, ids, scopeHashInput = "legac
if (!entry || reviewing.has(key)) return null;
reviewing.add(key);
const decisionHeading = legacySource ? heading : heading || retentionPolicyHeading(loaded, entry, reviewer);
const parts = [reviewSource(loaded, entry, scopeHashInput, legacySource, decisionHeading, reviewer)];
const parts = [reviewSource(loaded, entry, scopeHashInput, legacySource, decisionHeading, reviewer, lifecycleNeutral)];
let matchedSection = !decisionHeading;
for (const markdown of reviewMarkdownEntries(loaded, entry, legacySource, reviewer)) {
try {
Expand Down Expand Up @@ -257,6 +257,8 @@ export function resourceReviewRevisionMatches(loaded, revisions, id, stored, rev
: revisions.get(id);
if (!current || !revisionDigest("content", stored)) return false;
if (revisionsMatch("content", stored, current)) return true;
const prior = resourceReviewRevisionsSync(loaded, [id], "legacy", false, null, reviewer, false).get(id);
if (revisionsMatch("content", stored, prior)) return true;
if (!sourceHeading(reviewer, id)) {
const legacy = resourceReviewRevisionsSync(loaded, [id], "legacy", true).get(id);
if (revisionsMatch("content", stored, legacy)) return true;
Expand Down Expand Up @@ -297,8 +299,12 @@ function historicallyEquivalentReviewSource(loaded, id, stored, current, reviewe
// A row may narrow a source to its retention facts or a named Markdown
// section. Accept its old binding only when those facts still match the
// committed source that supplied the binding.
const original = resourceReviewRevisionsSync(snapshot, [id], "legacy", false, commit).get(id);
if (revisionsMatch("content", stored, original)) return true;
const prior = resourceReviewRevisionsSync(snapshot, [id], "legacy", false, commit, reviewer, false).get(id);
if (revisionsMatch("content", stored, prior)) return true;
for (const lifecycleNeutral of [true, false]) {
const original = resourceReviewRevisionsSync(snapshot, [id], "legacy", false, commit, null, lifecycleNeutral).get(id);
if (revisionsMatch("content", stored, original)) return true;
}
for (const scopeHashInput of ["legacy", "digest"]) {
const legacy = resourceReviewRevisionsSync(snapshot, [id], scopeHashInput, true, commit).get(id);
if (revisionsMatch("content", stored, legacy)) return true;
Expand Down Expand Up @@ -328,7 +334,7 @@ export function retentionUses(loaded, program) {
return [...new Map(uses.map((use) => [`${use.resource.id}:${use.informationTypeId}`, use])).values()];
}

function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = false, heading = null, reviewer = null) {
function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = false, heading = null, reviewer = null, lifecycleNeutral = true) {
if (heading && !legacySource) {
const governingFields = entry.record.type === "policy"
? ["policyKind", "effectiveOn", "supersedesId", "parentPolicyId", "relatedPolicyIds", "relatedDocumentIds", "requirementIds", "audience", "acknowledgementRequired", "programRole", "reportingRouteRequirements"]
Expand All @@ -338,7 +344,7 @@ function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = f
type: entry.record.type,
governingStatus: ["approved", "active"].includes(entry.record.status)
? "approved" : entry.record.status || null,
...Object.fromEntries(["extensions", "externalIds", ...governingFields].filter((field) => entry.record[field] !== undefined)
...Object.fromEntries(["extensions", "externalIds", ...governingFields].filter((field) => entry.record[field] !== undefined && (!lifecycleNeutral || field !== "effectiveOn"))
.map((field) => [field, entry.record[field]]))
});
}
Expand Down Expand Up @@ -366,7 +372,7 @@ function reviewSource(loaded, entry, scopeHashInput = "legacy", legacySource = f
return legacySource
? canonicalCalculatedRevisionJson(entry.source, scopeHashInput)
: canonicalCalculatedRevisionJson(JSON.stringify(substantiveReviewSource(
JSON.parse(entry.source), modelSupports(loaded.model, "retention-schedule-approval"), reviewer
JSON.parse(entry.source), modelSupports(loaded.model, "retention-schedule-approval"), reviewer, lifecycleNeutral
)), scopeHashInput);
}

Expand Down Expand Up @@ -508,7 +514,15 @@ const reviewDecisionFields = {
document: ["documentKind", "workflowScope", "template", "version", "effectiveOn", "supersedesId", "systemIds", "controlIds", "relatedDocumentIds", "audience", "acknowledgementRequired", "trainingIds", "classificationId", "programRole", "componentIds", "reportingRouteRequirements"]
};

function substantiveReviewSource(record, currentModel, reviewer = null) {
function substantiveReviewSource(record, currentModel, reviewer = null, lifecycleNeutral = true) {
// Adoption dates describe when unchanged approved content enters operation.
// Review decisions bind the governed facts and Markdown, while activation
// validates dates and exact approval revisions independently.
if (lifecycleNeutral && ["policy", "document", "training"].includes(record.type)) {
record = { ...record };
delete record.effectiveOn;
delete record.proposedEffectiveOn;
}
if (currentModel && reviewDecisionFields[record.type]) {
const fields = ["id", "type", "title", "extensions", "externalIds", ...reviewDecisionFields[record.type]];
const decision = Object.fromEntries(fields.filter((field) => record[field] !== undefined)
Expand Down
Loading
Loading