diff --git a/docs/architecture.md b/docs/architecture.md index af2169a..efaabb3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -873,3 +873,17 @@ preparation; no interface infers proof retention from a resource type. Source da expiration remains separate from retained control proof. The application names the approved Information Types, purpose, cutoff fit, disposal implementation and holds, without editing the rule or its independent approval. + +### Operating work and historical context + +The shared workflow distinguishes a live duty from dormant planning and historical proof. After setup review, a planned inventory proposal stays scheduled unless an implemented Control, active information processing or another live dependency makes pre-use work necessary. Provisioned setup accounts remain separately accountable without activating a service. Supporting inventory narrative changes remain context; real typed processing, scope and exposure changes keep their normal checks. Open reviewed Risks remain scheduled to their actual review or treatment checkpoint; keeping a Risk open does not itself create a completion assignment. + +A finalized superseding Obligation occurrence can replace a historical proof assignment only through the exact Program, Obligation, coverage and member chain. The successor must have passed with completed proof. The former proof remains immutable, and another live assignment keeps it actionable. + +Git commit counts are context, not evidence that a business change needs remediation. Period-impact checks compare operating facts and every Markdown companion, including the first substantive in-period change. Label-only edits, trailing whitespace and added rehearsal Evidence links do not create impact work. A dated `periodImpactReview` binds the current substantive revision, actual reviewers, effective business date, conclusion and continuity rationale. A later substantive edit makes that review stale. Its binding includes ordered meaningful source states, so a change and return to old bytes still needs review; unrelated commits, harmless formatting and committing already-reviewed working facts do not stale it. Exact normal Control design reviews and owner data-use decisions can satisfy the same checks when their accepted proof and source snapshots cover the current semantic lineage. A no-material-change decision also answers only the explicitly listed reconciliation materiality questions, bound to their exact transitions; it cannot suppress a different or later change. The review does not approve governed content, waive source coverage, accept Risk or complete a Policy Event. Required approvals, interval checks and event dispositions keep their separate normal checks. + +Never-effective Obligation Rules may be withdrawn with actual actors, date and reason. This preserves any prior approval without inventing an effective interval. Active or operated rules require normal retirement and prospective replacement. Withdrawal of an unchanged proposal explicitly excluded from an independently approved schedule set does not reopen that approval. + +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. diff --git a/docs/data-model.md b/docs/data-model.md index b54a3a1..775dd6a 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -738,6 +738,19 @@ Key format: `namespace`. | `holdsPreserved` | boolean | Yes | All approved holds and required records remain protected. | | `noConflictingDuties` | boolean | Yes | No legal, contractual or other duty conflicts with this application. | +### `period-impact-review` + +| Property | Type | Required | Notes | +| --- | --- | --- | --- | +| `reviewedByIds` | array of id | Yes | References: `person` | +| `reviewedOn` | date | Yes | | +| `effectiveOn` | date | Yes | | +| `conclusion` | enum | Yes | Values: `no-material-change`, `material-change-reviewed` | +| `rationale` | string | Yes | | +| `reviewedRevision` | string | Yes | | +| `reviewedTransitionKeys` | array of string | No | | +| `policyEventIds` | array of id | No | References: `obligation-event` | + ## Common fields | Field | Type | Required | Meaning | @@ -977,6 +990,7 @@ Markdown companions: | `procedureEffectiveOn` | date | No | Procedure effective date | | `componentIds` | array of id | No | Operating and supporting Components References: `component` | | `evidenceSourceComponentIds` | array of id | Conditional | Authoritative evidence-source Components References: `component` Required when `status` is `implemented`. | +| `periodImpactReview` | object (`period-impact-review`) | No | Period impact review | #### `control-test` @@ -1161,6 +1175,7 @@ Record Markdown: available when needed as an implicit companion file. | `evidenceIds` | array of id | No | References: `evidence` | | `statusTransition` | object (`status-transition`) | Conditional | Required when `status` is one of `ended`. Allowed when `status` is one of `ended`. | | `independenceRationale` | string | No | Independence rationale | +| `periodImpactReview` | object (`period-impact-review`) | No | Period impact review | #### `team` @@ -1284,6 +1299,7 @@ Markdown companions: | `programRole` | enum | No | Program role Values: `required`, `conditional`, `alternative`, `supporting` | | `proposedEffectiveOn` | date | No | Proposed effective date Allowed when `status` is one of `draft`, `in-review`, `approved`. | | `reportingRouteRequirements` | array of object (`reporting-route-requirement`) | No | Reporting route requirements | +| `periodImpactReview` | object (`period-impact-review`) | No | Period impact review | #### `policy-review` @@ -1970,6 +1986,7 @@ Record Markdown: shown by default as an implicit companion file. | `internetExposed` | boolean | No | | | `continuityObjectives` | object (`continuity-objectives`) | No | | | `statusTransition` | object (`status-transition`) | Conditional | Required when `status` is one of `deprecated`, `retired`. Allowed when `status` is one of `deprecated`, `retired`. | +| `periodImpactReview` | object (`period-impact-review`) | No | Period impact review | #### `asset` @@ -2160,6 +2177,7 @@ Markdown companions: | `classificationId` | id | No | Highest classification References: `classification` | | `continuityObjectives` | object (`continuity-objectives`) | No | | | `statusTransition` | object (`status-transition`) | Conditional | Required when `status` is one of `deprecated`, `retired`. Allowed when `status` is one of `deprecated`, `retired`. | +| `periodImpactReview` | object (`period-impact-review`) | No | Period impact review | ### Security Operations @@ -2520,6 +2538,7 @@ Record Markdown: shown by default as an implicit companion file. | `statusTransition` | object (`status-transition`) | Conditional | Required when `status` is `retired`. Allowed when `status` is `retired`. | | `componentId` | id | Conditional | Authoritative Component References: `component` Required when `coverageKind` is `external-component` and `status` is `active`. | | `retrievalMethodKey` | string (id) | No | Retrieval method key | +| `periodImpactReview` | object (`period-impact-review`) | No | Period impact review | #### `control-activity` @@ -2704,7 +2723,7 @@ Instructions: One immutable reviewed revision of an Obligation schedule, populat Policy basis: A reusable schedule must be reviewed before it governs work. The rule records the exact management rationale or authoritative source used for that decision. -Timing: Create a proposed revision, review it, then activate it at a real effective time. Never backdate a late decision or rewrite a prior effective rule. +Timing: Create a proposed revision, review it, then activate it at a real effective time. Never backdate a late decision or rewrite a prior effective rule. Withdraw a proposed or approved rule that never took effect with its actual decision actors, date, and reason. Preserve any approval and the withdrawn proposal; withdrawal does not approve a schedule or cancel an effective rule. Path: `data/obligation-rules/.json` @@ -2712,7 +2731,7 @@ Record Markdown: available when needed as an implicit companion file. | Field | Type | Required | Notes | | --- | --- | --- | --- | -| `status` | enum | Yes | Values: `proposed`, `approved`, `active`, `retired` | +| `status` | enum | Yes | Values: `proposed`, `approved`, `active`, `retired`, `withdrawn` | | `obligationId` | id | Yes | References: `obligation` | | `activityDefinitionVersion` | string | No | | | `recurrence` | object (`recurrence`) | Yes | | @@ -2729,6 +2748,9 @@ Record Markdown: available when needed as an implicit companion file. | `retiredOn` | date | Conditional | Required when `status` is `retired`. | | `preparationPurpose` | enum | No | Preparation purpose Values: `operation`, `applicability-review`, `test-execution` | | `completionCoverage` | enum | No | Required proof coverage Values: `point-in-time`, `whole-period` | +| `withdrawnByIds` | array of id | Conditional | References: `person` Required when `status` is `withdrawn`. | +| `withdrawnOn` | date | Conditional | Required when `status` is `withdrawn`. | +| `withdrawalReason` | string | Conditional | Required when `status` is `withdrawn`. | #### `obligation-occurrence` diff --git a/package-lock.json b/package-lock.json index 8942650..a0045d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.21.5", + "version": "0.21.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.21.5", + "version": "0.21.6", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.21.5", + "version": "0.21.6", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.21.5", + "version": "0.21.6", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 0ab7c23..7d883ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.21.5", + "version": "0.21.6", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index 33f6665..6e93dc4 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.21.5", + "version": "0.21.6", "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 3f792fe..cc7abb0 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.21.5", + version: "0.21.6", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.21.5", + version: "0.21.6", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/AGENTS.md b/packages/create-filegrc/template/AGENTS.md index 9f1e6da..f125552 100644 --- a/packages/create-filegrc/template/AGENTS.md +++ b/packages/create-filegrc/template/AGENTS.md @@ -184,6 +184,16 @@ Present “Continue to content activation without Autopilot” as the clear seco Program records, owners, Person email addresses, and dates remain authoritative in Git. Autopilot stores notification settings and Person-to-Slack links in its database. Manage those settings in the [Autopilot dashboard](https://app.filegrc.com). Keep credentials out of Git. Users perform control work, record events, and retain evidence locally. +## Operating changes and unused proposals + +Keep planned inventory separate from operating work. A dormant proposal is not an activation task, but complete its normal supplier, access and evidence checks before actual use. Keep an ongoing Risk open with its actual response, owner and review/treatment checkpoints. + +Withdraw a never-effective schedule proposal with `npx filegrc withdraw-obligation-rule RULE_ID --scaffold --json`, then preview and apply the actual decision. Do not fabricate approval or activation dates. Preserve active rules and their normal retirement workflow. + +When a procedure or operating fact changes during the observation period, use `npx filegrc review-period-impact RESOURCE_ID --scaffold --json`. Record the actual effective business date, reviewers and impact/continuity conclusion, reusing review work already done. Preview with `--preview` and apply with `--yes`. This review binds substantive facts, companion procedures and their meaningful history; later substantive edits or change-and-revert cycles make it stale. Exact accepted operating reviews may already cover the change, so inspect the shared result before creating another review. It does not replace independent approval, source coverage, required Policy Events or Evidence verification. Label-only changes and added rehearsal Evidence links do not create a review merely because Git has several commits. + +These actions require model v13 and an explicit FileGRC 0.21.6 or newer dependency and lockfile. Upgrade hosted readers before adopting their new source fields; existing records need no migration or repeat approval. A newer hosted reader cannot authorize new shapes under an older repository pin. If an earlier manual edit used the fields too soon, repair the current dependency and preserve the disclosed historical format limitation instead of rewriting business dates or approvals. + ## Work Queue and Policy Events Run `npx filegrc obligations --json` to see scheduled work, event tasks, owners, deadlines, and requested proof. Its `workQueue` includes dated source-record work such as risk reviews, treatments and Exception expirations; top-level `items` and `counts` retain the obligation-only view. In Step 4, record the work shown there. Use `npx filegrc guide obligation --json` and `data/AGENTS.md` for the completion and event-trigger commands. The resulting dated operating record or completed Action Item is the output. diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index da0539d..19bf343 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.21.5", + "version": "0.21.6", "private": true, "description": "filegrc workspace for a SOC 2 program", "type": "module", diff --git a/packages/filegrc/model/v13.json b/packages/filegrc/model/v13.json index 167fdcc..f3eab45 100644 --- a/packages/filegrc/model/v13.json +++ b/packages/filegrc/model/v13.json @@ -978,6 +978,12 @@ "independenceRationale": { "type": "string", "label": "Independence rationale" + }, + "periodImpactReview": { + "type": "object", + "objectType": "period-impact-review", + "label": "Period impact review", + "description": "A dated review of this record\u2019s effect on the candidate evidence period. This review does not replace required approval or actual Policy Events." } }, "listFields": [ @@ -1296,6 +1302,12 @@ "retired" ] } + }, + "periodImpactReview": { + "type": "object", + "objectType": "period-impact-review", + "label": "Period impact review", + "description": "A dated review of this record\u2019s effect on the candidate evidence period. This review does not replace required approval or actual Policy Events." } }, "listFields": [ @@ -3084,6 +3096,12 @@ "requiredWhen": { "status": "implemented" } + }, + "periodImpactReview": { + "type": "object", + "objectType": "period-impact-review", + "label": "Period impact review", + "description": "A dated review of this record\u2019s effect on the candidate evidence period. This review does not replace required approval or actual Policy Events." } }, "listFields": [ @@ -3919,6 +3937,12 @@ "items": "object", "itemObjectType": "reporting-route-requirement", "label": "Reporting route requirements" + }, + "periodImpactReview": { + "type": "object", + "objectType": "period-impact-review", + "label": "Period impact review", + "description": "A dated review of this record\u2019s effect on the candidate evidence period. This review does not replace required approval or actual Policy Events." } }, "listFields": [ @@ -8328,6 +8352,12 @@ "format": "id", "label": "Retrieval method key", "description": "Name the documented export or query method. Use the same method key on rehearsal Evidence; a changed extraction method needs another rehearsal." + }, + "periodImpactReview": { + "type": "object", + "objectType": "period-impact-review", + "label": "Period impact review", + "description": "A dated review of this record\u2019s effect on the candidate evidence period. This review does not replace required approval or actual Policy Events." } }, "listFields": [ @@ -8933,6 +8963,12 @@ "retired" ] } + }, + "periodImpactReview": { + "type": "object", + "objectType": "period-impact-review", + "label": "Period impact review", + "description": "A dated review of this record\u2019s effect on the candidate evidence period. This review does not replace required approval or actual Policy Events." } }, "listFields": [ @@ -8961,7 +8997,7 @@ "description": "One immutable reviewed revision of an Obligation schedule, population rule, and completion criteria.", "guidance": { "policyBasis": "A reusable schedule must be reviewed before it governs work. The rule records the exact management rationale or authoritative source used for that decision.", - "cadence": "Create a proposed revision, review it, then activate it at a real effective time. Never backdate a late decision or rewrite a prior effective rule." + "cadence": "Create a proposed revision, review it, then activate it at a real effective time. Never backdate a late decision or rewrite a prior effective rule. Withdraw a proposed or approved rule that never took effect with its actual decision actors, date, and reason. Preserve any approval and the withdrawn proposal; withdrawal does not approve a schedule or cancel an effective rule." }, "required": [ "status", @@ -8976,7 +9012,8 @@ "proposed", "approved", "active", - "retired" + "retired", + "withdrawn" ], "filter": true }, @@ -9099,6 +9136,31 @@ ], "label": "Required proof coverage", "description": "Point-in-time work is executed within the allowed window. Whole-period work must prove the complete recurrence period, separately from its allowed execution window." + }, + "withdrawnByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ], + "requiredWhen": { + "status": "withdrawn" + }, + "description": "People who actually decided to withdraw this never-effective proposal or approved rule." + }, + "withdrawnOn": { + "type": "date", + "requiredWhen": { + "status": "withdrawn" + }, + "description": "Actual date of the decision to withdraw this never-effective rule." + }, + "withdrawalReason": { + "type": "string", + "requiredWhen": { + "status": "withdrawn" + }, + "description": "Why management declined to adopt this rule. Preserve prior approval facts when present." } }, "listFields": [ @@ -11804,6 +11866,56 @@ } }, "additionalProperties": false + }, + "period-impact-review": { + "additionalProperties": false, + "required": [ + "reviewedByIds", + "reviewedOn", + "effectiveOn", + "conclusion", + "rationale", + "reviewedRevision" + ], + "properties": { + "reviewedByIds": { + "type": "array", + "items": "id", + "relation": [ + "person" + ] + }, + "reviewedOn": { + "type": "date" + }, + "effectiveOn": { + "type": "date" + }, + "conclusion": { + "type": "enum", + "values": [ + "no-material-change", + "material-change-reviewed" + ] + }, + "rationale": { + "type": "string" + }, + "reviewedRevision": { + "type": "string" + }, + "reviewedTransitionKeys": { + "type": "array", + "items": "string" + }, + "policyEventIds": { + "type": "array", + "items": "id", + "relation": [ + "obligation-event" + ] + } + } } }, "obligationActivities": { diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index b5eb3fb..8dee208 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.21.5", + "version": "0.21.6", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/agent.js b/packages/filegrc/src/agent.js index 87bad5c..3d6f43b 100644 --- a/packages/filegrc/src/agent.js +++ b/packages/filegrc/src/agent.js @@ -90,6 +90,8 @@ export function buildAgentGuide(loaded, type, options = {}) { correction: "npx filegrc correct-obligation-rule RULE_ID --scaffold --json", historical: "Preserve old approvals, dates and frozen member dispositions. A correction requires actual current authorization and retained committed occurrence rows; it does not approve shared schedule oversight." } } : {}), + ...(fields.periodImpactReview ? { periodImpactReview: { scaffold: "npx filegrc review-period-impact RESOURCE_ID --scaffold --json", instruction: "Adopt a declared and locked FileGRC 0.21.6 or newer reader first. Bind the actual effective date, reviewers and impact/continuity conclusion to the current facts and all companion procedures. Reuse prior review facts. This does not replace approval, source coverage, Policy Events or Evidence verification." } } : {}), + ...(type === "obligation-rule" && fields.status.values.includes("withdrawn") ? { withdrawal: "npx filegrc withdraw-obligation-rule RULE_ID --scaffold --json" } : {}), ...(type === "retention-schedule-item" && loaded.model.resources["retention-application"] ? { applyApprovedRule: "npx filegrc apply-retention-rule RULE_ID --scaffold" } : {}), nonmaterialAmendments: Object.fromEntries(Object.entries(loaded.model.nonmaterialAmendments || {}).filter(([, rule]) => rule.resourceTypes.includes(type))), ...(options.preparationChecks ? { preparationChecks: options.preparationChecks } : {}), diff --git a/packages/filegrc/src/applicability-scope.js b/packages/filegrc/src/applicability-scope.js index 2cdc1f7..87f08f7 100644 --- a/packages/filegrc/src/applicability-scope.js +++ b/packages/filegrc/src/applicability-scope.js @@ -11,6 +11,7 @@ const scopeSourceTypes = new Set([ const excludedResourceFields = new Set([ "applicabilityReview", + "periodImpactReview", "applicability", "status", "statusTransition", diff --git a/packages/filegrc/src/cli.js b/packages/filegrc/src/cli.js index fa36d07..42fa1be 100644 --- a/packages/filegrc/src/cli.js +++ b/packages/filegrc/src/cli.js @@ -1,4 +1,6 @@ -import { planWorkQueue } from "./work-queue.js"; +import { scaffoldPeriodImpactReview, previewPeriodImpactReview, recordPeriodImpactReview } from "./period-impact.js"; +import { scaffoldObligationRuleWithdrawal, previewObligationRuleWithdrawal, withdrawObligationRule } from "./obligation-rule-withdrawal.js"; +import { planWorkQueue, sourceDeadlineValidation } from "./work-queue.js"; import { scaffoldObligationRuleCorrection, previewObligationRuleCorrection, correctObligationRule } from "./obligation-rule-correction.js"; import { scaffoldRetentionApplication, saveRetentionApplication } from "./retention-applications.js"; import { execFile } from "node:child_process"; @@ -84,7 +86,7 @@ import { serveWorkspace } from "./server.js"; import { planWorkspaceSetup, setupWorkspace, summarizeSetupResult } from "./setup.js"; import { printGithubStarMessage } from "./startup.js"; import { assertWorkspaceEngineVersion } from "./runtime-version.js"; -import { createAppState } from "./state.js"; +import { readResourceMutation } from "./resource-mutation.js"; import { currentCalendarDate } from "./time.js"; import { validateWorkspace } from "./validate.js"; import { loadWorkspace } from "./workspace.js"; @@ -457,7 +459,7 @@ export async function runCli(argv = process.argv.slice(2)) { includeComplete: Boolean(flags.complete), model: loaded.model }); - result.workQueue = planWorkQueue(loaded.resources, result, { program: resolveProgram(loaded, flags.program), model: loaded.model, includeComplete: Boolean(flags.complete) }); + result.workQueue = planWorkQueue(loaded.resources, result, { validationOk: await sourceDeadlineValidation(loaded), program: resolveProgram(loaded, flags.program), model: loaded.model, includeComplete: Boolean(flags.complete) }); result.recommendations = [hostedAutomationForConnection(OPERATING_HOSTED_AUTOMATION, loaded.hostedAutomationConnection)]; if (flags.json) console.log(JSON.stringify(result, null, 2)); else { @@ -1002,17 +1004,7 @@ export async function runCli(argv = process.argv.slice(2)) { const record = loaded.resources.find((item) => item.id === id && (!type || item.type === type)); if (!record) throw new Error(`Resource "${type ? `${type}/` : ""}${id}" was not found.`); if (flags.mutation) { - const state = await createAppState(root); - const entry = state.resources.find((item) => item.record.id === record.id); - const contentEntries = Object.entries(entry.content ?? {}).filter(([, content]) => content.source !== null); - const mutation = { - record, - ...(contentEntries.length ? { - content: Object.fromEntries(contentEntries.map(([name, content]) => [name, content.source])), - contentRevisions: Object.fromEntries(contentEntries.map(([, content]) => [content.path, content.revision])) - } : {}), - revision: entry.revision - }; + const mutation = await readResourceMutation(loaded, type, id); console.log(JSON.stringify(mutation, null, 2)); return mutation; } @@ -1101,6 +1093,37 @@ export async function runCli(argv = process.argv.slice(2)) { else console.log(`Activated obligation-rule/${ruleId}`); return result; } + if (command === "review-period-impact") { + const [resourceId, file] = positionals; + if (!resourceId) throw new Error("A resource ID is required."); + if (flags.scaffold) { + const result = await scaffoldPeriodImpactReview(root, { resourceId }); + console.log(JSON.stringify(result, null, 2)); return result; + } + if (!file || !flags.preview && !flags.yes) throw new Error("Pass an impact review payload and --preview or --yes."); + const source = await readSetupPayload(file), payload = source.payload || source; + if (payload.resourceId !== resourceId) throw new Error("The review payload must name the requested resource."); + const result = flags.preview ? await previewPeriodImpactReview(root, payload) : await withWorkflowDelta(root, () => recordPeriodImpactReview(root, payload)); + console.log(JSON.stringify(result, null, 2)); return result; + } + if (command === "withdraw-obligation-rule") { + const [ruleId, file] = positionals; + if (!ruleId) throw new Error("An Obligation Rule ID is required."); + if (flags.scaffold) { + const result = await scaffoldObligationRuleWithdrawal(root, { ruleId }); + console.log(JSON.stringify(result, null, 2)); + return result; + } + if (!file || !flags.preview && !flags.yes) throw new Error("Pass a completed withdrawal payload and --preview or --yes."); + const source = await readSetupPayload(file); + const payload = source.payload || source; + if (payload.ruleId !== ruleId) throw new Error("The withdrawal payload must name the requested Obligation Rule."); + const result = flags.preview + ? await previewObligationRuleWithdrawal(root, payload) + : await withWorkflowDelta(root, () => withdrawObligationRule(root, payload)); + console.log(JSON.stringify(result, null, 2)); + return result; + } if (command === "correct-obligation-rule") { const [ruleId, file] = positionals; if (!ruleId) throw new Error("An Obligation Rule ID is required."); @@ -1238,12 +1261,11 @@ export async function runCli(argv = process.argv.slice(2)) { if (!slot) throw new Error(`Markdown slot "${requestedSlot ?? ""}" was not found for ${type}/${id}.`); if (flags.write !== undefined) { const source = await readTextInput(flags.write); - const state = await createAppState(root); - const stateEntry = state.resources.find((item) => item.record.type === type && item.record.id === id); - const existingContentRevision = stateEntry.content?.[slot.name]?.revision; + const mutation = await readResourceMutation(loaded, type, id); + const existingContentRevision = mutation.contentRevisions?.[slot.path]; const mutationResult = await withWorkflowDelta(root, () => updateResource(root, type, id, record, { content: { [slot.name]: source }, - expectedRevision: stateEntry.revision, + expectedRevision: mutation.revision, expectedContentRevisions: existingContentRevision ? { [slot.path]: flags["expected-revision"] ?? existingContentRevision } : undefined @@ -1506,6 +1528,8 @@ Usage: filegrc create [--json] filegrc complete --scaffold --window-start YYYY-MM-DD [--program program-id] [--completed-on YYYY-MM-DD] [--population-as-of YYYY-MM-DD] filegrc complete [--population-as-of YYYY-MM-DD] [--expected-revision hash] [--json] + filegrc review-period-impact [payload.json] [--scaffold | --preview | --yes] [--json] + filegrc withdraw-obligation-rule [payload.json] [--scaffold | --preview | --yes] [--json] filegrc correct-obligation-rule [payload.json] [--scaffold | --preview | --yes] [--json] filegrc activate-obligation-rule [payload.json] [--scaffold] [--json] filegrc reconcile-obligation --scaffold --window-start YYYY-MM-DD [--program program-id] [--as-of YYYY-MM-DD] [--population-as-of YYYY-MM-DD] [--correct-finalized] @@ -1857,6 +1881,8 @@ function agentOverview(model, connection) { previewMutation: "filegrc preview-mutation --json", create: "filegrc create ", complete: "filegrc complete ", + reviewPeriodImpact: "filegrc review-period-impact [--scaffold | --preview | --yes] --json", + withdrawObligationRule: "filegrc withdraw-obligation-rule [--scaffold | --preview | --yes] --json", correctObligationRule: "filegrc correct-obligation-rule [--scaffold | --preview | --yes] --json", reconcileObligation: "filegrc reconcile-obligation --scaffold --window-start ", completeAction: "filegrc complete-action --completed-on ", diff --git a/packages/filegrc/src/collection-revision.js b/packages/filegrc/src/collection-revision.js index a1119d7..b12dabc 100644 --- a/packages/filegrc/src/collection-revision.js +++ b/packages/filegrc/src/collection-revision.js @@ -1,3 +1,4 @@ +import { scheduleSetWithoutExcludedWithdrawals } from "./schedule-set.js"; import { resourceIndex } from "./resource-index.js"; import { calculationMemo } from "./calculation-cache.js"; import { createHash } from "node:crypto"; @@ -76,7 +77,8 @@ export function collectionReviewDecisionMatches(loaded, resourceType, review, re })) return false; // Preserve exact historical approval binding, then compare current schedule // design without incidental approval-date changes on governed content. - return revisionsMatch("collection", collectionRevision(loaded, "obligation", options), + const currentDesign = scheduleSetWithoutExcludedWithdrawals(loaded, historical, review); + return revisionsMatch("collection", collectionRevision(currentDesign, "obligation", options), collectionRevision(historical, "obligation", { program, historicalCommit: review.scopeRevision })); } if (resourceType === "retention-schedule-item" && loaded.model.resources["retention-application"]) { diff --git a/packages/filegrc/src/collection-scope.js b/packages/filegrc/src/collection-scope.js index 0f2b3ec..7619747 100644 --- a/packages/filegrc/src/collection-scope.js +++ b/packages/filegrc/src/collection-scope.js @@ -121,11 +121,52 @@ export function collectionRecordProposals(loaded, resourceType, records, program candidates.set(record.id, record); } } - return [...candidates.values()].map((record) => ({ - resourceId: record.id, - title: record.title, - complete: scopedIds.has(record.id) && collectionRecordProposalComplete(record, loaded) - })); + return [...candidates.values()].map((record) => { + const complete = scopedIds.has(record.id) && collectionRecordProposalComplete(record, loaded); + const dependencies = complete ? [] : operatingProposalDependencies(record, loaded, program); + const dormant = ["planned", "evaluating"].includes(record.status) && !dependencies.length; + return { + resourceId: record.id, + title: record.title, + complete, + operatingStatus: complete ? "complete" : dormant ? "later" : "action", + ...(dependencies.length ? { operatingDependencyIds: dependencies.map(({ id }) => id).sort() } : {}) + }; + }); +} + +// A proposal describes a possible future choice. Scope links alone do not prove +// use. Setup logins have their own account reconciliation; implementation and +// processing remain actionable even while inventory says planned/evaluating. +function operatingProposalDependencies(record, loaded, program) { + const selectedControlIds = new Set(program?.controlIds || []); + const componentDependencies = (componentId) => loaded.resources.filter((source) => ( + source.type === "control" && source.status === "implemented" && selectedControlIds.has(source.id) + && [...(source.componentIds || []), ...(source.evidenceSourceComponentIds || [])].includes(componentId) + || source.type === "asset" && source.status === "active" && (source.componentIds || []).includes(componentId) + || source.type === "source-coverage" && source.status === "active" && source.componentId === componentId + )); + // Deprecation does not end operation. A planned Component can also already + // be relied on, so propagate direct operational proof without following scope + // links recursively or treating proposals as proof for one another. + const componentIsOperating = (source) => ["active", "deprecated"].includes(source.status) + || source.status === "planned" && componentDependencies(source.id).length > 0; + if (record.type === "component") return componentDependencies(record.id); + if (record.type === "vendor") return loaded.resources.filter((source) => ( + source.type === "component" && source.vendorId === record.id && componentIsOperating(source) + )); + if (record.type === "information-type") return loaded.resources.filter((source) => ( + ["system", "vendor"].includes(source.type) && ["active", "deprecated"].includes(source.status) + && (source.informationTypeIds || []).includes(record.id) + || source.type === "component" && componentIsOperating(source) + && (source.informationUses || []).some(({ informationTypeId }) => informationTypeId === record.id) + )); + if (record.type === "complementary-control") return loaded.resources.filter((source) => ( + source.type === "control" && source.status === "implemented" && selectedControlIds.has(source.id) + && (record.relatedControlIds || []).includes(source.id) + || source.type === "component" && componentIsOperating(source) && (record.componentIds || []).includes(source.id) + )); + return []; } function collectionRecordIsCurrent(record) { diff --git a/packages/filegrc/src/control-period-review.js b/packages/filegrc/src/control-period-review.js new file mode 100644 index 0000000..0008ca5 --- /dev/null +++ b/packages/filegrc/src/control-period-review.js @@ -0,0 +1,107 @@ +import { isDeepStrictEqual } from "node:util"; +import { lstat } from "node:fs/promises"; +import { currentPeriodImpactRevision, periodImpactReviewersAuthorized } from "./period-impact.js"; +import { reviewHistoricalWorkspace, reviewHistoryContext } from "./historical-workspace.js"; +import { assessCompletionForObligation, completionDate } from "./obligations.js"; +import { getGitSummary, isDataHistoryAncestor, getFileObjectIdAtRevision, getWorkingFileObjectId } from "./git.js"; +import { preparationEvidenceIssues } from "./preparation-evidence.js"; +import { personWasActiveOn } from "./soc2.js"; +import { resolveDataPath } from "./paths.js"; +import { resourceDataPath } from "./resource-markdown.js"; +import { assertHistoricalRecordValid } from "./validate.js"; +import { revisionsMatch } from "./revisions.js"; + +const passing = new Set(["passed", "passed-with-exceptions"]); +const valid = (loaded, record) => { + if (!record) return false; + try { assertHistoricalRecordValid(record, loaded.model, resourceDataPath(loaded.model, record)); return true; } + catch { return false; } +}; +const actualPeople = (ids, byId, on) => Array.isArray(ids) && ids.length > 0 && ids.every(id => personWasActiveOn(byId.get(id), on)); + +function scopeCovers(control, scopeIds, byId) { + if (!Array.isArray(scopeIds)) return false; + if (scopeIds.includes(control.id)) return true; + const systems = new Set(scopeIds.filter(id => byId.get(id)?.type === "system")); + for (const id of scopeIds) { + const program = byId.get(id); + if (program?.type === "program" && program.status !== "retired" && program.controlIds?.includes(control.id)) { + for (const systemId of program.systemIds || []) systems.add(systemId); + } + } + return control.systemIds?.length > 0 && control.systemIds.every(id => systems.has(id)); +} + +// A normal design review can already have reviewed this exact procedure and +// implementation. Reuse its retained inputs and frozen occurrence; a later date +// or a same-looking current file alone is not a revision binding. +export async function assessBoundControlPeriodReview(loaded, control, options = {}) { + if (control?.type !== "control" || control.status !== "implemented" || !loaded.root || !valid(loaded, control)) return null; + const asOf = options.asOf; + if (!asOf) return null; + const context = reviewHistoryContext(loaded.root, loaded), index = context.index; + const head = index?.head || getGitSummary(loaded.root).commit; + if (!head || !index?.available) return null; + const byId = new Map(loaded.resources.map(record => [record.id, record])); + const currentRevision = options.currentRevision || await currentPeriodImpactRevision(loaded, control); + const occurrences = loaded.resources.filter(record => record.type === "obligation-occurrence" && record.status === "reconciled" + && ["complete", "complete-with-exceptions"].includes(record.conclusion) && valid(loaded, record)); + for (const review of loaded.resources.filter(record => record.type === "control-activity" && record.profileId === "control-design-review" + && record.status === "complete" && passing.has(record.result) && valid(loaded, record))) { + const obligation = byId.get(review.obligationId); + if (obligation?.type !== "obligation" || obligation.activityType !== "control-design-review" + || !valid(loaded, obligation) || !scopeCovers(control, review.scopeResourceIds, byId) + || !assessCompletionForObligation(review, obligation, loaded, { asOf }).complete) continue; + const completedOn = completionDate(review, loaded.workspace.timezone || "UTC"); + if (!completedOn || !review.reviewedOn || review.reviewedOn < completedOn || review.reviewedOn > asOf + || !actualPeople(review.performerIds, byId, completedOn) || !actualPeople(review.reviewerIds, byId, review.reviewedOn)) continue; + const relevant = occurrences.filter(occurrence => occurrence.obligationId === obligation.id && occurrence.members?.some(member => + member.disposition === "expected" && member.result === "passed" && member.completionResourceIds?.includes(review.id))); + for (const occurrence of relevant) { + const first = [...(index.historiesById.get(occurrence.id) || [])].reverse().find(({ commit }) => { + const snapshot = reviewHistoricalWorkspace(context, commit), prior = snapshot?.byId.get(occurrence.id); + return prior?.status === "reconciled"; + }); + const frozen = first && reviewHistoricalWorkspace(context, first.commit); + const frozenOccurrence = frozen?.byId.get(occurrence.id), frozenReview = frozen?.byId.get(review.id), frozenControl = frozen?.byId.get(control.id); + const frozenProgram = frozen?.byId.get(occurrence.programId), frozenRule = frozen?.byId.get(occurrence.ruleId); + if (!frozen || frozenProgram?.type !== "program" || !frozenProgram.controlIds?.includes(control.id) + || frozenRule?.type !== "obligation-rule" || frozenRule.obligationId !== obligation.id || !valid(frozen, frozenRule) + || !isDeepStrictEqual(frozenOccurrence, occurrence) || !isDeepStrictEqual(frozenReview, review) + || !frozenControl || !scopeCovers(frozenControl, review.scopeResourceIds, frozen.byId) + || !valid(frozen, frozenReview) || !valid(frozen, frozenOccurrence)) continue; + const ownerPerformed = periodImpactReviewersAuthorized(frozen, frozenControl, { reviewedByIds: review.performerIds, reviewedOn: completedOn }); + const ownerReviewed = periodImpactReviewersAuthorized(frozen, frozenControl, { reviewedByIds: review.reviewerIds, reviewedOn: review.reviewedOn }); + if (!ownerPerformed && !ownerReviewed) continue; + for (const evidenceId of review.evidenceIds || []) { + const evidence = byId.get(evidenceId), original = frozen.byId.get(evidenceId); + const frozenSource = frozen.byId.get(original?.sourceComponentId); + if (!valid(loaded, evidence) || !original || evidence.readinessTest === true || original.readinessTest === true + || preparationEvidenceIssues(evidence, byId, asOf).length || evidence.collectedOn > completedOn + || !actualPeople(original.collectorIds, frozen.byId, original.collectedOn) + || evidence.sourceKind !== "component" || original.sourceKind !== "component" + || !["system-export", "configuration-export", "capture", "business-record"].includes(evidence.artifactKind) + || evidence.artifactKind !== original.artifactKind || evidence.sourceCommit !== original.sourceCommit + || evidence.sourceComponentId !== original.sourceComponentId || !isDeepStrictEqual(evidence.filePaths, original.filePaths) + || frozenSource?.type !== "component" || !valid(frozen, frozenSource) || frozenSource.status !== "active" || !frozenSource.evidenceSourceKinds?.includes("governance") + || !evidence.sourceCommit || !isDataHistoryAncestor(loaded, evidence.sourceCommit, first.commit) + || !Array.isArray(evidence.filePaths) || !evidence.filePaths.length) continue; + let fixed = true; + for (const path of evidence.filePaths) { + try { + const info = await lstat(resolveDataPath(loaded.root, path)); + if (!info.isFile() || !info.size || getWorkingFileObjectId(loaded.root, `data/${path}`) !== getFileObjectIdAtRevision(loaded.root, first.commit, `data/${path}`)) fixed = false; + } catch { fixed = false; } + } + if (!fixed) continue; + const captured = reviewHistoricalWorkspace(context, evidence.sourceCommit), capturedControl = captured?.byId.get(control.id); + if (!capturedControl || !scopeCovers(capturedControl, review.scopeResourceIds, captured.byId)) continue; + const capturedRevision = await currentPeriodImpactRevision(captured, capturedControl); + if (revisionsMatch("content", currentRevision, capturedRevision)) return { + reviewResourceId: review.id, evidenceResourceId: evidence.id, occurrenceResourceId: occurrence.id, sourceRevision: evidence.sourceCommit + }; + } + } + } + return null; +} diff --git a/packages/filegrc/src/files.js b/packages/filegrc/src/files.js index 1afbcb3..9e58e21 100644 --- a/packages/filegrc/src/files.js +++ b/packages/filegrc/src/files.js @@ -15,6 +15,7 @@ import { currentCalendarDate, timestampFromLocalDateTime } from "./time.js"; import { loadWorkspace } from "./workspace.js"; import { validateWorkspace } from "./validate.js"; import { calculateRevision, displayRevision, revisionsMatch } from "./revisions.js"; +import { assertWorkflowSourceRecordAdopted } from "./workflow-source-runtime.js"; export const INTERNAL_WORKFLOW_CAPABILITIES = Object.freeze({ auditManagementReconciliation: Symbol("audit-management-reconciliation"), @@ -23,6 +24,7 @@ export const INTERNAL_WORKFLOW_CAPABILITIES = Object.freeze({ obligationOccurrenceSupersession: Symbol("obligation-occurrence-supersession"), obligationRuleCorrection: Symbol("obligation-rule-correction"), obligationRuleActivation: Symbol("obligation-rule-activation"), + obligationRuleWithdrawal: Symbol("obligation-rule-withdrawal"), collectionReviewReassessment: Symbol("collection-review-reassessment"), reportingRouteSetProposal: Symbol("reporting-route-set-proposal"), reportingRouteSetApproval: Symbol("reporting-route-set-approval"), @@ -679,6 +681,7 @@ async function updateResourceUnlocked(input, type, id, record, options) { } function assertImmutableWorkflowRecord(existing, next, options = {}, loaded = null) { + assertWorkflowSourceRecordAdopted(loaded, next); if (!existing) return; const preservesExisting = (allowed = []) => [...new Set([...Object.keys(existing), ...Object.keys(next)])].every((key) => ( allowed.includes(key) || JSON.stringify(next[key]) === JSON.stringify(existing[key]) @@ -771,6 +774,17 @@ function assertImmutableWorkflowRecord(existing, next, options = {}, loaded = nu + "Create a corrected completion and superseding occurrence instead." ); } + if (existing.type === "obligation-rule" && existing.status === "withdrawn" && !preservesExisting()) { + throw new Error(`Withdrawn Obligation rule "${existing.id}" is immutable. Preserve the decision and create a new proposal instead.`); + } + if (existing.type === "obligation-rule" && existing.status !== "withdrawn" && next.status === "withdrawn") { + if (capability !== INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleWithdrawal + || !["proposed", "approved"].includes(existing.status) + || existing.effectiveAt || existing.retiredOn + || !preservesExisting(["status", "withdrawnByIds", "withdrawnOn", "withdrawalReason"])) { + throw new Error(`Obligation rule "${existing.id}" withdrawal is workflow-managed. Withdraw a never-effective proposal or approved rule through its withdrawal action.`); + } + } if (existing.type === "obligation-rule" && ["active", "retired"].includes(existing.status)) { const retirement = [INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleActivation, INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleCorrection].includes(capability) && existing.status === "active" @@ -860,9 +874,10 @@ function assertFinalizedOccurrenceProofMutable(loaded, record) { } function assertWorkflowContentMutable(loaded, record) { + assertWorkflowSourceRecordAdopted(loaded, record); assertFinalizedOccurrenceProofMutable(loaded, record); if ( - (record.type === "obligation-rule" && ["active", "retired"].includes(record.status)) + (record.type === "obligation-rule" && ["active", "retired", "withdrawn"].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 === "obligation-event" && ["complete", "canceled"].includes(record.status)) @@ -891,6 +906,7 @@ function finalizedOccurrenceUsingProof(loaded, targetId) { } function assertSpecializedWorkflowCreate(record, options = {}, loaded = null) { + assertWorkflowSourceRecordAdopted(loaded, record); if (record?.type === "nonmaterial-amendment" && options.workflowCapability !== INTERNAL_WORKFLOW_CAPABILITIES.nonmaterialAmendment) { throw new Error("Use the nonmaterial amendment scaffold, preview and save workflow."); } @@ -992,7 +1008,7 @@ async function deleteResourceUnlocked(input, type, id, options) { || (record.type === "obligation-event" && ["complete", "canceled"].includes(record.status)) || (record.type === "action-item" && record.obligationId && ["done", "canceled"].includes(record.status)) || (record.type === "collection-review" && ["active", "retired"].includes(record.status)) - || (record.type === "obligation-rule" && ["active", "retired"].includes(record.status)) + || (record.type === "obligation-rule" && ["active", "retired", "withdrawn"].includes(record.status)) || (record.type === "reporting-route" && ["active", "retired"].includes(record.status)) || (record.type === "reporting-route-set" && ["proposed", "approved", "canceled", "historical", "withdrawn"].includes(record.status)) || (record.type === "attestation" && record.status === "completed") diff --git a/packages/filegrc/src/historical-operating-proof.js b/packages/filegrc/src/historical-operating-proof.js new file mode 100644 index 0000000..42f5152 --- /dev/null +++ b/packages/filegrc/src/historical-operating-proof.js @@ -0,0 +1,36 @@ +import { isDeepStrictEqual } from "node:util"; + +// Only an exact, unambiguous, finalized correction can replace an assignment. +// The old proof remains immutable and visible in its historical occurrence. +export function supersededOperatingProofIds(resources, validationOk) { + if (!validationOk) return new Set(); + const byId = new Map(resources.map(record => [record.id, record])); + const occurrences = resources.filter(record => record.type === "obligation-occurrence"); + const replaced = new Set(); + for (const original of occurrences.filter(record => record.status === "superseded")) { + let current = original; + const seen = new Set([original.id]); + while (current.status === "superseded") { + const next = occurrences.filter(record => record.supersedesId === current.id); + if (next.length !== 1 || seen.has(next[0].id)) { current = null; break; } + const candidate = next[0]; + if (candidate.programId !== original.programId || candidate.obligationId !== original.obligationId || !isDeepStrictEqual(candidate.coverage, original.coverage)) { current = null; break; } + seen.add(candidate.id); current = candidate; + } + if (current?.status !== "reconciled" || !["complete", "complete-with-exceptions", "zero-population"].includes(current.conclusion)) continue; + for (const member of original.members || []) { + const replacement = current.members?.find(row => row.resourceId === member.resourceId); + if (!replacement || replacement.disposition !== "expected" || replacement.result !== "passed" || !replacement.completionResourceIds?.length) continue; + const proofs = replacement.completionResourceIds.map(id => byId.get(id)); + if (proofs.some(record => !record || !["complete", "completed", "passed", "reconciled"].includes(record.status))) continue; + for (const id of member.completionResourceIds || []) { + if (replacement.completionResourceIds.includes(id)) continue; + // A separate live assignment still needs this record to be finished. + const live = occurrences.some(record => record.status !== "superseded" && (record.members || []).some(row => row.completionResourceIds?.includes(id))) + || resources.some(record => record.type === "action-item" && ["open", "in-progress", "blocked"].includes(record.status) && record.completionResourceIds?.includes(id)); + if (!live) replaced.add(id); + } + } + } + return replaced; +} diff --git a/packages/filegrc/src/index.js b/packages/filegrc/src/index.js index ad5b6ea..63349fe 100644 --- a/packages/filegrc/src/index.js +++ b/packages/filegrc/src/index.js @@ -1,3 +1,5 @@ +export { scaffoldPeriodImpactReview, previewPeriodImpactReview, recordPeriodImpactReview } from "./period-impact.js"; +export { scaffoldObligationRuleWithdrawal, previewObligationRuleWithdrawal, withdrawObligationRule } from "./obligation-rule-withdrawal.js"; export { planWorkQueue } from "./work-queue.js"; export { ACTIVE_MODEL_VERSION, @@ -123,7 +125,7 @@ export { assessRequirementMappingReadiness } from "./requirement-mapping.js"; export { assessRetentionReadiness, nearDuplicateInformationTypes, resourceReviewRevision, resourceReviewRevisions, retentionReviewResourceIds, retentionRuleIsCurrent, retentionUses } from "./retention.js"; export { createFilegrcServer, serveWorkspace } from "./server.js"; export { normalizeSetupPayload, planWorkspaceSetup, setupWorkspace, summarizeSetupResult } from "./setup.js"; -export { createAppState, createResourceDetail } from "./state.js"; +export { createAppState, createResourceDetail, readResourceMutation } from "./state.js"; export { currentCalendarDate, formatCalendarDate, formatLocalDateTime } from "./time.js"; export { assertWorkspaceEngineVersion, workspaceEngineVersion, assessWorkspaceRuntimeCompatibility, assessRuntimeCompatibility, runtimeCompatibilityLine, RUNTIME_API_CONTRACT_VERSION } from "./runtime-version.js"; export { calculatedRevisionDiagnostic, REVISION_SCHEME_VERSION } from "./revisions.js"; diff --git a/packages/filegrc/src/obligation-rule-withdrawal.js b/packages/filegrc/src/obligation-rule-withdrawal.js new file mode 100644 index 0000000..c5a3ceb --- /dev/null +++ b/packages/filegrc/src/obligation-rule-withdrawal.js @@ -0,0 +1,68 @@ +import { loadWorkspace } from "./workspace.js"; +import { applyResourceBatch, contentRevision, INTERNAL_WORKFLOW_CAPABILITIES } from "./files.js"; +import { serializeWorkspaceMutation } from "./mutation.js"; +import { revisionsMatch } from "./revisions.js"; +import { currentCalendarDate } from "./time.js"; +import { parseCalendarDate } from "./recurrence.js"; +import { personWasActiveOn } from "./soc2.js"; +import { assertWorkflowSourceRuntimeAdopted } from "./workflow-source-runtime.js"; + +export async function scaffoldObligationRuleWithdrawal(input, options = {}) { + const loaded = await loadWorkspace(input); + if (!loaded.model.resources["obligation-rule"]?.fields.status.values.includes("withdrawn")) { + throw new Error("Obligation Rule withdrawal requires a model supporting the withdrawn status."); + } + assertWorkflowSourceRuntimeAdopted(loaded); + const entry = loaded.entries.find(({ record }) => record.type === "obligation-rule" && record.id === options.ruleId); + if (!entry) throw new Error(`Obligation rule "${options.ruleId || ""}" was not found.`); + const rule = entry.record; + if (!["proposed", "approved"].includes(rule.status) || rule.effectiveAt || rule.retiredOn + || loaded.resources.some((record) => record.activeRuleId === rule.id || record.type === "obligation-occurrence" && record.ruleId === rule.id)) { + throw new Error("Withdraw only a proposed or approved rule that never took effect or governed work. Preserve effective rules and use prospective activation and retirement."); + } + return { + ruleId: rule.id, + expectedRevision: contentRevision(entry.source), + withdrawnByIds: [], + withdrawnOn: null, + withdrawalReason: null, + instructions: "Record the people who actually made this decision, its actual date and reason. Preserve the rule and any prior approval. Withdrawal does not approve schedules or retire an effective rule." + }; +} + +export async function previewObligationRuleWithdrawal(input, payload = {}) { + const loaded = await loadWorkspace(input); + const scaffold = await scaffoldObligationRuleWithdrawal(loaded.root, payload); + if (!revisionsMatch("content", payload.expectedRevision, scaffold.expectedRevision)) { + throw new Error("The Obligation Rule changed. Scaffold its withdrawal again."); + } + const rule = loaded.resources.find(({ id }) => id === payload.ruleId); + if (!parseCalendarDate(payload.withdrawnOn) + || payload.withdrawnOn > currentCalendarDate(loaded.workspace.timezone, new Date()) + || rule.approvedOn && payload.withdrawnOn < rule.approvedOn) { + throw new Error("Record the actual withdrawal date, on or after any existing approval and no later than today."); + } + if (!Array.isArray(payload.withdrawnByIds) || !payload.withdrawnByIds.length + || new Set(payload.withdrawnByIds).size !== payload.withdrawnByIds.length + || payload.withdrawnByIds.some((id) => !personWasActiveOn(loaded.resources.find((record) => record.id === id), payload.withdrawnOn))) { + throw new Error("Withdrawal needs the actual decision actors, each a Person active on the withdrawal date."); + } + if (typeof payload.withdrawalReason !== "string" || !payload.withdrawalReason.trim()) { + throw new Error("A reason for withdrawing this rule is required."); + } + const record = { ...rule, status: "withdrawn", withdrawnByIds: payload.withdrawnByIds, + withdrawnOn: payload.withdrawnOn, withdrawalReason: payload.withdrawalReason.trim() }; + return { ruleId: rule.id, previousStatus: rule.status, record, expectedRevision: scaffold.expectedRevision }; +} + +export async function withdrawObligationRule(input, payload = {}) { + return serializeWorkspaceMutation(input, async (root) => { + const preview = await previewObligationRuleWithdrawal(root, payload); + await applyResourceBatch(root, { + workflowCapability: INTERNAL_WORKFLOW_CAPABILITIES.obligationRuleWithdrawal, + update: [preview.record], + expectedRevisions: { [preview.ruleId]: preview.expectedRevision } + }); + return preview; + }); +} diff --git a/packages/filegrc/src/period-impact.js b/packages/filegrc/src/period-impact.js new file mode 100644 index 0000000..f8444b7 --- /dev/null +++ b/packages/filegrc/src/period-impact.js @@ -0,0 +1,255 @@ +import { readFile } from "node:fs/promises"; +import { reviewHistoryContext, reviewHistoricalWorkspace } from "./historical-workspace.js"; +import { readResourceMutation } from "./resource-mutation.js"; +import { loadWorkspace } from "./workspace.js"; +import { markdownEntries, resourceDataPath } from "./resource-markdown.js"; +import { resolveDataPath } from "./paths.js"; +import { getFileAtRevision, getFilesAtRevisions, runGitCommandSync } from "./git.js"; +import { calculateRevision, revisionsMatch } from "./revisions.js"; +import { contentRevision, updateResource } from "./files.js"; +import { serializeWorkspaceMutation } from "./mutation.js"; +import { currentCalendarDate } from "./time.js"; +import { recordBelongsToProgram } from "./program.js"; +import { appointmentWasAuthorizedOn, personWasActiveOn } from "./soc2.js"; +import { parseCalendarDate } from "./recurrence.js"; +import { assertWorkflowSourceRuntimeAdopted, workflowSourceRuntimeAdopted } from "./workflow-source-runtime.js"; + +export const PERIOD_IMPACT_TYPES = new Set(["appointment", "control", "policy", "source-coverage", "system", "component"]); + +// Labels and added rehearsal Evidence links do not change a source or procedure. +// All other structured facts and all substantive companion text are bound. +function facts(record) { + const { periodImpactReview, title, readinessTestEvidenceIds, ...result } = record; + return result; +} +function canonical(value) { + if (Array.isArray(value)) return value.map(canonical); + return value && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value; +} +function text(value) { return String(value ?? "").replaceAll("\r\n", "\n").split("\n").map(line => line.trimEnd()).join("\n").trim(); } +export function periodImpactStateRevision(record, content) { + return calculateRevision("content", JSON.stringify(canonical({ record: facts(record), content: Object.fromEntries(Object.entries(content).map(([name, source]) => [name, text(source)])) }))); +} +const revision = periodImpactStateRevision; +export function periodImpactReviewersAuthorized(loaded, record, review) { + const byId = new Map(loaded.resources.map(item => [item.id, item])); + if (!Array.isArray(review.reviewedByIds) || !review.reviewedByIds.length || review.reviewedByIds.some(id => !personWasActiveOn(byId.get(id), review.reviewedOn))) return false; + const owners = new Set(), seen = new Set(); + const addOwners = ids => { + for (const id of ids || []) { + if (seen.has(id)) continue; + seen.add(id); + const party = byId.get(id); + if (personWasActiveOn(party, review.reviewedOn)) owners.add(id); + else if (appointmentWasAuthorizedOn(party, review.reviewedOn, byId)) owners.add(party.holderId); + else if (party?.type === "team" && party.status === "active") addOwners([...(party.memberIds || []), ...(party.chairIds || [])]); + } + }; + addOwners(record.ownerIds); + const scopes = new Set([record.id, loaded.workspace.id, ...loaded.resources.filter(item => item.type === "program" && recordBelongsToProgram(record, item, byId, loaded.model)).map(item => item.id)]); + for (const appointment of loaded.resources) { + if (appointment.appointmentKind === "policy-owner" && appointmentWasAuthorizedOn(appointment, review.reviewedOn, byId) && appointment.scopeResourceIds?.some(id => scopes.has(id))) owners.add(appointment.holderId); + } + return review.reviewedByIds.some(id => owners.has(id)); +} +async function currentStateRevision(loaded, record) { + const content = {}; + for (const item of markdownEntries(loaded.model, record)) { + if (loaded.historicalCommit) { + content[item.name] = loaded.historicalFiles?.get(`data/${item.path}`) ?? getFileAtRevision(loaded.root, loaded.historicalCommit, `data/${item.path}`) ?? ""; + continue; + } + try { content[item.name] = await readFile(resolveDataPath(loaded.root, item.path), "utf8"); } + catch (error) { if (error.code !== "ENOENT") throw error; content[item.name] = ""; } + } + return revision(record, content); +} + +// Hash ordered meaningful states, not repository commit counts or global HEAD. +// Appending a working change and later committing it yields the same chain; +// a real change/revert/repeat adds states even when the final bytes match. +export function periodImpactHistoryRevision(loaded, record, throughRevision) { + const context = reviewHistoryContext(loaded.root, loaded), index = context.index; + const head = throughRevision || loaded.historicalCommit || index?.head; + if (!head) { + return { chain: null, state: null }; + } + if (!index?.available) throw new Error("Complete Git history is required for an impact review."); + context.periodImpactChains ||= new Map(); + const cacheKey = `${record.id}\0${head}`; + if (context.periodImpactChains.has(cacheKey)) return context.periodImpactChains.get(cacheKey); + const jsonPath = `data/${resourceDataPath(loaded.model, record)}`; + const slots = markdownEntries(loaded.model, record).map(item => ({ name: item.name, path: `data/${item.path}` })); + const paths = new Set([jsonPath, ...slots.map(item => item.path)]), commits = []; + const seen = new Set(); + for (let cursor = head; cursor; cursor = index.parentsByCommit.get(cursor)?.[0]) { + if (seen.has(cursor) || !index.parentsByCommit.has(cursor)) throw new Error("Complete first-parent Git history is required for an impact review."); + seen.add(cursor); + if ([...(index.fileChangesByCommit.get(cursor)?.keys() || [])].some(path => paths.has(path))) commits.push(cursor); + } + commits.reverse(); + const requests = commits.flatMap(commit => [...paths].map(relativePath => ({ revision: commit, relativePath }))); + const sources = getFilesAtRevisions(loaded.root, requests); + let chain = null, state = null, position = 0; + for (const commit of commits) { + const source = sources[position++], content = {}; + for (const slot of slots) content[slot.name] = sources[position++] ?? ""; + const next = source ? revision(JSON.parse(source), content) : null; + if (next !== state) { chain = calculateRevision("content", JSON.stringify([chain, next])); state = next; } + } + const result = { chain, state }; + context.periodImpactChains.set(cacheKey, result); + return result; +} + +export async function currentPeriodImpactRevision(loaded, record) { + const current = await currentStateRevision(loaded, record); + const historical = periodImpactHistoryRevision(loaded, record); + const chain = current === historical.state ? historical.chain : calculateRevision("content", JSON.stringify([historical.chain, current])); + return calculateRevision("content", JSON.stringify({ chain, current })); +} + +export async function periodImpactReviewCurrent(loaded, record, asOf = currentCalendarDate(loaded.workspace.timezone || "UTC")) { + const review = record?.periodImpactReview; + if (!review || !workflowSourceRuntimeAdopted(loaded) || !loaded.model.resources[record.type]?.fields.periodImpactReview + || !validDate(review.reviewedOn) || !validDate(review.effectiveOn) + || review.reviewedOn > asOf || review.effectiveOn > review.reviewedOn + || !periodImpactReviewersAuthorized(loaded, record, review) + || typeof review.rationale !== "string" || !review.rationale.trim() + || !["no-material-change", "material-change-reviewed"].includes(review.conclusion) + || review.reviewedTransitionKeys !== undefined && (!Array.isArray(review.reviewedTransitionKeys) || review.reviewedTransitionKeys.some(key => typeof key !== "string")) + || review.policyEventIds !== undefined && !Array.isArray(review.policyEventIds)) return false; + if (review.policyEventIds?.some(id => { + const event = loaded.resources.find(item => item.id === id); + return event?.type !== "obligation-event" || event.status !== "complete" || !event.subjectResourceIds?.includes(record.id); + })) return false; + return revisionsMatch("content", review.reviewedRevision, await currentPeriodImpactRevision(loaded, record)); +} +function historicalRevision(loaded, record, commit) { + const source = getFileAtRevision(loaded.root, commit, `data/${resourceDataPath(loaded.model, record)}`); + if (!source) return null; + const historical = JSON.parse(source), content = {}; + for (const item of markdownEntries(loaded.model, historical)) { + const source = getFileAtRevision(loaded.root, commit, `data/${item.path}`); + content[item.name] = source ?? ""; + } + return revision(historical, content); +} + +function inventoryOperatingRevision(record, omitDataUse = false) { + const operating = { ...record, systemUses: (record.systemUses || []).map(({ systemId, roles }) => ({ systemId, roles })) }; + return calculateRevision("content", JSON.stringify(canonical(Object.fromEntries([ + "status", "componentKind", "criticality", "ownerIds", "vendorId", "systemUses", "informationUses", "classificationId", "internetExposed", "continuityObjectives" + ].filter(field => operating[field] !== undefined && (!omitDataUse || !["informationUses", "classificationId"].includes(field))).map(field => [field, operating[field]]))))); +} + +function historicalInventoryRevision(loaded, record, commit) { + const source = getFileAtRevision(loaded.root, commit, `data/${resourceDataPath(loaded.model, record)}`); + return source ? inventoryOperatingRevision(JSON.parse(source)) : null; +} + +async function boundInventoryDataReview(loaded, record) { + const applications = loaded.resources.filter(item => item.type === "retention-application" && item.status === "active" && item.scopeResourceIds?.includes(record.id)); + if (!applications.length) return null; + const { retentionApplicationIssues } = await import("./retention-applications.js"); + const context = reviewHistoryContext(loaded.root, loaded); + for (const application of applications) { + if (retentionApplicationIssues(loaded, application).length) continue; + const firstCommit = context.index?.historiesById.get(application.id)?.at(-1)?.commit; + const snapshot = firstCommit && reviewHistoricalWorkspace(context, firstCommit); + const captured = snapshot?.resources.find(item => item.id === record.id); + if (captured && await currentPeriodImpactRevision(snapshot, captured) === await currentPeriodImpactRevision(loaded, record)) return application; + } + return null; +} + +export async function assessPeriodImpact(loaded, record, histories, coverage, asOf) { + if (!PERIOD_IMPACT_TYPES.has(record.type) || ["planned", "draft", "retired", "superseded"].includes(record.status)) return null; + const supportingInventory = record.type === "component" && !(record.evidenceSourceKinds || []).length + && !(record.systemUses || []).some(use => use.roles?.includes("evidence-source")) + && !(record.systemUses || []).some(use => use.roles?.includes("service-delivery")) + && !loaded.resources.some(source => source.type === "control" && source.status === "implemented" && source.evidenceSourceComponentIds?.includes(record.id) + || source.type === "source-coverage" && source.status === "active" && source.componentId === record.id); + const historyRevision = supportingInventory ? historicalInventoryRevision : historicalRevision; + const timezone = loaded.workspace.timezone || "UTC"; + const paths = [`data/${resourceDataPath(loaded.model, record)}`, ...markdownEntries(loaded.model, record).map(({ path }) => `data/${path}`)]; + const commits = [...new Map(paths.flatMap(path => histories.get(path) || []).map(item => [item.commit, item])).values()]; + const inside = commits.filter(({ timestamp }) => timestamp && currentCalendarDate(timezone, new Date(timestamp)) >= coverage.start && currentCalendarDate(timezone, new Date(timestamp)) <= [coverage.end, asOf].sort()[0]); + const historyContext = reviewHistoryContext(loaded.root, loaded); + const changes = []; + let onlyReviewedDataUse = supportingInventory; + for (const item of inside) { + const after = historyRevision(loaded, record, item.commit); + const parents = historyContext.index?.parentsByCommit.get(item.commit) || runGitCommandSync(loaded.root, ["rev-list", "--parents", "-n", "1", item.commit]).trim().split(" ").slice(1); + const before = parents[0] ? historyRevision(loaded, record, parents[0]) : null; + if (before && after !== before) { + changes.push(item.commit); + if (supportingInventory) { + const read = commit => JSON.parse(getFileAtRevision(loaded.root, commit, `data/${resourceDataPath(loaded.model, record)}`)); + if (inventoryOperatingRevision(read(parents[0]), true) !== inventoryOperatingRevision(read(item.commit), true)) onlyReviewedDataUse = false; + } + } + } + const currentState = supportingInventory ? inventoryOperatingRevision(record) : await currentStateRevision(loaded, record); + const latest = commits.sort((a, b) => b.timestamp.localeCompare(a.timestamp))[0]; + const uncommitted = latest && historyRevision(loaded, record, latest.commit) !== currentState; + if (supportingInventory && uncommitted) { + const prior = JSON.parse(getFileAtRevision(loaded.root, latest.commit, `data/${resourceDataPath(loaded.model, record)}`)); + if (inventoryOperatingRevision(prior, true) !== inventoryOperatingRevision(record, true)) onlyReviewedDataUse = false; + } + if (supportingInventory && !changes.length && !uncommitted) return inside.length + ? { state: "complete", substantive: false, message: "This supporting service inventory is not an authoritative evidence source. Its supplier, access and retention checks remain separate; ordinary narrative maintenance creates no additional source-impact review. Typed scope, processing, exposure and delivery changes remain subject to normal checks." } + : null; + if (!changes.length && !uncommitted) return inside.length > 1 ? { state: "complete", substantive: false, message: "Committed revisions retain the same operating facts and procedure. Git keeps their history; no separate impact review is needed." } : null; + if (onlyReviewedDataUse) { + const application = await boundInventoryDataReview(loaded, record); + if (application) return { state: "complete", substantive: true, message: `The current owner retention applicability review (${application.id}) covers these exact data-use and classification facts and their source history. Other supplier, access, scope, exposure and risk checks remain separate.` }; + } + if (!loaded.model.resources[record.type]?.fields.periodImpactReview) return { state: "complete", substantive: true, message: "Operating history changed. Preserve its review and dated evidence through the workspace’s normal workflows; this older model does not support revision-bound impact metadata." }; + const current = await currentPeriodImpactRevision(loaded, record); + if (record.type === "control") { + const { assessBoundControlPeriodReview } = await import("./control-period-review.js"); + const accepted = await assessBoundControlPeriodReview(loaded, record, { asOf, currentRevision: current }); + if (accepted) return { state: "complete", substantive: true, reviewedRevision: current, message: `The completed Control design review (${accepted.reviewResourceId}) and its retained source snapshot cover the current procedure, implementation and meaningful history. No duplicate impact review is needed; later substantive changes and normal operating duties remain separate.` }; + } + const reviewed = await periodImpactReviewCurrent(loaded, record, asOf); + return { state: reviewed ? "complete" : "ready", substantive: true, reviewedRevision: current, + message: reviewed + ? "The owner’s dated impact review covers the current operating facts and companion content. Existing approval, effective-date, source-continuity and Policy Event checks still apply." + : "Operating facts or companion procedures changed during the period. Record the actual effective date, owner review and continuity conclusion against this revision. Reuse prior review facts; do not repeat tests or invent approval. Material changes retain their normal approval and Policy Event duties." }; +} +function validDate(value) { try { parseCalendarDate(value); return Boolean(value); } catch { return false; } } + +export async function scaffoldPeriodImpactReview(input, { resourceId } = {}) { + const loaded = await loadWorkspace(input); + assertWorkflowSourceRuntimeAdopted(loaded); + const entry = loaded.entries.find(item => item.record.id === resourceId); + if (!entry || !PERIOD_IMPACT_TYPES.has(entry.record.type) || !loaded.model.resources[entry.record.type].fields.periodImpactReview) throw new Error("Select a resource supporting period impact review."); + const { planReconciliation } = await import("./reconciliation.js"); + const plan = await planReconciliation(loaded, { includeHandled: true }); + const materialityQuestions = plan.filteredPlan.candidates.filter(candidate => candidate.subject.id === resourceId && candidate.periodImpactKey).map(candidate => ({ title: candidate.subject.title, message: candidate.message, eventType: candidate.eventType, periodImpactKey: candidate.periodImpactKey })); + const retainedKeys = await periodImpactReviewCurrent(loaded, entry.record) + ? plan.candidates.filter(candidate => candidate.subject.id === resourceId && entry.record.periodImpactReview.reviewedTransitionKeys?.includes(candidate.periodImpactKey)).map(candidate => candidate.periodImpactKey) + : []; + return { resourceId, expectedRevision: contentRevision(entry.source), materialityQuestions, review: { reviewedByIds: [], reviewedOn: null, effectiveOn: null, conclusion: null, rationale: null, reviewedRevision: await currentPeriodImpactRevision(loaded, entry.record), reviewedTransitionKeys: [...new Set([...retainedKeys, ...materialityQuestions.map(({ periodImpactKey }) => periodImpactKey)])].sort() }, instructions: "Review the listed materiality questions and record the actual owner review and effective business date. A no-material-change conclusion answers only these exact transitions. This does not approve changed governed content, repair a coverage gap, verify Evidence or complete a required Policy Event." }; +} +export async function previewPeriodImpactReview(input, payload) { + const loaded = await loadWorkspace(input), scaffold = await scaffoldPeriodImpactReview(loaded.root, payload); + if (!revisionsMatch("content", payload.expectedRevision, scaffold.expectedRevision) || !revisionsMatch("content", payload.review?.reviewedRevision, scaffold.review.reviewedRevision)) throw new Error("The record or its companion content changed. Reload the impact-review scaffold."); + if (JSON.stringify([...(payload.review?.reviewedTransitionKeys || [])].sort()) !== JSON.stringify(scaffold.review.reviewedTransitionKeys)) throw new Error("The materiality questions changed. Reload and review the current transitions."); + const entry = loaded.entries.find(item => item.record.id === payload.resourceId), review = payload.review; + const asOf = currentCalendarDate(loaded.workspace.timezone || "UTC"); + if (!review || !validDate(review.reviewedOn) || !validDate(review.effectiveOn) || review.reviewedOn > asOf || review.effectiveOn > review.reviewedOn || !review.reviewedByIds?.length || new Set(review.reviewedByIds).size !== review.reviewedByIds.length || review.reviewedByIds.some(id => !personWasActiveOn(loaded.resources.find(item => item.id === id), review.reviewedOn)) || !review.rationale?.trim() || !["no-material-change", "material-change-reviewed"].includes(review.conclusion) || !periodImpactReviewersAuthorized(loaded, entry.record, review)) throw new Error("Supply actual active reviewers including the current resource owner or scoped Policy Owner, valid effective/review dates, conclusion and rationale."); + for (const id of review.policyEventIds || []) { + const event = loaded.resources.find(item => item.id === id); + if (event?.type !== "obligation-event" || event.status !== "complete" || !event.subjectResourceIds?.includes(entry.record.id)) throw new Error("Link only completed Policy Events for this reviewed resource. Required event work must actually be finished."); + } + const mutation = await readResourceMutation(loaded, entry.record.type, entry.record.id); + return { resourceId: entry.record.id, record: { ...entry.record, periodImpactReview: review }, materialityQuestions: scaffold.materialityQuestions, expectedRevision: scaffold.expectedRevision, content: mutation.content, contentRevisions: mutation.contentRevisions }; +} +export async function recordPeriodImpactReview(input, payload) { + return serializeWorkspaceMutation(input, async root => { + const preview = await previewPeriodImpactReview(root, payload); + return updateResource(root, preview.record.type, preview.record.id, preview.record, { expectedRevision: preview.expectedRevision, content: preview.content, expectedContentRevisions: preview.contentRevisions, requireExpectedContentRevisions: true }); + }); +} diff --git a/packages/filegrc/src/program-readiness.js b/packages/filegrc/src/program-readiness.js index 379821f..6336bb0 100644 --- a/packages/filegrc/src/program-readiness.js +++ b/packages/filegrc/src/program-readiness.js @@ -1,4 +1,4 @@ -import { planWorkQueue } from "./work-queue.js"; +import { planWorkQueue, sourceDeadlineValidation } from "./work-queue.js"; import { withAssessmentCache } from "./calculation-cache.js"; import { assessOperatingPreparation, firstOperatingTasks } from "./operating-preparation.js"; import { reportingCutoverIssues } from "./activation-readiness.js"; @@ -314,14 +314,18 @@ async function assessProgramReadinessUncached(input, options = {}) { complete && resourceType !== "retention-schedule-item" ))) { for (const proposal of assessment.incompleteRecordProposals || []) { + const dormant = proposal.operatingStatus === "later"; stages.at(-1).items.push(item( `operating-${assessment.resourceType}-${proposal.resourceId}`, - "action", - `Finish ${proposal.title}`, - "Finish or retire this new record, check its affected Systems and Controls, and start a change event only if the change is material under an approved Policy.", + dormant ? "later" : "action", + dormant ? `${proposal.title} remains a proposal` : `Check ${proposal.title} before relying on it`, + dormant + ? "No operational reliance is recorded. Keep its planned or evaluating status while considering it, and reconcile any setup accounts separately. Complete the service, processing and Control requirements before operational use begins." + : "Check the actual use and affected Systems and Controls, then complete this record to match current practice. Start a change event only if the change is material under an approved Policy.", { type: assessment.resourceType, id: proposal.resourceId, title: proposal.title }, { resourceType: assessment.resourceType, + ...(proposal.operatingDependencyIds?.length ? { operatingDependencyIds: proposal.operatingDependencyIds } : {}), destination: { kind: "record", resourceType: assessment.resourceType, resourceId: proposal.resourceId }, commands: [ `npx filegrc guide ${assessment.resourceType} --json`, @@ -2014,7 +2018,7 @@ async function operationStage(loaded, workspace, scope, records, byId, asOf, evi } const obligations = planObligations(records, { root: loaded.root, programId: workspace.id, asOf, through: asOf, model }); - const workQueue = planWorkQueue(records, obligations, { program: workspace, model }); + const workQueue = planWorkQueue(records, obligations, { validationOk: await sourceDeadlineValidation(loaded), program: workspace, model }); const start = progressWindow?.start || null; const end = progressWindow?.end || null; const formalWindow = progressWindow?.source === "audit"; diff --git a/packages/filegrc/src/provider-access-completion.js b/packages/filegrc/src/provider-access-completion.js index 5ebcd7f..0c00e28 100644 --- a/packages/filegrc/src/provider-access-completion.js +++ b/packages/filegrc/src/provider-access-completion.js @@ -21,8 +21,19 @@ export function providerAccessControls(loaded, program, controls = loaded.resour || byId.get(schedule.activeRuleId)?.controlIds?.includes(control.id))); } -export function providerAccessPopulation(loaded, program, controls) { - const componentIds = unique([...programComponents(loaded, program).filter(({ status }) => status === "active").map(({ id }) => id), +export function providerAccessPopulation(loaded, program, controls, options = {}) { + const activeGrantComponents = new Set(loaded.resources.filter((record) => record.type === "access-grant" && record.status === "active").map(({ componentId }) => componentId)); + const historicalIds = options.historicalReviewComponentIds && new Set(options.historicalReviewComponentIds); + // A provisioned setup login needs an account boundary review even when the + // provider's service and data processing remain only proposals. + // New setup-account classifications do not enlarge an already committed + // review's dated population. Its active providers, selected Control sources + // and explicitly reviewed setup boundaries remain checked. Current readiness + // still assesses every real active setup account under the current rules. + const componentIds = unique([...programComponents(loaded, program).filter(({ id, status }) => ( + ["active", "deprecated"].includes(status) + || status === "planned" && activeGrantComponents.has(id) && (!historicalIds || historicalIds.has(id)) + )).map(({ id }) => id), ...controls.flatMap((control) => [...control.componentIds || [], ...control.evidenceSourceComponentIds || []])]); const grants = loaded.resources.filter((grant) => grant.type === "access-grant" && componentIds.includes(grant.componentId) && grant.status === "active"); return { componentIds, grants }; @@ -108,7 +119,8 @@ export function assessProgramAccessReview(candidate, loaded, program, controls, } } const byId = new Map(loaded.resources.map((record) => [record.id, record])); - const population = providerAccessPopulation(loaded, program, controls); + const population = providerAccessPopulation(loaded, program, controls, loaded.historicalCommit + ? { historicalReviewComponentIds: candidate.componentIds || [] } : {}); const componentIds = unique([...population.componentIds, ...options.historicalComponentIds || []]); const grants = options.historicalComponentIds ? loaded.resources.filter((record) => record.type === "access-grant" && record.status === "active" && componentIds.includes(record.componentId)) : population.grants; @@ -130,9 +142,14 @@ export function assessProgramAccessReview(candidate, loaded, program, controls, if (!valid(candidate)) return { resourceId: candidate.id, complete: false, missingFacts: ["valid-access-review"], missingScopeIds: [], missingGrantIds: [], missingSourceIds: [], invalidGrantIds: [], unresolvedDecisionIds: [], permittedExceptionIds: [], remediationIds: [] }; const issues = [], missingScopeIds = componentIds.filter((id) => !candidate.componentIds?.includes(id)); const decisions = candidate.grantDecisions || []; + const scopedComponentIds = new Set(programComponents(loaded, program).map(({ id }) => id)); const invalidGrantIds = grants.filter((grant) => { const subject = byId.get(grant.subjectId); - return byId.get(grant.componentId)?.status !== "active" || !valid(grant) || grant.expiresOn && grant.expiresOn < asOf || !grant.provisionedOn || grant.provisionedOn > candidate.completedOn + const component = byId.get(grant.componentId); + const currentBoundary = ["active", "deprecated"].includes(component?.status) + || component?.status === "planned" && scopedComponentIds.has(component.id); + return !currentBoundary || !valid(grant) || grant.expiresOn && grant.expiresOn < asOf || !grant.provisionedOn || grant.provisionedOn > candidate.completedOn + || !actualPeople(grant.approvedByIds, grant.approvedOn) || !actualPeople(grant.provisionedByIds, grant.provisionedOn) || !subject || subject.type === "person" && !personWasActiveOn(subject, asOf) || subject.type === "service-account" && (subject.status !== "active" || currentPartyPeople(subject.ownerIds || [], byId).size === 0); }).map(({ id }) => id); diff --git a/packages/filegrc/src/reconciliation.js b/packages/filegrc/src/reconciliation.js index 4e83eea..3a15b85 100644 --- a/packages/filegrc/src/reconciliation.js +++ b/packages/filegrc/src/reconciliation.js @@ -5,12 +5,14 @@ import { performance } from "node:perf_hooks"; import { loadModel, modelSupports, SUPPORTED_MODEL_VERSIONS } from "../model/index.js"; import { createObligationEvent, obligationRule } from "./obligations.js"; import { createResource, INTERNAL_WORKFLOW_CAPABILITIES } from "./files.js"; -import { getDataRecordHistoryIndex, getFileAtRevision, getFilesAtRevisions, runGitCommandSync } from "./git.js"; +import { getDataRecordHistoryIndex, getFileAtRevision, getFilesAtRevisions, runGitCommandSync, isDataHistoryAncestor } from "./git.js"; import { markdownEntries } from "./resource-markdown.js"; import { loadWorkspace } from "./workspace.js"; import { serializeWorkspaceMutation } from "./mutation.js"; import { currentCalendarDate } from "./time.js"; import { assertHistoricalRecordValid } from "./validate.js"; +import { periodImpactReviewCurrent, periodImpactStateRevision, periodImpactHistoryRevision } from "./period-impact.js"; +import { reviewHistoryContext, reviewHistoricalWorkspace } from "./historical-workspace.js"; const TRANSITIONS = { person: [ @@ -355,7 +357,9 @@ export async function planReconciliation(input = process.cwd(), options = {}) { path, fingerprint, eventId, - { committedRevision: commit } + { committedRevision: commit, ...impactQuestionKey(loaded, transition.eventType, parent || commit, record, recordPath, previous, current, + changedMarkdownPaths.map(changed => historicalSource(sourceByRevisionAndPath, parent, changed)), + changedMarkdownPaths.map(changed => historicalSource(sourceByRevisionAndPath, commit, changed))) } ); rawCandidates.push(candidate); if (!transitionHandled(loaded.resources, handledEventIds, handledFingerprints, { @@ -420,7 +424,8 @@ export async function planReconciliation(input = process.cwd(), options = {}) { }); const eventId = `obligation-event-git-${fingerprint.slice(0, 16)}`; const legacyEventId = `obligation-event-git-${legacyFingerprint.slice(0, 16)}`; - const candidate = reconciliationCandidate(loaded, transition, record, path, fingerprint, eventId); + const candidate = reconciliationCandidate(loaded, transition, record, path, fingerprint, eventId, + impactQuestionKey(loaded, transition.eventType, baseRevision, record, currentRecordPath || previousRecordPath, previous, current, previousMarkdown, currentMarkdown)); rawCandidates.push(candidate); if (!transitionHandled(loaded.resources, [eventId, legacyEventId], [fingerprint, legacyFingerprint], { eventType: transition.eventType, @@ -438,6 +443,43 @@ export async function planReconciliation(input = process.cwd(), options = {}) { })) filteredCandidates.push(candidate); } } + // The same exact-revision owner conclusion answers the materiality question + // across all interfaces. It never removes an existing Policy Event or its + // tasks, and it cannot dismiss lifecycle transitions such as activation. + const impactDecisions = new Map(); + const answeredCandidates = new Set(); + const questions = filteredCandidates.filter(candidate => candidate.periodImpactKey); + if (questions.length) { + const context = reviewHistoryContext(loaded.root, loaded); + for (const [commit, records] of historyIndex.recordsByCommit) { + for (const { record } of records.values()) { + const review = record.periodImpactReview; + if (review?.conclusion !== "no-material-change" || !Array.isArray(review.reviewedTransitionKeys)) continue; + // An opaque key is not authority to review another resource or a + // transition that had not occurred in this reviewed source snapshot. + const covered = questions.filter(candidate => candidate.subject.id === record.id + && candidate.committedRevision && !answeredCandidates.has(candidate.id) + && review.reviewedTransitionKeys.includes(candidate.periodImpactKey) + && isDataHistoryAncestor(loaded, candidate.committedRevision, commit)); + if (!covered.length) continue; + const historical = reviewHistoricalWorkspace(context, commit); + const reviewed = historical?.resources.find(item => item.id === record.id); + if (reviewed && await periodImpactReviewCurrent(historical, reviewed)) { + for (const candidate of covered) answeredCandidates.add(candidate.id); + } + } + } + } + for (let index = filteredCandidates.length - 1; index >= 0; index--) { + const candidate = filteredCandidates[index]; + if (!["system-material-change", "policy-revised"].includes(candidate.eventType)) continue; + if (answeredCandidates.has(candidate.id)) { filteredCandidates.splice(index, 1); continue; } + const record = loaded.resources.find(item => item.id === candidate.subject.id); + if (record?.periodImpactReview?.conclusion !== "no-material-change") continue; + if (!record.periodImpactReview.reviewedTransitionKeys?.includes(candidate.periodImpactKey)) continue; + if (!impactDecisions.has(record.id)) impactDecisions.set(record.id, await periodImpactReviewCurrent(loaded, record)); + if (impactDecisions.get(record.id)) filteredCandidates.splice(index, 1); + } const result = { contractVersion: 1, gitRevision: baseRevision, @@ -1185,6 +1227,13 @@ function transitionFingerprint(value) { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); } +function impactQuestionKey(loaded, eventType, baseRevision, record, path, before, after, previousMarkdown, currentMarkdown) { + if (!["system-material-change", "policy-revised"].includes(eventType)) return {}; + const content = sources => Object.fromEntries(sources.map((source, index) => [String(index), source])); + return { periodImpactKey: transitionFingerprint({ baseHistory: periodImpactHistoryRevision(loaded, record, baseRevision).chain, eventType, subjectId: record.id, path, + before: periodImpactStateRevision(before, content(previousMarkdown)), after: periodImpactStateRevision(after, content(currentMarkdown)) }) }; +} + function eventNeedsTimestamp(loaded, eventType) { const byId = new Map(loaded.resources.map((record) => [record.id, record])); return loaded.resources.some((record) => ( diff --git a/packages/filegrc/src/resource-mutation.js b/packages/filegrc/src/resource-mutation.js new file mode 100644 index 0000000..f32f15b --- /dev/null +++ b/packages/filegrc/src/resource-mutation.js @@ -0,0 +1,38 @@ +import { readFile } from "node:fs/promises"; +import { contentRevision } from "./files.js"; +import { resolveDataPath } from "./paths.js"; +import { markdownEntries } from "./resource-markdown.js"; +import { loadWorkspace } from "./workspace.js"; + +// Read only the target's editable source. Exporting an edit does not require +// rendering, workflow assessment, validation or unrelated record histories. +export async function readResourceMutation(input, type, id) { + const loaded = input?.entries && input?.root ? input : await loadWorkspace(input); + const entry = loaded.entries.find(({ record }) => record.id === id && (!type || record.type === type)); + if (!entry) throw new Error(`Resource "${type ? `${type}/` : ""}${id}" was not found.`); + const content = await readResourceContent(loaded, entry.record); + const contentEntries = Object.entries(content); + return { + record: structuredClone(entry.record), + ...(contentEntries.length ? { + content: Object.fromEntries(contentEntries.map(([name, item]) => [name, item.source])), + contentRevisions: Object.fromEntries(contentEntries.map(([, item]) => [item.path, item.revision])) + } : {}), + revision: contentRevision(entry.source) + }; +} + +export async function readResourceContent(loaded, record) { + const content = {}; + if (!loaded.model.resources[record.type]) return content; + for (const item of markdownEntries(loaded.model, record)) { + try { + const source = await readFile(resolveDataPath(loaded.root, item.path), "utf8"); + content[item.name] = { source, path: item.path, revision: contentRevision(source) }; + } catch (error) { + if (error.code !== "ENOENT") throw error; + // An absent optional companion is not an editable source file. + } + } + return content; +} diff --git a/packages/filegrc/src/retention.js b/packages/filegrc/src/retention.js index f735320..8bf7774 100644 --- a/packages/filegrc/src/retention.js +++ b/packages/filegrc/src/retention.js @@ -612,6 +612,7 @@ function substantiveReviewSource(record, currentModel, reviewer = null, lifecycl .map(({ requirementId, decision }) => ({ requirementId, decision })) .sort((left, right) => left.requirementId.localeCompare(right.requirementId)) } : { ...record }; + delete source.periodImpactReview; if (source.applicabilityReview) { source.applicabilityReview = { decision: source.applicabilityReview.decision }; } diff --git a/packages/filegrc/src/schedule-set.js b/packages/filegrc/src/schedule-set.js index 72eb8f4..b45b359 100644 --- a/packages/filegrc/src/schedule-set.js +++ b/packages/filegrc/src/schedule-set.js @@ -1,3 +1,5 @@ +import { isDeepStrictEqual } from "node:util"; +import { parseCalendarDate } from "./recurrence.js"; import { resourceIndex } from "./resource-index.js"; import { calculationMemo } from "./calculation-cache.js"; import { personWasActiveOn } from "./soc2.js"; @@ -37,6 +39,8 @@ export function scheduleSetInputs(loaded, program, options = {}) { } return loaded.resources.filter(({ id }) => ids.has(id)).map((record) => { const value = structuredClone(record); + // Owner impact reviews assess continuity; they do not change schedule design. + delete value.periodImpactReview; if (value.type === "obligation") delete value.completionResourceIds; // Content adoption is the cutover after this review, not a change to its approved design. if (["policy", "document", "training"].includes(value.type) && ["approved", "active"].includes(value.status)) { @@ -48,6 +52,40 @@ export function scheduleSetInputs(loaded, program, options = {}) { }); } +// An explicitly excluded proposal never became part of the adopted design. +// Preserve its exact reviewed facts when comparing an existing approval after +// withdrawal; every other current source fact and Markdown revision still binds. +export function scheduleSetWithoutExcludedWithdrawals(loaded, historical, review) { + const previousById = resourceIndex(historical.resources).byId; + const currentIndex = resourceIndex(loaded.resources); + const byId = currentIndex.byId; + const today = currentCalendarDate(loaded.workspace?.timezone || "UTC"); + const decisions = Array.isArray(review.scheduleDecisions) ? review.scheduleDecisions : []; + const excluded = new Map(decisions.filter((decision) => decision?.disposition === "adopted") + .flatMap((decision) => (Array.isArray(decision.excludedRuleIds) ? decision.excludedRuleIds : []).map((id) => [id, decision.obligationId]))); + let changed = false; + const resources = loaded.resources.map((record) => { + const previous = previousById.get(record.id); + if (record.type !== "obligation-rule" || record.status !== "withdrawn" + || excluded.get(record.id) !== record.obligationId + || previous?.type !== "obligation-rule" || !["proposed", "approved"].includes(previous.status) + || previous.effectiveAt || previous.retiredOn + || currentIndex.related("obligation-occurrence", "ruleId", [record.id]).length + || !parseCalendarDate(record.withdrawnOn) || record.withdrawnOn > today + || previous.approvedOn && record.withdrawnOn < previous.approvedOn + || !Array.isArray(record.withdrawnByIds) || !record.withdrawnByIds.length + || new Set(record.withdrawnByIds).size !== record.withdrawnByIds.length + || record.withdrawnByIds.some((id) => !personWasActiveOn(byId.get(id), record.withdrawnOn)) + || typeof record.withdrawalReason !== "string" || !record.withdrawalReason.trim()) return record; + const compared = { ...record, status: previous.status }; + for (const field of ["withdrawnByIds", "withdrawnOn", "withdrawalReason"]) delete compared[field]; + if (!isDeepStrictEqual(compared, previous)) return record; + changed = true; + return compared; + }); + return changed ? { ...loaded, resources } : loaded; +} + export function scheduleReviewerConflicts(loaded, program) { const byId = resourceIndex(loaded.resources).byId; const controls = resourceIndex(loaded.resources).matchingIds("control", program?.controlIds || []); @@ -96,7 +134,7 @@ export function scheduleSetIssues(loaded, program, review, options = {}) { if (options.at && obligation.endsOn && obligation.endsOn < currentCalendarDate(loaded.workspace?.timezone || "UTC", new Date(options.at))) add(obligation.id, `${obligation.title} ends before the intended cutover.`); const excluded = Array.isArray(decision.excludedRuleIds) ? decision.excludedRuleIds : []; if (decision.excludedRuleIds !== undefined && !Array.isArray(decision.excludedRuleIds)) add(obligation.id, "Excluded rule IDs must be an array."); - if (excluded.some((id) => !rules.some((rule) => rule.id === id && ["proposed", "approved"].includes(rule.status))) + if (excluded.some((id) => !rules.some((rule) => rule.id === id && ["proposed", "approved", "withdrawn"].includes(rule.status))) || rules.some((rule) => ["proposed", "approved"].includes(rule.status) && rule.id !== decision.ruleId && !excluded.includes(rule.id))) { add(obligation.id, `${obligation.title} has unresolved rule proposals. Explicitly exclude unused proposals in the schedule review.`); } diff --git a/packages/filegrc/src/server.js b/packages/filegrc/src/server.js index 7814b8a..28c9f64 100644 --- a/packages/filegrc/src/server.js +++ b/packages/filegrc/src/server.js @@ -1,4 +1,6 @@ -import { planWorkQueue } from "./work-queue.js"; +import { scaffoldPeriodImpactReview, previewPeriodImpactReview, recordPeriodImpactReview } from "./period-impact.js"; +import { scaffoldObligationRuleWithdrawal, previewObligationRuleWithdrawal, withdrawObligationRule } from "./obligation-rule-withdrawal.js"; +import { planWorkQueue, sourceDeadlineValidation } from "./work-queue.js"; import { scaffoldObligationRuleCorrection, previewObligationRuleCorrection, correctObligationRule } from "./obligation-rule-correction.js"; import { scaffoldRetentionApplication, saveRetentionApplication } from "./retention-applications.js"; import { firstOperatingTasks } from "./operating-preparation.js"; @@ -268,7 +270,7 @@ export function createFilegrcServer(input = process.cwd(), options = {}) { includeComplete: url.searchParams.get("includeComplete") === "true", model: loaded.model }); - const workQueue = planWorkQueue(loaded.resources, obligations, { program: resolveProgram(loaded, url.searchParams.get("programId") || undefined), model: loaded.model, includeComplete: url.searchParams.get("includeComplete") === "true" }); + const workQueue = planWorkQueue(loaded.resources, obligations, { validationOk: await sourceDeadlineValidation(loaded), program: resolveProgram(loaded, url.searchParams.get("programId") || undefined), model: loaded.model, includeComplete: url.searchParams.get("includeComplete") === "true" }); return json(response, 200, { ...obligations, workQueue, recommendations: [hostedAutomationForConnection(OPERATING_HOSTED_AUTOMATION, loaded.hostedAutomationConnection)] }); } if (request.method === "GET" && url.pathname === "/api/workflow") { @@ -494,6 +496,30 @@ export function createFilegrcServer(input = process.cwd(), options = {}) { : payload.revision }))); } + if (request.method === "POST" && url.pathname.startsWith("/api/period-impact-reviews")) { + const action = url.pathname.slice("/api/period-impact-reviews".length); + if (["/scaffold", "/preview", ""].includes(action)) { + const payload = await readJson(request); + if (!safeSegment(payload.resourceId)) return json(response, 400, { error: "A safe resource ID is required." }); + if (action === "/scaffold") return json(response, 200, await scaffoldPeriodImpactReview(input, payload)); + if (action === "/preview") return json(response, 200, await previewPeriodImpactReview(input, payload)); + return json(response, 201, await browserMutation(input, requestOptions, { + message: () => `Review period impact: ${payload.resourceId}` + }, () => recordPeriodImpactReview(input, payload))); + } + } + if (request.method === "POST" && url.pathname.startsWith("/api/obligation-rule-withdrawals")) { + const action = url.pathname.slice("/api/obligation-rule-withdrawals".length); + if (["/scaffold", "/preview", ""].includes(action)) { + const payload = await readJson(request); + if (!safeSegment(payload.ruleId)) return json(response, 400, { error: "A safe Obligation Rule ID is required." }); + if (action === "/scaffold") return json(response, 200, await scaffoldObligationRuleWithdrawal(input, payload)); + if (action === "/preview") return json(response, 200, await previewObligationRuleWithdrawal(input, payload)); + return json(response, 201, await browserMutation(input, requestOptions, { + message: () => `Withdraw obligation rule: ${payload.ruleId}` + }, () => withdrawObligationRule(input, payload))); + } + } if (request.method === "POST" && url.pathname.startsWith("/api/obligation-rule-corrections")) { const action = url.pathname.slice("/api/obligation-rule-corrections".length); if (["/scaffold", "/preview", ""].includes(action)) { @@ -1440,6 +1466,7 @@ function urlHost(host) { function statusFor(error) { if (error?.code === "FILEGRC_FINALIZED_PROOF") return 400; if (error?.code === "FILEGRC_RETENTION_APPLICATION") return 400; + if (error?.code === "FILEGRC_SOURCE_RUNTIME_ADOPTION") return 400; if (error?.code === "FILEGRC_ACTIVATION_NOT_READY") return 400; if (error?.code === "FILEGRC_STATE_EXPIRED") return 409; if (error?.code === "FILEGRC_STATE_CAPACITY") return 429; diff --git a/packages/filegrc/src/state.js b/packages/filegrc/src/state.js index 43452a2..8c27c52 100644 --- a/packages/filegrc/src/state.js +++ b/packages/filegrc/src/state.js @@ -1,17 +1,15 @@ import { loadWorkspace } from "./workspace.js"; -import { planWorkQueue } from "./work-queue.js"; +import { planWorkQueue, sourceDeadlineValidation } from "./work-queue.js"; import { amendedResourceView } from "./nonmaterial-amendments.js"; import { assessReportingRouteSets } from "./reporting-route-sets.js"; -import { readFile } from "node:fs/promises"; import { assessAuditPreparation } from "./audit-preparation.js"; import { assessCollectionReviews } from "./collection-review.js"; import { getBrowserRepositoryState, getRepositorySnapshot, getWorkspaceHistories } from "./git.js"; import { renderMarkdown } from "./markdown.js"; import { planObligations } from "./obligations.js"; import { planReconciliation } from "./reconciliation.js"; -import { resolveDataPath, resolveWorkspaceRoot } from "./paths.js"; +import { resolveWorkspaceRoot } from "./paths.js"; import { assessProgramReadiness } from "./program-readiness.js"; -import { markdownEntries } from "./resource-markdown.js"; import { resolveProgram } from "./program.js"; import { currentCalendarDate } from "./time.js"; import { serializeWorkspaceMutation } from "./mutation.js"; @@ -22,6 +20,9 @@ import { soc2RequirementApplicabilityConstraint } from "./soc2.js"; import { modelSupports } from "../model/index.js"; import { calculateRevision } from "./revisions.js"; import { applicabilityReviewIsCurrent, createApplicabilityScopeIndex } from "./applicability-scope.js"; +import { readResourceContent } from "./resource-mutation.js"; + +export { readResourceMutation } from "./resource-mutation.js"; const renderedMarkdownCache = new Map(); const MAX_RENDERED_MARKDOWN_CACHE_ENTRIES = 1_000; @@ -175,7 +176,7 @@ export async function createAppStateSection(input, section, options = {}) { const obligations = planObligations(loaded.resources, { root: loaded.root, programId: activeProgram.id, asOf, now: options.now ?? generatedAt, model: loaded.model }); return { generatedAt, asOf, obligations, - workQueue: planWorkQueue(loaded.resources, obligations, { program: activeProgram, model: loaded.model }) }; + workQueue: planWorkQueue(loaded.resources, obligations, { validationOk: options.validation?.ok ?? await sourceDeadlineValidation(loaded), program: activeProgram, model: loaded.model }) }; } if (section === "audits") { const programReadiness = options.programReadiness; @@ -332,7 +333,7 @@ async function createAppStateUnlocked(input, options) { diagnostics: validation.diagnostics }, obligations, - workQueue: planWorkQueue(loaded.resources, obligations, { program: activeProgram, model: loaded.model }), + workQueue: planWorkQueue(loaded.resources, obligations, { validationOk: validation.ok, program: activeProgram, model: loaded.model }), reconciliation, collectionReviews, applicabilityConstraints, @@ -435,22 +436,9 @@ async function createStateEntry(loaded, entry, options) { ? amendedResourceView(loaded, record) : null; const routeAssessment = options.includeDetails && record.type === "reporting-route-set" && record.status === "proposed" ? (await assessReportingRouteSets(loaded, { programId: record.programId })).routeSets.find((candidate) => candidate.record.id === record.id) : null; - const content = {}; - if (loaded.model.resources[record.type]) { - for (const item of markdownEntries(loaded.model, record)) { - try { - const path = resolveDataPath(loaded.root, item.path); - const source = await readFile(path, "utf8"); - content[item.name] = { - source, - ...(options.includeDetails ? { html: renderMarkdownCached(source) } : {}), - path: item.path, - revision: contentRevision(source) - }; - } catch { - // Validation reports missing required Markdown. - } - } + const content = await readResourceContent(loaded, record); + if (options.includeDetails) { + for (const item of Object.values(content)) item.html = renderMarkdownCached(item.source); } return { record, diff --git a/packages/filegrc/src/validate.js b/packages/filegrc/src/validate.js index 92c0e0f..b4b4a3c 100644 --- a/packages/filegrc/src/validate.js +++ b/packages/filegrc/src/validate.js @@ -1,4 +1,5 @@ import { workspaceEngineVersion, runtimeCompatibilityLine } from "./runtime-version.js"; +import { workflowSourceRuntimeIssues } from "./workflow-source-runtime.js"; import { recordedCorrectionPopulation, currentSnapshotPopulation, samePopulation } from "./obligation-snapshot.js"; import { populationCutoffDate, wholePeriodCoverage, requiredPeriodIsFinal, wholePeriodRuleWindowIssue, wholePeriodOccurrenceWindowIssue } from "./obligation-timing.js"; import { occurrenceSelectorPopulation } from "./obligation-population.js"; @@ -98,6 +99,7 @@ export async function validateWorkspace(input = process.cwd()) { async function validateWorkspaceUnmeasured(input) { const loaded = typeof input === "object" && input.entries ? input : await loadWorkspace(input); const diagnostics = [...loaded.diagnostics]; + diagnostics.push(...workflowSourceRuntimeIssues(loaded)); const usesSnapshotTiming = loaded.resources.some(record => (["meeting", "vulnerability-scan", "exercise", "backup-test", "control-activity"].includes(record.type) && record.coverage) || record.completionCoverage || record.requiredCoverage || record.populationSourceRevision || ["review-date", "coverage-end"].includes(record.selector?.cutoff) || record.cutoverDecision === "correct-open-window"); @@ -179,6 +181,12 @@ async function validateWorkspaceUnmeasured(input) { validateLocation(record, definition, entry.relativePath, diagnostics); validateRecord(record, definition, loaded.model, displayPath, diagnostics); validateDateRanges(record, displayPath, diagnostics); + if (record.periodImpactReview) { + const review = record.periodImpactReview; + if (!parseCalendarDate(review.reviewedOn) || !parseCalendarDate(review.effectiveOn) || review.reviewedOn > asOf || review.effectiveOn > review.reviewedOn) diagnostics.push(error("invalid-period-impact-review-date", displayPath, "Period impact review needs actual nonfuture review/effective dates, with the effective date no later than review.")); + if (!Array.isArray(review.reviewedByIds) || !review.reviewedByIds.length || new Set(review.reviewedByIds).size !== review.reviewedByIds.length || review.reviewedByIds.some(id => !personWasActiveOn(byId.get(id), review.reviewedOn))) diagnostics.push(error("invalid-period-impact-reviewer", displayPath, "Period impact review must name the actual People active on its review date.")); + } + validateProposedEffectiveDate(record, asOf, displayPath, diagnostics); if (record.type === "appointment") validateAppointment(record, byId, displayPath, diagnostics); if (record.type === "program") validateProgram(record, displayPath, diagnostics); @@ -517,6 +525,28 @@ function validateDocumentWorkflowScopes(resources, model, byId, pathById, diagno } function validateObligationRule(record, model, byId, path, diagnostics) { + if (record.status === "withdrawn") { + if ((record.withdrawnByIds || []).some((id) => !personWasActiveOn(byId.get(id), record.withdrawnOn))) { + diagnostics.push(error("invalid-obligation-rule-withdrawal-actor", path, "Withdrawal actors must have been active on the withdrawal date.")); + } + if (record.effectiveAt || record.retiredOn || byId.get(record.obligationId)?.activeRuleId === record.id + || [...byId.values()].some((item) => item.type === "obligation-occurrence" && item.ruleId === record.id)) { + diagnostics.push(error("effective-obligation-rule-withdrawal", path, "An effective or operated rule cannot be withdrawn. Preserve it and use prospective rule activation and retirement.")); + } + if ((record.approvedOn || record.approvedByIds?.length) && (!record.approvedOn || !record.approvedByIds?.length || !record.timezone)) { + diagnostics.push(error("incomplete-withdrawn-rule-approval", path, "Preserve the complete existing approval when withdrawing an approved rule.")); + } + if (record.approvedOn && record.withdrawnOn < record.approvedOn) { + diagnostics.push(error("backdated-obligation-rule-withdrawal", path, "Withdrawal cannot precede the preserved approval.")); + } + if (record.withdrawnOn > currentCalendarDate([...byId.values()].find(({ type }) => type === "workspace")?.timezone || "UTC", new Date())) { + diagnostics.push(error("future-obligation-rule-withdrawal", path, "Record the actual withdrawal date, which cannot be in the future.")); + } + return; + } + if (record.withdrawnByIds || record.withdrawnOn || record.withdrawalReason) { + diagnostics.push(error("unexpected-obligation-rule-withdrawal", path, "Withdrawal facts belong only on a withdrawn rule.")); + } const obligation = byId.get(record.obligationId); if (obligation?.type !== "obligation") return; validateObligation({ diff --git a/packages/filegrc/src/web.js b/packages/filegrc/src/web.js index dd6c0b5..a0567f4 100644 --- a/packages/filegrc/src/web.js +++ b/packages/filegrc/src/web.js @@ -2708,6 +2708,98 @@ function observationDateForItem(item) { }); } +async function openPeriodImpactReview(resourceId) { + try { + const response = await localFetch("/api/period-impact-reviews/scaffold", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ resourceId }) }); + const scaffold = await response.json(); + if (!response.ok) throw new Error(scaffold.error || "Could not prepare the impact review."); + const record = state.resources.find(entry => entry.record.id === resourceId).record; + const dialog = document.createElement("dialog"); + dialog.className = "editor rule-withdrawal"; + dialog.innerHTML = '

