From 12bce3aaa9fa2d68824a28e9bb6dfa1835882afa Mon Sep 17 00:00:00 2001 From: Abe Wheeler Date: Fri, 23 Oct 2026 15:54:39 -0500 Subject: [PATCH] Fix operating proof and activation windows; release 0.19.1 --- docs/architecture.md | 2 + package-lock.json | 8 +- package.json | 2 +- packages/create-filegrc/package.json | 2 +- packages/create-filegrc/src/index.js | 4 +- packages/create-filegrc/template/package.json | 2 +- packages/filegrc/package.json | 2 +- packages/filegrc/src/files.js | 58 +-------- packages/filegrc/src/obligations.js | 44 ++++--- packages/filegrc/src/validate.js | 50 +++++--- .../filegrc/src/workflow-history-integrity.js | 55 ++------- packages/filegrc/src/workflow-proof.js | 35 ++++++ .../test/completion-reconciliation.test.js | 110 ++++++++++++++++++ packages/filegrc/test/workflow-proof.test.js | 76 ++++++++++++ 14 files changed, 308 insertions(+), 142 deletions(-) create mode 100644 packages/filegrc/src/workflow-proof.js create mode 100644 packages/filegrc/test/workflow-proof.test.js diff --git a/docs/architecture.md b/docs/architecture.md index 693002b..bca16fe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 3bd4bbf..642a1d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.19.0", + "version": "0.19.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.19.0", + "version": "0.19.1", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.19.0", + "version": "0.19.1", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.19.0", + "version": "0.19.1", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 1191bc1..d2dcc75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.19.0", + "version": "0.19.1", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index 3d125f3..699eb70 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -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": { diff --git a/packages/create-filegrc/src/index.js b/packages/create-filegrc/src/index.js index 8cf656f..d96d2f5 100644 --- a/packages/create-filegrc/src/index.js +++ b/packages/create-filegrc/src/index.js @@ -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 } } } diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index 825c92c..b812cb4 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -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", diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index abb5117..4527184 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.19.0", + "version": "0.19.1", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/files.js b/packages/filegrc/src/files.js index e4a8785..cd11de1 100644 --- a/packages/filegrc/src/files.js +++ b/packages/filegrc/src/files.js @@ -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"; @@ -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; } diff --git a/packages/filegrc/src/obligations.js b/packages/filegrc/src/obligations.js index c46c504..93e7b53 100644 --- a/packages/filegrc/src/obligations.js +++ b/packages/filegrc/src/obligations.js @@ -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) @@ -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; @@ -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 diff --git a/packages/filegrc/src/validate.js b/packages/filegrc/src/validate.js index 5fc428a..332c006 100644 --- a/packages/filegrc/src/validate.js +++ b/packages/filegrc/src/validate.js @@ -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"; @@ -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"; @@ -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 @@ -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, diff --git a/packages/filegrc/src/workflow-history-integrity.js b/packages/filegrc/src/workflow-history-integrity.js index 0d9a956..13ce352 100644 --- a/packages/filegrc/src/workflow-history-integrity.js +++ b/packages/filegrc/src/workflow-history-integrity.js @@ -1,3 +1,4 @@ +import { workflowProofIds } from "./workflow-proof.js"; import { amendmentEvidenceObservationMatches } from "./nonmaterial-amendments.js"; import { readFile } from "node:fs/promises"; import { @@ -6,7 +7,6 @@ import { getFileHistory, getFileObjectIdAtRevision, getFilePathAtRevision, - getRecordIdentityHistories, getWorkingFileObjectId, isDataHistoryAncestor } from "./git.js"; @@ -31,17 +31,6 @@ const FINAL_STATUSES = new Map([ ["exception", new Set(["approved", "revoked", "closed"])] ]); -const PROOF_FIELDS = [ - "completionResourceIds", - "evidenceIds", - "sampleEvidenceIds", - "sourceEvidenceId", - "sourceResourceIds", - "exceptionId", - "exceptionIds", - "attestationIds" -]; - export async function validateWorkflowHistoryIntegrity(loaded, diagnostics) { const historical = historicalState(loaded); if (!historical.available) { @@ -261,7 +250,6 @@ export async function validateWorkflowHistoryIntegrity(loaded, diagnostics) { await validateReportingRouteAuthorityHistory(loaded, commits, revisionRecords, historiesById, diagnostics); - const byId = new Map(loaded.resources.map((record) => [record.id, record])); const proofOwners = loaded.resources.filter((record) => ( record.type === "nonmaterial-amendment" || (record.type === "obligation-occurrence" && ["reconciled", "superseded"].includes(record.status)) @@ -269,11 +257,6 @@ export async function validateWorkflowHistoryIntegrity(loaded, diagnostics) { || (record.type === "attestation" && record.status === "completed") || (record.type === "exception" && ["approved", "revoked", "closed"].includes(record.status)) )); - const proofIdsByOwner = new Map(proofOwners.map((owner) => [owner.id, proofIds(owner, byId)])); - const proofIdentityHistories = getRecordIdentityHistories( - loaded.root, - new Set([...proofIdsByOwner.values()].flatMap((ids) => [...ids])) - ); for (const owner of proofOwners) { const finalized = firstFinalized.get(owner.id); if (!finalized) continue; @@ -284,10 +267,17 @@ export async function validateWorkflowHistoryIntegrity(loaded, diagnostics) { const ownerEntry = loaded.entries.find(({ record }) => record.id === owner.id); diagnostics.push(integrityError(ownerEntry, `Occurrence "${owner.id}" must bind a Collection Review committed after its scope revision and before the occurrence was finalized.`)); } - const protectedIds = proofIdsByOwner.get(owner.id); + // Resolve the closure from the actual finalized snapshot, never today's + // links or types. Context may evolve, but proof cannot hide by changing it. + const historicalRecords = new Map(); + const historicalRecord = (id) => { + if (!historicalRecords.has(id)) historicalRecords.set(id, recordAtRevision(loaded, finalized.commit, id, historiesById.get(id) || [])); + return historicalRecords.get(id); + }; + const protectedIds = workflowProofIds(finalized.record, (id) => historicalRecord(id)?.record); for (const id of protectedIds) { const currentEntry = loaded.entries.find(({ record }) => record.id === id); - const historical = recordAtRevision(loaded, finalized.commit, id, proofIdentityHistories.get(id) || []); + const historical = historicalRecord(id); if (!currentEntry || !historical) { const ownerEntry = loaded.entries.find(({ record }) => record.id === owner.id); diagnostics.push(integrityError(ownerEntry, `Proof record "${id}" must exist in the commit where ${owner.type} "${owner.id}" was first finalized.`)); @@ -849,31 +839,6 @@ function canonicalJson(value, field = null, revisionMap = false) { ])); } -function proofIds(occurrence, byId) { - if (occurrence.type === "nonmaterial-amendment") return new Set(occurrence.evidenceIds || []); - const pending = [ - ...(occurrence.evidenceIds || []), - ...(occurrence.sourceEvidenceId ? [occurrence.sourceEvidenceId] : []), - ...(occurrence.members || []).flatMap((member) => [ - ...(member.completionResourceIds || []), - member.exceptionId - ]) - ].filter(Boolean); - const ids = new Set(); - while (pending.length) { - const id = pending.pop(); - if (!id || ids.has(id)) continue; - ids.add(id); - const record = byId.get(id); - if (!record) continue; - for (const field of PROOF_FIELDS) { - const value = record[field]; - pending.push(...(Array.isArray(value) ? value : value ? [value] : [])); - } - } - return ids; -} - async function compareMarkdownAtRevision(loaded, entry, historical, diagnostics, occurrenceId = null) { for (const markdown of markdownEntries(loaded.model, entry.record)) { const historicalPath = markdownEntries(loaded.model, historical.record) diff --git a/packages/filegrc/src/workflow-proof.js b/packages/filegrc/src/workflow-proof.js new file mode 100644 index 0000000..ad7f46d --- /dev/null +++ b/packages/filegrc/src/workflow-proof.js @@ -0,0 +1,35 @@ +// Proof relationships preserve operating facts. Evidence sourceResourceIds also +// describe mutable context (Controls, inventories, ongoing reviews), whose past +// versions remain in Git rather than becoming permanently frozen records. +const PROOF_FIELDS = [ + "completionResourceIds", "evidenceIds", "sampleEvidenceIds", + "sourceEvidenceId", "exceptionId", "exceptionIds", "attestationIds" +]; +const SOURCE_PROOF_TYPES = new Set(["evidence", "exception", "attestation"]); + +export function workflowProofIds(owner, resolveRecord) { + if (owner.type === "nonmaterial-amendment") return new Set(owner.evidenceIds || []); + const pending = [ + ...(owner.evidenceIds || []), + ...(owner.sourceEvidenceId ? [owner.sourceEvidenceId] : []), + ...(owner.members || []).flatMap((member) => [ + ...(member.completionResourceIds || []), member.exceptionId + ]) + ].filter(Boolean); + const ids = new Set(); + while (pending.length) { + const id = pending.pop(); + if (!id || ids.has(id)) continue; + ids.add(id); + const record = resolveRecord(id); + if (!record) continue; + for (const field of PROOF_FIELDS) { + const value = record[field]; + pending.push(...(Array.isArray(value) ? value : value ? [value] : [])); + } + for (const sourceId of record.sourceResourceIds || []) { + if (SOURCE_PROOF_TYPES.has(resolveRecord(sourceId)?.type)) pending.push(sourceId); + } + } + return ids; +} diff --git a/packages/filegrc/test/completion-reconciliation.test.js b/packages/filegrc/test/completion-reconciliation.test.js index 60fab2a..130ac3e 100644 --- a/packages/filegrc/test/completion-reconciliation.test.js +++ b/packages/filegrc/test/completion-reconciliation.test.js @@ -353,3 +353,113 @@ test("CLI reconciliation consumes create, update, and supersession scaffolds wit await writeJson(payload, correction); await cli("reconcile-obligation", f.obligation.id, payload); }); + +for (const { unit, window, startsOn, endsOn } of [ + { unit: "year", window: { precision: "date", startsAfter: 0, dueAfter: 30 }, startsOn: "2025-10-23", endsOn: "2025-11-22" }, + { unit: "month", window: { precision: "date", startsAfter: 3, dueAfter: 12 }, startsOn: "2025-10-26", endsOn: "2025-11-04" }, + { unit: "year", startsOn: "2025-10-23", endsOn: "2026-10-22" }, + { unit: "month", startsOn: "2025-10-23", endsOn: "2025-11-22" } +]) test(`activation-anchored ${unit} completion shares its ${window ? "explicit" : "full-cycle"} window with validation`, async (context) => { + const f = await fixture(context); + const rule = { ...f.rule, recurrence: { mode: "calendar", unit, interval: 1, + anchorDate: "2025-10-08", anchorMode: "activation" }, effectiveAt: "2025-10-08T00:00:00-05:00" }; + if (window) rule.window = window; + else delete rule.window; + await writeJson(f.ruleEntry.path, rule); + const loaded = await loadWorkspace(f.root); + const policy = loaded.entries.find(({ record }) => record.id === f.obligation.policyIds[0]); + await writeJson(policy.path, { ...policy.record, effectiveOn: "2025-10-23" }); + const evidence = loaded.entries.find(({ record }) => record.type === "evidence"); + await writeJson(evidence.path, { ...evidence.record, collectedOn: "2025-10-27", verifiedOn: "2025-10-27" }); + const record = { ...f.record, completedAt: "2025-10-27T15:30:00Z", reviewedOn: "2025-10-27" }; + const scaffold = await scaffoldObligationCompletion(f.root, { obligationId: f.obligation.id, + windowStart: startsOn, completedOn: "2025-10-27" }); + assert.equal(scaffold.scaffold.dueWindowStart, startsOn); + assert.equal(scaffold.scaffold.dueWindowEnd, endsOn); + const result = await completeObligationOccurrence(f.root, { obligationId: f.obligation.id, + expectedRevision: f.revision, completionContext: scaffold.completionContext, record }); + assert.deepEqual(result.linked.coverage, { kind: "range", startsOn, endsOn }); + const validation = await validateWorkspace(f.root); + assert.deepEqual(validation.diagnostics.filter(({ code }) => /occurrence|member|population/.test(code)), []); + await initializeGitWorkspace(f.root, "Retain the original activation window"); + await writeJson(policy.path, { ...policy.record, effectiveOn: "2026-12-10" }); + const later = await validateWorkspace(f.root); + assert.equal(later.diagnostics.some(({ code }) => code === "invalid-obligation-occurrence-schedule-binding"), false); + const current = await loadWorkspace(f.root); + const items = planObligations(current.resources, { model: current.model, + asOf: "2026-12-12", through: "2026-12-12", includeComplete: true }).calendarItems; + assert.equal(items.filter(({ occurrenceId }) => occurrenceId === result.linked.id).length, 1); + assert.equal(items.filter((item) => item.dueWindowStart <= endsOn && item.dueWindowEnd >= startsOn).length, 1, + "Later cutover does not duplicate the frozen cycle"); + const occurrence = current.entries.find(({ record }) => record.id === result.linked.id); + await writeJson(occurrence.path, { ...occurrence.record, + coverage: { ...occurrence.record.coverage, endsOn: "2025-11-07" } }); + const rejected = await validateWorkspace(f.root); + assert.ok(rejected.diagnostics.some(({ code }) => code === "invalid-obligation-occurrence-schedule-binding")); +}); + +test("committing an invalid activation window cannot authorize it after a later cutover", async (context) => { + const f = await fixture(context); + await writeJson(f.ruleEntry.path, { ...f.rule, recurrence: { ...f.rule.recurrence, anchorMode: "activation" } }); + const result = await completeObligationOccurrence(f.root, { obligationId: f.obligation.id, + expectedRevision: f.revision, record: f.record }); + const loaded = await loadWorkspace(f.root); + const occurrence = loaded.entries.find(({ record }) => record.id === result.linked.id); + await writeJson(occurrence.path, { ...occurrence.record, + coverage: { ...occurrence.record.coverage, endsOn: "2026-07-26" } }); + await initializeGitWorkspace(f.root, "Retain an invalid window for validation"); + const policy = loaded.entries.find(({ record }) => record.id === f.obligation.policyIds[0]); + await writeJson(policy.path, { ...policy.record, effectiveOn: "2026-08-10" }); + const validation = await validateWorkspace(f.root); + assert.ok(validation.diagnostics.some(({ code }) => code === "invalid-obligation-occurrence-schedule-binding")); +}); + + +test("new activation-based cycles after cutover use the same scaffold and validation basis", async (context) => { + const f = await fixture(context); + const loaded = await loadWorkspace(f.root); + const policy = loaded.entries.find(({ record }) => record.id === f.obligation.policyIds[0]); + await writeJson(f.ruleEntry.path, { ...f.rule, recurrence: { mode: "calendar", unit: "month", interval: 1, + anchorMode: "activation", anchorDate: "2025-10-08" }, effectiveAt: "2025-10-08T00:00:00Z" }); + await writeJson(policy.path, { ...policy.record, effectiveOn: "2025-10-23" }); + const initial = { ...f.record, id: "control-activity-initial-cycle", completedAt: "2025-10-27T15:30:00Z", reviewedOn: "2025-10-27" }; + const evidence = loaded.entries.find(({ record }) => record.type === "evidence"); + await writeJson(evidence.path, { ...evidence.record, collectedOn: "2025-10-27", verifiedOn: "2025-10-27" }); + await completeObligationOccurrence(f.root, { obligationId: f.obligation.id, expectedRevision: f.revision, record: initial }); + await initializeGitWorkspace(f.root); + await writeJson(policy.path, { ...policy.record, effectiveOn: "2026-07-10" }); + const scaffold = await scaffoldObligationCompletion(f.root, { obligationId: f.obligation.id, + windowStart: "2026-07-10", completedOn: "2026-07-12" }); + const current = { ...f.record, id: "control-activity-current-cycle", completedAt: "2026-07-12T15:30:00Z", reviewedOn: "2026-07-12" }; + const result = await completeObligationOccurrence(f.root, { obligationId: f.obligation.id, + expectedRevision: f.revision, completionContext: scaffold.completionContext, record: current }); + assert.deepEqual(result.linked.coverage, { kind: "range", startsOn: scaffold.scaffold.dueWindowStart, + endsOn: scaffold.scaffold.dueWindowEnd }); + const validation = await validateWorkspace(f.root); + assert.equal(validation.diagnostics.some(({ code }) => code === "invalid-obligation-occurrence-schedule-binding"), false); +}); + +for (const [unit, anchorDate, startsOn, endsOn, nextStart] of [ + ["month", "2024-01-31", "2024-02-29", "2024-03-30", "2024-03-31"], + ["year", "2024-02-29", "2025-02-28", "2026-02-27", "2026-02-28"] +]) test(`recording a later ${unit} cycle does not replace its original calendar anchor`, async (context) => { + const f = await fixture(context); + const loaded = await loadWorkspace(f.root); + const policy = loaded.entries.find(({ record }) => record.id === f.obligation.policyIds[0]); + await writeJson(policy.path, { ...policy.record, effectiveOn: anchorDate }); + const rule = { ...f.rule, recurrence: { mode: "calendar", unit, interval: 1, anchorMode: "activation" }, + effectiveAt: `${anchorDate}T00:00:00Z` }; + delete rule.window; + await writeJson(f.ruleEntry.path, rule); + const current = await loadWorkspace(f.root); + const options = { model: current.model, asOf: startsOn, through: nextStart, includeComplete: true }; + const before = planObligations(current.resources, options).calendarItems.map(({ dueWindowStart, dueWindowEnd }) => [dueWindowStart, dueWindowEnd]); + const record = { ...f.record, completedAt: `${startsOn}T15:30:00Z`, reviewedOn: startsOn }; + const evidence = current.entries.find(({ record }) => record.type === "evidence"); + await writeJson(evidence.path, { ...evidence.record, collectedOn: startsOn, verifiedOn: startsOn }); + const result = await completeObligationOccurrence(f.root, { obligationId: f.obligation.id, + expectedRevision: f.revision, record }); + assert.deepEqual(result.linked.coverage, { kind: "range", startsOn, endsOn }); + const after = await loadWorkspace(f.root); + assert.deepEqual(planObligations(after.resources, options).calendarItems.map(({ dueWindowStart, dueWindowEnd }) => [dueWindowStart, dueWindowEnd]), before); +}); diff --git a/packages/filegrc/test/workflow-proof.test.js b/packages/filegrc/test/workflow-proof.test.js new file mode 100644 index 0000000..0ca0fef --- /dev/null +++ b/packages/filegrc/test/workflow-proof.test.js @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { loadWorkspace, updateResource, validateWorkspace } from "../src/index.js"; +import { validateWorkflowHistoryIntegrity } from "../src/workflow-history-integrity.js"; +import { workflowProofIds } from "../src/workflow-proof.js"; +import { makeComprehensiveWorkspace } from "./fixtures.js"; +import { commitWorkspaceFiles, initializeGitWorkspace, writeJson } from "./helpers.js"; + +test("proof links distinguish context from evidence without losing explicit completions", () => { + const records = new Map([ + ["completion", { id: "completion", type: "access-review", status: "complete", evidenceIds: ["proof"] }], + ["proof", { id: "proof", type: "evidence", sourceResourceIds: ["control", "ongoing", "nested"] }], + ["control", { id: "control", type: "control", evidenceIds: ["unrelated"] }], + ["ongoing", { id: "ongoing", type: "access-review", status: "in-progress", evidenceIds: ["unrelated"] }], + ["nested", { id: "nested", type: "evidence", sourceResourceIds: ["proof"] }] + ]); + const ids = workflowProofIds({ members: [{ completionResourceIds: ["completion"] }] }, (id) => records.get(id)); + assert.deepEqual([...ids].sort(), ["completion", "nested", "proof"]); +}); + +test("mutable source context evolves while finalized proof stays bound to Git", async (t) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-proof-context-")); + t.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "9"); + const loaded = await loadWorkspace(root); + const entry = (type) => loaded.entries.find(({ record }) => record.type === type); + const occurrence = entry("obligation-occurrence"); + const completion = loaded.entries.find(({ record }) => record.id === occurrence.record.members[0].completionResourceIds[0]); + const evidence = loaded.entries.find(({ record }) => record.id === completion.record.evidenceIds[0]); + const control = entry("control"); + const review = entry("access-review"); + review.record.status = "in-progress"; + delete review.record.outcome; + delete review.record.approvedOn; + delete review.record.completedOn; + evidence.record.sourceResourceIds = [control.record.id, review.record.id]; + evidence.record.sourceKind = "file"; + delete evidence.record.externalReference; + delete evidence.record.sourceComponentId; + evidence.record.filePaths = [`evidence/${evidence.record.id}/proof.txt`]; + await mkdir(join(root, "data", "evidence", evidence.record.id), { recursive: true }); + await writeFile(join(root, "data", evidence.record.filePaths[0]), "Fixed export\n"); + await writeJson(review.path, review.record); + await writeJson(evidence.path, evidence.record); + await initializeGitWorkspace(root); + const historyDiagnostics = async () => { + const diagnostics = []; + await validateWorkflowHistoryIntegrity(await loadWorkspace(root), diagnostics); + return diagnostics; + }; + assert.deepEqual(await historyDiagnostics(), []); + assert.deepEqual((await validateWorkspace(root)).diagnostics.filter(({ severity }) => severity === "error"), []); + await updateResource(root, "control", control.record.id, { ...control.record, title: "Current endpoint procedure" }); + await writeJson(review.path, { ...review.record, title: "Ongoing current access review" }); + assert.deepEqual(await historyDiagnostics(), []); + assert.deepEqual((await validateWorkspace(root)).diagnostics.filter(({ severity }) => severity === "error"), []); + await commitWorkspaceFiles(root, "Update current source context"); + await writeJson(occurrence.path, { ...occurrence.record, status: "superseded" }); + assert.deepEqual(await historyDiagnostics(), []); + await commitWorkspaceFiles(root, "Preserve superseded occurrence"); + await writeFile(completion.path, JSON.stringify({ ...completion.record, title: "Rewritten completion" })); + assert.ok((await historyDiagnostics()).some(({ message }) => message.includes(`Proof record "${completion.record.id}" differs`))); + await writeJson(completion.path, completion.record); + // Removing a nested proof link from current evidence cannot hide its old proof. + await writeJson(evidence.path, { ...evidence.record, sourceResourceIds: [] }); + assert.ok((await historyDiagnostics()).some(({ message }) => message.includes(`Proof record "${evidence.record.id}" differs`))); + await writeJson(evidence.path, evidence.record); + const attachment = evidence.record.filePaths?.[0]; + assert.ok(attachment); + const original = await readFile(join(root, "data", attachment)); + await writeFile(join(root, "data", attachment), Buffer.concat([original, Buffer.from("rewritten")])); + assert.ok((await historyDiagnostics()).some(({ message }) => /attachment/i.test(message))); +});