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 @@ -817,3 +817,5 @@ A risk baseline covers every selected System, active Component, material provide
Operating ownership can use existing implemented Control procedures and approved program Documents. Dated rehearsal Evidence selects the procedures actually used, or management selects their IDs on the Program. Existing Document approval revisions and normal Control oversight remain authoritative. Material changes follow the existing review process; no separate Document or duplicate independent approval is introduced solely for this group.

Tokenized resource navigation projects the same snapshot-scoped workflow section used by root navigation. Previously, detail requests repeated full synchronous workflow and Git-history work on the HTTP thread after the shared worker had already calculated it. Detail now joins or reuses that worker calculation, retains each caller's deadline and verifies live file and Git signatures before responding. The 10-second request budgets remain unchanged. Tests cover warm computation bounds, concurrent cold requests, recovery after an earlier caller deadline, transient Git failures and invalidation after source changes.

Finalized operating proof freezes explicit completion, Evidence, Exception and Attestation relationships at the owner's first finalized Git revision. Evidence source-resource links describe context, so current Controls, inventories and ongoing reviews can evolve without rewriting that proof. Source links to Evidence, Exceptions and Attestations still protect those proof records. Mutation guards and historical validation share the same traversal; historical validation resolves the traversal from committed records rather than current links. Evidence packets retain committed source versions alongside current context.
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.19.0",
"version": "0.19.1",
"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.19.0",
"version": "0.19.1",
"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.19.0",
version: "0.19.1",
lockfileVersion: 3,
requires: true,
packages: {
"": {
name,
version: "0.19.0",
version: "0.19.1",
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.19.0",
"version": "0.19.1",
"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.19.0",
"version": "0.19.1",
"description": "Zero-dependency Git-native GRC engine",
"license": "MIT",
"repository": {
Expand Down
58 changes: 4 additions & 54 deletions packages/filegrc/src/files.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { workflowProofIds } from "./workflow-proof.js";
import { randomUUID } from "node:crypto";
import { constants, link, lstat, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
Expand Down Expand Up @@ -865,63 +866,12 @@ function assertWorkflowContentMutable(loaded, record) {
function finalizedOccurrenceUsingProof(loaded, targetId) {
if (!loaded?.resources?.length || !targetId) return null;
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
const proofTypes = new Set(["evidence", "exception", "attestation"]);
const proofFields = [
"completionResourceIds",
"evidenceIds",
"sampleEvidenceIds",
"sourceEvidenceId",
"sourceResourceIds",
"exceptionId",
"exceptionIds",
"attestationIds"
];
for (const occurrence of loaded.resources.filter((record) => (
record.type === "obligation-occurrence" && ["reconciled", "superseded"].includes(record.status)
))) {
const pending = (occurrence.members || []).flatMap((member) => [
...(member.completionResourceIds || []),
...(member.exceptionId ? [member.exceptionId] : [])
]);
const visited = new Set();
while (pending.length) {
const id = pending.pop();
if (!id || visited.has(id)) continue;
if (id === targetId) return occurrence;
visited.add(id);
const record = byId.get(id);
if (!record) continue;
for (const field of proofFields) {
const value = record[field];
for (const relatedId of Array.isArray(value) ? value : value ? [value] : []) {
if (proofTypes.has(byId.get(relatedId)?.type)) pending.push(relatedId);
}
}
}
}
for (const owner of loaded.resources.filter((record) => (
(record.type === "audit-population" && ["reconciled", "not-applicable", "superseded"].includes(record.status))
(record.type === "obligation-occurrence" && ["reconciled", "superseded"].includes(record.status))
|| (record.type === "audit-population" && ["reconciled", "not-applicable", "superseded"].includes(record.status))
|| (record.type === "attestation" && record.status === "completed")
))) {
const pending = [
...(owner.evidenceIds || []),
...(owner.sourceEvidenceId ? [owner.sourceEvidenceId] : [])
];
const visited = new Set();
while (pending.length) {
const id = pending.pop();
if (!id || visited.has(id)) continue;
if (id === targetId) return owner;
visited.add(id);
const record = byId.get(id);
if (!record) continue;
for (const field of proofFields) {
const value = record[field];
for (const relatedId of Array.isArray(value) ? value : value ? [value] : []) {
if (proofTypes.has(byId.get(relatedId)?.type)) pending.push(relatedId);
}
}
}
if (workflowProofIds(owner, (id) => byId.get(id)).has(targetId)) return owner;
}
return null;
}
Expand Down
44 changes: 28 additions & 16 deletions packages/filegrc/src/obligations.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,21 +179,9 @@ export function planObligations(resources, options = {}) {
}

const configuredAnchor = schedule.recurrence?.anchorDate || schedule.startsOn;
const ruleActivationDate = !modelSupports(model, "operating-preparation")
? obligationActivationDate(obligation, byId, rule || model, workspace?.timezone || "UTC")
: [obligationActivationDate(obligation, byId, rule || model, workspace?.timezone || "UTC"),
obligationActivationDate(obligation, byId, model, workspace?.timezone || "UTC")].filter(Boolean).sort().at(-1);
const activationDate = ruleActivationDate;
const recurrence = {
...(rule ? obligationRuleRecurrence(rule, workspace?.timezone || "UTC") : schedule.recurrence || {}),
anchorDate: schedule.recurrence?.anchorMode === "activation" && activationDate
? activationDate
: rule
? configuredAnchor || activationDate
: configuredAnchor && activationDate
? [configuredAnchor, activationDate].sort().at(-1)
: configuredAnchor || activationDate
};
const { recurrence, activationDate } = obligationScheduleRecurrence(
obligation, rule, byId, model, workspace?.timezone || "UTC"
);
if (!validCalendarRecurrence(recurrence)) continue;
const from = rule
? [requestedFrom || recurrence.anchorDate, activationDate].filter(Boolean).sort().at(-1)
Expand Down Expand Up @@ -1778,7 +1766,7 @@ function contentRevision(source) {
return calculateRevision("content", source);
}

function calendarWindow(recurrence, configuredWindow, index) {
export function calendarWindow(recurrence, configuredWindow, index) {
const occurrence = calendarOccurrence(recurrence, index);
const next = calendarOccurrence(recurrence, index + 1);
if (!occurrence || !next) return null;
Expand Down Expand Up @@ -2107,6 +2095,30 @@ function occurrenceStatus(window, asOf, complete) {
return "upcoming";
}

// Planning and persisted occurrence validation must use the same activation
// anchor, including the latest governing content cutover in operating models.
export function obligationScheduleRecurrence(obligation, rule, byId, model, timezone = "UTC") {
const schedule = rule || obligation;
const configuredAnchor = schedule.recurrence?.anchorDate || schedule.startsOn;
const activationDate = !modelSupports(model, "operating-preparation")
? obligationActivationDate(obligation, byId, rule || model, timezone)
: [obligationActivationDate(obligation, byId, rule || model, timezone),
obligationActivationDate(obligation, byId, model, timezone)].filter(Boolean).sort().at(-1);
return {
activationDate,
recurrence: {
...(rule ? obligationRuleRecurrence(rule, timezone) : schedule.recurrence || {}),
anchorDate: schedule.recurrence?.anchorMode === "activation" && activationDate
? activationDate
: rule
? configuredAnchor || activationDate
: configuredAnchor && activationDate
? [configuredAnchor, activationDate].sort().at(-1)
: configuredAnchor || activationDate
}
};
}

export function obligationActivationDate(obligation, byId, ruleOrModel, timezone = "UTC") {
if (ruleOrModel?.type === "obligation-rule") {
return ruleOrModel.effectiveAt
Expand Down
50 changes: 33 additions & 17 deletions packages/filegrc/src/validate.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { nonmaterialAmendmentIssues } from "./nonmaterial-amendments.js";
import { completionMatchesMember, assessCompletionForObligation, obligationActivationDate } from "./obligations.js";
import { completionMatchesMember, assessCompletionForObligation, obligationScheduleRecurrence, calendarWindow } from "./obligations.js";
import { reportingCutoverIssues, scheduleCutoverIssues, historicalContentCutover } from "./activation-readiness.js";
import { scheduleSetIssues, scheduleReviewerConflicts } from "./schedule-set.js";
import { createHash } from "node:crypto";
Expand All @@ -18,14 +18,12 @@ import { getDataRecordHistoryIndex, getFileAtRevision, getFileBufferAtRevision,
import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
import {
addCalendarDays,
calendarOccurrence,
calendarOccurrenceIndex,
parseCalendarDate,
validCalendarRecurrence
} from "./recurrence.js";
import { resourceReviewRevisionMatches, resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js";
import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js";
import { obligationRuleRecurrence } from "./obligation-rule.js";
import { obligationIsEnabled, obligationRuleIsEnabled } from "./program-lifecycle.js";
import { currentPartyPeople, partyPeople } from "./parties.js";
import { isMarkdownChoice, markdownEntries } from "./resource-markdown.js";
Expand Down Expand Up @@ -774,24 +772,17 @@ function validateObligationOccurrence(record, model, resources, entries, root, w
}
}

function validateOccurrenceScheduleBinding(record, obligation, rule, resources, model, root, path, diagnostics) {
function validateOccurrenceScheduleBinding(record, obligation, rule, resources, model, root, path, diagnostics, allowHistorical = true) {
if (obligation?.type !== "obligation" || rule?.type !== "obligation-rule") return;
const start = record.coverage?.kind === "range" ? record.coverage.startsOn : null;
const recurrence = obligationRuleRecurrence(rule);
const byId = new Map(resources.map((candidate) => [candidate.id, candidate]));
const timezone = resources.find(({ type }) => type === "workspace")?.timezone || "UTC";
const { recurrence, activationDate: cutover } = obligationScheduleRecurrence(obligation, rule, byId, model, timezone);
if (!start || !validCalendarRecurrence(recurrence)) return;
const index = calendarOccurrenceIndex(recurrence, start);
const occurrence = calendarOccurrence(recurrence, index);
const next = calendarOccurrence(recurrence, index + 1);
const startOffset = rule.window?.precision === "date" && Number.isInteger(rule.window.startsAfter)
? rule.window.startsAfter
: 0;
let expectedStart = occurrence ? addCalendarDays(occurrence, startOffset) : null;
const expectedEnd = rule.window?.precision === "date" && Number.isInteger(rule.window.dueAfter)
? addCalendarDays(occurrence, rule.window.dueAfter)
: next ? addCalendarDays(next, -1) : null;
const byId = new Map(resources.map((candidate) => [candidate.id, candidate]));
const cutover = [obligationActivationDate(obligation, byId, rule, rule.timezone || "UTC"),
obligationActivationDate(obligation, byId, model, rule.timezone || "UTC")].filter(Boolean).sort().at(-1);
const window = calendarWindow(recurrence, rule.window, index);
let expectedStart = window?.dueWindowStart || null;
const expectedEnd = window?.dueWindowEnd || null;
if (modelSupports(model, "operating-preparation") && cutover && expectedStart < cutover
&& start !== expectedStart) {
// A committed partial window keeps its binding even when a later content
Expand Down Expand Up @@ -822,6 +813,31 @@ function validateOccurrenceScheduleBinding(record, obligation, rule, resources,
|| governingRule?.id !== rule.id
|| JSON.stringify([...(record.ownerIds || [])].sort()) !== JSON.stringify([...(obligation.ownerIds || [])].sort())
) {
if (allowHistorical && rule.recurrence?.anchorMode === "activation") {
const basis = record.supersedesId ? resources.find(({ id }) => id === record.supersedesId) : record;
const history = basis && getDataRecordHistoryIndex(root).historiesById.get(basis.id);
// The first saved window must itself have been valid in its original
// governing context. Merely committing arbitrary dates grants no authority.
const original = history?.at(-1);
if (original) {
const snapshot = recordsAtRevision({ root }, original.commit);
const historical = snapshot.find(({ id }) => id === basis.id);
if (historical && ["programId", "obligationId", "ruleId", "occurrenceKey", "membershipCutoffAt"]
.every((key) => historical[key] === record[key])
&& JSON.stringify(historical.coverage) === JSON.stringify(record.coverage)) {
const originalObligation = snapshot.find(({ id }) => id === record.obligationId);
const originalRule = snapshot.find(({ id }) => id === record.ruleId);
if (originalObligation?.type === "obligation" && originalRule?.type === "obligation-rule"
&& validCalendarRecurrence(obligationScheduleRecurrence(originalObligation, originalRule,
new Map(snapshot.map((candidate) => [candidate.id, candidate])), model, timezone).recurrence)) {
const issues = [];
validateOccurrenceScheduleBinding(record, originalObligation,
originalRule, snapshot, model, root, path, issues, false);
if (issues.length === 0) return;
}
}
}
}
diagnostics.push(error(
"invalid-obligation-occurrence-schedule-binding",
path,
Expand Down
Loading
Loading