Operating change

Review period impact

Review the actual change to ' + esc(record.title) + '. Reuse work already performed. This records its impact, not a new approval, retrieval test or risk acceptance.

Reviewed by
' + resourcesOfType("person").map(({ record }) => '').join("") + '

Required approvals, effective dates, source coverage and Policy Events keep their normal checks.

'; + document.body.append(dialog); dialog.showModal(); + dialog.addEventListener("close", () => dialog.remove()); + dialog.querySelectorAll("[data-cancel]").forEach(button => button.addEventListener("click", () => dialog.close())); + const form = dialog.querySelector("form"), error = dialog.querySelector(".dialog-error"), output = dialog.querySelector(".withdrawal-preview"), apply = dialog.querySelector("[data-apply]"); + let candidate = null, generation = 0; + form.addEventListener("input", () => { generation++; candidate = null; output.hidden = true; apply.hidden = true; }); + form.addEventListener("submit", async event => { + event.preventDefault(); error.textContent = ""; + const requested = ++generation; + try { + const payload = { ...scaffold, review: { ...scaffold.review, reviewedByIds: [...form.querySelectorAll('input[name="actor"]:checked')].map(input => input.value), reviewedOn: form.elements.reviewedOn.value, effectiveOn: form.elements.effectiveOn.value, conclusion: form.elements.conclusion.value, rationale: form.elements.rationale.value } }; + const response = await localFetch("/api/period-impact-reviews/preview", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) }); + const preview = await response.json(); + if (requested !== generation) return; + if (!response.ok) throw new Error(preview.error || "Impact preview failed."); + candidate = payload; + output.innerHTML = '

Review the recorded decision

' + esc(record.title) + '

' + esc(payload.review.rationale) + '

' + (preview.materialityQuestions?.length ? '

' + (payload.review.conclusion === 'no-material-change' ? 'This decision also resolves the listed materiality questions for these exact reviewed transitions.' : 'Material changes retain their normal Policy Event work.') + '

    ' + [...new Set(preview.materialityQuestions.map(question => question.message))].map(message => '
  • ' + esc(message) + '
  • ').join('') + '
' : '') + '

The review binds the current facts and all companion procedures. Later substantive changes require another impact decision.

'; + output.hidden = false; apply.hidden = false; + } catch (caught) { error.textContent = caught.message; } + }); + apply.addEventListener("click", async () => { + if (!candidate) return; + setButtonBusy(apply, true, "Recording…"); + try { + const response = await localFetch("/api/period-impact-reviews", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(candidate) }); + if (!response.ok) throw new Error(await responseMessage(response)); + applyMutationState(await response.json()); dialog.close(); render(); + } catch (caught) { error.textContent = caught.message; setButtonBusy(apply, false); } + }); + } catch (error) { showError(error.message); } +} + +async function openObligationRuleWithdrawal(ruleId) { + try { + const response = await localFetch("/api/obligation-rule-withdrawals/scaffold", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ruleId }) + }); + const scaffold = await response.json(); + if (!response.ok) throw new Error(scaffold.error || "Could not prepare this rule for withdrawal."); + const rule = state.resources.find(({ record }) => record.id === ruleId).record; + const people = resourcesOfType("person"); + const dialog = document.createElement("dialog"); + dialog.className = "editor rule-withdrawal"; + dialog.innerHTML = '

Unused schedule

Withdraw rule

Withdraw ' + esc(rule.title) + ' because it never took effect. The rule and any prior approval stay in the program history. Schedule approval remains a separate decision.

Decision made by
' + people.map(({ record }) => '').join("") + '
'; + document.body.append(dialog); + dialog.showModal(); + dialog.addEventListener("close", () => dialog.remove()); + dialog.querySelectorAll("[data-cancel]").forEach((button) => button.addEventListener("click", () => dialog.close())); + const form = dialog.querySelector("form"); + const error = dialog.querySelector(".dialog-error"); + const apply = dialog.querySelector("[data-apply]"); + const output = dialog.querySelector(".withdrawal-preview"); + let previewedPayload = null; + let previewGeneration = 0; + form.addEventListener("input", () => { previewGeneration += 1; previewedPayload = null; apply.hidden = true; output.hidden = true; }); + form.addEventListener("submit", async (event) => { + event.preventDefault(); error.textContent = ""; + const requestedGeneration = ++previewGeneration; + try { + const candidate = { ...scaffold, withdrawnOn: form.elements.withdrawnOn.value, + withdrawnByIds: [...form.querySelectorAll('[name="actor"]:checked')].map(({ value }) => value), + withdrawalReason: form.elements.withdrawalReason.value }; + const response = await localFetch("/api/obligation-rule-withdrawals/preview", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(candidate) }); + const preview = await response.json(); + if (requestedGeneration !== previewGeneration) return; + if (!response.ok) throw new Error(preview.error || "Withdrawal preview failed."); + previewedPayload = candidate; + output.innerHTML = '

Review withdrawal

' + esc(rule.title) + ' will change from ' + esc(properCase(preview.previousStatus)) + ' to Withdrawn.

' + (rule.approvedOn ? 'The existing approval dated ' + esc(rule.approvedOn) + ' stays unchanged.' : 'No approval or effective date will be recorded.') + '

'; + output.hidden = false; apply.hidden = false; + } catch (caught) { error.textContent = caught.message; } + }); + apply.addEventListener("click", async () => { + if (!previewedPayload) return; + setButtonBusy(apply, true, "Withdrawing…"); + try { + const response = await localFetch("/api/obligation-rule-withdrawals", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(previewedPayload) }); + if (!response.ok) throw new Error(await responseMessage(response)); + applyMutationState(await response.json()); dialog.close(); render(); + } catch (caught) { error.textContent = caught.message; setButtonBusy(apply, false); } + }); + } catch (error) { showError(error.message); } +} + async function openObligationRuleCorrection(ruleId) { try { const response = await localFetch("/api/obligation-rule-corrections/scaffold", { @@ -3642,11 +3734,13 @@ function renderDetail(main, type, id, params = new URLSearchParams()) { } if (entry.historyLoaded === false) loadResourceHistory(type, id); const fields = { ...state.model.commonFields, ...definition.fields }; + const withdrawnRuleLocked = type === "obligation-rule" && entry.record.status === "withdrawn"; const recordContent = recordContentDefinition(type); const narrative = recordNarrative(entry.record, fields); const narrativeNames = new Set(narrative.map(([name]) => name)); const visible = Object.entries(entry.record).filter(([name]) => ( !["id", "type", "title", "extensions"].includes(name) + && name !== "periodImpactReview" && !fields[name]?.content && !narrativeNames.has(name) )); @@ -3657,8 +3751,8 @@ function renderDetail(main, type, id, params = new URLSearchParams()) { const narrativeContent = narrative.length ? '
' + narrative.map(([name, value]) => '

' + esc(titleCase(fields[name]?.label || humanize(name))) + '

' + esc(value) + '

').join("") + '
' : ""; - const markdownContent = content.map(([name, item]) => '
' + item.html + '
').join(""); - const addRecordContent = recordContent && !entry.content[recordContent.slot] && !state.readOnly + const markdownContent = content.map(([name, item]) => '
' + item.html + '
').join(""); + const addRecordContent = recordContent && !entry.content[recordContent.slot] && !state.readOnly && !withdrawnRuleLocked ? '
' : ""; const hasRecordBody = Boolean(narrativeContent || markdownContent); @@ -3706,7 +3800,18 @@ function renderDetail(main, type, id, params = new URLSearchParams()) { const amendmentAllowed = !state.readOnly && amendmentTarget && Object.values(state.model.nonmaterialAmendments || {}).some((rule) => rule.resourceTypes.includes(amendmentTarget.record.type)) && (type !== "reporting-route-set" || ["approved", "proposed"].includes(entry.record.status)); const retentionApplicationAction = !state.readOnly && type === "retention-schedule-item" && entry.record.status === "active" && state.model.resources["retention-application"] ? '' : ""; + const periodImpactAction = !state.readOnly && (entry.workflow?.findings || state.workflow?.findings)?.some(finding => finding.key === "period.change." + type + "." + entry.record.id && finding.state === "ready") + ? '' : ""; + const ruleWithdrawalAction = !state.readOnly && type === "obligation-rule" + && fields.status.values.includes("withdrawn") && ["proposed", "approved"].includes(entry.record.status) + && !entry.record.effectiveAt && !entry.record.retiredOn + && !state.resources.some(({ record }) => record.activeRuleId === entry.record.id || record.type === "obligation-occurrence" && record.ruleId === entry.record.id) + ? '' : ""; const ruleCorrectionAction = !state.readOnly && type === "obligation-rule" && entry.record.status === "active" && entry.record.selector && entry.record.completionCoverage !== "whole-period" ? '' : ""; + const impactReview = entry.record.periodImpactReview; + const periodImpactPanel = impactReview ? '

Period impact decision

' + esc(impactReview.rationale) + '

' : ""; + const ruleWithdrawalPanel = withdrawnRuleLocked && entry.record.withdrawnOn + ? '

Withdrawal decision

This rule never took effect. Its proposal and any prior approval remain in the program history.

' : ""; const amendmentAction = amendmentAllowed ? '' : ""; const amendmentView = entry.nonmaterialView; const amendmentPanel = amendmentView?.amendmentIds.length ? '

Current administrative details

The source record and any original approval remain unchanged. These are owner-authorized administrative corrections.

' + amendmentView.amendmentIds.map((id) => '' + esc(state.resources.find(({ record }) => record.id === id)?.record.title || id) + '').join(" · ") + '

' : ""; @@ -3725,15 +3830,17 @@ function renderDetail(main, type, id, params = new URLSearchParams()) { metadataPanel: hasStructuredMetadata ? "" : recordFieldsPanel, attachmentPanel }); - const mayDelete = !state.readOnly && !routeSetLocked && !definition.singleton; + const mayDelete = !state.readOnly && !routeSetLocked && !withdrawnRuleLocked && !definition.singleton; const applicabilityStatus = state.applicabilityReviewStatuses?.[entry.record.id]; const applicabilityAction = !state.readOnly && ["requirement", "control", "commitment", "complementary-control"].includes(type) && ["stale", "missing"].includes(applicabilityStatus) ? '' + (applicabilityStatus === "stale" ? "Reconfirm" : "Review") + ' applicability' : ""; const recordMenu = '
' + (mayDelete ? '
' : '') + '
'; - main.innerHTML = '

' + esc(titleCase(entry.nonmaterialView?.record.title || entry.record.title)) + '

' + reportingRouteSetActions + ruleCorrectionAction + amendmentAction + retentionApplicationAction + auditCycleAction + auditPopulationCorrectionAction + (type === "audit" ? 'Audit Evidence & Packet' : "") + governanceActions + lifecycleActions + issueActions + applicabilityAction + addRecordContentAction + (!state.readOnly && !routeSetLocked ? '' : "") + recordMenu + '
' + amendmentPanel + '
' + detailMain + supportPanels + (hasStructuredMetadata ? recordFieldsPanel : "") + '
'; + main.innerHTML = '

' + esc(titleCase(entry.nonmaterialView?.record.title || entry.record.title)) + '

' + reportingRouteSetActions + periodImpactAction + ruleWithdrawalAction + ruleCorrectionAction + amendmentAction + retentionApplicationAction + auditCycleAction + auditPopulationCorrectionAction + (type === "audit" ? 'Audit Evidence & Packet' : "") + governanceActions + lifecycleActions + issueActions + applicabilityAction + addRecordContentAction + (!state.readOnly && !routeSetLocked && !withdrawnRuleLocked ? '' : "") + recordMenu + '
' + amendmentPanel + periodImpactPanel + ruleWithdrawalPanel + '
' + detailMain + supportPanels + (hasStructuredMetadata ? recordFieldsPanel : "") + '
'; main.querySelectorAll("[data-completion-requirements]").forEach((button) => button.addEventListener("click", () => openCompletionRequirementsDialog(button.dataset.completionRequirements))); + main.querySelector("[data-review-period-impact]")?.addEventListener("click", () => openPeriodImpactReview(entry.record.id)); + main.querySelector("[data-withdraw-obligation-rule]")?.addEventListener("click", () => openObligationRuleWithdrawal(entry.record.id)); main.querySelector("[data-correct-obligation-rule]")?.addEventListener("click", () => openObligationRuleCorrection(entry.record.id)); main.querySelector("[data-select-program-for-review]")?.addEventListener("click", () => { selectProgram(entry.record.id, "#/resources/requirement?review=1"); @@ -5695,11 +5802,12 @@ function editorField(type, name, field, value, required, editing, oneOfRequired const values = field.values || (field.type === "rating" ? state.model.primitives.rating : state.model.primitives.outcome) || []; const activationManagedType = ["policy", "document"].includes(type) || (type === "training" && Boolean(state.model.resources.training?.fields?.activatedContentRevisions)); + const editableValues = type === "obligation-rule" && name === "status" ? values.filter((item) => item !== "withdrawn" || item === value) : values; const availableValues = type === "reporting-route-set" && name === "status" && editing ? values.filter((item) => item === value) : activationManagedType && name === "status" && value !== "active" ? values.filter((item) => item !== "active") - : values; + : editableValues; control = ''; const enumHelp = activationManagedType && name === "status" ? type === "document" @@ -7146,6 +7254,7 @@ dialog::backdrop{background:rgba(0,0,24,.62)} .obligation-card-foot{display:flex;align-items:flex-end;justify-content:space-between;gap:9px;margin-top:10px}.obligation-card-foot .obligation-links{margin-top:0;min-width:0}.obligation-action{flex:0 0 auto;border:0;border-radius:6px;background:var(--accent-soft);color:var(--accent);padding:7px 9px;font-family:inherit;font-size:10.8px;font-weight:700;line-height:1;text-decoration:none;cursor:pointer}.obligation-action:hover{filter:brightness(1.08)}.obligation-action.blocked{background:var(--surface-muted);color:var(--muted)}.obligation-more{width:100%;margin-top:9px}.workflow-section{scroll-margin-top:92px} .occurrence-review{margin:14px 0;border:1px solid var(--line);border-radius:8px;overflow:hidden}.occurrence-review-head{display:flex;justify-content:space-between;gap:12px;padding:10px 12px;background:var(--surface-soft);font-size:10.8px}.occurrence-review-head span,.occurrence-member small{color:var(--muted)}.occurrence-members{display:grid}.occurrence-member{display:grid;grid-template-columns:minmax(150px,1.4fr) minmax(110px,.7fr) minmax(170px,1.2fr) minmax(150px,1fr);gap:8px;align-items:end;padding:10px 12px;border-top:1px solid var(--line)}.occurrence-member:first-child{border-top:0}.occurrence-member-name{align-self:center;min-width:0}.occurrence-member-name strong,.occurrence-member-name small{display:block;overflow:hidden;text-overflow:ellipsis}.occurrence-member label span{display:block;margin-bottom:4px;color:var(--muted);font-size:9.6px}.occurrence-member input,.occurrence-member select{width:100%}.occurrence-empty,.occurrence-pending{margin:0;padding:12px;color:var(--muted);font-size:10.8px}.occurrence-pending{border-bottom:1px solid var(--line);background:#fff7df;color:#765410} .activation-review{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:14px 0}.activation-review>div{padding:10px 12px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.activation-review .full{grid-column:1/-1}.activation-review span,.activation-review strong,.activation-review small{display:block}.activation-review span{color:var(--muted);font-size:9.6px;text-transform:uppercase;letter-spacing:.06em}.activation-review strong{margin-top:4px;font-size:12px}.activation-review small{margin-top:3px;color:var(--muted);font-size:9.6px}.confirmation{display:flex!important;align-items:flex-start;gap:8px}.confirmation input{flex:none;width:auto!important;margin-top:2px}.cutover-note{margin:12px 0;padding:9px 11px;border-radius:7px;background:#fff7df;color:#765410;font-size:10.8px} +.rule-withdrawal .field-group{display:grid;gap:6px;min-width:0}.rule-withdrawal .form-grid>.full{grid-column:1/-1}.rule-withdrawal fieldset{border:0;margin:0;padding:0}.rule-withdrawal legend{margin-bottom:6px}.rule-withdrawal input[type="date"]{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);padding:9px 10px}.rule-withdrawal select{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font:inherit}.rule-withdrawal textarea{height:96px;min-height:80px;max-height:150px;font:inherit;background:var(--field);color:var(--ink);padding:10px;resize:vertical}.rule-withdrawal .withdrawal-preview{padding:18px;margin-top:16px}.rule-withdrawal .withdrawal-preview h3{margin:0 0 10px}.rule-withdrawal .withdrawal-preview p:last-child{margin-bottom:0} .nonmaterial-current-panel{margin-bottom:16px;padding:20px}.nonmaterial-current-panel .metadata{margin-bottom:12px}.nonmaterial-amendment-dialog .form-field.full{grid-column:1/-1}.nonmaterial-amendment-dialog .form-field>span{display:block;margin-bottom:6px}.nonmaterial-amendment-dialog .form-field.confirmation{display:flex;align-items:center;gap:10px}.nonmaterial-amendment-dialog .form-field.confirmation>span{margin:0}.nonmaterial-amendment-dialog .form-field.confirmation input{width:auto;min-height:0}.nonmaterial-amendment-dialog .form-field textarea{height:100px;min-height:100px;font:inherit;resize:vertical}.nonmaterial-amendment-dialog .checkbox{display:flex;align-items:center;gap:8px;margin-top:8px}.nonmaterial-amendment-dialog .checkbox input{width:auto} .event-dialog label{display:block;margin-top:13px}.event-dialog label[hidden]{display:none}.event-dialog label>span{display:block;font-size:12px;font-weight:720;margin-bottom:6px}.event-dialog input,.event-dialog select,.event-dialog textarea{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:14.4px}.event-dialog textarea{resize:vertical}.commit-dialog .form-grid label.full{grid-column:1/-1}.workflow-preview{margin-top:14px;padding:0 12px;border-radius:7px;background:var(--surface-soft)}.workflow-preview:not(:empty){padding-top:10px;padding-bottom:10px}.workflow-preview strong,.workflow-preview p{display:block;margin:0}.workflow-preview p{margin-top:5px;color:var(--muted);font-size:12px;line-height:1.5}.event-dialog-steps{display:grid;gap:6px;margin-top:15px;padding:10px;background:var(--surface-soft);border-radius:7px}.event-dialog-steps strong,.event-dialog-steps small{display:block}.event-dialog-steps strong{font-size:12px}.event-dialog-steps small{font-size:9.6px;color:var(--muted);margin-top:2px} .applicability-dialog{width:min(980px,calc(100vw - 30px));max-height:calc(100vh - 32px);border:0;border-radius:12px;padding:0;background:var(--panel);color:var(--ink);box-shadow:0 25px 80px rgba(0,0,24,.28)}.applicability-dialog form{padding:23px}.applicability-dialog form>p{color:var(--muted);font-size:13.2px}.applicability-dialog form>.applicability-baseline-note{padding:10px 12px;border-radius:7px;background:var(--accent-soft);color:var(--ink)}.review-context label>span{display:block;font-size:12px;font-weight:720;margin-bottom:6px}.review-context input,.review-context select,.applicability-row input,.applicability-row select{width:100%;min-height:38px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:8px 9px;font-size:13.2px}.review-context label.full{grid-column:1/-1}.applicability-rows{display:grid;gap:7px;max-height:46vh;overflow:auto;margin-top:16px;padding-right:4px}.applicability-row{display:grid;grid-template-columns:minmax(210px,1fr) 180px minmax(240px,1.3fr);gap:9px;align-items:center;padding:9px;border:1px solid var(--line);border-radius:8px}.applicability-row strong,.applicability-row small{display:block}.applicability-row small{margin-top:3px;color:var(--muted);font-size:10.8px}.applicability-row .applicability-constraint{color:var(--accent);line-height:1.35} diff --git a/packages/filegrc/src/work-queue.js b/packages/filegrc/src/work-queue.js index b9b56c2..044a70f 100644 --- a/packages/filegrc/src/work-queue.js +++ b/packages/filegrc/src/work-queue.js @@ -1,3 +1,5 @@ +import { validateWorkspace } from "./validate.js"; +import { supersededOperatingProofIds } from "./historical-operating-proof.js"; import { loadModel } from "../model/index.js"; import { recordBelongsToProgram, resolveProgram } from "./program.js"; import { notificationContacts } from "./notifications.js"; @@ -57,6 +59,7 @@ export function workPriority(state, dueOn, asOf) { function firstDate(record) { + if (record.type === "risk") return [record.treatmentTargetOn, record.reviewDueOn, record.response === "accept" ? record.acceptance?.expiresOn : null].filter(Boolean).sort()[0] || null; if (record.type === "exception" && typeof record.approval?.expiresOn === "string") return record.approval.expiresOn; if (typeof record.completionWindow?.dueAt === "string" && record.completionWindow.dueAt) return record.completionWindow.dueAt; if (typeof record.completionWindow?.dueOn === "string" && record.completionWindow.dueOn) return record.completionWindow.dueOn; @@ -165,13 +168,23 @@ function shellArgument(value) { // Shared source deadlines preserve the workflow's lifecycle and Program scope. // Action Items and Obligation occurrences remain owned by the obligation planner. +export async function sourceDeadlineValidation(loaded) { + const historical = supersededOperatingProofIds(loaded.resources, true); + const byId = new Map(loaded.resources.map(record => [record.id, record])); + // Most queues have no old dated proof. Do not load unrelated history merely + // to confirm that no source deadline can be removed. + if (![...historical].some(id => byId.has(id) && firstDate(byId.get(id)))) return false; + return (await validateWorkspace(loaded)).ok; +} + export function sourceDeadlineWorkItems(records, options) { const byId = new Map(records.map((record) => [record.id, record])); const items = []; + const historicalProofIds = supersededOperatingProofIds(records, options.validationOk === true); for (const record of records) { // The queue planner owns all Action Items, including filtered and terminal // work. Recreating them from deadlines would undo its scope and lifecycle. - if (["obligation", "action-item"].includes(record.type) || options.excludedSourceIds?.has(record.id)) continue; + if (["obligation", "action-item"].includes(record.type) || options.excludedSourceIds?.has(record.id) || historicalProofIds.has(record.id)) continue; if (!recordBelongsToProgram(record, options.program, byId, options.model)) continue; const due = firstDate(record); if (!due) continue; diff --git a/packages/filegrc/src/workflow-history-integrity.js b/packages/filegrc/src/workflow-history-integrity.js index 6a04a71..dc162de 100644 --- a/packages/filegrc/src/workflow-history-integrity.js +++ b/packages/filegrc/src/workflow-history-integrity.js @@ -20,7 +20,7 @@ const FINAL_STATUSES = new Map([ ["reconciliation-dismissal", new Set([undefined])], ["nonmaterial-amendment", new Set([undefined])], ["collection-review", new Set(["active", "retired"])], - ["obligation-rule", new Set(["active", "retired"])], + ["obligation-rule", new Set(["active", "retired", "withdrawn"])], ["obligation-occurrence", new Set(["reconciled", "superseded"])], ["audit-population", new Set(["reconciled", "not-applicable", "superseded"])], ["reporting-route", new Set(["active", "retired"])], diff --git a/packages/filegrc/src/workflow-source-runtime.js b/packages/filegrc/src/workflow-source-runtime.js new file mode 100644 index 0000000..8567942 --- /dev/null +++ b/packages/filegrc/src/workflow-source-runtime.js @@ -0,0 +1,85 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { getDataRecordHistoryIndex, runGitCommandSync } from "./git.js"; +import { runtimeCompatibilityLine } from "./runtime-version.js"; + +export const WORKFLOW_SOURCE_MINIMUM_VERSION = "0.21.6"; +const instruction = "Adopt FileGRC 0.21.6 or newer in both package.json and package-lock.json, and upgrade every hosted reader, before saving withdrawn rules or period-impact decisions."; +const historicalPins = new Map(); + +function version(value) { + if (!runtimeCompatibilityLine(value)) return null; + return value.split("+")[0].split(".").map(Number); +} +function atLeast(value, minimum) { + const parsed = version(value), required = version(minimum); + if (!parsed || !required) return false; + for (let index = 0; index < 3; index++) { + if (parsed[index] !== required[index]) return parsed[index] > required[index]; + } + return true; +} +function inDeclaredRange(declared, minimum, locked) { + const start = version(minimum), actual = version(locked); + if (!start || !actual || !atLeast(locked, minimum)) return false; + if (declared.startsWith("~")) return actual[0] === start[0] && actual[1] === start[1]; + if (declared.startsWith("^")) return actual[0] === start[0] && (start[0] > 0 || actual[1] === start[1] && (start[1] > 0 || actual[2] === start[2])); + return actual.every((part, index) => part === start[index]); +} + +export function workflowSourceRuntimeAdopted(loaded, commit = loaded.historicalCommit || null) { + const cacheKey = commit && `${loaded.root}\0${commit}`; + if (cacheKey && historicalPins.has(cacheKey)) return historicalPins.get(cacheKey); + try { + if (commit && !/^[a-f0-9]{40}$/i.test(commit)) return false; + const read = name => JSON.parse(commit ? runGitCommandSync(loaded.root, ["show", `${commit}:./${name}`]) : readFileSync(join(loaded.root, name), "utf8")); + const manifest = read("package.json"), lock = read("package-lock.json"); + const declared = manifest.dependencies?.filegrc || manifest.devDependencies?.filegrc; + const minimum = typeof declared === "string" && /^(?:\^|~)?(\d+\.\d+\.\d+)$/.exec(declared)?.[1]; + const rootDeclaration = lock.packages?.[""]?.dependencies?.filegrc || lock.packages?.[""]?.devDependencies?.filegrc; + const installed = lock.packages?.["node_modules/filegrc"]; + const supported = rootDeclaration === declared && !installed?.link && atLeast(minimum, WORKFLOW_SOURCE_MINIMUM_VERSION) + && atLeast(installed?.version, WORKFLOW_SOURCE_MINIMUM_VERSION) && inDeclaredRange(declared, minimum, installed?.version); + if (cacheKey) { historicalPins.set(cacheKey, supported); if (historicalPins.size > 512) historicalPins.delete(historicalPins.keys().next().value); } + return supported; + } catch { return false; } +} + +export function assertWorkflowSourceRuntimeAdopted(loaded) { + if (!workflowSourceRuntimeAdopted(loaded)) throw Object.assign(new Error(instruction), { code: "FILEGRC_SOURCE_RUNTIME_ADOPTION" }); +} +export function assertWorkflowSourceRecordAdopted(loaded, record) { + if (usesNewSource(record)) assertWorkflowSourceRuntimeAdopted(loaded); +} + +function usesNewSource(record) { + return record && (Object.hasOwn(record, "periodImpactReview") || record.type === "obligation-rule" + && (record.status === "withdrawn" || ["withdrawnByIds", "withdrawnOn", "withdrawalReason"].some(field => Object.hasOwn(record, field)))); +} + +export function workflowSourceRuntimeIssues(loaded) { + const current = loaded.resources.filter(usesNewSource); + const index = loaded.root ? getDataRecordHistoryIndex(loaded.root) : null; + const firstUses = new Map(); + if (index?.available) { + let ancestors = null; + if (loaded.historicalCommit) { + ancestors = new Set(); const pending = [loaded.historicalCommit]; + while (pending.length) { const commit = pending.pop(); if (ancestors.has(commit)) continue; ancestors.add(commit); pending.push(...index.parentsByCommit.get(commit) || []); } + } + for (const [id, history] of index.historiesById) for (const { commit, path } of [...history].reverse()) { + if (ancestors && !ancestors.has(commit)) continue; + const record = index.recordsByCommit.get(commit)?.get(id)?.record; + if (usesNewSource(record)) { firstUses.set(id, { commit, path }); break; } + } + } + if (!current.length && !firstUses.size) return []; + if (!workflowSourceRuntimeAdopted(loaded)) return [{ severity: "error", code: "source-runtime-adoption-required", path: "package-lock.json", message: instruction }]; + const checked = new Map(), issues = []; + for (const { commit, path } of firstUses.values()) { + if (!checked.has(commit)) checked.set(commit, workflowSourceRuntimeAdopted(loaded, commit)); + if (!checked.get(commit)) issues.push({ severity: "warning", code: "historical-source-runtime-adoption", path, + message: `New source fields were first committed at ${commit.slice(0, 12)} before the repository adopted their reader. The current dependency is repaired; preserve this historical format limitation. Older-pin snapshots cannot prove these new decisions were readable then. No business dates or prior approvals need to change.` }); + } + return issues; +} diff --git a/packages/filegrc/src/workflow.js b/packages/filegrc/src/workflow.js index 4fbe75e..04ab963 100644 --- a/packages/filegrc/src/workflow.js +++ b/packages/filegrc/src/workflow.js @@ -1,3 +1,9 @@ +import { assessReportingRoutePeriod } from "./reporting-route-sets.js"; +import { assessPeriodImpact } from "./period-impact.js"; +import { markdownEntries } from "./resource-markdown.js"; +import { supersededOperatingProofIds } from "./historical-operating-proof.js"; +import { currentPartyPeople, partiesIndependent } from "./parties.js"; +import { personWasActiveOn } from "./soc2.js"; import { actionBlockingReason, planWorkQueue, sourceDeadlineWorkItems, workPriority } from "./work-queue.js"; import { withAssessmentCache } from "./calculation-cache.js"; import { resourceIndex } from "./resource-index.js"; @@ -123,8 +129,8 @@ async function assessWorkflowUnmeasured(input, options = {}) { ...await auditLifecycleFindings(loaded, audits, programRecord) ]; const findings = [ - ...validationFindings(validation, loaded), - ...removeRedundantRecordFindings(recordFinalizationFindings(loaded, programRecord), guidedFindings), + ...validationFindings(validation, loaded, program, reconciliation), + ...removeRedundantRecordFindings(recordFinalizationFindings(loaded, programRecord, asOf, validation.ok), guidedFindings), ...guidedFindings ]; const workItems = buildWorkItems(loaded.resources, obligationPlan, { @@ -132,12 +138,13 @@ async function assessWorkflowUnmeasured(input, options = {}) { includeComplete: Boolean(options.includeComplete), programId: programRecord.id, program: programRecord, - model: loaded.model + model: loaded.model, + validationOk: validation.ok }).map((item) => ({ ...item, notificationContacts: notificationContacts(item.ownerIds, loaded.resources) })); - const workQueue = planWorkQueue(loaded.resources, obligationPlan, { asOf, program: programRecord, model: loaded.model, includeComplete: Boolean(options.includeComplete) }); + const workQueue = planWorkQueue(loaded.resources, obligationPlan, { asOf, program: programRecord, model: loaded.model, validationOk: validation.ok, includeComplete: Boolean(options.includeComplete) }); const git = options.git || safeGitSummary(loaded.root); const assessments = buildAssessments({ program, @@ -369,14 +376,19 @@ function auditFindings(preparation) { })); } -function validationFindings(validation, loaded) { +function validationFindings(validation, loaded, program, reconciliation) { + const currentChannels = program.stages.flatMap(stage => stage.items || []).some(item => item.id === "reporting-channel-cutover" && item.status === "complete"); const entriesByPath = new Map(loaded.entries.map((entry) => [ `data/${entry.relativePath}`, entry.record ])); - return (validation?.diagnostics || []).filter(diagnostic => diagnostic.path !== ".filegrc/hosted-automation.json").map((diagnostic) => { + return (validation?.diagnostics || []).filter(diagnostic => diagnostic.path !== ".filegrc/hosted-automation.json") + .filter(diagnostic => diagnostic.code !== "unreconciled-committed-transition" || !reconciliation?.candidates?.some(candidate => candidate.sourcePath === diagnostic.path && candidate.committedRevision)) + .map((diagnostic) => { const record = entriesByPath.get(diagnostic.path); - const state = "ready"; + const historicalChannelGap = diagnostic.code === "uncovered-reporting-route-interval" && diagnostic.severity === "warning" && currentChannels; + const historicalFormatContext = diagnostic.code === "historical-source-runtime-adoption" && diagnostic.severity === "warning"; + const state = historicalChannelGap || historicalFormatContext ? "complete" : "ready"; const code = `structural.${diagnostic.code}.${record?.id || pathKey(diagnostic.path)}.${stableSuffix(diagnostic.message)}`; return { key: code, @@ -387,24 +399,25 @@ function validationFindings(validation, loaded) { severity: diagnostic.severity, requiredness: "required", title: record?.title || "Workspace structure", - message: diagnostic.message, + message: historicalChannelGap ? `${diagnostic.message} This historical gap remains disclosed; do not backdate approval. Current channels are operating, and period coverage is checked separately.` : diagnostic.message, fieldPath: diagnostic.path, ...(record ? { subject: { type: record.type, id: record.id } } : {}), dependencies: [], - actions: record + actions: historicalChannelGap || historicalFormatContext ? [] : record ? [mutationAction(record)] : [{ kind: "command", command: "npx filegrc validate --json" }] }; }); } -function recordFinalizationFindings(loaded, program) { +function recordFinalizationFindings(loaded, program, asOf, validationOk) { + const historicalProofIds = supersededOperatingProofIds(loaded.resources, validationOk); const scopeIndex = createApplicabilityScopeIndex(loaded.resources, program); const findings = []; for (const record of loaded.resources) { if (["workspace", "renderer-settings"].includes(record.type)) continue; - if (["retired", "superseded"].includes(record.status)) continue; - const incomplete = recordIncompleteReason(record, loaded, program); + if (["retired", "superseded", "withdrawn"].includes(record.status)) continue; + const incomplete = historicalProofIds.has(record.id) ? null : recordIncompleteReason(record, loaded, program, asOf); if (incomplete) { findings.push(finalizationFinding(record, incomplete, loaded.model)); } @@ -465,7 +478,8 @@ function subjectKey(subject) { return `${subject.type}:${subject.id}`; } -function recordIncompleteReason(record, loaded, program) { +function recordIncompleteReason(record, loaded, program, asOf) { + if (record.type === "risk" && ["open", "monitoring"].includes(record.status)) return riskOperatingGuidance(record, loaded, asOf); if (record.type === "requirement" && record.applicability === "undetermined") { return { state: "ready", @@ -517,6 +531,24 @@ function recordIncompleteReason(record, loaded, program) { }; } +function riskOperatingGuidance(record, loaded, asOf) { + const byId = resourceIndex(loaded.resources).byId; + const owned = currentPartyPeople(record.ownerIds || [], byId).size > 0; + const reviewed = loaded.resources.some(assessment => assessment.type === "risk-assessment" && assessment.status === "complete" + && [...assessment.riskIds || [], ...assessment.newRiskIds || [], ...assessment.changedRiskIds || []].includes(record.id) + && assessment.completedOn && assessment.completedOn <= asOf && assessment.approvedOn >= assessment.completedOn && assessment.approvedOn <= asOf + && assessment.assessorIds?.length && assessment.reviewerIds?.length + && assessment.assessorIds.every(id => personWasActiveOn(byId.get(id), assessment.completedOn)) + && assessment.reviewerIds.every(id => personWasActiveOn(byId.get(id), assessment.approvedOn)) + && partiesIndependent(assessment.assessorIds, assessment.reviewerIds, byId) && assessment.evidenceIds?.length); + const acceptanceCurrent = record.response !== "accept" || record.acceptance?.rationale?.trim() && record.acceptance.acceptedOn <= asOf && record.acceptance.expiresOn >= asOf && record.acceptance.acceptedByIds?.length && record.acceptance.acceptedByIds.every(id => personWasActiveOn(byId.get(id), record.acceptance.acceptedOn)); + const dates = [record.treatmentTargetOn, record.reviewDueOn, record.response === "accept" ? record.acceptance?.expiresOn : null].filter(Boolean).sort(); + if (owned && acceptanceCurrent && record.response && record.inherentRating && reviewed && record.reviewDueOn && (record.response !== "mitigate" || record.treatmentTargetOn) && dates[0] > asOf) { + return { state: "scheduled", requiredness: "required", message: `Continue the recorded ${record.response} response and review progress by ${dates[0]}. An open risk does not need to be closed to operate the program; its assigned actions and deadlines remain separate.` }; + } + return { state: "ready", requiredness: "required", message: !acceptanceCurrent ? "Review the expired or incomplete risk acceptance; do not treat it as an ongoing accepted response." : !owned ? "Assign a current owner for this risk." : !record.response || !record.inherentRating ? "Record the rating and actual response decision." : !reviewed ? "Complete the risk's normal assessment and review with actual results and proof." : !record.reviewDueOn || record.response === "mitigate" && !record.treatmentTargetOn ? "Set the actual risk review and treatment checkpoints." : `The risk's review or treatment checkpoint is due. Review progress and record the resulting response; do not close an unresolved risk merely to clear this prompt.` }; +} + function recordFinalizationRequiredness(record, loaded, program) { if (!["policy", "document", "training"].includes(record.type)) return "required"; const target = program || resolveProgram(loaded); @@ -631,7 +663,7 @@ function finalizationFinding(record, details, model) { state: details.state, severity: details.state === "blocked" ? "error" : "warning", requiredness: details.requiredness, - title: `Finalize ${record.title}`, + title: record.type === "risk" ? `Review risk: ${record.title}` : `Finalize ${record.title}`, message: details.message, subject: { type: record.type, id: record.id }, dependencies: details.dependencies || [], @@ -779,8 +811,8 @@ async function assessPeriodHealth(loaded, options) { "source-coverage", "system", "component" - ].includes(record.type)); - const paths = relevantEntries.map(({ relativePath }) => `data/${relativePath}`); + ].includes(record.type) && recordBelongsToProgram(record, target, resourceIndex(loaded.resources).byId, loaded.model)); + const paths = relevantEntries.flatMap(({ relativePath, record }) => [`data/${relativePath}`, ...markdownEntries(loaded.model, record).map(({ path }) => `data/${path}`)]); const histories = getWorkspaceHistories(loaded.root, paths, 50, { deadlineAt: options.historyDeadlineAt, strict: options.strictHistory === true @@ -873,6 +905,13 @@ async function assessPeriodHealth(loaded, options) { } const periodThrough = [coverage.end, options.asOf].sort()[0]; + if (loaded.model.resources["reporting-route-set"]) { + const routePeriod = await assessReportingRoutePeriod(loaded, { programId: target.id, start: coverage.start, end: periodThrough, timezone: loaded.workspace.timezone || "UTC" }); + for (const issue of routePeriod.issues) { + const source = recordById.get(issue.resourceId); + findings.push(periodFinding(`period.reporting-channel.${issue.code}.${issue.resourceId}.${stableSuffix(issue.message)}`, "Review reporting-channel period coverage", `${issue.message} Preserve the actual gap and approvals; record its audit impact or an allowed Exception rather than backdating.`, "ready", source ? { type: source.type, id: source.id } : { type: target.type, id: target.id }, source ? [mutationAction(source)] : [])); + } + } const periodResources = loaded.resources.filter((record) => ( record.type !== "obligation" || !(record.controlIds || []).length @@ -922,18 +961,13 @@ async function assessPeriodHealth(loaded, options) { for (const entry of relevantEntries) { const record = entry.record; const history = histories.get(`data/${entry.relativePath}`) || []; - const changesInsidePeriod = history.filter(({ timestamp }) => ( - timestamp?.slice(0, 10) > coverage.start - && timestamp?.slice(0, 10) <= coverage.end - )); - if (changesInsidePeriod.length > 1 && ["appointment", "control", "policy", "source-coverage", "system"].includes(record.type)) { + const impact = await assessPeriodImpact(loaded, record, histories, coverage, options.asOf); + if (impact) { findings.push(periodFinding( `period.change.${record.type}.${record.id}`, - `Review period impact for ${record.title}`, - `${changesInsidePeriod.length} committed revisions fall inside the period. Confirm the effective date, review, source continuity, and any needed Policy Event or Exception.`, - "ready", - { type: record.type, id: record.id }, - [mutationAction(record)] + impact.state === "complete" ? `Period history for ${record.title}` : `Review period impact for ${record.title}`, + impact.message, impact.state, { type: record.type, id: record.id }, + impact.state === "complete" ? [] : [{ kind: "command", command: `npx filegrc review-period-impact ${shellArgument(record.id)} --scaffold` }] )); } if (record.type === "evidence") { diff --git a/packages/filegrc/test/agent.test.js b/packages/filegrc/test/agent.test.js index 8d80416..3e90378 100644 --- a/packages/filegrc/test/agent.test.js +++ b/packages/filegrc/test/agent.test.js @@ -117,6 +117,7 @@ test("agent guides and scaffolds cover every resource type from the model", asyn "previewMutation", "create", "complete", + "reviewPeriodImpact", "withdrawObligationRule", "correctObligationRule", "reconcileObligation", "completeAction", diff --git a/packages/filegrc/test/component-period-retention-review.test.js b/packages/filegrc/test/component-period-retention-review.test.js new file mode 100644 index 0000000..9255f7c --- /dev/null +++ b/packages/filegrc/test/component-period-retention-review.test.js @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { makeRetentionApplicationWorkspace } from "./fixtures/retention-applications.js"; +import { commitWorkspaceFiles, writeJson } from "./helpers.js"; +import { loadWorkspace } from "../src/workspace.js"; +import { getWorkspaceHistories } from "../src/git.js"; +import { markdownEntries, resourceDataPath } from "../src/resource-markdown.js"; +import { scaffoldRetentionApplication, saveRetentionApplication, retentionApplicationIssues } from "../src/retention-applications.js"; +import { assessPeriodImpact } from "../src/period-impact.js"; +import { currentCalendarDate } from "../src/time.js"; + +test("current exact owner data-use application covers supporting inventory changes without waiving other changes", async context => { + const root = await mkdtemp(join(tmpdir(), "filegrc-component-period-fit-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeRetentionApplicationWorkspace(root); + let loaded = await loadWorkspace(root); + const source = loaded.resources.find(record => record.id === "component-example"); + const component = { ...source, id: "component-business-service", title: "Business working-record service", vendorId: "vendor-example", + systemUses: [{ systemId: "system-example", roles: ["supporting-operations"], rationale: "Ordinary business administration." }], + informationUses: [{ informationTypeId: "information-type-example", processingOperations: ["store"] }] }; + delete component.evidenceSourceKinds; delete component.evidenceOwnerIds; + const path = join(root, "data", resourceDataPath(loaded.model, component)); + await writeJson(path, component); + const slots = markdownEntries(loaded.model, component); + for (const slot of slots) { await mkdir(join(root, "data", slot.path, ".."), { recursive: true }); await writeFile(join(root, "data", slot.path), "# Business use\n\nOrdinary working records under the approved rule.\n"); } + await commitWorkspaceFiles(root, "Catalog supporting business service"); + component.informationUses[0].processingOperations.push("use"); + await writeJson(path, component); await commitWorkspaceFiles(root, "Record working-record data use"); + const payload = await scaffoldRetentionApplication(root, "retention-schedule-item-example"); + Object.assign(payload.record, { scopeResourceIds: [component.id], informationTypeIds: ["information-type-example"], reviewedById: "person-example", authorityAppointmentId: "appointment-example", purpose: "Ordinary working business records.", rationale: "Same approved category, purpose, cutoff, period and holds.", disposalImplementation: "Owner deletes expired working records through the provider and checks holds." }); + Object.keys(payload.record.confirmations).forEach(key => payload.record.confirmations[key] = true); + payload.record.reviewedScopeRevisions = Object.fromEntries(payload.scopeCandidates.map(({ id, revision }) => [id, revision])); + await saveRetentionApplication(root, payload, { confirmed: true }); + await commitWorkspaceFiles(root, "Record owner data-use decision"); + const assess = async () => { + loaded = await loadWorkspace(root); + const record = loaded.resources.find(item => item.id === component.id); + const paths = [`data/${resourceDataPath(loaded.model, record)}`, ...markdownEntries(loaded.model, record).map(slot => `data/${slot.path}`)]; + const asOf = currentCalendarDate(loaded.workspace.timezone); + return assessPeriodImpact(loaded, record, getWorkspaceHistories(root, paths, 100), { start: `${asOf.slice(0, 4)}-01-01`, end: `${asOf.slice(0, 4)}-12-31` }, asOf); + }; + let impact = await assess(); + assert.equal(impact.state, "complete"); assert.match(impact.message, /owner retention applicability review/); + component.internetExposed = !component.internetExposed; + await writeJson(path, component); + assert.equal((await assess()).state, "ready", "data-use approval cannot waive exposure changes"); + component.internetExposed = source.internetExposed; + await writeJson(path, component); + assert.equal((await assess()).state, "complete"); + component.informationUses[0].processingOperations = ["store"]; + await writeJson(path, component); await commitWorkspaceFiles(root, "Change actual data use"); + component.informationUses[0].processingOperations.push("use"); + await writeJson(path, component); await commitWorkspaceFiles(root, "Repeat old data-use state"); + loaded = await loadWorkspace(root); + assert.deepEqual(retentionApplicationIssues(loaded, loaded.resources.find(record => record.id === payload.record.id)), []); + assert.equal((await assess()).state, "ready", "equal current scope hash cannot waive an intervening unreviewed change"); +}); diff --git a/packages/filegrc/test/control-period-review.test.js b/packages/filegrc/test/control-period-review.test.js new file mode 100644 index 0000000..7c5708c --- /dev/null +++ b/packages/filegrc/test/control-period-review.test.js @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import test from "node:test"; +import { makeComprehensiveWorkspace } from "./fixtures.js"; +import { loadWorkspace } from "../src/workspace.js"; +import { resourceDataPath, markdownEntries } from "../src/resource-markdown.js"; +import { assessBoundControlPeriodReview } from "../src/control-period-review.js"; + +const execute = promisify(execFile); +const asOf = "2026-10-28"; +async function fixture(context, configure = () => {}) { + const root = await mkdtemp(join(tmpdir(), "filegrc-bound-control-review-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "13"); + let loaded = await loadWorkspace(root); + const control = loaded.resources.find(record => record.type === "control"); + const owner = loaded.resources.find(record => record.type === "person" && record.id === control.ownerIds[0]); + const program = loaded.resources.find(record => record.type === "program"); + const system = loaded.resources.find(record => record.type === "system"); + const source = { id: "component-governance-inputs", type: "component", title: "Governance record repository", status: "active", componentKind: "software", description: "Authoritative governance record inputs.", ownerIds: [owner.id], systemUses: [{ systemId: system.id, roles: ["evidence-source"], rationale: "Produces retained design-review inputs." }], evidenceSourceKinds: ["governance"] }; + const save = async record => { + const path = join(root, "data", resourceDataPath(loaded.model, record)); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(record, null, 2) + "\n"); + }; + await save(source); + const obligation = { ...loaded.resources.find(record => record.type === "obligation"), id: "obligation-design-review", title: "Annual design review", activityType: "control-design-review", activeRuleId: "obligation-rule-design-review", ruleIds: ["obligation-rule-design-review"] }; + const rule = { ...loaded.resources.find(record => record.type === "obligation-rule"), id: obligation.activeRuleId, obligationId: obligation.id, recurrence: { mode: "calendar", unit: "year", interval: 1, anchorDate: "2026-01-01" } }; + delete rule.window; + await save(obligation); await save(rule); + const git = async (...args) => (await execute("git", args, { cwd: root })).stdout.trim(); + await git("init", "--initial-branch=main"); + const commit = async message => { + await git("add", "."); + await execute("git", ["-c", "commit.gpgSign=false", "commit", "--quiet", "-m", message], { cwd: root, env: { ...process.env, GIT_AUTHOR_NAME: "Fixture Reviewer", GIT_AUTHOR_EMAIL: "reviewer@example.test", GIT_COMMITTER_NAME: "Fixture Reviewer", GIT_COMMITTER_EMAIL: "reviewer@example.test" } }); + return git("rev-parse", "HEAD"); + }; + await commit("Record baseline design"); + const slot = markdownEntries(loaded.model, control)[0], contentPath = join(root, "data", slot.path); + const baseline = await readFile(contentPath, "utf8"); + await writeFile(contentPath, baseline + "\nInspect current source scope before retaining the review result.\n"); + const sourceCommit = await commit("Clarify reviewed operating procedure"); + const evidence = { id: "evidence-design-inputs", type: "evidence", title: "Retained design-review inputs", status: "collected", artifactKind: "system-export", sourceKind: "component", sourceComponentId: source.id, sourceDescription: "Fixed current design inputs from the governance repository.", collectedOn: "2026-10-25", collectedAt: "2026-10-25T12:00:00Z", collectorIds: [owner.id], classificationId: "classification-example", systemIds: [system.id], sourceCommit, filePaths: ["evidence/evidence-design-inputs/design-inputs.json"] }; + await mkdir(join(root, "data/evidence/evidence-design-inputs"), { recursive: true }); + await writeFile(join(root, "data", evidence.filePaths[0]), JSON.stringify({ reviewPurpose: "Current Control design and source inputs.", procedure: await readFile(contentPath, "utf8") })); + const review = { id: "control-activity-design-review", type: "control-activity", title: "Completed design review", status: "complete", profileId: "control-design-review", scopeResourceIds: [system.id], ownerIds: [owner.id], obligationId: obligation.id, controlIds: [control.id], performerIds: [owner.id], completedAt: "2026-10-25T12:10:00Z", method: "Reviewed the scoped current Control design and retained exact source inputs.", result: "passed", reviewerIds: [owner.id], reviewedOn: "2026-10-25", evidenceIds: [evidence.id] }; + const occurrence = { id: "obligation-occurrence-design-review", type: "obligation-occurrence", title: "Reconciled design review", status: "reconciled", programId: program.id, obligationId: obligation.id, ruleId: rule.id, occurrenceKey: "2026-01-01", coverage: { kind: "range", startsOn: "2026-01-01", endsOn: "2026-12-31" }, membershipCutoffAt: "2026-10-25", members: [{ resourceId: system.id, disposition: "expected", result: "passed", completionResourceIds: [review.id] }], expectedCount: 1, completedCount: 1, ownerIds: [owner.id], reviewedByIds: [owner.id], reconciledAt: "2026-10-25T12:20:00Z", conclusion: "complete" }; + configure({ review, evidence, occurrence, source }); + await save(source); await save(evidence); await save(review); await save(occurrence); + await commit("Retain completed review and frozen occurrence"); + loaded = await loadWorkspace(root); + const assess = async () => { const current = await loadWorkspace(root); return assessBoundControlPeriodReview(current, current.resources.find(record => record.id === control.id), { asOf }); }; + return { root, control, source, owner, program, system, obligation, rule, evidence, review, occurrence, save, commit, assess, contentPath, reviewedContent: await readFile(contentPath, "utf8") }; +} + +test("normal scoped owner design review reuses exact fixed inputs without a second impact decision", async context => { + const f = await fixture(context); + assert.deepEqual(await f.assess(), { reviewResourceId: f.review.id, evidenceResourceId: f.evidence.id, occurrenceResourceId: f.occurrence.id, sourceRevision: f.evidence.sourceCommit }); + await writeFile(join(f.root, "notes.md"), "Unrelated repository update.\n"); await f.commit("Update unrelated notes"); + assert.equal((await f.assess())?.reviewResourceId, f.review.id); +}); + +test("changed procedure and change/revert/repeat cannot reuse old design inputs", async context => { + const f = await fixture(context); + await writeFile(f.contentPath, f.reviewedContent + "\nUse a different privileged-access population.\n"); + assert.equal(await f.assess(), null, "Same effective date and procedure label cannot hide changed companion procedures."); + await f.commit("Change the actual procedure"); + await writeFile(f.contentPath, f.reviewedContent); await f.commit("Return to earlier procedure bytes"); + assert.equal(await f.assess(), null, "A later repeated state needs a new actual review."); +}); + +test("unbound, incomplete, failed, unauthorized or differently scoped reviews remain work", async context => { + for (const [label, mutate] of [ + ["incomplete", f => { f.review.status = "planned"; }], + ["failed", f => { f.review.result = "failed"; }], + ["different scope", f => { f.review.scopeResourceIds = [f.source.id]; }], + ["not a design review", f => { f.review.profileId = "inventory-review"; }], + ["no captured source", f => { delete f.evidence.sourceCommit; }], + ["not repository inputs", f => { f.source.evidenceSourceKinds = ["network-security"]; }], + ["not retained fixed proof", f => { f.evidence.filePaths = []; }], + ["rehearsal only", f => { f.evidence.readinessTest = true; }], + ["failed occurrence", f => { f.occurrence.members[0].result = "failed"; }], + ["wrong completion", f => { f.occurrence.members[0].completionResourceIds = []; }], + ["unauthorized actor", f => { f.review.performerIds = ["person-independent-approver-example"]; f.review.reviewerIds = ["person-independent-approver-example"]; }] + ]) { + const f = await fixture(context, mutate); + assert.equal(await f.assess(), null, label); + } +}); + +test("fixed input replacement cannot manufacture a bound review", async context => { + const f = await fixture(context); + await writeFile(join(f.root, "data", f.evidence.filePaths[0]), '{"different":"input"}'); + assert.equal(await f.assess(), null); +}); diff --git a/packages/filegrc/test/fixtures/workflow-runtime.js b/packages/filegrc/test/fixtures/workflow-runtime.js new file mode 100644 index 0000000..8b3bac0 --- /dev/null +++ b/packages/filegrc/test/fixtures/workflow-runtime.js @@ -0,0 +1,10 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +export async function adoptWorkflowRuntime(root, version = "0.21.6") { + const dependencies = { filegrc: `^${version}` }; + await writeFile(join(root, "package.json"), JSON.stringify({ private: true, dependencies }, null, 2) + "\n"); + await writeFile(join(root, "package-lock.json"), JSON.stringify({ lockfileVersion: 3, packages: { + "": { dependencies }, "node_modules/filegrc": { version } + } }, null, 2) + "\n"); +} diff --git a/packages/filegrc/test/historical-operating-proof.test.js b/packages/filegrc/test/historical-operating-proof.test.js new file mode 100644 index 0000000..ff639be --- /dev/null +++ b/packages/filegrc/test/historical-operating-proof.test.js @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadModel } from "../model/index.js"; +import { supersededOperatingProofIds } from "../src/historical-operating-proof.js"; +import { assessWorkflow } from "../src/workflow.js"; +import { loadWorkspace } from "../src/workspace.js"; +import { makeWorkspace } from "./helpers.js"; + +function proofFixture() { + const old = { id: "occurrence-original", type: "obligation-occurrence", status: "superseded", programId: "program-example", obligationId: "obligation-example", + coverage: { kind: "range", startsOn: "2026-09-01", endsOn: "2026-09-30" }, + members: [{ resourceId: "person-example", disposition: "expected", result: "failed", completionResourceIds: ["review-original"] }] }; + const replacement = { ...structuredClone(old), id: "occurrence-replacement", supersedesId: old.id, status: "reconciled", conclusion: "complete", + members: [{ resourceId: "person-example", disposition: "expected", result: "passed", completionResourceIds: ["review-replacement"] }] }; + const originalProof = { id: "review-original", type: "access-review", status: "in-progress", findings: "Unresolved historical result." }; + const replacementProof = { id: "review-replacement", type: "access-review", status: "complete", reviewedOn: "2026-10-01", evidenceIds: ["evidence-example"] }; + return { old, replacement, originalProof, replacementProof, resources: [old, replacement, originalProof, replacementProof] }; +} + +test("exact finalized replacement suppresses only the old generic completion prompt without changing frozen proof", () => { + const { resources, originalProof } = proofFixture(); + const before = structuredClone(resources); + Object.freeze(originalProof); + assert.deepEqual([...supersededOperatingProofIds(resources, true)], [originalProof.id]); + assert.deepEqual(resources, before); + assert.equal(originalProof.status, "in-progress"); + assert.deepEqual([...supersededOperatingProofIds(resources, false)], []); +}); + +test("unrelated, ambiguous, unfinished or incomplete replacements cannot hide historical work", () => { + for (const [label, mutate] of [ + ["different Program", ({ replacement }) => { replacement.programId = "program-other"; }], + ["different Obligation", ({ replacement }) => { replacement.obligationId = "obligation-other"; }], + ["different period", ({ replacement }) => { replacement.coverage.endsOn = "2026-10-01"; }], + ["different member", ({ replacement }) => { replacement.members[0].resourceId = "person-other"; }], + ["omitted member", ({ replacement }) => { replacement.members = []; }], + ["not finalized", ({ replacement }) => { replacement.status = "in-progress"; }], + ["failed conclusion", ({ replacement }) => { replacement.conclusion = "incomplete"; }], + ["failed member", ({ replacement }) => { replacement.members[0].result = "failed"; }], + ["excluded member", ({ replacement }) => { replacement.members[0].disposition = "excluded"; }], + ["missing proof", ({ replacement }) => { replacement.members[0].completionResourceIds = []; }], + ["nonexistent proof", ({ replacement }) => { replacement.members[0].completionResourceIds = ["review-missing"]; }], + ["unfinished proof", ({ replacementProof }) => { replacementProof.status = "in-progress"; }], + ["ambiguous successor", ({ resources, replacement }) => { resources.push({ ...structuredClone(replacement), id: "occurrence-competing" }); }], + ["reused old proof", ({ replacement }) => { replacement.members[0].completionResourceIds = ["review-original"]; }] + ]) { + const fixture = proofFixture(); + mutate(fixture); + assert.deepEqual([...supersededOperatingProofIds(fixture.resources, true)], [], label); + } +}); + +test("replacement chains must preserve exact identity and terminate without cycles", () => { + let fixture = proofFixture(); + const terminal = { ...structuredClone(fixture.replacement), id: "occurrence-final", supersedesId: fixture.replacement.id }; + fixture.replacement.status = "superseded"; + fixture.resources.push(terminal); + assert.equal(supersededOperatingProofIds(fixture.resources, true).has(fixture.originalProof.id), true); + terminal.obligationId = "obligation-other"; + assert.equal(supersededOperatingProofIds(fixture.resources, true).has(fixture.originalProof.id), false); + fixture = proofFixture(); + fixture.old.supersedesId = fixture.replacement.id; + fixture.replacement.status = "superseded"; + assert.deepEqual([...supersededOperatingProofIds(fixture.resources, true)], []); +}); + +test("any live occurrence or assigned action retaining old proof prevents suppression", () => { + for (const assignment of [ + { id: "occurrence-live-other", type: "obligation-occurrence", status: "reconciled", members: [{ resourceId: "person-other", completionResourceIds: ["review-original"] }] }, + ...["open", "in-progress", "blocked"].map(status => ({ id: "action-live-other", type: "action-item", status, completionResourceIds: ["review-original"] })) + ]) { + const fixture = proofFixture(); + fixture.resources.push(assignment); + assert.deepEqual([...supersededOperatingProofIds(fixture.resources, true)], [], assignment.status); + } +}); + +test("workflow removes only the validly superseded proof's generic finalize prompt", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-historical-proof-guidance-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeWorkspace(root); + const loaded = await loadWorkspace(root); + loaded.model = loadModel("13"); + const program = { id: "program-example", type: "program", status: "active", assuranceGoal: "none", controlIds: [], systemIds: [] }; + const fixture = proofFixture(); + loaded.resources.push(program, ...fixture.resources); + const before = structuredClone(fixture.originalProof); + Object.freeze(fixture.originalProof); + const assess = (ok = true) => assessWorkflow(loaded, { + asOf: "2026-10-10", now: "2026-10-10T12:00:00Z", programId: program.id, + programReadiness: { stages: [], evidenceReady: true, program: { id: program.id } }, auditPreparations: {}, + validation: { ok, diagnostics: [], counts: {} }, obligations: { items: [], triggers: [], counts: {}, through: "2026-10-10" }, + reconciliation: { candidates: [] }, git: {} + }); + const hasOldPrompt = workflow => workflow.findings.some(({ code, subject }) => code.endsWith(".finalize") && subject?.id === fixture.originalProof.id); + assert.equal(hasOldPrompt(await assess()), false); + assert.deepEqual(fixture.originalProof, before); + assert.equal(hasOldPrompt(await assess(false)), true); + fixture.replacement.coverage.endsOn = "2026-10-01"; + assert.equal(hasOldPrompt(await assess()), true); + assert.deepEqual(fixture.originalProof, before); +}); + +test("a dated superseded proof is not recreated as overdue source work", async context => { + const { sourceDeadlineWorkItems, planWorkQueue } = await import("../src/work-queue.js"); + const fixture = proofFixture(); + fixture.originalProof.scheduledFor = "2026-09-20"; + const model = loadModel("13"), program = { id:"program-example",type:"program",status:"active",systemIds:[],controlIds:[] }; + const records = [program,...fixture.resources]; + const options = { model, program, asOf:"2026-10-10", validationOk:true }; + assert.equal(sourceDeadlineWorkItems(records, options).some(item=>item.source?.id===fixture.originalProof.id),false); + assert.equal(planWorkQueue(records,{items:[],dataModelVersion:"13",asOf:options.asOf,through:options.asOf},options).items.some(item=>item.source?.id===fixture.originalProof.id),false); + const root = await mkdtemp(join(tmpdir(),"filegrc-historical-source-queue-")); + context.after(()=>rm(root,{recursive:true,force:true})); await makeWorkspace(root); + const loaded = await loadWorkspace(root);loaded.model=model;loaded.resources.push(...records); + const workflow=await assessWorkflow(loaded,{asOf:options.asOf,programId:program.id,programReadiness:{stages:[],evidenceReady:true},auditPreparations:{},validation:{ok:true,diagnostics:[]},obligations:{items:[],triggers:[],counts:{},asOf:options.asOf,through:options.asOf},reconciliation:{candidates:[]},git:{}}); + assert.equal(workflow.workItems.some(item=>item.source?.id===fixture.originalProof.id),false); + assert.equal(workflow.workQueue.items.some(item=>item.source?.id===fixture.originalProof.id),false); + assert.equal(sourceDeadlineWorkItems(records,{...options,validationOk:undefined}).find(item=>item.source?.id===fixture.originalProof.id)?.state,"overdue"); + assert.equal(sourceDeadlineWorkItems(records,{...options,validationOk:false}).find(item=>item.source?.id===fixture.originalProof.id)?.state,"overdue"); + fixture.replacement.members[0].result="failed"; + assert.equal(sourceDeadlineWorkItems(records,options).find(item=>item.source?.id===fixture.originalProof.id)?.state,"overdue"); +}); + +test("missing historical proof fails closed without crashing queue validation preflight", async context => { + const { sourceDeadlineValidation } = await import("../src/work-queue.js"); + const root = await mkdtemp(join(tmpdir(), "filegrc-missing-historical-proof-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeWorkspace(root); + const loaded = await loadWorkspace(root); + const fixture = proofFixture(); + loaded.resources.push(...fixture.resources.filter(record => record.id !== fixture.originalProof.id)); + assert.equal(await sourceDeadlineValidation(loaded), false); +}); diff --git a/packages/filegrc/test/obligation-rule-withdrawal.test.js b/packages/filegrc/test/obligation-rule-withdrawal.test.js new file mode 100644 index 0000000..7ebe232 --- /dev/null +++ b/packages/filegrc/test/obligation-rule-withdrawal.test.js @@ -0,0 +1,192 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { makeComprehensiveWorkspace } from "./fixtures.js"; +import { adoptWorkflowRuntime } from "./fixtures/workflow-runtime.js"; +import { initializeGitWorkspace, initializeExistingModel12Workspace, commitWorkspaceFiles, writeJson } from "./helpers.js"; +import { currentCalendarDate } from "../src/time.js"; +import { assessCollectionReview, scaffoldCollectionReview, applyCollectionReview } from "../src/collection-review.js"; +import { + loadWorkspace, createResource, updateResource, deleteResource, validateWorkspace, serveWorkspace, + scaffoldObligationRuleWithdrawal, previewObligationRuleWithdrawal, withdrawObligationRule, + scaffoldObligationRuleActivation, buildAgentGuide +} from "../src/index.js"; + +async function fixture(t, status = "proposed") { + const root = await mkdtemp(join(tmpdir(), "filegrc-withdrawal-test-")); + t.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "13"); + await adoptWorkflowRuntime(root); + const loaded = await loadWorkspace(root); + const active = loaded.resources.find(({ type }) => type === "obligation-rule"); + const rule = { ...active, id: "obligation-rule-unused-proposal", title: "Unused schedule proposal", status }; + for (const field of ["effectiveAt", "retiredOn", "supersedesId", "cutoverDecision"]) delete rule[field]; + if (status === "proposed") for (const field of ["approvedByIds", "approvedOn", "timezone"]) delete rule[field]; + await createResource(root, rule); + const payload = { ...await scaffoldObligationRuleWithdrawal(root, { ruleId: rule.id }), + withdrawnByIds: [loaded.resources.find(({ type, status }) => type === "person" && status === "active").id], + withdrawnOn: "2026-07-20", withdrawalReason: "Management selected another reviewed schedule. This proposal never governed work." }; + return { root, rule, active, payload }; +} + +test("withdraws an unused proposal with actual decision facts and no invented approval", async (t) => { + const { root, rule, payload } = await fixture(t); + const before = await loadWorkspace(root); + const baseline = await validateWorkspace(root); + const preview = await previewObligationRuleWithdrawal(root, payload); + assert.equal(preview.record.status, "withdrawn"); + assert.equal(preview.record.approvedOn, undefined); + assert.equal(preview.record.effectiveAt, undefined); + assert.equal((await loadWorkspace(root)).resources.find(({ id }) => id === rule.id).status, "proposed"); + await withdrawObligationRule(root, payload); + const after = await loadWorkspace(root); + const withdrawn = after.resources.find(({ id }) => id === rule.id); + assert.equal(withdrawn.withdrawnOn, payload.withdrawnOn); + assert.deepEqual(after.resources.filter(({ id }) => id !== rule.id), before.resources.filter(({ id }) => id !== rule.id)); + const validated = await validateWorkspace(root); + assert.deepEqual(validated.diagnostics, baseline.diagnostics); + await assert.rejects(() => updateResource(root, rule.type, rule.id, { ...withdrawn, status: "proposed" }), /immutable/); + await assert.rejects(() => deleteResource(root, rule.type, rule.id), /cannot be deleted/); + await assert.rejects(() => scaffoldObligationRuleActivation(root, { ruleId: rule.id }), /already withdrawn/); + assert.match(buildAgentGuide(after, "obligation-rule").withdrawal, /withdraw-obligation-rule/); +}); + +test("an older declared reader cannot adopt withdrawal through a newer running engine", async t => { + const { root, rule, payload } = await fixture(t); + const entry = (await loadWorkspace(root)).entries.find(item => item.record.id === rule.id); + const before = await readFile(entry.path, "utf8"); + await adoptWorkflowRuntime(root, "0.21.5"); + await assert.rejects(scaffoldObligationRuleWithdrawal(root, { ruleId: rule.id }), /Adopt FileGRC 0\.21\.6/); + await assert.rejects(withdrawObligationRule(root, payload), /Adopt FileGRC 0\.21\.6/); + assert.equal(await readFile(entry.path, "utf8"), before); + assert.equal((await validateWorkspace(root)).diagnostics.some(item => item.code === "source-runtime-adoption-required"), false, "the unchanged proposal remains compatible"); +}); + +test("withdrawal preserves a prior approval without changing independent schedule oversight", async (t) => { + const { root, rule, payload } = await fixture(t, "approved"); + const before = await loadWorkspace(root); + await withdrawObligationRule(root, payload); + const after = await loadWorkspace(root); + const withdrawn = after.resources.find(({ id }) => id === rule.id); + assert.deepEqual(withdrawn.approvedByIds, rule.approvedByIds); + assert.equal(withdrawn.approvedOn, rule.approvedOn); + assert.equal(withdrawn.timezone, rule.timezone); + assert.equal(withdrawn.effectiveAt, undefined); + assert.deepEqual(after.resources.filter(({ type }) => type === "collection-review"), before.resources.filter(({ type }) => type === "collection-review")); +}); + +test("withdrawal rejects effective rules, stale edits, invalid dates, unknown actors and blank reasons", async (t) => { + const { root, rule, active, payload } = await fixture(t); + await assert.rejects(() => scaffoldObligationRuleWithdrawal(root, { ruleId: active.id }), /never took effect/); + for (const patch of [{ expectedRevision: "stale" }, { withdrawnOn: "2099-01-01" }, { withdrawnOn: "not-a-date" }, + { withdrawnByIds: [] }, { withdrawnByIds: ["person-missing"] }, { withdrawalReason: " " }]) { + await assert.rejects(() => previewObligationRuleWithdrawal(root, { ...payload, ...patch })); + } + await assert.rejects(() => updateResource(root, rule.type, rule.id, { ...rule, status: "withdrawn", + withdrawnOn: payload.withdrawnOn, withdrawnByIds: payload.withdrawnByIds, withdrawalReason: payload.withdrawalReason }), /workflow-managed/); + const loaded = await loadWorkspace(root); + const path = loaded.entries.find(({ record }) => record.id === active.id).path; + await writeFile(path, JSON.stringify({ ...active, status: "withdrawn", withdrawnOn: payload.withdrawnOn, + withdrawnByIds: payload.withdrawnByIds, withdrawalReason: payload.withdrawalReason })); + const result = await validateWorkspace(root); + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some(({ code }) => code === "effective-obligation-rule-withdrawal")); +}); + +test("HTTP and CLI use the shared withdrawal scaffold and preview", async (t) => { + const { root, rule, payload } = await fixture(t); + const server = await serveWorkspace(root, { port: 0, allowNonAuthoritativeWrites: true }); + t.after(() => new Promise((resolve) => server.server.close(resolve))); + const endpoint = `http://127.0.0.1:${server.server.address().port}`; + const post = (path, data) => fetch(endpoint + path, { method: "POST", headers: { "content-type": "application/json", origin: endpoint }, body: JSON.stringify(data) }); + const scaffold = await post("/api/obligation-rule-withdrawals/scaffold", { ruleId: rule.id }); + assert.equal(scaffold.status, 200); + const apiScaffold = await scaffold.json(); + const cliScaffold = JSON.parse(execFileSync(process.execPath, [join(import.meta.dirname, "../bin/filegrc.js"), "withdraw-obligation-rule", rule.id, "--scaffold", "--root", root], { encoding: "utf8" })); + assert.deepEqual(apiScaffold, cliScaffold); + const response = await post("/api/obligation-rule-withdrawals/preview", payload); + assert.equal(response.status, 200, await response.clone().text()); + assert.deepEqual(await response.json(), await previewObligationRuleWithdrawal(root, payload)); + await adoptWorkflowRuntime(root, "0.21.5"); + const denied = await post("/api/obligation-rule-withdrawals/scaffold", { ruleId: rule.id }); + assert.equal(denied.status, 400); + assert.match((await denied.json()).error, /Adopt FileGRC 0\.21\.6/); + await adoptWorkflowRuntime(root); + const file = join(root, "withdrawal.json"); + await writeFile(file, JSON.stringify(payload)); + const cliPreview = JSON.parse(execFileSync(process.execPath, [join(import.meta.dirname, "../bin/filegrc.js"), "withdraw-obligation-rule", rule.id, file, "--preview", "--root", root], { encoding: "utf8" })); + assert.equal(cliPreview.record.status, "withdrawn"); + const sourcePath = join(root, "data/obligation-rules", `${rule.id}.json`); + assert.equal(JSON.parse(await readFile(sourcePath)).status, "proposed"); + const applied = await post("/api/obligation-rule-withdrawals", payload); + assert.equal(applied.status, 201, await applied.clone().text()); + assert.equal(JSON.parse(await readFile(sourcePath)).status, "withdrawn"); +}); + +test("committed withdrawal remains immutable under direct source edits", async (t) => { + const { root, rule, payload } = await fixture(t); + await withdrawObligationRule(root, payload); + await initializeGitWorkspace(root); + const path = join(root, "data/obligation-rules", `${rule.id}.json`); + const withdrawn = JSON.parse(await readFile(path)); + await writeFile(path, JSON.stringify({ ...withdrawn, withdrawalReason: "Rewritten decision" })); + const result = await validateWorkspace(root); + assert.ok(result.diagnostics.some(({ code }) => code === "rewritten-finalized-record"), JSON.stringify(result.diagnostics)); +}); + +test("withdrawing an explicitly excluded successor preserves the exact independent schedule approval", async (t) => { + const { root, rule, active, payload } = await fixture(t); + let loaded = await loadWorkspace(root); + const proposalEntry = loaded.entries.find(({ record }) => record.id === rule.id); + await writeJson(proposalEntry.path, { ...rule, supersedesId: active.id, cutoverDecision: "new-windows-only" }); + const obligationEntry = loaded.entries.find(({ record }) => record.id === active.obligationId); + await writeJson(obligationEntry.path, { ...obligationEntry.record, ruleIds: [...obligationEntry.record.ruleIds, rule.id] }); + await initializeExistingModel12Workspace(root); + loaded = await loadWorkspace(root); + const program = loaded.resources.find(({ type }) => type === "program"); + const scaffold = await scaffoldCollectionReview(root, { resourceType: "obligation", programId: program.id }); + const reviewedOn = currentCalendarDate(loaded.workspace.timezone); + await applyCollectionReview(root, { ...scaffold, confirmed: true, + reviewedByIds: ["person-independent-approver-example"], reviewedOn, + rationale: "Reviewed the adopted rule and explicitly excluded its unused successor.", + scheduleDecisions: scaffold.scheduleDecisions.map((decision) => ({ ...decision, disposition: "adopted", + rationale: "The current rule remains adopted; the proposed successor will not govern work.", + ...(decision.obligationId === active.obligationId ? { excludedRuleIds: [rule.id] } : {}) })) }); + await commitWorkspaceFiles(root, "Approve the current schedule set and exclude the unused successor"); + const before = await loadWorkspace(root); + const approval = assessCollectionReview(before, "obligation", { programId: program.id }); + assert.equal(approval.complete, true, JSON.stringify(approval)); + assert.equal(approval.status, "current"); + const beforeReview = before.entries.find(({ record }) => record.id === approval.review.id); + const activeBefore = before.entries.find(({ record }) => record.id === active.id); + const obligationBefore = before.entries.find(({ record }) => record.id === active.obligationId); + await withdrawObligationRule(root, { ...payload, + ...await scaffoldObligationRuleWithdrawal(root, { ruleId: rule.id }), + withdrawnByIds: payload.withdrawnByIds, withdrawnOn: reviewedOn, withdrawalReason: payload.withdrawalReason }); + const after = await loadWorkspace(root); + const assessment = assessCollectionReview(after, "obligation", { programId: program.id }); + assert.equal(assessment.complete, true, JSON.stringify(assessment)); + assert.equal(assessment.status, "current"); + assert.deepEqual(assessment.review, approval.review); + for (const entry of [beforeReview, activeBefore, obligationBefore]) { + assert.equal(await readFile(entry.path, "utf8"), entry.source, `${entry.record.id} must remain byte-for-byte unchanged`); + } + const changedExcluded = { ...after, resources: after.resources.map((record) => record.id === rule.id + ? { ...record, rationale: "Changed the reviewed proposal before withdrawing it." } : record) }; + assert.equal(assessCollectionReview(changedExcluded, "obligation", { programId: program.id }).complete, false); + // A new proposal and any change to the adopted duty still need independent review. + const newProposal = { ...rule, id: "obligation-rule-another-proposal" }; + const extra = { ...after, resources: [...after.resources, newProposal] }; + assert.equal(assessCollectionReview(extra, "obligation", { programId: program.id }).complete, false); + const changedDuty = { ...after, resources: after.resources.map((record) => record.id === active.id + ? { ...record, window: { precision: "date", startsAfter: 0, dueAfter: 4 } } : record) }; + assert.equal(assessCollectionReview(changedDuty, "obligation", { programId: program.id }).complete, false); + const newAdoption = { ...after, resources: after.resources.map((record) => record.id === active.obligationId + ? { ...record, activeRuleId: rule.id } : record.id === active.id ? { ...record, status: "retired", retiredOn: reviewedOn } + : record.id === rule.id ? { ...record, status: "active", approvedByIds: payload.withdrawnByIds, + approvedOn: reviewedOn, effectiveAt: `${reviewedOn}T00:00:00Z`, timezone: "UTC" } : record) }; + assert.equal(assessCollectionReview(newAdoption, "obligation", { programId: program.id }).complete, false); +}); diff --git a/packages/filegrc/test/operating-proposals.test.js b/packages/filegrc/test/operating-proposals.test.js new file mode 100644 index 0000000..e23cab1 --- /dev/null +++ b/packages/filegrc/test/operating-proposals.test.js @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { loadModel } from "../model/index.js"; +import { collectionRecordProposals } from "../src/collection-scope.js"; +import { assessCollectionReview, collectionRevision } from "../src/collection-review.js"; +import { assessProgramReadiness, collectionReviewReadinessItem } from "../src/program-readiness.js"; +import { loadWorkspace } from "../src/workspace.js"; +import { assessWorkflow } from "../src/workflow.js"; +import { makeComprehensiveWorkspace } from "./fixtures.js"; + +function proposal(record, sources = []) { + const program = { id: "program-example", type: "program", status: "active", controlIds: ["control-example"] }; + const loaded = { model: loadModel(), resources: [program, record, ...sources] }; + return collectionRecordProposals(loaded, record.type, [record], program)[0]; +} + +test("dormant inventory proposals retain incomplete setup state without becoming live operating work", () => { + for (const [type, status] of [["component", "planned"], ["vendor", "evaluating"], ["information-type", "planned"], ["complementary-control", "planned"]]) { + const record = { id: `${type}-future`, title: "Future capability", type, status }; + const result = proposal(record); + assert.equal(result.complete, false, type); + assert.equal(result.operatingStatus, "later", type); + assert.equal(result.operatingDependencyIds, undefined, type); + const setup = collectionReviewReadinessItem({ + resourceType: type, recordCount: 1, complete: false, + configuration: { title: "Inventory", decisions: ["complete"] }, + recordProposals: [result], incompleteRecordProposals: [result] + }); + assert.equal(setup.status, "action", type); + assert.equal(setup.resourceId, record.id, type); + } +}); + +test("live and pre-use dependencies keep proposed Components and evaluating Vendors actionable", () => { + const component = { id: "component-future", title: "Future source", type: "component", status: "planned", vendorId: "vendor-future" }; + const vendor = { id: "vendor-future", title: "Potential provider", type: "vendor", status: "evaluating" }; + for (const source of [ + { id: "control-example", type: "control", status: "implemented", evidenceSourceComponentIds: [component.id] }, + { id: "control-example", type: "control", status: "implemented", componentIds: [component.id] }, + { id: "asset-example", type: "asset", status: "active", componentIds: [component.id] }, + { id: "coverage-example", type: "source-coverage", status: "active", componentId: component.id } + ]) { + const result = proposal(component, [source]); + assert.equal(result.operatingStatus, "action", source.type); + assert.deepEqual(result.operatingDependencyIds, [source.id], source.type); + const provider = proposal(vendor, [component, source]); + assert.equal(provider.operatingStatus, "action", source.type); + assert.deepEqual(provider.operatingDependencyIds, [component.id], source.type); + } + for (const status of ["active", "deprecated"]) { + assert.equal(proposal(vendor, [{ ...component, status }]).operatingStatus, "action", status); + } + for (const source of [ + { id: "control-example", type: "control", status: "planned", componentIds: [component.id] }, + { id: "control-unselected", type: "control", status: "implemented", componentIds: [component.id] }, + { id: "access-example", type: "access-grant", status: "requested", componentId: component.id }, + { id: "access-example", type: "access-grant", status: "approved", componentId: component.id }, + { id: "access-example", type: "access-grant", status: "active", componentId: component.id }, + { id: "access-example", type: "access-grant", status: "revoked", componentId: component.id }, + { id: "coverage-example", type: "source-coverage", status: "retired", componentId: component.id }, + { id: "coverage-example", type: "source-coverage", status: "planned", componentId: component.id } + ]) { + assert.equal(proposal(component, [source]).operatingStatus, "later", `${source.type}/${source.status}`); + assert.equal(proposal(vendor, [component, source]).operatingStatus, "later", `${source.type}/${source.status}`); + } +}); + +test("potential information uses do not imply processing, while live uses remain actionable", () => { + const information = { id: "information-type-future", title: "Future data", type: "information-type", status: "planned" }; + for (const source of [ + { id: "system-example", type: "system", informationTypeIds: [information.id] }, + { id: "vendor-example", type: "vendor", informationTypeIds: [information.id] }, + { id: "component-example", type: "component", informationUses: [{ informationTypeId: information.id }] } + ]) { + assert.equal(proposal(information, [{ ...source, status: "planned" }]).operatingStatus, "later", source.type); + assert.equal(proposal(information, [{ ...source, status: "active" }]).operatingStatus, "action", source.type); + assert.equal(proposal(information, [{ ...source, status: "deprecated" }]).operatingStatus, "action", source.type); + assert.equal(proposal(information, [{ ...source, status: "retired" }]).operatingStatus, "later", source.type); + } +}); + +test("live reliance on a planned Component propagates to its information processing and complementary duties", () => { + const information = { id: "information-type-future", title: "Future data", type: "information-type", status: "planned" }; + const component = { id: "component-future", title: "Future processor", type: "component", status: "planned", informationUses: [{ informationTypeId: information.id, processingOperations: ["store"] }] }; + const complementary = { id: "complementary-control-future", title: "Future duty", type: "complementary-control", status: "planned", componentIds: [component.id] }; + for (const source of [ + { id: "control-example", type: "control", status: "implemented", componentIds: [component.id] }, + { id: "control-example", type: "control", status: "implemented", evidenceSourceComponentIds: [component.id] }, + { id: "asset-example", type: "asset", status: "active", componentIds: [component.id] }, + { id: "coverage-example", type: "source-coverage", status: "active", componentId: component.id } + ]) { + for (const record of [information, complementary]) { + const result = proposal(record, [component, source]); + assert.equal(result.operatingStatus, "action", `${record.type}/${source.type}`); + assert.deepEqual(result.operatingDependencyIds, [component.id]); + assert.equal(result.complete, false); + } + } + for (const record of [information, complementary]) { + const result = proposal(record, [{ ...component, status: "deprecated" }]); + assert.equal(result.operatingStatus, "action", record.type); + assert.deepEqual(result.operatingDependencyIds, [component.id]); + } +}); + +test("ended sources and proposal links cannot supply their own proof of live operation", () => { + const information = { id: "information-type-future", title: "Future data", type: "information-type", status: "planned" }; + const component = { id: "component-future", title: "Future processor", type: "component", status: "planned", vendorId: "vendor-future", informationUses: [{ informationTypeId: information.id, processingOperations: ["store"] }] }; + const vendor = { id: "vendor-future", title: "Potential provider", type: "vendor", status: "evaluating", informationTypeIds: [information.id] }; + const complementary = { id: "complementary-control-future", title: "Future duty", type: "complementary-control", status: "planned", componentIds: [component.id], relatedControlIds: ["control-example"] }; + for (const source of [ + { id: "access-example", type: "access-grant", status: "requested", componentId: component.id }, + { id: "access-example", type: "access-grant", status: "approved", componentId: component.id }, + { id: "access-example", type: "access-grant", status: "active", componentId: component.id }, + { id: "access-example", type: "access-grant", status: "revoked", componentId: component.id }, + { id: "access-example", type: "access-grant", status: "expired", componentId: component.id }, + { id: "control-example", type: "control", status: "planned", componentIds: [component.id] }, + { id: "control-example", type: "control", status: "retired", componentIds: [component.id] }, + { id: "coverage-example", type: "source-coverage", status: "retired", componentId: component.id }, + { id: "asset-example", type: "asset", status: "retired", componentIds: [component.id] }, + { id: "asset-example", type: "asset", status: "disposed", componentIds: [component.id] } + ]) { + for (const record of [information, complementary]) { + assert.equal(proposal(record, [component, vendor, source]).operatingStatus, "later", `${record.type}/${source.type}/${source.status}`); + } + } + const activeAccess = { id: "access-example", type: "access-grant", status: "active", componentId: component.id }; + for (const record of [information, complementary]) { + assert.equal(proposal(record, [{ ...component, status: "retired" }, activeAccess]).operatingStatus, "later", record.type); + } + for (const source of [ + { id: "system-former", type: "system", status: "retired", informationTypeIds: [information.id] }, + { id: "vendor-former", type: "vendor", status: "terminated", informationTypeIds: [information.id] } + ]) assert.equal(proposal(information, [source]).operatingStatus, "later", source.type); + // These proposal relationships form a connected dependency graph, but none + // is a live access grant, implementation or processing assertion. + for (const record of [information, vendor, complementary]) { + assert.equal(proposal(record, [component, ...[information, vendor, complementary].filter(item => item.id !== record.id)]).operatingStatus, "later", record.type); + } +}); + +test("live incomplete inventory and real complementary dependencies remain operating actions", () => { + for (const status of ["active", "deprecated"]) { + assert.equal(proposal({ id: "component-example", type: "component", title: "Incomplete source", status }).operatingStatus, "action"); + } + const complementary = { id: "complementary-control-future", type: "complementary-control", title: "Future responsibility", status: "planned", relatedControlIds: ["control-example"] }; + assert.equal(proposal(complementary, [{ id: "control-example", type: "control", status: "implemented" }]).operatingStatus, "action"); + assert.equal(proposal(complementary, [{ id: "control-example", type: "control", status: "planned" }]).operatingStatus, "later"); + for (const [type, status] of [["component", "retired"], ["vendor", "terminated"], ["information-type", "superseded"], ["complementary-control", "retired"]]) { + assert.equal(proposal({ id: `${type}-former`, type, status }), undefined, `${type}/${status}`); + } +}); + +test("shared program readiness schedules dormant proposals after setup and keeps genuine dependencies actionable", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-operating-proposals-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "11"); + const loaded = await loadWorkspace(root); + const program = loaded.resources.find(({ type }) => type === "program"); + const component = { id: "component-future", type: "component", title: "Future capability", status: "planned" }; + const vendor = { id: "vendor-future", type: "vendor", title: "Potential provider", status: "evaluating" }; + loaded.resources.push(component, vendor); + for (const resourceType of ["component", "vendor"]) { + const assessment = assessCollectionReview(loaded, resourceType, { programId: program.id }); + const review = { + id: `collection-review-${resourceType}-example`, type: "collection-review", title: "Inventory review", status: "active", + resourceType, scopeResourceIds: [program.id], decision: "complete", reviewedByIds: ["person-example"], + reviewedOn: "2026-09-12", coverage: { kind: "as-of", on: "2026-09-12" }, knowledgeCutoffAt: "2026-09-12T12:00:00Z", + populationResourceIds: assessment.records.map(({ id }) => id), collectionRevision: collectionRevision(loaded, resourceType, { programId: program.id }) + }; + loaded.resources = loaded.resources.filter((record) => !(record.type === "collection-review" && record.resourceType === resourceType)); + loaded.entries = loaded.entries.filter(({ record }) => !(record.type === "collection-review" && record.resourceType === resourceType)); + loaded.resources.push(review); + loaded.entries.push({ record: review, source: JSON.stringify(review) }); + assert.equal(assessCollectionReview(loaded, resourceType, { programId: program.id }).complete, true); + } + let readiness = await assessProgramReadiness(loaded, { asOf: "2026-09-12" }); + for (const record of [component, vendor]) { + const item = readiness.stages.at(-1).items.find(({ resourceId }) => resourceId === record.id); + assert.equal(item?.status, "later", record.type); + assert.doesNotMatch(item.message, /retire|activate/i); + assert.ok(!readiness.nextAction || readiness.nextAction.resourceId !== record.id); + } + const workflow = await assessWorkflow(loaded, { programReadiness: readiness, auditPreparations: {}, asOf: "2026-09-12" }); + for (const record of [component, vendor]) { + const finding = workflow.findings.find(({ key }) => key.endsWith(`operating-${record.type}-${record.id}`)); + assert.equal(finding?.state, "scheduled", record.type); + assert.equal(finding?.severity, "info", record.type); + } + component.vendorId = vendor.id; + loaded.resources.push({ id: "access-future-example", type: "access-grant", status: "active", componentId: component.id }); + readiness = await assessProgramReadiness(loaded, { asOf: "2026-09-12" }); + for (const record of [component, vendor]) { + const item = readiness.stages.at(-1).items.find(({ resourceId }) => resourceId === record.id); + assert.equal(item?.status, "later", record.type); + assert.match(item.message, /setup accounts separately/); + } + loaded.resources.push({ id: "coverage-future-example", type: "source-coverage", status: "active", componentId: component.id }); + readiness = await assessProgramReadiness(loaded, { asOf: "2026-09-12" }); + for (const record of [component, vendor]) { + const item = readiness.stages.at(-1).items.find(({ resourceId }) => resourceId === record.id); + assert.equal(item?.status, "action", record.type); + assert.match(item.message, /actual use/); + } +}); diff --git a/packages/filegrc/test/period-impact.test.js b/packages/filegrc/test/period-impact.test.js new file mode 100644 index 0000000..ce0a7c0 --- /dev/null +++ b/packages/filegrc/test/period-impact.test.js @@ -0,0 +1,408 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import test from "node:test"; +import { loadModel } from "../model/index.js"; +import { assessPeriodImpact, currentPeriodImpactRevision, periodImpactReviewCurrent, recordPeriodImpactReview, scaffoldPeriodImpactReview } from "../src/period-impact.js"; +import { markdownEntries, resourceDataPath } from "../src/resource-markdown.js"; +import { loadWorkspace } from "../src/workspace.js"; +import { assessWorkflow } from "../src/workflow.js"; +import { validateWorkspace } from "../src/validate.js"; +import { planReconciliation } from "../src/reconciliation.js"; +import { makeComprehensiveWorkspace } from "./fixtures.js"; +import { makeWorkspace } from "./helpers.js"; +import { adoptWorkflowRuntime } from "./fixtures/workflow-runtime.js"; + +const execute = promisify(execFile); +const coverage = { start: "2026-10-01", end: "2026-10-31" }; +const asOf = "2026-10-10"; + +async function impactFixture(context, type = "component", multiSlot = false) { + const root = await mkdtemp(join(tmpdir(), "filegrc-period-impact-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeWorkspace(root); + await adoptWorkflowRuntime(root); + const workspacePath = join(root, "data", "workspace.json"); + const workspace = JSON.parse(await readFile(workspacePath, "utf8")); + workspace.dataModelVersion = "13"; + delete workspace.classificationDefinitions; + await writeFile(workspacePath, JSON.stringify(workspace), "utf8"); + const model = structuredClone(loadModel("13")); + if (multiSlot) model.resources[type].markdown = { + record: { primary: true, required: false }, + retrievalNotes: { primary: false, required: false } + }; + const record = { id: `${type}-impact-example`, type, title: "Operating source", status: "active", description: "Source used for current operations.", ownerIds: ["person-owner"] }; + if (type === "component") Object.assign(record, { componentKind: "software", evidenceSourceKinds: ["access-review"], systemUses: [{ systemId: "system-example", roles: ["evidence-source"], rationale: "Produces scoped proof." }] }); + if (type === "system") { delete record.description; Object.assign(record, { purpose: "Provide the company service.", boundary: "Company-managed service.", servicesProvided: ["Application"], criticality: "low" }); } + const recordPath = join(root, "data", resourceDataPath(model, record)); + await mkdir(dirname(recordPath), { recursive: true }); + const saveRecord = () => writeFile(recordPath, `${JSON.stringify(record, null, 2)}\n`, "utf8"); + await saveRecord(); + const slots = markdownEntries(model, record); + for (const slot of slots) await writeFile(join(root, "data", slot.path), `# ${slot.name}\n\nRetrieve the scoped population with the recorded method.\n`, "utf8"); + const git = async (...args) => (await execute("git", args, { cwd: root })).stdout.trim(); + await git("init", "--initial-branch=main"); + const commit = async (date, message, paths = ["."]) => { + await git("add", ...paths); + await execute("git", ["-c", "commit.gpgSign=false", "commit", "--quiet", "-m", message], { + cwd: root, env: { ...process.env, GIT_AUTHOR_NAME: "Fixture Reviewer", GIT_AUTHOR_EMAIL: "reviewer@example.test", GIT_COMMITTER_NAME: "Fixture Reviewer", GIT_COMMITTER_EMAIL: "reviewer@example.test", GIT_AUTHOR_DATE: `${date}T12:00:00Z`, GIT_COMMITTER_DATE: `${date}T12:00:00Z` } + }); + return git("rev-parse", "HEAD"); + }; + await commit("2026-09-28", "Record operating baseline"); + const refresh = async () => { + const loaded = await loadWorkspace(root); + loaded.model = model; + const currentRecord = loaded.resources.find(item => item.id === record.id); + const histories = new Map(); + for (const path of [`data/${resourceDataPath(model, record)}`, ...slots.map(slot => `data/${slot.path}`)]) { + const output = await git("log", "--format=%H|%cI", "--", path); + histories.set(path, output ? output.split("\n").map(line => { + const [commit, timestamp] = line.split("|"); + return { commit, timestamp }; + }) : []); + } + return { loaded, record: currentRecord, histories }; + }; + const assess = async () => { const current = await refresh(); return assessPeriodImpact(current.loaded, current.record, current.histories, coverage, asOf); }; + return { root, record, recordPath, slots, saveRecord, commit, refresh, assess }; +} + +test("first actual in-period structured or companion change requires an exact-revision impact review", async (context) => { + for (const mode of ["structured", "companion"]) { + const fixture = await impactFixture(context); + assert.equal(await fixture.assess(), null); + if (mode === "structured") { + fixture.record.description = "The source now uses a changed collection boundary."; + await fixture.saveRecord(); + } else { + await writeFile(join(fixture.root, "data", fixture.slots[0].path), "# Revised procedure\n\nExport every included account and reconcile the filter.\n", "utf8"); + } + await fixture.commit("2026-10-02", `Record ${mode} operating change`); + const impact = await fixture.assess(); + assert.equal(impact?.state, "ready", mode); + assert.equal(impact?.substantive, true, mode); + assert.ok(impact.reviewedRevision, mode); + assert.doesNotMatch(impact.message, /changed \d+ times|more than once/i); + } +}); + +test("title changes and added proof examples never become blockers merely because change counts increase", async (context) => { + for (const type of ["component", "source-coverage"]) { + const fixture = await impactFixture(context, type); + for (let index = 1; index <= 3; index++) { + fixture.record.title = `Operating source label ${index}`; + if (type === "source-coverage") fixture.record.readinessTestEvidenceIds = Array.from({ length: index }, (_, item) => `evidence-example-${item + 1}`); + await fixture.saveRecord(); + await fixture.commit(`2026-10-0${index + 1}`, "Clarify source label and retrieval examples"); + } + const impact = await fixture.assess(); + assert.ok(!impact || impact.state === "complete", type); + assert.ok(!impact || impact.substantive === false, type); + } +}); + +test("every Markdown slot is revision-bound and substantive edits stale the review", async (context) => { + const fixture = await impactFixture(context, "component", true); + const secondary = fixture.slots.find(({ primary }) => !primary); + await writeFile(join(fixture.root, "data", secondary.path), "# Retrieval notes\n\nInclude all privileged identities.\n", "utf8"); + await fixture.commit("2026-10-02", "Change secondary procedure slot"); + let current = await fixture.refresh(); + let impact = await assessPeriodImpact(current.loaded, current.record, current.histories, coverage, asOf); + assert.equal(impact?.state, "ready"); + current.record.periodImpactReview = { + reviewedByIds: ["person-owner"], reviewedOn: "2026-10-03", effectiveOn: "2026-10-02", + conclusion: "no-material-change", rationale: "The reviewed extraction still covers the intended population.", reviewedRevision: impact.reviewedRevision + }; + const review = structuredClone(current.record.periodImpactReview); + impact = await assessPeriodImpact(current.loaded, current.record, current.histories, coverage, asOf); + assert.equal(impact.state, "complete"); + current.record.title = "Clearer source label"; + current.record.readinessTestEvidenceIds = ["evidence-extra-example"]; + assert.equal((await assessPeriodImpact(current.loaded, current.record, current.histories, coverage, asOf)).state, "complete"); + await writeFile(join(fixture.root, "data", secondary.path), "# Retrieval notes\n\nUse the replacement population filter.\n", "utf8"); + assert.equal((await assessPeriodImpact(current.loaded, current.record, current.histories, coverage, asOf)).state, "ready"); + assert.deepEqual(current.record.periodImpactReview, review); + await writeFile(join(fixture.root, "data", secondary.path), "# Retrieval notes\n\nInclude all privileged identities.\n", "utf8"); + current.record.description = "A materially different operational boundary."; + assert.equal((await assessPeriodImpact(current.loaded, current.record, current.histories, coverage, asOf)).state, "ready"); +}); + +test("impact revision ignores formatting and review metadata but binds all substantive structured facts", async (context) => { + const fixture = await impactFixture(context); + const { loaded, record } = await fixture.refresh(); + const before = await currentPeriodImpactRevision(loaded, record); + const path = join(fixture.root, "data", fixture.slots[0].path); + const source = await readFile(path, "utf8"); + await writeFile(path, `\r\n${source.replaceAll("\n", " \r\n")}\r\n`, "utf8"); + record.periodImpactReview = { rationale: "Review metadata does not change the reviewed facts." }; + assert.equal(await currentPeriodImpactRevision(loaded, record), before); + record.ownerIds = ["person-approver"]; + assert.notEqual(await currentPeriodImpactRevision(loaded, record), before); +}); + +test("saving an impact review rejects stale companion revisions and never supplies policy approval", async (context) => { + const fixture = await impactFixture(context); + const payload = await scaffoldPeriodImpactReview(fixture.root, { resourceId: fixture.record.id }); + Object.assign(payload.review, { reviewedByIds: ["person-owner"], reviewedOn: "2026-10-03", effectiveOn: "2026-10-02", conclusion: "no-material-change", rationale: "Reviewed the actual collection change and continuity." }); + await writeFile(join(fixture.root, "data", fixture.slots[0].path), "# Changed after scaffold\n", "utf8"); + const before = await readFile(fixture.recordPath, "utf8"); + await assert.rejects(recordPeriodImpactReview(fixture.root, payload), /changed|Reload/i); + assert.equal(await readFile(fixture.recordPath, "utf8"), before); + assert.match(payload.instructions, /does not approve changed governed content/); +}); + +test("an impact review leaves changed Policy approval and source continuity gaps actionable", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-impact-independent-gates-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "13"); + const loaded = await loadWorkspace(root); + const policy = loaded.resources.find(({ type }) => type === "policy"); + const program = loaded.resources.find(({ type }) => type === "program"); + const control = loaded.resources.find(({ type }) => type === "control"); + const source = loaded.resources.find(({ type }) => type === "source-coverage"); + const family = loaded.model.evidenceSourceFamilies[0]; + const policySlot = markdownEntries(loaded.model, policy)[0]; + const approvedRevisions = structuredClone(policy.approvedContentRevisions); + await writeFile(join(root, "data", policySlot.path), "# Changed policy\n\nThis changed policy requires a new independent approval.\n", "utf8"); + source.sourceFamilyId = family.id; + source.status = "active"; + source.validFrom = "2026-10-05"; + control.code = family.controlCodes[0]; + program.controlIds = [control.id]; + for (const record of [policy, source]) { + record.periodImpactReview = { + reviewedByIds: ["person-example"], reviewedOn: "2026-10-06", effectiveOn: "2026-10-05", + conclusion: "material-change-reviewed", rationale: "Recorded the actual change and current continuity decision.", + reviewedRevision: await currentPeriodImpactRevision(loaded, record) + }; + } + const validation = await validateWorkspace(loaded); + assert.ok(validation.diagnostics.some(({ code, path }) => code === "approval-content-changed" && path.includes(policy.id))); + assert.deepEqual(policy.approvedContentRevisions, approvedRevisions); + const workflow = await assessWorkflow(loaded, { + asOf, now: "2026-10-10T12:00:00Z", coverage, programId: program.id, + programReadiness: { stages: [], evidenceReady: false, program: { id: program.id } }, auditPreparations: {}, + validation, obligations: { items: [], triggers: [], counts: {}, through: asOf }, reconciliation: { candidates: [] }, git: {} + }); + assert.equal(workflow.findings.find(({ code }) => code === `period.coverage.source-coverage.${source.id}`)?.state, "ready"); + assert.ok(workflow.findings.some(({ code, state }) => code.includes("approval-content-changed") && state === "ready")); +}); + +test("impact review needs the actual resource owner or an existing scoped Policy Owner", async context => { + const fixture = await impactFixture(context); + const payload = await scaffoldPeriodImpactReview(fixture.root, { resourceId: fixture.record.id }); + payload.review = { ...payload.review, reviewedByIds: ["person-approver"], reviewedOn: "2026-10-10", effectiveOn: "2026-10-02", conclusion: "no-material-change", rationale: "The source procedure remains available." }; + const before = await readFile(fixture.recordPath, "utf8"); + await assert.rejects(recordPeriodImpactReview(fixture.root, payload), /resource owner or scoped Policy Owner/); + assert.equal(await readFile(fixture.recordPath, "utf8"), before); +}); + +test("one current no-material impact decision answers only its exact listed materiality transitions", async context => { + const fixture = await impactFixture(context, "system"); + fixture.record.boundary += " Clarified existing operations."; + await fixture.saveRecord(); + await fixture.commit("2026-10-02", "Clarify existing boundary"); + const candidates = plan => plan.candidates.filter(item => item.eventType === "system-material-change"); + assert.equal(candidates(await planReconciliation(fixture.root)).length, 1); + const payload = await scaffoldPeriodImpactReview(fixture.root, { resourceId: fixture.record.id }); + assert.equal(payload.materialityQuestions.length, 1); + assert.equal(payload.review.reviewedTransitionKeys.length, 1); + Object.assign(payload.review, { reviewedByIds: ["person-owner"], reviewedOn: "2026-10-03", effectiveOn: "2026-10-02", conclusion: "no-material-change", rationale: "Reviewed the listed transition against actual unchanged operation and source continuity." }); + await recordPeriodImpactReview(fixture.root, payload); + assert.equal(candidates(await planReconciliation(fixture.root)).length, 0); + const raw = await planReconciliation(fixture.root, { includeHandled: true }); + assert.equal(candidates(raw).length, 1, "Git retains the actual transition"); + assert.equal(candidates(raw)[0].periodImpactKey, payload.review.reviewedTransitionKeys[0], "review metadata cannot change the question identity"); + const saved = JSON.parse(await readFile(fixture.recordPath, "utf8")); + for (const [label, mutate] of [ + ["not a materiality decision", record => { record.periodImpactReview.conclusion = "material-change-reviewed"; }], + ["no exact transition binding", record => { record.periodImpactReview.reviewedTransitionKeys = []; }], + ["wrong transition binding", record => { record.periodImpactReview.reviewedTransitionKeys = ["different-transition"]; }], + ["unauthorized reviewer", record => { record.periodImpactReview.reviewedByIds = ["person-approver"]; }], + ["later actual change", record => { record.boundary += " New processing boundary."; }] + ]) { + const record = structuredClone(saved); mutate(record); + await writeFile(fixture.recordPath, JSON.stringify(record, null, 2) + "\n"); + assert.ok(candidates(await planReconciliation(fixture.root)).length > 0, label); + } + await writeFile(fixture.recordPath, JSON.stringify(saved, null, 2) + "\n"); + assert.equal(candidates(await planReconciliation(fixture.root)).length, 0); +}); + +test("owner Appointment authority must cover the actual impact-review date", async context => { + const fixture = await impactFixture(context); + const { periodImpactReviewersAuthorized } = await import("../src/period-impact.js"); + const { loaded } = await fixture.refresh(); + const appointment = { id:"appointment-future-owner",type:"appointment",title:"Policy Owner",status:"active",appointmentKind:"policy-owner",holderId:"person-owner",scopeResourceIds:[loaded.workspace.id],startsOn:"2026-10-01" }; + loaded.resources.push(appointment); + const record = {...fixture.record,ownerIds:[appointment.id]}; + assert.equal(periodImpactReviewersAuthorized(loaded,record,{reviewedByIds:["person-owner"],reviewedOn:"2026-09-01"}),false); + assert.equal(periodImpactReviewersAuthorized(loaded,record,{reviewedByIds:["person-owner"],reviewedOn:"2026-10-10"}),true); + appointment.endsOn="2026-10-05"; + assert.equal(periodImpactReviewersAuthorized(loaded,record,{reviewedByIds:["person-owner"],reviewedOn:"2026-10-10"}),false); +}); + +test("semantic lineage survives unrelated commits and recording reviewed work, but not change/revert/repeat", async context => { + for (const type of ["component", "source-coverage", "system"]) { + const fixture = await impactFixture(context, type); + const field = type === "system" ? "boundary" : "description"; + const original = fixture.record[field]; + fixture.record[field] += " Clarified existing operations."; + await fixture.saveRecord(); + const current = await fixture.refresh(); + fixture.record.periodImpactReview = { + reviewedByIds: ["person-owner"], reviewedOn: "2026-10-03", effectiveOn: "2026-10-02", conclusion: "no-material-change", + rationale: "Reviewed the actual unchanged service and continuity.", reviewedRevision: await currentPeriodImpactRevision(current.loaded, current.record) + }; + await fixture.saveRecord(); + await writeFile(join(fixture.root, "notes.md"), "Unrelated repository notes.\n"); + await fixture.commit("2026-10-03", "Update unrelated notes", ["notes.md"]); + let refreshed = await fixture.refresh(); + assert.equal(await periodImpactReviewCurrent(refreshed.loaded, refreshed.record, asOf), true, `${type}: unrelated commit`); + await fixture.commit("2026-10-04", "Record already reviewed working change"); + refreshed = await fixture.refresh(); + assert.equal(await periodImpactReviewCurrent(refreshed.loaded, refreshed.record, asOf), true, `${type}: committing reviewed work`); + const markdown = join(fixture.root, "data", fixture.slots[0].path); + await writeFile(markdown, (await readFile(markdown, "utf8")).replaceAll("\n", " \n")); + await fixture.commit("2026-10-05", "Normalize procedure whitespace"); + refreshed = await fixture.refresh(); + assert.equal(await periodImpactReviewCurrent(refreshed.loaded, refreshed.record, asOf), true, `${type}: benign whitespace`); + fixture.record[field] = original; + await fixture.saveRecord(); await fixture.commit("2026-10-06", "Change operating facts"); + fixture.record[field] += " Clarified existing operations."; + await fixture.saveRecord(); await fixture.commit("2026-10-07", "Repeat previous operating state"); + refreshed = await fixture.refresh(); + assert.equal(await periodImpactReviewCurrent(refreshed.loaded, refreshed.record, asOf), false, `${type}: repeated state`); + assert.equal((await fixture.assess()).state, "ready", `${type}: repeated state requires new review`); + } +}); + +test("working materiality keys ignore unrelated HEAD movement and survive committing the reviewed transition", async context => { + const fixture = await impactFixture(context, "system"); + fixture.record.boundary += " Existing boundary clarification."; await fixture.saveRecord(); + const payload = await scaffoldPeriodImpactReview(fixture.root, { resourceId: fixture.record.id }); + Object.assign(payload.review, { reviewedByIds: ["person-owner"], reviewedOn: "2026-10-03", effectiveOn: "2026-10-02", conclusion: "no-material-change", rationale: "Reviewed the exact listed before and after boundary." }); + await recordPeriodImpactReview(fixture.root, payload); + const questions = async () => (await planReconciliation(fixture.root)).candidates.filter(candidate => candidate.eventType === "system-material-change"); + assert.equal((await questions()).length, 0); + await writeFile(join(fixture.root, "notes.md"), "Unrelated notes.\n"); + await fixture.commit("2026-10-03", "Change unrelated notes", ["notes.md"]); + assert.equal((await questions()).length, 0); + await fixture.commit("2026-10-04", "Commit reviewed transition"); + assert.equal((await questions()).length, 0); + const reviewed = JSON.parse(await readFile(fixture.recordPath, "utf8")); + const originalBoundary = "Company-managed service."; + await writeFile(fixture.recordPath, JSON.stringify({ ...reviewed, boundary: originalBoundary }, null, 2) + "\n"); + await fixture.commit("2026-10-05", "Change service boundary again"); + await writeFile(fixture.recordPath, JSON.stringify(reviewed, null, 2) + "\n"); + await fixture.commit("2026-10-06", "Repeat old boundary"); + const repeated = await questions(); + assert.ok(repeated.some(candidate => !reviewed.periodImpactReview.reviewedTransitionKeys.includes(candidate.periodImpactKey))); +}); + +test("re-recording a current decision retains its exact answered questions", async context => { + const fixture = await impactFixture(context, "system"); + fixture.record.boundary += " Existing boundary clarification."; await fixture.saveRecord(); + const fill = payload => Object.assign(payload.review, { reviewedByIds: ["person-owner"], reviewedOn: "2026-10-03", effectiveOn: "2026-10-02", conclusion: "no-material-change", rationale: "Reviewed the unchanged operation and listed transition." }); + const first = await scaffoldPeriodImpactReview(fixture.root, { resourceId: fixture.record.id }); fill(first); + await recordPeriodImpactReview(fixture.root, first); + const second = await scaffoldPeriodImpactReview(fixture.root, { resourceId: fixture.record.id }); fill(second); + assert.deepEqual(second.review.reviewedTransitionKeys, first.review.reviewedTransitionKeys); + assert.equal(second.materialityQuestions.length, 0, "do not ask the answered question again"); + await recordPeriodImpactReview(fixture.root, second); + assert.equal((await planReconciliation(fixture.root)).candidates.filter(candidate => candidate.eventType === "system-material-change").length, 0); +}); + +test("an absent optional companion does not stale reviewed work when committed", async context => { + const fixture = await impactFixture(context, "system"); + await rm(join(fixture.root, "data", fixture.slots[0].path)); + await fixture.commit("2026-10-01", "Remove empty optional companion"); + fixture.record.boundary += " Existing boundary clarification."; await fixture.saveRecord(); + const payload = await scaffoldPeriodImpactReview(fixture.root, { resourceId: fixture.record.id }); + Object.assign(payload.review, { reviewedByIds: ["person-owner"], reviewedOn: "2026-10-03", effectiveOn: "2026-10-02", conclusion: "no-material-change", rationale: "Reviewed the operation with no optional companion." }); + await recordPeriodImpactReview(fixture.root, payload); + await fixture.commit("2026-10-04", "Commit reviewed work without companion"); + const current = await fixture.refresh(); + assert.equal(await periodImpactReviewCurrent(current.loaded, current.record, asOf), true); + assert.equal((await planReconciliation(fixture.root)).candidates.filter(candidate => candidate.eventType === "system-material-change").length, 0); +}); + +test("a later change retains the historical answer but requires a new current impact decision", async context => { + const fixture = await impactFixture(context, "system"); + fixture.record.boundary += " First reviewed clarification."; await fixture.saveRecord(); + await fixture.commit("2026-10-02", "Record first boundary clarification"); + const payload = await scaffoldPeriodImpactReview(fixture.root, { resourceId: fixture.record.id }); + Object.assign(payload.review, { reviewedByIds: ["person-owner"], reviewedOn: "2026-10-03", effectiveOn: "2026-10-02", conclusion: "no-material-change", rationale: "Reviewed the first exact transition." }); + await recordPeriodImpactReview(fixture.root, payload); + await fixture.commit("2026-10-03", "Record first owner decision"); + const record = JSON.parse(await readFile(fixture.recordPath, "utf8")); + record.boundary += " New processing boundary."; + await writeFile(fixture.recordPath, JSON.stringify(record, null, 2) + "\n"); + const plan = await planReconciliation(fixture.root); + const questions = plan.candidates.filter(candidate => candidate.eventType === "system-material-change"); + assert.equal(questions.length, 1); + assert.ok(!payload.review.reviewedTransitionKeys.includes(questions[0].periodImpactKey)); + const current = await fixture.refresh(); + assert.equal(await periodImpactReviewCurrent(current.loaded, current.record, asOf), false); + assert.equal((await fixture.assess()).state, "ready"); +}); + +test("ordinary service inventory history does not invent an evidence-source review", async context => { + const fixture = await impactFixture(context); + delete fixture.record.evidenceSourceKinds; + fixture.record.systemUses[0].roles = ["supporting-operations"]; + await fixture.saveRecord(); await fixture.commit("2026-09-29", "Record supporting service role"); + await writeFile(join(fixture.root, "data", fixture.slots[0].path), "# Supplier review context\n\nThe provider remains within the approved business-use boundary.\n"); + await fixture.commit("2026-10-03", "Record current supplier context"); + let impact = await fixture.assess(); + assert.equal(impact.state, "complete"); assert.match(impact.message, /not an authoritative evidence source/); + for (const source of [ + { id: "control-live-source", type: "control", status: "implemented", evidenceSourceComponentIds: [fixture.record.id] }, + { id: "coverage-live-source", type: "source-coverage", status: "active", componentId: fixture.record.id } + ]) { + const current = await fixture.refresh(); current.loaded.resources.push(source); + impact = await assessPeriodImpact(current.loaded, current.record, current.histories, coverage, asOf); + assert.equal(impact.state, "ready", source.type); + } +}); + +test("a historical owner review cannot answer another resource's copied materiality key", async context => { + const fixture = await impactFixture(context, "system"); + const other = { ...fixture.record, id: "system-other-owner", title: "Other owned service", ownerIds: ["person-approver"] }; + const otherPath = join(fixture.root, "data/systems", `${other.id}.json`); + await writeFile(otherPath, JSON.stringify(other, null, 2) + "\n"); + await fixture.commit("2026-09-29", "Record another owner's service"); + fixture.record.boundary += " Actual changed boundary."; await fixture.saveRecord(); + await fixture.commit("2026-10-02", "Change the first service"); + const question = (await planReconciliation(fixture.root)).candidates.find(candidate => candidate.subject.id === fixture.record.id); + const loaded = await loadWorkspace(fixture.root); + other.periodImpactReview = { reviewedByIds: ["person-approver"], reviewedOn: "2026-10-03", effectiveOn: "2026-10-02", conclusion: "no-material-change", + rationale: "The other owner reviewed this other service only.", reviewedRevision: await currentPeriodImpactRevision(loaded, other), reviewedTransitionKeys: [question.periodImpactKey] }; + await writeFile(otherPath, JSON.stringify(other, null, 2) + "\n"); + await fixture.commit("2026-10-03", "Record another service's review"); + const current = await loadWorkspace(fixture.root), reviewedOther = current.resources.find(record => record.id === other.id); + assert.equal(await periodImpactReviewCurrent(current, reviewedOther, asOf), true); + assert.ok((await planReconciliation(fixture.root)).candidates.some(candidate => candidate.subject.id === fixture.record.id), "an opaque foreign key conveys no authority over the actual changed resource"); +}); + +test("a historical review cannot pre-authorize an unperformed future transition by copying its key", async context => { + const fixture = await impactFixture(context, "system"); + const beforeBoundary = fixture.record.boundary; + fixture.record.boundary += " Planned replacement boundary."; await fixture.saveRecord(); + const future = (await planReconciliation(fixture.root)).candidates.find(candidate => candidate.subject.id === fixture.record.id); + fixture.record.boundary = beforeBoundary; await fixture.saveRecord(); + const loaded = await loadWorkspace(fixture.root); + fixture.record.periodImpactReview = { reviewedByIds: ["person-owner"], reviewedOn: "2026-10-01", effectiveOn: "2026-09-28", conclusion: "no-material-change", + rationale: "Reviewed the still-unchanged current service.", reviewedRevision: await currentPeriodImpactRevision(loaded, fixture.record), reviewedTransitionKeys: [future.periodImpactKey] }; + await fixture.saveRecord(); await fixture.commit("2026-10-01", "Record unchanged service review"); + fixture.record.boundary += " Planned replacement boundary."; await fixture.saveRecord(); + await fixture.commit("2026-10-02", "Actually change the service later"); + const questions = (await planReconciliation(fixture.root)).candidates.filter(candidate => candidate.subject.id === fixture.record.id); + assert.equal(questions.length, 1); + assert.equal(questions[0].periodImpactKey, future.periodImpactKey); +}); diff --git a/packages/filegrc/test/program-access-occurrence.test.js b/packages/filegrc/test/program-access-occurrence.test.js index 1a88ad5..fad4369 100644 --- a/packages/filegrc/test/program-access-occurrence.test.js +++ b/packages/filegrc/test/program-access-occurrence.test.js @@ -10,6 +10,7 @@ import { scaffoldObligationOccurrence, saveObligationOccurrence, loadWorkspace, import { occurrenceCompletionContext } from "../src/obligation-completion-context.js"; import { reviewHistoryContext } from "../src/historical-workspace.js"; import { assessCompletionForObligation, completionMatchesMember } from "../src/obligations.js"; +import { assessProgramAccessReview, providerAccessControls } from "../src/provider-access-completion.js"; async function fixture(t) { const root = await mkdtemp(join(tmpdir(), "filegrc-program-access-")); @@ -88,6 +89,56 @@ test("committed reconciliations retain their dated population after provider and assert.ok((await validateWorkspace(f.root)).diagnostics.some(({ code }) => code === "missing-passing-member-completion")); }); +test("new setup-account classification preserves a frozen review while current real accounts remain actionable", async t => { + const f = await fixture(t); + const payload = await scaffold(f), occurrence = reconcile(payload.record); + await saveObligationOccurrence(f.root, { record: occurrence }); + const initial = await loadWorkspace(f.root); + const grant = initial.resources.find(record => record.type === "access-grant"); + const setupComponent = { ...f.component, id: "component-unused-setup", title: "Unused setup service", status: "planned" }; + const setupGrant = { ...grant, id: "access-grant-unused-setup", componentId: setupComponent.id, businessNeed: "Evaluate an unused provider account." }; + // These records model the old classifier's committed source population; the + // new classifier must not recast its already finalized review as unfinished. + await f.save(setupComponent); await f.save(setupGrant); + await initializeGitWorkspace(f.root); + const occurrencePath = join(f.root, "data/obligation-occurrences", `${occurrence.id}.json`); + const reviewPath = join(f.root, "data/access-reviews", `${f.review.id}.json`); + const originalOccurrence = await readFile(occurrencePath, "utf8"), originalReview = await readFile(reviewPath, "utf8"); + let loaded = await loadWorkspace(f.root); + let program = loaded.resources.find(record => record.type === "program"); + let assessment = assessProgramAccessReview(f.review, loaded, program, providerAccessControls(loaded, program), "2026-07-07"); + assert.equal(assessment.complete, false, "Actual live setup access still needs current reconciliation."); + assert.ok(assessment.missingFacts.includes("all-provider-components")); + assert.deepEqual((await validateWorkspace(f.root)).diagnostics.filter(({ code }) => /occurrence|member|population/.test(code)), []); + const correction = { ...setupGrant, status: "approved" }; + delete correction.provisionedByIds; delete correction.provisionedOn; + await f.save(correction); + loaded = await loadWorkspace(f.root); program = loaded.resources.find(record => record.type === "program"); + assessment = assessProgramAccessReview(f.review, loaded, program, providerAccessControls(loaded, program), "2026-07-07"); + assert.equal(assessment.complete, true, JSON.stringify(assessment.missingFacts)); + assert.deepEqual((await validateWorkspace(f.root)).diagnostics.filter(({ code }) => /occurrence|member|population/.test(code)), []); + assert.equal(await readFile(occurrencePath, "utf8"), originalOccurrence); + assert.equal(await readFile(reviewPath, "utf8"), originalReview); +}); + +test("historical setup handling cannot hide a real active provider missing from frozen proof", async t => { + for (const status of ["active", "deprecated", "planned"]) { + const f = await fixture(t); + const payload = await scaffold(f), occurrence = reconcile(payload.record); + await saveObligationOccurrence(f.root, { record: occurrence }); + const loaded = await loadWorkspace(f.root), grant = loaded.resources.find(record => record.type === "access-grant"); + const missingProvider = { ...f.component, id: "component-missing-operating-provider", title: "Operating provider", status }; + await f.save(missingProvider); + await f.save({ ...grant, id: "access-grant-missing-provider", componentId: missingProvider.id }); + if (status === "planned") { + const control = loaded.resources.find(record => record.type === "control"); + await f.save({ ...control, componentIds: [...control.componentIds || [], missingProvider.id] }); + } + await initializeGitWorkspace(f.root); + assert.ok((await validateWorkspace(f.root)).diagnostics.some(({ code }) => code === "missing-passing-member-completion"), status); + } +}); + test("rule-only selected Control links retain all provider boundaries", async (t) => { const f = await fixture(t); diff --git a/packages/filegrc/test/provider-planning-access.test.js b/packages/filegrc/test/provider-planning-access.test.js new file mode 100644 index 0000000..02a91d5 --- /dev/null +++ b/packages/filegrc/test/provider-planning-access.test.js @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { assessProgramAccessReview, providerAccessPopulation } from "../src/provider-access-completion.js"; +import { collectionRecordProposals } from "../src/collection-scope.js"; +import { assessEvidenceMap } from "../src/program-readiness.js"; +import { loadWorkspace } from "../src/workspace.js"; +import { makeComprehensiveWorkspace } from "./fixtures.js"; + +async function setupFixture(context) { + const root = await mkdtemp(join(tmpdir(), "filegrc-provider-setup-access-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "13"); + const loaded = await loadWorkspace(root); + const get = type => loaded.resources.find(record => record.type === type); + const program = get("program"), component = get("component"), vendor = get("vendor"), grant = get("access-grant"); + const review = get("access-review"), proof = get("evidence"), control = get("control"), coverage = get("source-coverage"); + const information = { id: "information-type-future", type: "information-type", title: "Future service data", status: "planned", description: "Data considered for future service use." }; + component.status = "planned"; + component.vendorId = vendor.id; + component.informationUses = [{ informationTypeId: information.id, processingOperations: ["store"] }]; + vendor.status = "evaluating"; + program.controlIds = []; + // Keep actual operational reliance out of this setup-account fixture. + loaded.resources = loaded.resources.filter(record => !["control", "source-coverage", "asset"].includes(record.type)); + loaded.resources.push(information); + Object.assign(grant, { status: "active", componentId: component.id, businessNeed: "Set up and evaluate the provider account before service adoption.", provisionedOn: "2026-07-01" }); + Object.assign(review, { status: "complete", componentIds: [component.id], completedOn: "2026-07-01", approvedOn: "2026-07-02", populationCount: 1, exceptionCount: 0, + grantDecisions: [{ accessGrantId: grant.id, decision: "retain" }], evidenceIds: [proof.id], + sourceReconciliations: [{ componentId: component.id, boundary: "direct", populationEvidenceId: proof.id, + accounts: [{ accountReference: "setup-account-1", disposition: "authorized", subjectId: grant.subjectId, authorizationKind: "access-grant", accessGrantIds: [grant.id] }] }] }); + Object.assign(proof, { artifactKind: "population-export", sourceKind: "component", sourceComponentId: component.id, + collectedOn: "2026-07-01", verifiedOn: "2026-07-01", generatedAt: "2026-07-01T10:00:00Z", timezone: "UTC", + externalReference: { url: "https://evidence.example.test/setup-account-population" }, queryDescription: "All setup accounts and permissions in this provider account.", + populationCount: 1, completenessValidation: "Reconciled every account class against the provider account total.", accuracyValidation: "Checked identity, authorization and permissions against the account details.", + coverage: { kind: "as-of", on: "2026-07-01" } }); + delete proof.filePaths; + const assess = () => assessProgramAccessReview(review, loaded, program, [], "2026-10-13", { historicalAssessment: true }); + const proposal = record => collectionRecordProposals(loaded, record.type, [record], program)[0]; + return { loaded, program, component, vendor, information, grant, review, proof, control, coverage, assess, proposal }; +} + +test("planned scoped provider accounts can be fully reconciled while service and information proposals remain dormant", async (context) => { + const { loaded, program, component, vendor, information, grant, assess, proposal } = await setupFixture(context); + assert.deepEqual(providerAccessPopulation(loaded, program, []).componentIds, [component.id]); + assert.deepEqual(providerAccessPopulation(loaded, program, []).grants.map(({ id }) => id), [grant.id]); + const result = assess(); + assert.equal(result.complete, true, JSON.stringify(result.missingFacts)); + for (const record of [component, vendor, information]) { + assert.equal(proposal(record).operatingStatus, "later", record.type); + assert.equal(proposal(record).complete, false, record.type); + } + assert.equal(component.status, "planned"); + assert.equal(vendor.status, "evaluating"); + assert.equal(information.status, "planned"); +}); + +test("approved unprovisioned setup access stays outside actual provider population and cannot pass as active access", async (context) => { + const { loaded, program, component, vendor, grant, assess, proposal } = await setupFixture(context); + grant.status = "approved"; + delete grant.provisionedOn; + delete grant.provisionedByIds; + assert.deepEqual(providerAccessPopulation(loaded, program, []).componentIds, []); + assert.deepEqual(providerAccessPopulation(loaded, program, []).grants, []); + assert.equal(proposal(component).operatingStatus, "later"); + assert.equal(proposal(vendor).operatingStatus, "later"); + assert.equal(assess().complete, false, "A retained active-account decision cannot claim unprovisioned approved access."); +}); + +test("setup reconciliation preserves expired, invalid, unknown and unauthorized account blockers", async (context) => { + const fixture = await setupFixture(context); + const originalGrant = structuredClone(fixture.grant), originalReview = structuredClone(fixture.review); + for (const [label, mutate, expected] of [ + ["expired grant", ({ grant }) => { grant.expiresOn = "2026-10-12"; }, "current-authorized-access"], + ["invalid grant", ({ grant }) => { delete grant.approvedByIds; }, "current-authorized-access"], + ["unknown subject", ({ grant }) => { grant.subjectId = "person-unknown"; }, "current-authorized-access"], + ["unknown authorizer", ({ grant }) => { grant.approvedByIds = ["person-unknown"]; }, "current-authorized-access"], + ["unknown provisioner", ({ grant }) => { grant.provisionedByIds = ["person-unknown"]; }, "current-authorized-access"], + ["unknown provider account", ({ review }) => { review.sourceReconciliations[0].accounts[0].disposition = "unresolved"; delete review.sourceReconciliations[0].accounts[0].authorizationKind; }, "unresolved-provider-accounts"], + ["unauthorized account", ({ review }) => { review.sourceReconciliations[0].accounts[0].accessGrantIds = []; }, "accountable-provider-identities"], + ["future provisioning", ({ grant }) => { grant.provisionedOn = "2026-10-14"; }, "current-authorized-access"], + ["unapproved grant", ({ grant }) => { delete grant.approvedOn; }, "current-authorized-access"] + ]) { + for (const key of Object.keys(fixture.grant)) delete fixture.grant[key]; + for (const key of Object.keys(fixture.review)) delete fixture.review[key]; + Object.assign(fixture.grant, structuredClone(originalGrant)); + Object.assign(fixture.review, structuredClone(originalReview)); + mutate(fixture); + const result = fixture.assess(); + assert.equal(result.complete, false, label); + assert.ok(result.missingFacts.includes(expected), `${label}: ${JSON.stringify(result.missingFacts)}`); + assert.equal(fixture.proposal(fixture.component).operatingStatus, "later", label); + } +}); + +test("planned setup-account boundaries stay within the selected Program", async (context) => { + const { loaded, program, component, grant, assess } = await setupFixture(context); + const other = { ...structuredClone(component), id: "component-other-program", systemUses: [{ ...component.systemUses[0], systemId: "system-other-program" }] }; + const otherProgram = { ...structuredClone(program), id: "program-other", systemIds: ["system-other-program"] }; + const otherGrant = { ...structuredClone(grant), id: "access-grant-other-program", componentId: other.id }; + loaded.resources.push(other, otherProgram, otherGrant); + assert.deepEqual(providerAccessPopulation(loaded, program, []).componentIds, [component.id]); + assert.deepEqual(providerAccessPopulation(loaded, program, []).grants.map(({ id }) => id), [grant.id]); + assert.equal(assess().complete, true); + assert.deepEqual(providerAccessPopulation(loaded, otherProgram, []).componentIds, [other.id]); + component.systemUses = []; + const result = assess(); + assert.equal(result.complete, false, "An old reviewed row cannot give an unscoped planned provider authority."); + assert.deepEqual(providerAccessPopulation(loaded, program, []).componentIds, []); +}); + +test("real implementation and processing dependencies retain ordinary activation and source gates", async (context) => { + const { loaded, program, component, vendor, information, control, coverage, proposal, assess } = await setupFixture(context); + assert.equal(assess().complete, true); + Object.assign(control, { status: "implemented", evidenceSourceComponentIds: [component.id], componentIds: [component.id] }); + program.controlIds = [control.id]; + loaded.resources.push(control); + assert.equal(proposal(component).operatingStatus, "action"); + assert.equal(proposal(vendor).operatingStatus, "action"); + assert.equal(proposal(information).operatingStatus, "action"); + const map = await assessEvidenceMap(loaded, { asOf: "2026-10-13" }); + assert.equal(map.status, "action", "Reviewing setup accounts cannot activate the planned operational evidence source."); + assert.ok(map.items.some(item => item.sourceComponentChecks?.some(check => check.sourceComponentId === component.id && check.checks.active === false))); + loaded.resources = loaded.resources.filter(record => record.id !== control.id); + program.controlIds = []; + Object.assign(coverage, { status: "active", componentId: component.id }); + loaded.resources.push(coverage); + assert.equal(proposal(component).operatingStatus, "action"); + assert.equal(proposal(vendor).operatingStatus, "action"); + assert.equal(component.status, "planned"); +}); diff --git a/packages/filegrc/test/resource-mutation.test.js b/packages/filegrc/test/resource-mutation.test.js new file mode 100644 index 0000000..d77f87e --- /dev/null +++ b/packages/filegrc/test/resource-mutation.test.js @@ -0,0 +1,154 @@ +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 { runCli } from "../src/cli.js"; +import { contentRevision, updateResource } from "../src/files.js"; +import { setGitSubprocessObserverForTests, setHistoricalRevisionReadObserverForTests } from "../src/git.js"; +import { markdownEntries } from "../src/resource-markdown.js"; +import { createResourceDetail, readResourceMutation } from "../src/state.js"; +import { collectTimings } from "../src/timing.js"; +import { loadWorkspace } from "../src/workspace.js"; +import { makeComprehensiveWorkspace } from "./fixtures.js"; +import { captureCli } from "./helpers.js"; + +async function mutationFixture(context) { + const root = await mkdtemp(join(tmpdir(), "filegrc-resource-mutation-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "11"); + const record = { + id: "meeting-export-example", type: "meeting", title: "Planned review", status: "planned", + teamId: "team-example", scheduledFor: "2026-10-01", chairIds: ["person-example"] + }; + const recordPath = join(root, "data", "meetings", `${record.id}.json`); + const source = `${JSON.stringify(record, null, 4)}\r\n`; + await mkdir(join(root, "data", "meetings"), { recursive: true }); + await writeFile(recordPath, source, "utf8"); + const loaded = await loadWorkspace(root); + const slots = markdownEntries(loaded.model, record); + const content = { agenda: "# Review agenda\r\n\r\nReview access for café staff.", minutes: "" }; + for (const slot of slots) await writeFile(join(root, "data", slot.path), content[slot.name], "utf8"); + return { root, record, recordPath, source, slots, content }; +} + +function updateFromMutation(root, mutation) { + return updateResource(root, mutation.record.type, mutation.record.id, mutation.record, { + content: mutation.content, expectedRevision: mutation.revision, + expectedContentRevisions: mutation.contentRevisions, requireExpectedContentRevisions: true + }); +} + +test("mutation export preserves exact record and all companion bytes, paths and revisions", async (context) => { + const { root, record, source, slots, content } = await mutationFixture(context); + const mutation = await readResourceMutation(root, record.type, record.id); + assert.deepEqual(mutation.record, record); + assert.equal(mutation.revision, contentRevision(source)); + assert.deepEqual(mutation.content, content); + assert.deepEqual(mutation.contentRevisions, Object.fromEntries(slots.map((slot) => [slot.path, contentRevision(content[slot.name])]))); + const loaded = await loadWorkspace(root); + const detail = await createResourceDetail(loaded, record.type, record.id, { includeHistory: false }); + assert.equal(detail.revision, mutation.revision); + for (const slot of slots) { + assert.equal(detail.content[slot.name].source, mutation.content[slot.name]); + assert.equal(detail.content[slot.name].revision, mutation.contentRevisions[slot.path]); + } + for (const args of [[record.id], [record.type, record.id]]) { + const exported = await captureCli(runCli, ["get", ...args, "--mutation", "--root", root]); + assert.deepEqual(exported.result, mutation); + assert.deepEqual(JSON.parse(exported.stdout), mutation); + } +}); + +test("mutation export skips unrelated workflow, validation and Git history calculation", async (context) => { + const { root, record } = await mutationFixture(context); + // Unrelated broken source remains visible to validation but cannot prevent + // the owner from reading an existing record for repair. + await writeFile(join(root, "data", "unrelated-invalid.json"), "{invalid", "utf8"); + let subprocesses = 0; + let historicalReads = 0; + const restoreSubprocesses = setGitSubprocessObserverForTests(() => { subprocesses++; }); + const restoreReads = setHistoricalRevisionReadObserverForTests(() => { historicalReads++; }); + let measured; + try { + measured = await collectTimings(() => captureCli(runCli, ["get", record.id, "--mutation", "--root", root])); + } finally { + restoreSubprocesses(); + restoreReads(); + } + assert.equal(measured.result.result.record.id, record.id); + assert.equal(measured.timings["workspace-load"].count, 1); + assert.equal(measured.timings.validation, undefined); + assert.equal(measured.timings["workflow-assessment"], undefined); + assert.equal(measured.timings["git-history-export"], undefined); + assert.equal(subprocesses, 0); + assert.equal(historicalReads, 0); +}); + +test("mutation export covers singleton, nested and implicit Markdown records and omits missing companions", async (context) => { + const { root } = await mutationFixture(context); + const loaded = await loadWorkspace(root); + for (const type of ["workspace", "evidence", "person", "policy"]) { + const entry = loaded.entries.find(({ record }) => record.type === type); + const mutation = await readResourceMutation(loaded, type, entry.record.id); + assert.deepEqual(mutation.record, entry.record, type); + assert.equal(mutation.revision, contentRevision(entry.source), type); + const expectedContent = {}; + const expectedRevisions = {}; + for (const slot of markdownEntries(loaded.model, entry.record)) { + let source; + try { source = await readFile(join(root, "data", slot.path), "utf8"); } + catch (error) { if (error.code === "ENOENT") continue; throw error; } + expectedContent[slot.name] = source; + expectedRevisions[slot.path] = contentRevision(source); + } + assert.deepEqual(mutation.content, Object.keys(expectedContent).length ? expectedContent : undefined, type); + assert.deepEqual(mutation.contentRevisions, Object.keys(expectedContent).length ? expectedRevisions : undefined, type); + } + await assert.rejects(readResourceMutation(loaded, "vendor", "person-example"), /Resource "vendor\/person-example" was not found/); + await assert.rejects(readResourceMutation(loaded, null, "missing-resource"), /Resource "missing-resource" was not found/); +}); + +test("exported mutation rejects a stale JSON revision without overwriting newer source", async (context) => { + const { root, record, recordPath } = await mutationFixture(context); + const mutation = await readResourceMutation(root, record.type, record.id); + const newer = `${JSON.stringify({ ...record, title: "Newer source title" }, null, 2)}\n`; + await writeFile(recordPath, newer, "utf8"); + await assert.rejects(updateFromMutation(root, mutation), /The record changed after you opened it/); + assert.equal(await readFile(recordPath, "utf8"), newer); +}); + +test("exported mutation protects each companion independently and requires every existing revision", async (context) => { + const { root, record, recordPath, slots, content, source } = await mutationFixture(context); + for (const slot of slots) { + const mutation = await readResourceMutation(root, record.type, record.id); + const newer = `${content[slot.name]}\nNewer ${slot.name}.\n`; + await writeFile(join(root, "data", slot.path), newer, "utf8"); + await assert.rejects(updateFromMutation(root, mutation), /Content at data\/.* changed after you opened it/, slot.name); + assert.equal(await readFile(join(root, "data", slot.path), "utf8"), newer); + assert.equal(await readFile(recordPath, "utf8"), source); + await writeFile(join(root, "data", slot.path), content[slot.name], "utf8"); + delete mutation.contentRevisions[slot.path]; + await assert.rejects(updateFromMutation(root, mutation), /A content revision is required for existing content/, slot.name); + } +}); + +test("lightweight export keeps full mutation validation and permits a current valid edit", async (context) => { + const { root, record, recordPath, source } = await mutationFixture(context); + const invalid = await readResourceMutation(root, record.type, record.id); + invalid.record.status = "unknown-status"; + const measured = await collectTimings(() => assert.rejects(updateFromMutation(root, invalid), /unknown-status|must be one of/i)); + assert.ok(measured.timings.validation.count >= 2); + assert.equal(await readFile(recordPath, "utf8"), source); + const current = await readResourceMutation(root, record.type, record.id); + current.record.title = "Reviewed meeting plan"; + await updateFromMutation(root, current); + assert.equal(JSON.parse(await readFile(recordPath, "utf8")).title, current.record.title); +}); + +test("mutation export does not hide an unreadable companion as an absent optional file", async context => { + const { root, record, slots } = await mutationFixture(context); + const path = join(root, "data", slots[0].path); + await rm(path); await mkdir(path); + await assert.rejects(readResourceMutation(root, record.type, record.id)); +}); diff --git a/packages/filegrc/test/revision-upgrade.test.js b/packages/filegrc/test/revision-upgrade.test.js index f51abc2..6a4a0d6 100644 --- a/packages/filegrc/test/revision-upgrade.test.js +++ b/packages/filegrc/test/revision-upgrade.test.js @@ -146,8 +146,13 @@ test("a setup Component confirmation survives later inventory and procedure chan assert.doesNotMatch(componentStep.title, /proposal/i); const operatingChange = readiness.stages.find(({ id }) => id === "operation").items .find(({ id }) => id === "operating-component-component-new-workforce-source"); - assert.equal(operatingChange?.status, "action"); + assert.equal(operatingChange?.status, "later"); assert.equal(operatingChange?.resourceId, added.id); + assert.match(operatingChange.message, /Keep its planned or evaluating status/); + loaded.resources.push({ id: "source-coverage-new-workforce-source", type: "source-coverage", status: "active", componentId: added.id }); + const reliedOn = await assessProgramReadiness(loaded, { programId: program.id }); + assert.equal(reliedOn.stages.find(({ id }) => id === "operation").items + .find(({ id }) => id === "operating-component-component-new-workforce-source")?.status, "action"); }); test("a Framework selection change reopens its specific setup review", async (context) => { diff --git a/packages/filegrc/test/risk-operating-guidance.test.js b/packages/filegrc/test/risk-operating-guidance.test.js new file mode 100644 index 0000000..2fa7198 --- /dev/null +++ b/packages/filegrc/test/risk-operating-guidance.test.js @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { loadModel } from "../model/index.js"; +import { assessWorkflow } from "../src/workflow.js"; +import { loadWorkspace } from "../src/workspace.js"; +import { makeWorkspace } from "./helpers.js"; + +async function riskFixture(context) { + const root = await mkdtemp(join(tmpdir(), "filegrc-risk-guidance-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeWorkspace(root); + const loaded = await loadWorkspace(root); + loaded.model = loadModel("13"); + const program = { id: "program-example", type: "program", status: "active", assuranceGoal: "none", controlIds: [], systemIds: [] }; + const risk = { id: "risk-example", type: "risk", title: "Continuing service risk", status: "open", description: "A managed operational exposure.", categories: ["security"], ownerIds: ["person-owner"], response: "mitigate", inherentRating: { likelihood: 2, impact: 3 }, reviewDueOn: "2026-10-15", treatmentTargetOn: "2026-10-20" }; + const assessment = { id: "risk-assessment-example", type: "risk-assessment", status: "complete", riskIds: [risk.id], assessorIds: ["person-owner"], reviewerIds: ["person-approver"], completedOn: "2026-09-20", approvedOn: "2026-09-21", evidenceIds: ["evidence-example"] }; + loaded.resources.push(program, risk, assessment); + const assess = () => assessWorkflow(loaded, { + asOf: "2026-10-01", now: "2026-10-01T12:00:00Z", programId: program.id, + programReadiness: { stages: [], evidenceReady: true, program: { id: program.id } }, auditPreparations: {}, + obligations: { items: [], triggers: [], counts: {}, asOf: "2026-10-01", through: "2026-11-01" }, + validation: { ok: true, diagnostics: [], counts: {} }, reconciliation: { candidates: [] }, git: {} + }); + return { loaded, risk, assessment, assess }; +} + +function riskFinding(workflow, risk) { + return workflow.findings.find(({ code, subject }) => code.endsWith(".finalize") && subject?.id === risk.id); +} + +test("reviewed open and monitoring risks with future checkpoints remain scheduled without generic closure pressure", async (context) => { + const { risk, assess } = await riskFixture(context); + for (const status of ["open", "monitoring"]) { + risk.status = status; + const workflow = await assess(); + const finding = riskFinding(workflow, risk); + assert.equal(finding?.state, "scheduled", status); + assert.match(finding.message, /2026-10-15/); + assert.doesNotMatch(finding.message, /Complete this work with the required proof|formally close it|Finish the work, record the result/); + const work = workflow.workItems.find(({ source }) => source?.id === risk.id); + assert.equal(work?.state, "scheduled", status); + assert.equal(work?.dueOn, "2026-10-15", status); + } +}); + +test("missing ownership, decision, rating, independent approved assessment or required checkpoint stays actionable", async (context) => { + const fixture = await riskFixture(context); + const baselineRisk = structuredClone(fixture.risk), baselineAssessment = structuredClone(fixture.assessment); + for (const [label, mutate] of [ + ["owner", ({ risk }) => { risk.ownerIds = []; }], + ["response", ({ risk }) => { delete risk.response; }], + ["rating", ({ risk }) => { delete risk.inherentRating; }], + ["incomplete assessment", ({ assessment }) => { assessment.status = "in-progress"; }], + ["unrelated assessment", ({ assessment }) => { assessment.riskIds = ["risk-other"]; }], + ["missing completion date", ({ assessment }) => { delete assessment.completedOn; }], + ["missing approval", ({ assessment }) => { delete assessment.approvedOn; }], + ["approval before completion", ({ assessment }) => { assessment.approvedOn = "2026-09-19"; }], + ["future approval", ({ assessment }) => { assessment.approvedOn = "2026-10-02"; }], + ["dependent reviewer", ({ assessment }) => { assessment.reviewerIds = ["person-owner"]; }], + ["missing reviewer", ({ assessment }) => { assessment.reviewerIds = []; }], + ["missing assessor", ({ assessment }) => { assessment.assessorIds = []; }], + ["missing proof", ({ assessment }) => { assessment.evidenceIds = []; }], + ["review deadline", ({ risk }) => { delete risk.reviewDueOn; }], + ["mitigation deadline", ({ risk }) => { delete risk.treatmentTargetOn; }] + ]) { + for (const key of Object.keys(fixture.risk)) delete fixture.risk[key]; + for (const key of Object.keys(fixture.assessment)) delete fixture.assessment[key]; + Object.assign(fixture.risk, structuredClone(baselineRisk)); + Object.assign(fixture.assessment, structuredClone(baselineAssessment)); + mutate(fixture); + assert.equal(riskFinding(await fixture.assess(), fixture.risk)?.state, "ready", label); + } +}); + +test("earliest overdue review or treatment remains due without closing an unresolved risk", async (context) => { + const { risk, assess } = await riskFixture(context); + for (const [reviewDueOn, treatmentTargetOn, expected] of [ + ["2026-09-30", "2026-10-20", "2026-09-30"], + ["2026-10-15", "2026-09-29", "2026-09-29"], + ["2026-09-30", "2026-09-28", "2026-09-28"] + ]) { + Object.assign(risk, { reviewDueOn, treatmentTargetOn }); + const workflow = await assess(); + assert.ok(["ready", "overdue"].includes(riskFinding(workflow, risk)?.state)); + const work = workflow.workItems.find(({ source }) => source?.id === risk.id); + assert.equal(work?.state, "overdue"); + assert.equal(work?.dueOn, expected); + assert.equal(risk.status, "open"); + } +}); + +test("expired or missing acceptance stays actionable and expiry is the earliest real checkpoint", async context => { + const { risk, assess } = await riskFixture(context); + risk.response = "accept"; + for (const acceptance of [undefined, { rationale: "Time-limited acceptance.", acceptedByIds: ["person-owner"], acceptedOn: "2026-09-20", expiresOn: "2026-09-30" }]) { + risk.acceptance = acceptance; + const workflow = await assess(); + assert.equal(riskFinding(workflow, risk).state, "ready"); + if (acceptance) { + const work = workflow.workItems.find(item => item.source?.id === risk.id); + assert.equal(work.state, "overdue"); + assert.equal(work.dueOn, "2026-09-30"); + } + } +}); + +test("assessors are checked at completion while independent reviewers are checked at approval", async context => { + const { loaded, risk, assessment, assess } = await riskFixture(context); + const assessor = loaded.resources.find(record => record.id === "person-owner"); + const before = structuredClone(assessor); + // The operating owner is separate from the assessor who left after completing the work. + risk.ownerIds = ["person-approver"]; + Object.assign(assessor, { status: "inactive", startDate: "2026-01-01", endDate: "2026-09-20" }); + assert.equal(riskFinding(await assess(), risk).state, "scheduled"); + assessor.endDate = "2026-09-19"; + assert.equal(riskFinding(await assess(), risk).state, "ready"); + Object.assign(assessor, before); +}); diff --git a/packages/filegrc/test/schedule-set-period-impact.test.js b/packages/filegrc/test/schedule-set-period-impact.test.js new file mode 100644 index 0000000..54cfbdd --- /dev/null +++ b/packages/filegrc/test/schedule-set-period-impact.test.js @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { loadModel } from "../model/index.js"; +import { makeComprehensiveWorkspace } from "./fixtures.js"; +import { adoptWorkflowRuntime } from "./fixtures/workflow-runtime.js"; +import { initializeExistingModel12Workspace, commitWorkspaceFiles } from "./helpers.js"; +import { loadWorkspace } from "../src/workspace.js"; +import { assessCollectionReview, scaffoldCollectionReview, applyCollectionReview } from "../src/collection-review.js"; +import { collectionRevision } from "../src/collection-revision.js"; +import { scheduleSetInputs } from "../src/schedule-set.js"; +import { scaffoldPeriodImpactReview, recordPeriodImpactReview } from "../src/period-impact.js"; +import { currentCalendarDate } from "../src/time.js"; +import { markdownEntries } from "../src/resource-markdown.js"; + +const impactTypes = ["appointment", "control", "policy", "source-coverage", "system", "component"]; + +test("period impact metadata stays outside every covered schedule design input", () => { + const sources = impactTypes.map((type) => ({ id: `${type}-source`, type, title: `Reviewed ${type}`, periodImpactReview: { rationale: "Owner reviewed the unchanged operating facts." } })); + const obligation = { id: "obligation-one", type: "obligation", status: "active", controlIds: ["control-source"], scopeResourceIds: sources.map(({ id }) => id) }; + const program = { id: "program-one", type: "program", controlIds: ["control-source"] }; + const loaded = { model: loadModel("13"), resources: [program, obligation, ...sources] }; + for (const source of sources) { + const input = scheduleSetInputs(loaded, program).find(({ record }) => record.id === source.id); + assert.ok(input, source.type); + assert.equal(input.value.periodImpactReview, undefined, source.type); + assert.deepEqual(input.record.periodImpactReview, { rationale: "Owner reviewed the unchanged operating facts." }, "Source decisions must remain intact"); + } +}); + +test("owner impact metadata preserves the committed independent schedule approval without neutralizing duty changes", async (t) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-schedule-impact-")); + t.after(() => rm(root, { recursive: true, force: true })); + await makeComprehensiveWorkspace(root, "13"); + await adoptWorkflowRuntime(root); + await initializeExistingModel12Workspace(root); + let loaded = await loadWorkspace(root); + const program = loaded.resources.find(({ type }) => type === "program"); + const scaffold = await scaffoldCollectionReview(root, { resourceType: "obligation", programId: program.id }); + const today = currentCalendarDate(loaded.workspace.timezone); + await applyCollectionReview(root, { ...scaffold, confirmed: true, reviewedByIds: ["person-independent-approver-example"], reviewedOn: today, + rationale: "Independently reviewed the current adopted duties and their schedule.", + scheduleDecisions: scaffold.scheduleDecisions.map((decision) => ({ ...decision, disposition: "adopted", rationale: "Required schedule for the selected Control." })) }); + await commitWorkspaceFiles(root, "Approve the reviewed schedule set"); + const before = await loadWorkspace(root); + const beforeAssessment = assessCollectionReview(before, "obligation", { programId: program.id }); + assert.equal(beforeAssessment.complete, true); + const preserved = before.entries.filter(({ record }) => record.id === beforeAssessment.review.id || ["obligation", "obligation-rule"].includes(record.type)); + const beforeRevision = collectionRevision(before, "obligation", { programId: program.id }); + for (const type of ["control", "policy"]) { + const target = loaded.resources.find((record) => record.type === type); + const payload = await scaffoldPeriodImpactReview(root, { resourceId: target.id }); + Object.assign(payload.review, { reviewedByIds: ["person-example"], reviewedOn: today, effectiveOn: today, + conclusion: "no-material-change", rationale: "The owner reviewed the unchanged operating facts and procedure." }); + await recordPeriodImpactReview(root, payload); + loaded = await loadWorkspace(root); + const assessment = assessCollectionReview(loaded, "obligation", { programId: program.id }); + assert.equal(assessment.complete, true, `${type}: ${JSON.stringify(assessment)}`); + assert.equal(assessment.status, "current"); + assert.equal(collectionRevision(loaded, "obligation", { programId: program.id }), beforeRevision); + assert.deepEqual(assessment.review, beforeAssessment.review); + } + for (const entry of preserved) assert.equal(await readFile(entry.path, "utf8"), entry.source, entry.record.id); + for (const type of ["obligation-rule", "control", "policy"]) { + const altered = { ...loaded, resources: loaded.resources.map((record) => record.type !== type ? record + : type === "obligation-rule" ? { ...record, window: { precision: "date", startsAfter: 0, dueAfter: 4 } } + : type === "control" ? { ...record, description: "The Control now performs a different operating duty." } + : { ...record, version: "materially-revised" }) }; + assert.equal(assessCollectionReview(altered, "obligation", { programId: program.id }).complete, false, type); + } + const policy = loaded.resources.find(({ type }) => type === "policy"); + const policyPath = join(root, "data", markdownEntries(loaded.model, policy)[0].path); + const originalContent = await readFile(policyPath, "utf8"); + await writeFile(policyPath, `${originalContent}\nManagement requires an additional operating duty.\n`); + assert.equal(assessCollectionReview(await loadWorkspace(root), "obligation", { programId: program.id }).complete, false); +}); diff --git a/packages/filegrc/test/workflow-source-runtime.test.js b/packages/filegrc/test/workflow-source-runtime.test.js new file mode 100644 index 0000000..9bd27ab --- /dev/null +++ b/packages/filegrc/test/workflow-source-runtime.test.js @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, readFile, writeFile, rm, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { adoptWorkflowRuntime } from "./fixtures/workflow-runtime.js"; +import { makeWorkspace, initializeGitWorkspace, commitWorkspaceFiles, writeJson } from "./helpers.js"; +import { loadWorkspace } from "../src/workspace.js"; +import { updateResource, createResource, applyResourceBatch, updateContent } from "../src/files.js"; +import { validateWorkspace } from "../src/validate.js"; +import { scaffoldPeriodImpactReview, currentPeriodImpactRevision, periodImpactReviewCurrent } from "../src/period-impact.js"; +import { workflowSourceRuntimeAdopted } from "../src/workflow-source-runtime.js"; +import { reviewHistoryContext, reviewHistoricalWorkspace } from "../src/historical-workspace.js"; +import { getGitSummary } from "../src/git.js"; +import { assessWorkflow } from "../src/workflow.js"; +import { markdownEntries } from "../src/resource-markdown.js"; + +async function fixture(context, version = "0.21.5") { + const root = await mkdtemp(join(tmpdir(), "filegrc-source-adoption-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeWorkspace(root); await adoptWorkflowRuntime(root, version); + const workspacePath = join(root, "data/workspace.json"), workspace = JSON.parse(await readFile(workspacePath, "utf8")); + workspace.dataModelVersion = "13"; delete workspace.classificationDefinitions; await writeJson(workspacePath, workspace); + const record = { id: "system-example", type: "system", title: "Company service", status: "active", purpose: "Provide the company application.", boundary: "Company-managed service.", servicesProvided: ["Application"], criticality: "low", ownerIds: ["person-owner"] }; + await mkdir(join(root, "data/systems")); const path = join(root, "data/systems/system-example.json"); await writeJson(path, record); + return { root, record, path }; +} + +test("new source forms require a consistent declared and locked minimum reader", async context => { + const { root } = await fixture(context); + for (const [version, expected] of [["0.21.5", false], ["0.21.6", true], ["0.21.7", true], ["0.20.9", false]]) { + await adoptWorkflowRuntime(root, version); + assert.equal(workflowSourceRuntimeAdopted(await loadWorkspace(root)), expected, version); + } + await adoptWorkflowRuntime(root); + const lockPath = join(root, "package-lock.json"), validLock = JSON.parse(await readFile(lockPath, "utf8")); + for (const [label, mutate] of [ + ["old locked reader", lock => { lock.packages["node_modules/filegrc"].version = "0.21.5"; }], + ["outside caret range", lock => { lock.packages["node_modules/filegrc"].version = "0.22.0"; }], + ["mismatched declaration", lock => { lock.packages[""].dependencies.filegrc = "^0.21.5"; }], + ["unresolved link", lock => { lock.packages["node_modules/filegrc"].link = true; }], + ["prerelease", lock => { lock.packages["node_modules/filegrc"].version = "0.21.6-beta.1"; }] + ]) { + const lock = structuredClone(validLock); mutate(lock); await writeJson(lockPath, lock); + assert.equal(workflowSourceRuntimeAdopted(await loadWorkspace(root)), false, label); + } + for (const [declared, locked, expected] of [["0.21.6", "0.21.7", false], ["~0.21.6", "0.22.0", false], ["~0.21.6", "0.21.7", true], ["^0.21.6", "0.21.7", true]]) { + await writeJson(join(root, "package.json"), { dependencies: { filegrc: declared } }); + await writeJson(lockPath, { packages: { "": { dependencies: { filegrc: declared } }, "node_modules/filegrc": { version: locked } } }); + assert.equal(workflowSourceRuntimeAdopted(await loadWorkspace(root)), expected, `${declared}/${locked}`); + } +}); + +test("ordinary older workspaces remain valid while actions and generic source writes reject unsupported shapes", async context => { + const { root, record, path } = await fixture(context); + assert.equal((await validateWorkspace(root)).diagnostics.some(item => item.code === "source-runtime-adoption-required"), false); + await assert.rejects(scaffoldPeriodImpactReview(root, { resourceId: record.id }), /Adopt FileGRC 0\.21\.6/); + const loaded = await loadWorkspace(root), before = await readFile(path, "utf8"); + const changed = { ...record, periodImpactReview: { reviewedByIds: ["person-owner"], reviewedOn: "2026-10-28", effectiveOn: "2026-10-28", conclusion: "no-material-change", rationale: "Actual owner review.", reviewedRevision: await currentPeriodImpactRevision(loaded, record), reviewedTransitionKeys: [] } }; + await assert.rejects(updateResource(root, record.type, record.id, changed), /Adopt FileGRC 0\.21\.6/); + assert.equal(await readFile(path, "utf8"), before); + await writeJson(path, changed); + assert.ok((await validateWorkspace(root)).diagnostics.some(item => item.code === "source-runtime-adoption-required" && item.severity === "error")); +}); + +test("truthful forward reader repair restores current work without rewriting historical format failures", async context => { + const { root, record, path } = await fixture(context); + await initializeGitWorkspace(root); + const originalCommit = getGitSummary(root).commit; + const loaded = await loadWorkspace(root); + const changed = { ...record, periodImpactReview: { reviewedByIds: ["person-owner"], reviewedOn: "2026-10-28", effectiveOn: "2026-10-28", conclusion: "no-material-change", rationale: "Actual unchanged owner review facts.", reviewedRevision: await currentPeriodImpactRevision(loaded, record), reviewedTransitionKeys: [] } }; + await writeJson(path, changed); await commitWorkspaceFiles(root, "Record decision before correcting reader dependency"); + const badCommit = getGitSummary(root).commit; + assert.ok((await validateWorkspace(root)).diagnostics.some(item => item.code === "source-runtime-adoption-required")); + await adoptWorkflowRuntime(root); await commitWorkspaceFiles(root, "Correct the current reader dependency"); + const current = await loadWorkspace(root), actual = current.resources.find(item => item.id === record.id); + assert.deepEqual(actual.periodImpactReview, changed.periodImpactReview, "business decision dates and facts do not need rewriting"); + const result = await validateWorkspace(current); + assert.equal(result.ok, true, JSON.stringify(result.diagnostics)); + assert.ok(result.diagnostics.some(item => item.code === "historical-source-runtime-adoption" && item.severity === "warning")); + assert.equal(await periodImpactReviewCurrent(current, actual), true); + const historical = reviewHistoricalWorkspace(reviewHistoryContext(root, current), badCommit); + assert.equal(await periodImpactReviewCurrent(historical, historical.byId.get(record.id)), false); + assert.ok((await validateWorkspace({ ...historical, diagnostics: [] })).diagnostics.some(item => item.code === "source-runtime-adoption-required")); + const beforeFeature = reviewHistoricalWorkspace(reviewHistoryContext(root, current), originalCommit); + assert.equal((await validateWorkspace({ ...beforeFeature, diagnostics: [] })).diagnostics.some(item => item.code === "source-runtime-adoption-required"), false, "future source shapes cannot invalidate an earlier ordinary snapshot"); + const workflow = await assessWorkflow(current, { validation: result, programReadiness: { stages: [], evidenceReady: false }, auditPreparations: {}, obligations: { items: [], triggers: [], counts: {} }, reconciliation: { candidates: [] }, git: {} }); + const contextFinding = workflow.findings.find(item => item.code.includes("historical-source-runtime-adoption")); + assert.equal(contextFinding.state, "complete"); assert.deepEqual(contextFinding.actions, []); +}); + +test("an existing manual violation cannot authorize more unsupported generic mutations", async context => { + const { root, record, path } = await fixture(context); + let loaded = await loadWorkspace(root); + const review = { reviewedByIds: ["person-owner"], reviewedOn: "2026-10-28", effectiveOn: "2026-10-28", conclusion: "no-material-change", rationale: "Actual owner review.", reviewedRevision: await currentPeriodImpactRevision(loaded, record), reviewedTransitionKeys: [] }; + await writeJson(path, { ...record, periodImpactReview: review }); + const other = { ...record, id: "system-other", title: "Other service" }; + await createResource(root, other); + loaded = await loadWorkspace(root); + const entry = loaded.entries.find(item => item.record.id === other.id), original = await readFile(entry.path, "utf8"); + const next = { ...other, periodImpactReview: { ...review, reviewedRevision: await currentPeriodImpactRevision(loaded, other) } }; + await assert.rejects(updateResource(root, other.type, other.id, next), /Adopt FileGRC 0\.21\.6/); + await assert.rejects(applyResourceBatch(root, { update: [next] }), /Adopt FileGRC 0\.21\.6/); + await assert.rejects(createResource(root, { ...next, id: "system-third" }), /Adopt FileGRC 0\.21\.6/); + const markdown = markdownEntries(loaded.model, record)[0].path; + await assert.rejects(updateContent(root, markdown, "# New procedure\n"), /Adopt FileGRC 0\.21\.6/); + assert.equal(await readFile(entry.path, "utf8"), original); + assert.equal((await loadWorkspace(root)).resources.some(item => item.id === "system-third"), false); +}); + +test("malformed typed review actors produce diagnostics rather than an uncaught validation failure", async context => { + const { root, record, path } = await fixture(context, "0.21.6"); + const loaded = await loadWorkspace(root); + await writeJson(path, { ...record, periodImpactReview: { reviewedByIds: "owner", reviewedOn: "2026-10-28", effectiveOn: "2026-10-28", conclusion: "no-material-change", rationale: "Malformed input.", reviewedRevision: await currentPeriodImpactRevision(loaded, record) } }); + const result = await validateWorkspace(root); + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some(item => item.code === "invalid-period-impact-reviewer")); +}); diff --git a/packages/filegrc/test/workflow.test.js b/packages/filegrc/test/workflow.test.js index d07e7b6..629d1f8 100644 --- a/packages/filegrc/test/workflow.test.js +++ b/packages/filegrc/test/workflow.test.js @@ -886,3 +886,18 @@ test("dated review deadlines agree across workflow, CLI and lazy app/API queue s const current = JSON.parse(await readFile(join(root, "data", "risk-assessments", record.id + ".json"), "utf8")); assert.deepEqual(current, record); }); + +test("historical channel warnings stay disclosed without inventing a current approval task", async context => { + const root = await mkdtemp(join(tmpdir(), "filegrc-historical-channel-guidance-")); + context.after(() => import("node:fs/promises").then(({rm}) => rm(root, {recursive:true,force:true}))); + await makeComprehensiveWorkspace(root, "13"); + const loaded = await loadWorkspace(root), record = loaded.resources.find(record => record.type === "policy"); + const entry = loaded.entries.find(entry => entry.record.id === record.id); + const options = { programReadiness: { stages: [{id:"controls", items:[{id:"reporting-channel-cutover",status:"complete"}]}], evidenceReady:true }, auditPreparations:{}, obligations:{items:[],triggers:[],counts:{},asOf:"2026-10-01",through:"2026-10-01"}, validation:{ok:true,diagnostics:[{code:"uncovered-reporting-route-interval",severity:"warning",path:`data/${entry.relativePath}`,message:"Historical channel coverage gap."}]},reconciliation:{candidates:[]},git:{},asOf:"2026-10-01" }; + let workflow = await assessWorkflow(loaded, options); + const finding = workflow.findings.find(f => f.code.includes("uncovered-reporting-route-interval")); + assert.equal(finding.state,"complete"); assert.equal(finding.severity,"warning"); assert.deepEqual(finding.actions,[]); assert.match(finding.message,/do not backdate/); + options.programReadiness.stages[0].items[0].status="action"; + workflow = await assessWorkflow(loaded, options); + assert.equal(workflow.findings.find(f=>f.code.includes("uncovered-reporting-route-interval")).state,"ready"); +});