From 6cdab00eba208a0bcb59a71f0f1ab48b8c5704c3 Mon Sep 17 00:00:00 2001 From: Abe Wheeler Date: Fri, 23 Oct 2026 11:28:29 -0500 Subject: [PATCH] Support bounded owner-authorized nonmaterial amendments --- docs/data-model.md | 52 +++- package-lock.json | 8 +- package.json | 2 +- packages/create-filegrc/package.json | 2 +- packages/create-filegrc/src/index.js | 4 +- packages/create-filegrc/template/AGENTS.md | 6 + packages/create-filegrc/template/README.md | 4 + packages/create-filegrc/template/package.json | 2 +- packages/filegrc/model/v13.json | 217 +++++++++++++- packages/filegrc/package.json | 2 +- packages/filegrc/src/agent.js | 1 + packages/filegrc/src/cli.js | 21 ++ packages/filegrc/src/evidence-packet.js | 12 +- packages/filegrc/src/files.js | 15 +- packages/filegrc/src/index.js | 2 + .../filegrc/src/nonmaterial-amendments.js | 276 +++++++++++++++++ packages/filegrc/src/reporting-route-sets.js | 5 + packages/filegrc/src/runtime-version.js | 7 +- packages/filegrc/src/server.js | 14 + packages/filegrc/src/state.js | 4 + packages/filegrc/src/validate.js | 12 +- packages/filegrc/src/web.js | 63 +++- .../filegrc/src/workflow-history-integrity.js | 10 +- packages/filegrc/test/agent.test.js | 1 + packages/filegrc/test/fixtures.js | 2 +- packages/filegrc/test/model.test.js | 2 +- .../test/nonmaterial-amendments.test.js | 283 ++++++++++++++++++ packages/filegrc/test/render.test.js | 2 +- packages/filegrc/test/runtime-version.test.js | 4 +- 29 files changed, 1004 insertions(+), 31 deletions(-) create mode 100644 packages/filegrc/src/nonmaterial-amendments.js create mode 100644 packages/filegrc/test/nonmaterial-amendments.test.js diff --git a/docs/data-model.md b/docs/data-model.md index c597c70..24f3fd9 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -712,6 +712,22 @@ Key format: `namespace`. | `resolutionEvidenceIds` | array of id | No | References: `evidence` | | `authorizationKind` | enum | Conditional | Values: `access-grant`, `review-renewal`, `service-account-inventory` Required when `disposition` is `authorized`. Allowed when `disposition` is `authorized`. | +### `nonmaterial-amendment-confirmations` + +| Property | Type | Required | Notes | +| --- | --- | --- | --- | +| `sameDestination` | boolean | No | Same underlying delivery destination | +| `sameRecipients` | boolean | No | Same recipients | +| `sameAccess` | boolean | No | Same access boundaries | +| `sameAvailability` | boolean | No | Same fallback and availability behavior | +| `sameDependencies` | boolean | No | Same system dependencies | + +### `reporting-route-withdrawal` + +| Property | Type | Required | Notes | +| --- | --- | --- | --- | +| `amendmentId` | id | Yes | References: `nonmaterial-amendment` | + ## Common fields | Field | Type | Required | Meaning | @@ -1544,7 +1560,7 @@ Instructions: Replace the reporting-route placeholders with real normal and fall Policy basis: A reporting channel is an email address, phone number, web form, in-person contact, or other destination people use to raise a concern. Policies and other approved decisions say when channels are required; this record stores the current contact details so governed text does not copy them. -Timing: Choose the normal channel and a usable fallback, then commit the proposal before approval. Approve the exact committed pair with the actual authority, time, and verified fixed evidence of the approval event. Record cancellation evidence the same way. Create a successor when either channel changes instead of editing an approved set. +Timing: Choose the normal channel and a usable fallback, then commit the proposal before approval. Approve the exact committed pair with the actual authority, time, and verified fixed evidence of the approval event. Record cancellation evidence the same way. Create a successor when either channel changes instead of editing an approved set. A verified equivalent email alias can instead use an owner-authorized nonmaterial amendment; the original set and its approval remain unchanged. Path: `data/reporting-route-sets/.json` @@ -1554,7 +1570,7 @@ Markdown companions: | Field | Type | Required | Notes | | --- | --- | --- | --- | -| `status` | enum | Yes | Values: `draft`, `proposed`, `approved`, `canceled`, `historical` | +| `status` | enum | Yes | Values: `draft`, `proposed`, `approved`, `canceled`, `historical`, `withdrawn` | | `programId` | id | Yes | References: `program` | | `purposeKey` | string (id) | Yes | Stable purpose key | | `purposeLabel` | string | Yes | Reports covered | @@ -1569,6 +1585,38 @@ Markdown companions: | `cancellation` | object (`reporting-route-cancellation`) | Conditional | Managed by filegrc. Required when `status` is `canceled`. Allowed when `status` is `canceled`. | | `legacyRouteIds` | array of id | No | References: `reporting-route` Allowed when `status` is `historical`. | | `legacyBasis` | string | No | Allowed when `status` is `historical`. | +| `withdrawal` | object (`reporting-route-withdrawal`) | Conditional | Managed by filegrc. Required when `status` is `withdrawn`. Allowed when `status` is `withdrawn`. | + +#### `nonmaterial-amendment` + +An owner-authorized administrative correction attached to an unchanged source record. It preserves the original approval and is not a new independent approval. + +Instructions: An owner-authorized administrative correction attached to an unchanged source record. It preserves the original approval and is not a new independent approval. + +Policy basis: Use only the model-defined categories and fields. The owner records why the correction preserves the approved decision. Changes to commitments, recipients, access, scope, timing, content or evidence conclusions keep their normal review. + +Timing: Record the actual decision time against committed source facts and fixed supporting evidence. Commit before relying on a correction. Preserve each decision; use another amendment or normal review for subsequent changes. + +Path: `data/nonmaterial-amendments/.json` + +Record Markdown: available when needed as an implicit companion file. + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `targetResourceId` | id | Yes | Amended record References: `policy`, `document`, `training`, `retention-schedule-item`, `obligation-rule`, `control`, `component`, `vendor`, `reporting-route-set` | +| `category` | enum | Yes | Values: `label-correction`, `reporting-email-alias` Values come from the `nonmaterialAmendments` registry. | +| `sourceCommit` | string | Yes | Unchanged source revision | +| `previousAmendmentId` | id | No | References: `nonmaterial-amendment` | +| `fieldKey` | string | Yes | Corrected field | +| `previousValue` | string | Yes | | +| `replacementValue` | string | Yes | | +| `amendedAt` | string | Yes | Actual amendment time | +| `amendedById` | id | Yes | Owner making the decision References: `person` | +| `authorityAppointmentId` | id | Yes | References: `appointment` | +| `rationale` | string | Yes | | +| `evidenceIds` | array of id | No | References: `evidence` | +| `confirmations` | object (`nonmaterial-amendment-confirmations`) | No | | +| `withdrawnProposalId` | id | No | References: `reporting-route-set` | ### Risk diff --git a/package-lock.json b/package-lock.json index b268e50..3bd4bbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.18.16", + "version": "0.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.18.16", + "version": "0.19.0", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.18.16", + "version": "0.19.0", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.18.16", + "version": "0.19.0", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index 2467432..1191bc1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.18.16", + "version": "0.19.0", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index d5f3373..3d125f3 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.18.16", + "version": "0.19.0", "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 a43cc71..8cf656f 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.18.16", + version: "0.19.0", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.18.16", + version: "0.19.0", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/AGENTS.md b/packages/create-filegrc/template/AGENTS.md index 0b5847b..539bc2e 100644 --- a/packages/create-filegrc/template/AGENTS.md +++ b/packages/create-filegrc/template/AGENTS.md @@ -258,3 +258,9 @@ Do not loosen validation to make a bad record pass. Fix the record or update the Model v12 requires a complete Program Obligation schedule-set review before activation. Use `npx filegrc review-collection obligation --program PROGRAM_ID --scaffold`, record adopted rules, replacements, retired duplicates, and intentionally inapplicable proposals, then preview and save the exact revision with an independent reviewer. Reporting Channel Sets need independent approval in Step 1; Step 3 verifies their effective coverage and usability at cutover. Existing model v11 workspaces preview `npx filegrc migrate --to-model 12 --preview --json`. See the [model v12 upgrade guide](https://github.com/Alignbase/filegrc/blob/main/docs/upgrading-to-model-v12.md). Model v13 checks completed full-program risk assessment, current workforce training and policy acknowledgements, and actual provider-access reconciliation when selected Controls require them. Retrieval rehearsals prove collection access; they do not complete baseline work. Operating ownership reuses substantive Control procedures and unchanged approved program Documents without a separate Document or duplicate approval. Run `npx filegrc program-readiness --json` for exact gaps, candidates and normal mutation commands shared with the browser. Existing v13 repositories update the engine within the 0.18 release line; no new model migration is needed. Existing v12 repositories preview `npx filegrc migrate --to-model 13 --preview --json` before applying the upgrade. Active programs repair current gaps with actual dates and preserve historical activation and reviews. Material content, schedule and retention changes keep their normal reviews. See the [model v13 upgrade guide](https://github.com/Alignbase/filegrc/blob/main/docs/upgrading-to-model-v13.md). + +## Nonmaterial administrative amendments + +Use `npx filegrc amend-nonmaterial RESOURCE_ID --scaffold` for a model-supported administrative label correction or verified equivalent reporting email alias. Fill the actual owner, scoped authority, rationale, time and supporting fixed evidence; preview with `--preview`, then save with `--yes` and commit. The source record, Markdown and independent approval remain unchanged. `get RESOURCE_ID --workflow --json` includes the current administrative view and amendment IDs. The browser exposes the same action on supported record pages. + +Email aliases require the owner to confirm the same underlying destination, recipients, access boundaries, availability and dependencies. This decision does not independently verify its Evidence or create a new independent approval. A matching proposed alias successor may be withdrawn atomically. Never use an amendment for changed commitments, recipients, access, scope, retention periods, cadence, deadlines, governed prose or evidence conclusions. Keep those normal reviews. Preserve committed amendments and the observations and captures supporting them; correct them through a subsequent amendment. Later independent verification may add verification metadata without rewriting those observations. diff --git a/packages/create-filegrc/template/README.md b/packages/create-filegrc/template/README.md index c3f0d53..48aef25 100644 --- a/packages/create-filegrc/template/README.md +++ b/packages/create-filegrc/template/README.md @@ -57,3 +57,7 @@ FileGRC manages GRC records and audit evidence. Your infrastructure, identity, m Your team performs the control work and reviews responses before submission. An independent CPA firm performs the SOC 2 examination, judges evidence sufficiency, and issues the report. Using FileGRC alone does not establish compliance. [Learn more](https://filegrc.com) · [Source code](https://github.com/Alignbase/filegrc) · [Report an issue](https://github.com/Alignbase/filegrc/issues) + +For a nonmaterial administrative correction, choose **Nonmaterial amendment** on a supported record page or run `npx filegrc amend-nonmaterial RESOURCE_ID --scaffold`. The owner can correct an administrative label or a verified equivalent reporting email alias while preserving the original record and approval. Changes to commitments, recipients, access, scope, schedules, governed content or evidence conclusions keep their normal reviews. + +The nonmaterial amendment workflow is available from FileGRC 0.19.0 with model v13. Existing workspaces need no model migration. The 0.19 runtime also reads existing 0.18 workspaces; 0.18 runtimes reject repositories pinned to 0.19, so hosted services must approve and install the new runtime before owners adopt these records. diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index e23f693..825c92c 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.18.16", + "version": "0.19.0", "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 a1431aa..533007a 100644 --- a/packages/filegrc/model/v13.json +++ b/packages/filegrc/model/v13.json @@ -9351,7 +9351,7 @@ "description": "The normal and fallback ways people send a report for one purpose, such as an email address and a hotline for security concerns. The set records where each channel leads, who keeps it usable, and when the approved pair applies.", "guidance": { "policyBasis": "A reporting channel is an email address, phone number, web form, in-person contact, or other destination people use to raise a concern. Policies and other approved decisions say when channels are required; this record stores the current contact details so governed text does not copy them.", - "cadence": "Choose the normal channel and a usable fallback, then commit the proposal before approval. Approve the exact committed pair with the actual authority, time, and verified fixed evidence of the approval event. Record cancellation evidence the same way. Create a successor when either channel changes instead of editing an approved set." + "cadence": "Choose the normal channel and a usable fallback, then commit the proposal before approval. Approve the exact committed pair with the actual authority, time, and verified fixed evidence of the approval event. Record cancellation evidence the same way. Create a successor when either channel changes instead of editing an approved set. A verified equivalent email alias can instead use an owner-authorized nonmaterial amendment; the original set and its approval remain unchanged." }, "required": [ "status", @@ -9373,7 +9373,8 @@ "proposed", "approved", "canceled", - "historical" + "historical", + "withdrawn" ], "filter": true }, @@ -9491,6 +9492,17 @@ "allowedWhen": { "status": "historical" } + }, + "withdrawal": { + "type": "object", + "objectType": "reporting-route-withdrawal", + "managed": true, + "requiredWhen": { + "status": "withdrawn" + }, + "allowedWhen": { + "status": "withdrawn" + } } }, "listFields": [ @@ -9507,6 +9519,132 @@ "required": true } } + }, + "nonmaterial-amendment": { + "title": "Nonmaterial amendment", + "pluralTitle": "Nonmaterial amendments", + "group": "governance", + "collection": "nonmaterial-amendments", + "description": "An owner-authorized administrative correction attached to an unchanged source record. It preserves the original approval and is not a new independent approval.", + "guidance": { + "policyBasis": "Use only the model-defined categories and fields. The owner records why the correction preserves the approved decision. Changes to commitments, recipients, access, scope, timing, content or evidence conclusions keep their normal review.", + "cadence": "Record the actual decision time against committed source facts and fixed supporting evidence. Commit before relying on a correction. Preserve each decision; use another amendment or normal review for subsequent changes." + }, + "required": [ + "targetResourceId", + "category", + "sourceCommit", + "fieldKey", + "previousValue", + "replacementValue", + "amendedAt", + "amendedById", + "authorityAppointmentId", + "rationale" + ], + "fields": { + "targetResourceId": { + "type": "id", + "relation": [ + "policy", + "document", + "training", + "retention-schedule-item", + "obligation-rule", + "control", + "component", + "vendor", + "reporting-route-set" + ], + "label": "Amended record" + }, + "category": { + "type": "enum", + "registry": "nonmaterialAmendments", + "filter": true + }, + "sourceCommit": { + "type": "string", + "label": "Unchanged source revision" + }, + "previousAmendmentId": { + "type": "id", + "relation": [ + "nonmaterial-amendment" + ] + }, + "fieldKey": { + "type": "string", + "label": "Corrected field" + }, + "previousValue": { + "type": "string" + }, + "replacementValue": { + "type": "string" + }, + "amendedAt": { + "type": "string", + "label": "Actual amendment time" + }, + "amendedById": { + "type": "id", + "relation": [ + "person" + ], + "label": "Owner making the decision" + }, + "authorityAppointmentId": { + "type": "id", + "relation": [ + "appointment" + ] + }, + "rationale": { + "type": "string", + "search": true + }, + "evidenceIds": { + "type": "array", + "items": "id", + "relation": [ + "evidence" + ] + }, + "confirmations": { + "type": "object", + "objectType": "nonmaterial-amendment-confirmations" + }, + "withdrawnProposalId": { + "type": "id", + "relation": [ + "reporting-route-set" + ] + } + }, + "listFields": [ + "title", + "targetResourceId", + "category", + "amendedAt", + "amendedById" + ], + "formFields": [ + "targetResourceId", + "category", + "sourceCommit", + "previousAmendmentId", + "fieldKey", + "previousValue", + "replacementValue", + "amendedAt", + "amendedById", + "authorityAppointmentId", + "rationale", + "evidenceIds", + "confirmations", + "withdrawnProposalId" + ] } }, "primitives": { @@ -11378,6 +11516,45 @@ } } } + }, + "nonmaterial-amendment-confirmations": { + "additionalProperties": false, + "properties": { + "sameDestination": { + "type": "boolean", + "label": "Same underlying delivery destination" + }, + "sameRecipients": { + "type": "boolean", + "label": "Same recipients" + }, + "sameAccess": { + "type": "boolean", + "label": "Same access boundaries" + }, + "sameAvailability": { + "type": "boolean", + "label": "Same fallback and availability behavior" + }, + "sameDependencies": { + "type": "boolean", + "label": "Same system dependencies" + } + } + }, + "reporting-route-withdrawal": { + "additionalProperties": false, + "required": [ + "amendmentId" + ], + "properties": { + "amendmentId": { + "type": "id", + "relation": [ + "nonmaterial-amendment" + ] + } + } } }, "obligationActivities": { @@ -12727,5 +12904,41 @@ "resourceType": "access-review" } ] + }, + "nonmaterialAmendments": { + "label-correction": { + "title": "Administrative label correction", + "resourceTypes": [ + "policy", + "document", + "training", + "retention-schedule-item", + "obligation-rule", + "control", + "component", + "vendor", + "reporting-route-set" + ], + "fieldPaths": [ + "title" + ] + }, + "reporting-email-alias": { + "title": "Equivalent reporting email alias", + "resourceTypes": [ + "reporting-route-set" + ], + "fieldPaths": [ + "primaryLane.destination", + "alternateLane.destination" + ], + "requiredConfirmations": [ + "sameDestination", + "sameRecipients", + "sameAccess", + "sameAvailability", + "sameDependencies" + ] + } } } diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index cfbf076..abb5117 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.18.16", + "version": "0.19.0", "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 4f8f780..844b3cd 100644 --- a/packages/filegrc/src/agent.js +++ b/packages/filegrc/src/agent.js @@ -84,6 +84,7 @@ export function buildAgentGuide(loaded, type, options = {}) { return { type, + nonmaterialAmendments: Object.fromEntries(Object.entries(loaded.model.nonmaterialAmendments || {}).filter(([, rule]) => rule.resourceTypes.includes(type))), ...(options.preparationChecks ? { preparationChecks: options.preparationChecks } : {}), title: definition.title, pluralTitle: definition.pluralTitle, diff --git a/packages/filegrc/src/cli.js b/packages/filegrc/src/cli.js index 50f8045..e65340e 100644 --- a/packages/filegrc/src/cli.js +++ b/packages/filegrc/src/cli.js @@ -69,6 +69,7 @@ import { proposeReportingRouteSet, scaffoldReportingRouteSet } from "./reporting-route-sets.js"; +import { amendedResourceView, scaffoldNonmaterialAmendment, saveNonmaterialAmendment } from "./nonmaterial-amendments.js"; import { planProgramAmendment } from "./program-amendment.js"; import { resolveProgram } from "./program.js"; import { resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js"; @@ -600,6 +601,22 @@ export async function runCli(argv = process.argv.slice(2)) { } throw new Error("Use reporting-route-set scaffold [approve|cancel|successor], propose ROUTE_SET_ID, approve PAYLOAD.json, or cancel PAYLOAD.json."); } + if (command === "amend-nonmaterial") { + const targetId = positionals[0]; + if (!targetId) throw new Error("Pass the source resource ID."); + if (flags.scaffold) { + const result = await scaffoldNonmaterialAmendment(root, targetId, { category: flags.category, fieldKey: flags.field }); + console.log(JSON.stringify(result, null, 2)); + return result; + } + if (!positionals[1]) throw new Error("Pass a scaffolded amendment payload path."); + const payload = JSON.parse(await readFile(resolve(positionals[1]), "utf8")); + if (payload.record?.targetResourceId !== targetId) throw new Error("The amendment must match the requested source record."); + if (!flags.preview && !flags.yes) throw new Error("Preview the decision, then pass --yes to record it."); + const result = await withWorkflowDelta(root, () => saveNonmaterialAmendment(root, payload, { preview: Boolean(flags.preview) })); + console.log(JSON.stringify(result, null, 2)); + return result; + } if (command === "program-amendment") { const sourceResourceId = flags.source || positionals[0]; if (!sourceResourceId) throw new Error("Pass a source resource ID or --source resource-id."); @@ -978,6 +995,7 @@ export async function runCli(argv = process.argv.slice(2)) { const output = flags.workflow ? { record, + nonmaterialView: amendedResourceView(loaded, record), workflow: workflowForResource(await assessWorkflow(loaded), record.type, record.id, loaded) } : record; @@ -1415,6 +1433,8 @@ Usage: filegrc automation [--json] [--open] [--root PATH] filegrc obligations [--program program-id] [--as-of YYYY-MM-DD] [--from YYYY-MM-DD] [--through YYYY-MM-DD] [--now RFC3339] [--complete] [--json] filegrc program-readiness [--as-of YYYY-MM-DD] [--require-ready] [--summary | --control CONTROL_ID] [--json] + filegrc amend-nonmaterial --scaffold [--category label-correction|reporting-email-alias] [--field field.path] + filegrc amend-nonmaterial --preview|--yes [--json] filegrc program-amendment [--json] filegrc review-bindings [--json] filegrc evidence-map [--as-of YYYY-MM-DD] [--json] @@ -1766,6 +1786,7 @@ function agentOverview(model, connection) { search: "filegrc search --json", obligations: "filegrc obligations --json", programReadiness: "filegrc program-readiness --json", + nonmaterialAmendment: "filegrc amend-nonmaterial RESOURCE_ID --scaffold", programAmendment: "filegrc program-amendment --json", reviewBindings: "filegrc review-bindings --json", evidenceMap: "filegrc evidence-map --json", diff --git a/packages/filegrc/src/evidence-packet.js b/packages/filegrc/src/evidence-packet.js index 5ab5c0d..6e89ac7 100644 --- a/packages/filegrc/src/evidence-packet.js +++ b/packages/filegrc/src/evidence-packet.js @@ -487,7 +487,8 @@ export async function prepareEvidencePacket(input, options = {}) { canceled, authorities, approvalAssertionTiming, - cancellationAssertionTiming + cancellationAssertionTiming, + nonmaterialView }) => ({ id: record.id, title: record.title, @@ -509,6 +510,7 @@ export async function prepareEvidencePacket(input, options = {}) { authorityRevision: reportingRouteEventCommit(loaded, record, "cancellation") } : null, + nonmaterialView: nonmaterialView || null, primaryLane: record.primaryLane, alternateLane: record.alternateLane || null, committed, @@ -651,9 +653,10 @@ function expandEvidenceWorkflowContext(selectedIds, byId) { const queue = [...selectedIds]; const childrenBySource = new Map(); for (const record of byId.values()) { - if (!["finding", "action-item"].includes(record.type) || !record.sourceResourceId) continue; - if (!childrenBySource.has(record.sourceResourceId)) childrenBySource.set(record.sourceResourceId, []); - childrenBySource.get(record.sourceResourceId).push(record.id); + const sourceId = record.type === "nonmaterial-amendment" ? record.targetResourceId : record.sourceResourceId; + if (!["finding", "action-item", "nonmaterial-amendment"].includes(record.type) || !sourceId) continue; + if (!childrenBySource.has(sourceId)) childrenBySource.set(sourceId, []); + childrenBySource.get(sourceId).push(record.id); } const enqueue = (ids = []) => { for (const id of ids) { @@ -674,6 +677,7 @@ function expandEvidenceWorkflowContext(selectedIds, byId) { record?.sourceEvidenceId, record?.exceptionId ]); + if (record?.type === "nonmaterial-amendment") enqueue([record.targetResourceId, record.previousAmendmentId, record.withdrawnProposalId, record.amendedById, record.authorityAppointmentId]); if (record?.type === "evidence") enqueue([...(record.sourceResourceIds || []), record.sourceComponentId, record.sourceSystemId]); if (record?.type === "audit-population") enqueue([record.sourceEvidenceId, ...(record.controlIds || [])]); if (record?.type === "control-test") enqueue([record.populationId, ...(record.sampleEvidenceIds || [])]); diff --git a/packages/filegrc/src/files.js b/packages/filegrc/src/files.js index d0ba2d6..e4a8785 100644 --- a/packages/filegrc/src/files.js +++ b/packages/filegrc/src/files.js @@ -24,6 +24,7 @@ export const INTERNAL_WORKFLOW_CAPABILITIES = Object.freeze({ reportingRouteSetProposal: Symbol("reporting-route-set-proposal"), reportingRouteSetApproval: Symbol("reporting-route-set-approval"), reportingRouteSetCancellation: Symbol("reporting-route-set-cancellation"), + nonmaterialAmendment: Symbol("nonmaterial-amendment"), reconciliationDismissal: Symbol("reconciliation-dismissal") }); @@ -679,6 +680,9 @@ function assertImmutableWorkflowRecord(existing, next, options = {}, loaded = nu const preservesExisting = (allowed = []) => [...new Set([...Object.keys(existing), ...Object.keys(next)])].every((key) => ( allowed.includes(key) || JSON.stringify(next[key]) === JSON.stringify(existing[key]) )); + if (existing.type === "nonmaterial-amendment" && !preservesExisting()) { + throw new Error("Nonmaterial amendments are immutable owner decisions. Record a new amendment instead."); + } if (existing.type === "reconciliation-dismissal" && !preservesExisting()) { throw new Error(`Reconciliation dismissal "${existing.id}" is immutable because it records a fingerprint-bound review decision.`); } @@ -718,6 +722,10 @@ function assertImmutableWorkflowRecord(existing, next, options = {}, loaded = nu if (capability !== INTERNAL_WORKFLOW_CAPABILITIES.reportingRouteSetProposal || !preservesExisting(["status"])) { throw new Error(`Reporting Route Set "${existing.id}" must use the managed proposal action.`); } + } else if (existing.status === "proposed" && next.status === "withdrawn") { + if (capability !== INTERNAL_WORKFLOW_CAPABILITIES.nonmaterialAmendment || !preservesExisting(["status", "withdrawal"])) { + throw new Error("Withdraw the matching proposal through the nonmaterial amendment workflow."); + } } else if (existing.status === "proposed" && next.status === "approved") { if ( capability !== INTERNAL_WORKFLOW_CAPABILITIES.reportingRouteSetApproval @@ -919,6 +927,9 @@ function finalizedOccurrenceUsingProof(loaded, targetId) { } function assertSpecializedWorkflowCreate(record, options = {}, loaded = null) { + if (record?.type === "nonmaterial-amendment" && options.workflowCapability !== INTERNAL_WORKFLOW_CAPABILITIES.nonmaterialAmendment) { + throw new Error("Use the nonmaterial amendment scaffold, preview and save workflow."); + } if ( record?.type === "reconciliation-dismissal" && options.workflowCapability !== INTERNAL_WORKFLOW_CAPABILITIES.reconciliationDismissal @@ -1010,7 +1021,7 @@ async function deleteResourceUnlocked(input, type, id, options) { assertRevision(source, options.expectedRevision, "The record"); const record = JSON.parse(source); if ( - record.type === "reconciliation-dismissal" + ["reconciliation-dismissal", "nonmaterial-amendment"].includes(record.type) || (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)) @@ -1018,7 +1029,7 @@ async function deleteResourceUnlocked(input, type, id, options) { || (record.type === "collection-review" && ["active", "retired"].includes(record.status)) || (record.type === "obligation-rule" && ["active", "retired"].includes(record.status)) || (record.type === "reporting-route" && ["active", "retired"].includes(record.status)) - || (record.type === "reporting-route-set" && ["proposed", "approved", "canceled", "historical"].includes(record.status)) + || (record.type === "reporting-route-set" && ["proposed", "approved", "canceled", "historical", "withdrawn"].includes(record.status)) || (record.type === "attestation" && record.status === "completed") || (["policy", "document", "commitment", "risk"].includes(record.type) && record.reportingRouteRequirements?.length diff --git a/packages/filegrc/src/index.js b/packages/filegrc/src/index.js index 1d1c714..fd3bc26 100644 --- a/packages/filegrc/src/index.js +++ b/packages/filegrc/src/index.js @@ -140,3 +140,5 @@ export { indexResources, loadWorkspace } from "./workspace.js"; export { notificationContacts } from "./notifications.js"; export { assessHostedAutomation, githubRepository, hostedAutomationForConnection } from "./hosted-automation.js"; + +export { amendedResourceView, scaffoldNonmaterialAmendment, saveNonmaterialAmendment } from "./nonmaterial-amendments.js"; diff --git a/packages/filegrc/src/nonmaterial-amendments.js b/packages/filegrc/src/nonmaterial-amendments.js new file mode 100644 index 0000000..065a30b --- /dev/null +++ b/packages/filegrc/src/nonmaterial-amendments.js @@ -0,0 +1,276 @@ +import { readFileSync } from "node:fs"; +import { validateWorkspace } from "./validate.js"; +import { join } from "node:path"; +import { runtimeCompatibilityLine } from "./runtime-version.js"; +import { markdownEntries } from "./resource-markdown.js"; +import { applyResourceBatch, INTERNAL_WORKFLOW_CAPABILITIES } from "./files.js"; +import { serializeWorkspaceMutation } from "./mutation.js"; +import { loadWorkspace } from "./workspace.js"; +import { calculateRevision, revisionsMatch } from "./revisions.js"; +import { appointmentWasAuthorizedOn } from "./soc2.js"; +import { isRfc3339Timestamp, localDateTimeValue } from "./time.js"; +import { getDataRecordHistoryIndex, getFileAtRevision, getFileObjectIdAtRevision, getGitSummary, getWorkingFileObjectId, isDataHistoryAncestor, runGitCommandSync } from "./git.js"; + +const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const valueAt = (record, path) => String(path || "").split(".").reduce((value, key) => value?.[key], record); + +function hasAmendmentRuntimePin(loaded, commit = null) { + try { + const source = commit + ? runGitCommandSync(loaded.root, ["show", `${commit}:./package-lock.json`], { timeout: 5_000, maxBuffer: 2_000_000 }) + : readFileSync(join(loaded.root, "package-lock.json"), "utf8"); + const engine = JSON.parse(source)?.packages?.["node_modules/filegrc"]; + return !engine?.link && runtimeCompatibilityLine(engine?.version) === "0.19.x"; + } catch { return false; } +} + +function historicalRecord(loaded, id, commit) { + const history = getDataRecordHistoryIndex(loaded.root).historiesById.get(id) || []; + const identity = history.find((item) => isDataHistoryAncestor(loaded, item.commit, commit)); + if (!identity) return null; + try { return JSON.parse(getFileAtRevision(loaded.root, commit, identity.path)); } catch { return null; } +} + +// A later source decision on this field ends the old correction chain, even +// when a subsequent decision happens to restore the original text. +function fieldEpoch(loaded, id, field, commit) { + const history = [...(getDataRecordHistoryIndex(loaded.root).historiesById.get(id) || [])].reverse(); + let epoch = null; + let previous; + for (const item of history) { + if (!isDataHistoryAncestor(loaded, item.commit, commit)) continue; + const value = valueAt(historicalRecord(loaded, id, item.commit), field); + if (epoch === null || value !== previous) epoch = item.commit; + previous = value; + } + return epoch; +} + +function firstCommit(loaded, id) { + return [...(getDataRecordHistoryIndex(loaded.root).historiesById.get(id) || [])].reverse()[0]?.commit || null; +} + +// The amendment is a separate decision. Never project it into loaded.resources: +// approval, history, attestations and collection revisions bind the original. +export function nonmaterialAmendmentIssues(loaded, amendment) { + const issues = []; + const fail = (message) => issues.push({ severity: "error", code: "invalid-nonmaterial-amendment", resourceId: amendment.id, message }); + if (!hasAmendmentRuntimePin(loaded)) { + fail("Upgrade and commit the repository dependency and lockfile to FileGRC 0.19 before adopting nonmaterial amendments. The 0.19 runtime can read older repositories but cannot add these records to an older pin."); + return issues; + } + const rule = loaded.model.nonmaterialAmendments?.[amendment.category]; + const head = getGitSummary(loaded.root).commit; + const eventCommit = firstCommit(loaded, amendment.id); + if (eventCommit && !hasAmendmentRuntimePin(loaded, eventCommit)) { + fail("The repository must adopt the 0.19 dependency and lockfile no later than its first amendment commit."); + return issues; + } + const source = /^[a-f0-9]{40}$/.test(amendment.sourceCommit || "") && head + && isDataHistoryAncestor(loaded, amendment.sourceCommit, eventCommit || head) + ? historicalRecord(loaded, amendment.targetResourceId, amendment.sourceCommit) : null; + if (!source || !rule?.resourceTypes.includes(source.type) || !rule.fieldPaths.includes(amendment.fieldKey)) { + fail("Choose a supported nonmaterial category and field on an existing committed source record."); + return issues; + } + const at = new Date(amendment.amendedAt); + if (!isRfc3339Timestamp(amendment.amendedAt) || at > new Date()) { + fail("Record the actual amendment time, not a future time."); + return issues; + } + if (eventCommit && JSON.stringify(historicalRecord(loaded, amendment.id, eventCommit)) !== JSON.stringify(amendment)) { + fail("Preserve the committed amendment decision; create a subsequent amendment for corrections."); + } + const sourceTimestamp = (getDataRecordHistoryIndex(loaded.root).historiesById.get(source.id) || []).find(({ commit }) => commit === amendment.sourceCommit)?.timestamp; + if (!sourceTimestamp || new Date(sourceTimestamp) > at) fail("The amendment cannot predate its committed source facts."); + const sourceAtEvent = eventCommit ? historicalRecord(loaded, source.id, eventCommit) : loaded.resources.find(({ id }) => id === source.id); + if (JSON.stringify(sourceAtEvent) !== JSON.stringify(source)) fail("The source changed before this amendment decision; use its current committed revision."); + const timezone = (eventCommit ? historicalRecord(loaded, loaded.workspace.id, eventCommit)?.timezone : loaded.workspace.timezone) || loaded.workspace.timezone; + const date = localDateTimeValue(at, timezone).slice(0, 10); + const recordAtEvent = (id) => eventCommit ? historicalRecord(loaded, id, eventCommit) : loaded.resources.find((record) => record.id === id); + const authority = recordAtEvent(amendment.authorityAppointmentId); + const person = authority && recordAtEvent(authority.holderId); + const byId = new Map([[authority?.id, authority], [person?.id, person]]); + const kind = source.type === "reporting-route-set" ? source.authorityAppointmentKind : "policy-owner"; + if (!authority || authority.type !== "appointment" || authority.appointmentKind !== kind + || authority.holderId !== amendment.amendedById || !person || !appointmentWasAuthorizedOn(authority, date, byId) + || !(authority.scopeResourceIds || []).some((id) => [source.id, source.programId, loaded.workspace.id].includes(id))) { + fail("The decision must be made by the authorized, in-scope owner at the actual amendment time."); + } + if (!String(amendment.rationale || "").trim() || !String(amendment.replacementValue || "").trim() + || amendment.previousValue === amendment.replacementValue) fail("Provide a changed value and a substantive nonmaterial rationale."); + const prior = amendment.previousAmendmentId ? recordAtEvent(amendment.previousAmendmentId) : null; + if (amendment.previousAmendmentId && (!prior || prior.type !== "nonmaterial-amendment" + || prior.targetResourceId !== source.id || prior.fieldKey !== amendment.fieldKey + || fieldEpoch(loaded, prior.targetResourceId, prior.fieldKey, prior.sourceCommit) !== fieldEpoch(loaded, source.id, amendment.fieldKey, amendment.sourceCommit) + || new Date(prior.amendedAt) > at + || !firstCommit(loaded, prior.id))) fail("A prior amendment must be committed and belong to the same source and field."); + const before = prior ? prior.replacementValue : valueAt(source, amendment.fieldKey); + if (amendment.previousValue !== before) fail("The previous value must match the exact source or prior amendment."); + const siblings = loaded.resources.filter((record) => record.type === "nonmaterial-amendment" + && record.id !== amendment.id && record.targetResourceId === source.id + && record.fieldKey === amendment.fieldKey + && fieldEpoch(loaded, record.targetResourceId, record.fieldKey, record.sourceCommit) === fieldEpoch(loaded, source.id, amendment.fieldKey, amendment.sourceCommit) + && (record.previousAmendmentId || null) === (amendment.previousAmendmentId || null)); + if (siblings.length) fail("Competing amendments are not allowed; continue the committed amendment chain."); + if (amendment.category === "reporting-email-alias") { + const lane = source[amendment.fieldKey.split(".")[0]]; + if (source.status !== "approved" || lane?.channelKind !== "email" + || !EMAIL.test(amendment.previousValue) || !EMAIL.test(amendment.replacementValue) + || !source.approval?.effectiveAt || new Date(source.approval.effectiveAt) > at) { + fail("Only an email alias on an effective, approved reporting channel can use this correction."); + } + if (rule.requiredConfirmations.some((field) => amendment.confirmations?.[field] !== true)) { + fail("Confirm unchanged destination, recipients, access, availability and dependencies. Any change needs normal independent review."); + } + const evidence = (amendment.evidenceIds || []).map(recordAtEvent); + if (!evidence.length || evidence.some((item) => !fixedSupport(loaded, item, amendment, date, eventCommit || head))) { + fail("Link retained fixed evidence for the alias equivalence, available when the owner made this decision. Owner review does not mark Evidence independently verified."); + } + } + if (amendment.withdrawnProposalId) { + const proposal = recordAtEvent(amendment.withdrawnProposalId); + if (amendment.category !== "reporting-email-alias" || !proposal || proposal.type !== "reporting-route-set" + || proposal.status !== "withdrawn" || proposal.withdrawal?.amendmentId !== amendment.id + || !proposalMatches(source, proposal, amendment)) fail("Only the matching unapproved alias successor can be withdrawn with this amendment."); + } + return issues; +} + +// Independent verification can be added later without changing the observation +// the owner relied on. Captures, collectors, dates and conclusions stay fixed. +export function amendmentEvidenceObservationMatches(original, current, loaded = null, reliedOnCommit = null) { + if (!original || !current) return false; + if (original.status === "collected" && loaded && reliedOnCommit) { + const history = [...(getDataRecordHistoryIndex(loaded.root).historiesById.get(original.id) || [])].reverse(); + const firstVerified = history.filter(({ commit }) => isDataHistoryAncestor(loaded, reliedOnCommit, commit)) + .map(({ commit }) => historicalRecord(loaded, original.id, commit)).find((record) => record?.status === "verified"); + if (firstVerified && (!amendmentEvidenceObservationMatches(original, firstVerified) || JSON.stringify(firstVerified) !== JSON.stringify(current))) return false; + } + const verification = original.status === "collected" && current.status === "verified" + ? new Set(["status", "verifiedOn", "verifiedAt", "verifierIds"]) : new Set(); + return [...new Set([...Object.keys(original), ...Object.keys(current)])].every((key) => verification.has(key) || JSON.stringify(original[key]) === JSON.stringify(current[key])); +} + +function fixedSupport(loaded, evidence, amendment, date, commit) { + return evidence?.type === "evidence" && ["collected", "verified"].includes(evidence.status) + && amendmentEvidenceObservationMatches(evidence, loaded.resources.find(({ id }) => id === evidence.id), loaded, commit) + && evidence.sourceKind === "file" && evidence.collectedOn <= date + && [evidence.collectedAt, evidence.sourceGeneratedAt, evidence.businessEventAt].filter(Boolean).every((value) => isRfc3339Timestamp(value) && new Date(value) <= new Date(amendment.amendedAt)) + && (evidence.sourceResourceIds || []).some((id) => [amendment.targetResourceId, amendment.withdrawnProposalId].includes(id)) + && markdownEntries(loaded.model, evidence).every(({ path }) => getFileObjectIdAtRevision(loaded.root, commit, `data/${path}`) === getWorkingFileObjectId(loaded.root, `data/${path}`)) + && evidence.filePaths?.length && evidence.filePaths.every((path) => { + const object = getFileObjectIdAtRevision(loaded.root, commit, `data/${path}`); + return object && object === getWorkingFileObjectId(loaded.root, `data/${path}`); + }); +} + +function proposalMatches(source, proposal, amendment) { + const expected = structuredClone(source); + const [lane, field] = amendment.fieldKey.split("."); + expected[lane][field] = amendment.replacementValue; + return ["programId", "purposeKey", "purposeLabel", "primaryLane", "alternateLane", "authorityAppointmentKind", "approvalAppointmentKind", "sourceResourceIds"] + .every((key) => JSON.stringify(expected[key]) === JSON.stringify(proposal[key])) + && proposal.predecessorId === source.id && !proposal.approval && !proposal.cancellation; +} + +export function amendedResourceView(loaded, target, options = {}) { + const at = new Date(options.at || new Date()); + const value = structuredClone(target); + const amendments = loaded.resources.filter((record) => record.type === "nonmaterial-amendment" + && record.targetResourceId === target.id && new Date(record.amendedAt) <= at); + const applied = []; + const pending = [...amendments]; + // Apply only valid, committed decisions and only after their predecessor. + for (let round = 0; round < amendments.length && pending.length; round++) { + for (let i = pending.length - 1; i >= 0; i--) { + const amendment = pending[i]; + if (amendment.previousAmendmentId && !applied.includes(amendment.previousAmendmentId)) continue; + if (!firstCommit(loaded, amendment.id) || nonmaterialAmendmentIssues(loaded, amendment).length) continue; + if (fieldEpoch(loaded, target.id, amendment.fieldKey, amendment.sourceCommit) !== fieldEpoch(loaded, target.id, amendment.fieldKey, getGitSummary(loaded.root).commit)) continue; + if (valueAt(value, amendment.fieldKey) !== amendment.previousValue) continue; + const parts = amendment.fieldKey.split("."); + if (parts.length === 1) value[parts[0]] = amendment.replacementValue; + else value[parts[0]][parts[1]] = amendment.replacementValue; + applied.push(amendment.id); + pending.splice(i, 1); + } + } + return { record: value, amendmentIds: applied, originalRecord: target }; +} + +export async function scaffoldNonmaterialAmendment(input, targetId, options = {}) { + const loaded = input?.resources ? input : await loadWorkspace(input); + if (!hasAmendmentRuntimePin(loaded)) throw new Error("Upgrade and commit the repository dependency and lockfile to FileGRC 0.19 before adopting nonmaterial amendments."); + const entry = loaded.entries.find(({ record }) => record.id === targetId); + if (!entry) throw new Error(`Resource "${targetId}" was not found.`); + const head = getGitSummary(loaded.root).commit; + const hasEmailLane = [entry.record.primaryLane, entry.record.alternateLane].some((lane) => lane?.channelKind === "email"); + const category = options.category || (entry.record.type === "reporting-route-set" && hasEmailLane ? "reporting-email-alias" : "label-correction"); + const rule = loaded.model.nonmaterialAmendments?.[category]; + if (!rule?.resourceTypes.includes(entry.record.type)) throw new Error("No nonmaterial amendment rule supports this record and category."); + const fieldKey = options.fieldKey || (category === "reporting-email-alias" + ? ["alternateLane.destination", "primaryLane.destination"].find((path) => entry.record[path.split(".")[0]]?.channelKind === "email") : "title"); + if (!rule.fieldPaths.includes(fieldKey)) throw new Error("This field needs normal review."); + const view = amendedResourceView(loaded, entry.record); + const prior = view.amendmentIds.map((id) => loaded.resources.find((record) => record.id === id)).filter((record) => record.fieldKey === fieldKey).at(-1); + return { + record: { + id: `nonmaterial-amendment-${targetId}-${Date.now()}`, + type: "nonmaterial-amendment", title: `${rule.title}: ${entry.record.title}`, + targetResourceId: targetId, category, sourceCommit: getDataRecordHistoryIndex(loaded.root).historiesById.get(targetId)?.[0]?.commit || head, + ...(prior ? { previousAmendmentId: prior.id } : {}), fieldKey, + previousValue: valueAt(view.record, fieldKey), replacementValue: null, + amendedAt: new Date().toISOString(), amendedById: null, authorityAppointmentId: null, rationale: null, + ...(category === "reporting-email-alias" ? { evidenceIds: [], confirmations: Object.fromEntries(rule.requiredConfirmations.map((key) => [key, false])) } : {}) + }, + expectedTargetRevision: calculateRevision("content", entry.source), + instructions: "Preserve the approved source. Fill the actual owner decision and evidence. Preview, then save and commit before relying on the amendment. Material changes need normal review." + }; +} + +export async function saveNonmaterialAmendment(input, payload, options = {}) { + return serializeWorkspaceMutation(input, async (root) => { + const loaded = await loadWorkspace(root); + const record = payload.record; + if (record?.type !== "nonmaterial-amendment") throw new Error("Pass a nonmaterial amendment mutation envelope."); + const target = loaded.entries.find((entry) => entry.record.id === record.targetResourceId); + if (!target || !payload.expectedTargetRevision || !revisionsMatch("content", payload.expectedTargetRevision, calculateRevision("content", target.source))) { + throw new Error("The source record changed; reload the amendment scaffold."); + } + if (loaded.resources.some((item) => item.id === record.id)) throw new Error("Preserve existing amendment decisions; create a new amendment instead."); + const update = []; + if (record.withdrawnProposalId) { + const proposal = loaded.entries.find((entry) => entry.record.id === record.withdrawnProposalId); + if (!proposal || proposal.record.status !== "proposed" || !payload.expectedProposalRevision + || !revisionsMatch("content", payload.expectedProposalRevision, calculateRevision("content", proposal.source))) { + throw new Error("Reload the exact unapproved successor before withdrawing it."); + } + update.push({ ...proposal.record, status: "withdrawn", withdrawal: { amendmentId: record.id } }); + } + const simulated = { ...loaded, resources: [...loaded.resources.filter((item) => !update.some(({ id }) => id === item.id)), ...update, record] }; + const issues = nonmaterialAmendmentIssues(simulated, record); + if (issues.length) throw new Error(issues.map(({ message }) => message).join("\n")); + if (options.preview) { + const projectedEntry = (value, entry = null) => { + const relativePath = entry?.relativePath || `${loaded.model.resources[value.type].collection}/${value.id}.json`; + return { ...entry, record: value, source: JSON.stringify(value, null, 2) + "\n", relativePath, path: join(loaded.dataRoot, relativePath) }; + }; + const previewState = { ...simulated, entries: [ + ...loaded.entries.map((entry) => { + const replacement = update.find(({ id }) => id === entry.record.id); + return replacement ? projectedEntry(replacement, entry) : entry; + }), projectedEntry(record) + ] }; + const validation = await validateWorkspace(previewState); + if (!validation.ok) throw new Error(validation.diagnostics.filter(({ severity }) => severity === "error").map(({ message }) => message).join("\n")); + return { record, withdrawal: update[0] || null, preview: true, commitRequired: true }; + } + return { ...await applyResourceBatch(root, { + create: [record], update, + expectedRevisions: record.withdrawnProposalId ? { [record.withdrawnProposalId]: payload.expectedProposalRevision } : {}, + validateWholeWorkspace: true, + workflowCapability: INTERNAL_WORKFLOW_CAPABILITIES.nonmaterialAmendment + }), record, commitRequired: true }; + }); +} diff --git a/packages/filegrc/src/reporting-route-sets.js b/packages/filegrc/src/reporting-route-sets.js index ef63248..2fbeedc 100644 --- a/packages/filegrc/src/reporting-route-sets.js +++ b/packages/filegrc/src/reporting-route-sets.js @@ -1,3 +1,4 @@ +import { amendedResourceView } from "./nonmaterial-amendments.js"; import { getRepositorySnapshot, isDataHistoryAncestor } from "./git.js"; import { applyResourceBatch, INTERNAL_WORKFLOW_CAPABILITIES, updateResource } from "./files.js"; import { @@ -284,6 +285,9 @@ export async function assessReportingRoutePeriod(input = process.cwd(), options if (["policy", "document", "commitment", "risk"].includes(record.type)) { addDateBoundary(record.statusTransition?.changedOn, false, timezone, record.id, `${record.title || record.id} lifecycle date`); } + if (record.type === "nonmaterial-amendment" && loaded.resources.some((target) => target.id === record.targetResourceId && target.type === "reporting-route-set" && (!options.programId || target.programId === options.programId))) { + addInstantBoundary(record.amendedAt, record.id, "Nonmaterial amendment time"); + } if (record.type !== "reporting-route-set") continue; for (const value of [record.approval?.effectiveAt, record.cancellation?.canceledAt]) { addInstantBoundary(value, record.id, `${record.title || record.id} lifecycle timestamp`); @@ -692,6 +696,7 @@ function assessRouteSet(record, loaded, at, repository) { : "inactive"; return { record, + nonmaterialView: amendedResourceView(loaded, record, { at }), committed, effective, canceled, diff --git a/packages/filegrc/src/runtime-version.js b/packages/filegrc/src/runtime-version.js index 302f08c..11c4a26 100644 --- a/packages/filegrc/src/runtime-version.js +++ b/packages/filegrc/src/runtime-version.js @@ -17,15 +17,18 @@ export function runtimeCompatibilityLine(version) { export function assessRuntimeCompatibility(runtimeVersion, repositoryVersion, options = {}) { const supportedLine = runtimeCompatibilityLine(runtimeVersion); const repositoryLine = runtimeCompatibilityLine(repositoryVersion); + // 0.19 adds optional nonmaterial-amendment records. It reads existing 0.18 + // workspaces unchanged; 0.18 runtimes must reject the new release line. + const supportedRepositoryLines = supportedLine === "0.19.x" ? ["0.18.x", "0.19.x"] : [supportedLine]; const dataModelVersion = options.dataModelVersion ?? null; const requiredApiContractVersion = Object.hasOwn(options, "requiredApiContractVersion") ? options.requiredApiContractVersion : RUNTIME_API_CONTRACT_VERSION; const reason = !supportedLine ? "invalid-runtime-version" : repositoryVersion !== null && !repositoryLine ? "invalid-repository-version" - : repositoryLine && repositoryLine !== supportedLine ? "unsupported-compatibility-line" + : repositoryLine && !supportedRepositoryLines.includes(repositoryLine) ? "unsupported-compatibility-line" : dataModelVersion !== null && !SUPPORTED_MODEL_VERSIONS.includes(dataModelVersion) ? "unsupported-workspace-model" : requiredApiContractVersion !== RUNTIME_API_CONTRACT_VERSION ? "unsupported-api-contract" : null; return { compatible: reason === null, installedVersion: runtimeVersion, repositoryVersion, - supportedLine, repositoryLine, dataModelVersion, apiContractVersion: RUNTIME_API_CONTRACT_VERSION, + supportedLine, supportedRepositoryLines, repositoryLine, dataModelVersion, apiContractVersion: RUNTIME_API_CONTRACT_VERSION, supportedModelVersions: [...SUPPORTED_MODEL_VERSIONS], reason }; } diff --git a/packages/filegrc/src/server.js b/packages/filegrc/src/server.js index 35c1296..d734f4c 100644 --- a/packages/filegrc/src/server.js +++ b/packages/filegrc/src/server.js @@ -80,6 +80,7 @@ import { previewWorkflowMutation, workflowForResource } from "./workflow.js"; +import { scaffoldNonmaterialAmendment, saveNonmaterialAmendment } from "./nonmaterial-amendments.js"; import { loadWorkspace } from "./workspace.js"; import { APP_SCRIPT, APP_STYLES, renderIndex } from "./web.js"; import { assertWorkspaceEngineVersion } from "./runtime-version.js"; @@ -380,6 +381,19 @@ export function createFilegrcServer(input = process.cwd(), options = {}) { message: (result) => `Create policy event: ${result.event?.title || payload.title || payload.eventType}` }, () => createObligationEvent(input, { ...payload, programId: payload.programId || requestOptions.programId }))); } + if (request.method === "POST" && url.pathname === "/api/nonmaterial-amendments/scaffold") { + const payload = await readJson(request); + return json(response, 200, await scaffoldNonmaterialAmendment(input, payload.targetResourceId, payload)); + } + if (request.method === "POST" && url.pathname === "/api/nonmaterial-amendments/preview") { + return json(response, 200, await saveNonmaterialAmendment(input, await readJson(request), { preview: true })); + } + if (request.method === "POST" && url.pathname === "/api/nonmaterial-amendments") { + const payload = await readJson(request); + return json(response, 201, await browserMutation(input, requestOptions, { + message: () => `Record nonmaterial amendment: ${payload.record?.targetResourceId}` + }, () => saveNonmaterialAmendment(input, payload))); + } if (request.method === "GET" && url.pathname === "/api/reporting-route-sets") { return json(response, 200, await assessReportingRouteSets(input, { programId: url.searchParams.get("programId") || requestOptions.programId, diff --git a/packages/filegrc/src/state.js b/packages/filegrc/src/state.js index b079c07..4aba93f 100644 --- a/packages/filegrc/src/state.js +++ b/packages/filegrc/src/state.js @@ -1,3 +1,4 @@ +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"; @@ -432,6 +433,8 @@ function createResourceHistoryFromLoaded(loaded, type, id, options) { async function createStateEntry(loaded, entry, options) { const record = structuredClone(entry.record); + const amendmentView = options.includeDetails && loaded.resources.some((item) => item.type === "nonmaterial-amendment" && item.targetResourceId === record.id) + ? 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 = {}; @@ -454,6 +457,7 @@ async function createStateEntry(loaded, entry, options) { return { record, ...(routeAssessment ? { proposalCommit: routeAssessment.proposalCommit } : {}), + ...(amendmentView?.amendmentIds.length ? { nonmaterialView: amendmentView } : {}), relativePath: `data/${entry.relativePath}`, revision: contentRevision(entry.source), content, diff --git a/packages/filegrc/src/validate.js b/packages/filegrc/src/validate.js index 925ffb0..5fc428a 100644 --- a/packages/filegrc/src/validate.js +++ b/packages/filegrc/src/validate.js @@ -1,3 +1,4 @@ +import { nonmaterialAmendmentIssues } from "./nonmaterial-amendments.js"; import { completionMatchesMember, assessCompletionForObligation, obligationActivationDate } from "./obligations.js"; import { reportingCutoverIssues, scheduleCutoverIssues, historicalContentCutover } from "./activation-readiness.js"; import { scheduleSetIssues, scheduleReviewerConflicts } from "./schedule-set.js"; @@ -123,6 +124,15 @@ async function validateWorkspaceUnmeasured(input) { for (const entry of loaded.entries) { const { record } = entry; + if (record.type === "nonmaterial-amendment") { + diagnostics.push(...nonmaterialAmendmentIssues(loaded, record).map((item) => ({ ...item, path: `data/${entry.relativePath}` }))); + } + if (record.type === "reporting-route-set" && record.status === "withdrawn") { + const amendment = byId.get(record.withdrawal?.amendmentId); + if (amendment?.type !== "nonmaterial-amendment" || amendment.withdrawnProposalId !== record.id) { + diagnostics.push({ severity: "error", code: "invalid-reporting-route-withdrawal", path: `data/${entry.relativePath}`, message: "A withdrawn proposal must name its matching owner amendment." }); + } + } const displayPath = `data/${entry.relativePath}`; if (!record || Array.isArray(record) || typeof record !== "object") { diagnostics.push(error("invalid-record", displayPath, "A resource must be a JSON object.")); @@ -1033,7 +1043,7 @@ function validateReportingRouteSets(loaded, byId, pathById, diagnostics) { current[route.status === "approved" ? "approved" : "pending"].push(route); currentByPurpose.set(key, current); } - if (route.predecessorId) { + if (route.predecessorId && route.status !== "withdrawn") { const predecessor = byId.get(route.predecessorId); if (route.predecessorId === route.id) { diagnostics.push(error("invalid-reporting-route-lineage", path, "A Reporting Route Set cannot name itself as its predecessor.")); diff --git a/packages/filegrc/src/web.js b/packages/filegrc/src/web.js index f3506dd..9c83697 100644 --- a/packages/filegrc/src/web.js +++ b/packages/filegrc/src/web.js @@ -3600,6 +3600,13 @@ function renderDetail(main, type, id, params = new URLSearchParams()) { ? '' : "" : ""; + const amendmentTarget = type === "reporting-route-set" && entry.record.status === "proposed" && entry.record.predecessorId + ? state.resources.find(({ record }) => record.id === entry.record.predecessorId && record.status === "approved") : entry; + 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 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(" · ") + '

' : ""; const routeSetLocked = type === "reporting-route-set" && entry.record.status !== "draft"; const detailMain = hasRecordBody ? '
' + narrativeContent + markdownContent + addRecordContent + '
' @@ -3607,7 +3614,7 @@ function renderDetail(main, type, id, params = new URLSearchParams()) { const attachmentPanel = type === "evidence" ? evidenceAttachmentPanel(entry) : ""; const hasStructuredMetadata = visible.some(([name, value]) => Array.isArray(value) && value.length && fields[name]?.items === "object"); const recordFieldsPanel = visible.length - ? '' + ? '' : ""; const supportPanels = renderDetailSupport({ hasRecordBody, @@ -3622,11 +3629,12 @@ function renderDetail(main, type, id, params = new URLSearchParams()) { ? '' + (applicabilityStatus === "stale" ? "Reconfirm" : "Review") + ' applicability' : ""; const recordMenu = '
' + (mayDelete ? '
' : '') + '
'; - main.innerHTML = '

' + esc(titleCase(entry.record.title)) + '

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

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

' + reportingRouteSetActions + amendmentAction + auditCycleAction + auditPopulationCorrectionAction + (type === "audit" ? 'Audit Evidence & Packet' : "") + governanceActions + lifecycleActions + issueActions + applicabilityAction + addRecordContentAction + (!state.readOnly && !routeSetLocked ? '' : "") + recordMenu + '
' + amendmentPanel + '
' + detailMain + supportPanels + (hasStructuredMetadata ? recordFieldsPanel : "") + '
'; main.querySelectorAll("[data-completion-requirements]").forEach((button) => button.addEventListener("click", () => openCompletionRequirementsDialog(button.dataset.completionRequirements))); main.querySelector("[data-select-program-for-review]")?.addEventListener("click", () => { selectProgram(entry.record.id, "#/resources/requirement?review=1"); }); + main.querySelector("[data-amend-nonmaterial]")?.addEventListener("click", () => openNonmaterialAmendmentDialog(amendmentTarget, amendmentTarget !== entry ? entry : null)); main.querySelector("#edit-resource")?.addEventListener("click", () => openEditor(type, entry)); main.querySelectorAll("[data-record-modal]").forEach((button) => button.addEventListener("click", () => { button.closest("details")?.removeAttribute("open"); @@ -4397,6 +4405,56 @@ function openCommitDialog() { dialog.querySelector('input[name="message"]').focus(); } +async function openNonmaterialAmendmentDialog(entry, proposal = null, category = null, fieldKey = null) { + try { + const scaffold = await fetchJson("/api/nonmaterial-amendments/scaffold", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ targetResourceId: entry.record.id, category, fieldKey }) }); + const rules = state.model.nonmaterialAmendments; + const rule = rules[scaffold.record.category]; + const categories = Object.entries(rules).filter(([key, item]) => item.resourceTypes.includes(entry.record.type) + && (key !== "reporting-email-alias" || [entry.record.primaryLane, entry.record.alternateLane].some((lane) => lane?.channelKind === "email"))); + const authorityKind = entry.record.type === "reporting-route-set" ? entry.record.authorityAppointmentKind : "policy-owner"; + const appointments = resourcesOfType("appointment").filter(({ record }) => record.appointmentKind === authorityKind); + const evidence = resourcesOfType("evidence").filter(({ record }) => ["collected", "verified"].includes(record.status) && record.sourceKind === "file" && record.filePaths?.length && (record.sourceResourceIds || []).some((id) => [entry.record.id, proposal?.record.id].includes(id))); + const dialog = document.createElement("dialog"); + dialog.className = "editor nonmaterial-amendment-dialog"; + dialog.innerHTML = '

Owner decision

Record nonmaterial amendment

Keep the original record and independent approval unchanged. Correct only an administrative label or an equivalent email alias. Changes to recipients, access, scope, commitments, schedules or content need normal review.

' + (rule.requiredConfirmations || []).map((key) => '').join("") + (scaffold.record.category === "reporting-email-alias" ? '
Fixed evidence of equivalence' + evidence.map(({ record }) => '').join("") + '
' : '') + (proposal && scaffold.record.category === "reporting-email-alias" ? '

This also withdraws the unapproved successor proposal. The original channel set remains in force.

' : '') + '
'; + document.body.append(dialog); + dialog.showModal(); + dialog.addEventListener("close", () => dialog.remove()); + dialog.querySelectorAll("[data-dismiss]").forEach((button) => button.addEventListener("click", () => dialog.close())); + dialog.querySelectorAll('select[name="category"], select[name="fieldKey"]').forEach((control) => control.addEventListener("change", () => { + const nextCategory = dialog.querySelector('[name="category"]').value; + const nextField = nextCategory === scaffold.record.category ? dialog.querySelector('[name="fieldKey"]').value : null; + dialog.close(); + openNonmaterialAmendmentDialog(entry, proposal, nextCategory, nextField); + })); + dialog.querySelector("form").addEventListener("submit", async (event) => { + event.preventDefault(); + const form = event.currentTarget; + if (!form.reportValidity()) return; + const data = new FormData(form); + const authority = appointments.find(({ record }) => record.id === data.get("authorityAppointmentId"))?.record; + scaffold.record.replacementValue = data.get("replacementValue").trim(); + scaffold.record.authorityAppointmentId = authority?.id; + scaffold.record.amendedById = authority?.holderId; + scaffold.record.rationale = data.get("rationale").trim(); + scaffold.record.amendedAt = new Date().toISOString(); + if (rule.requiredConfirmations) { + scaffold.record.confirmations = Object.fromEntries(rule.requiredConfirmations.map((key) => [key, data.has(key)])); + scaffold.record.evidenceIds = data.getAll("evidenceIds"); + if (proposal) { scaffold.record.withdrawnProposalId = proposal.record.id; scaffold.expectedProposalRevision = proposal.revision; } + } + try { + setMutationBusy(dialog, true, "Recording amendment…"); + const response = await localFetch("/api/nonmaterial-amendments", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(scaffold) }); + if (!response.ok) throw new Error(await responseMessage(response)); + applyMutationState(await response.json()); + dialog.close(); render(); + } catch (error) { setMutationBusy(dialog, false, ""); form.querySelector(".dialog-error").textContent = error.message; } + }); + } catch (error) { showError(error.message); } +} + async function mutateReportingRouteSet(action, payload, dialog) { try { if (dialog) setMutationBusy(dialog, true, action === "approve" ? "Approving…" : action === "cancel" ? "Canceling…" : "Proposing…"); @@ -6968,6 +7026,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} +.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} .object-value-list{display:grid;gap:7px}.object-value{min-width:0;padding:7px 8px;border:1px solid var(--line);border-radius:7px;background:var(--surface-soft)}.object-value-facts{display:flex;flex-wrap:wrap;gap:5px 10px}.object-value-facts>.object-value-fact{display:flex;align-items:baseline;gap:5px;min-width:0;font-size:11px}.object-value-fact:has(>.object-value-list){display:grid;flex-basis:100%;align-items:start}.object-value-facts b{color:var(--muted);font-size:8.8px;text-transform:uppercase;letter-spacing:.05em}.object-value>small{display:block;margin-top:6px;color:var(--muted);font-size:10px;line-height:1.45}.object-value-list.compact .object-value{padding:0;border:0;background:none}.object-value-list.compact .object-value-facts{display:grid;gap:5px}.object-value-list.compact .object-value-facts>.object-value-fact{display:grid;gap:2px;font-size:10px} diff --git a/packages/filegrc/src/workflow-history-integrity.js b/packages/filegrc/src/workflow-history-integrity.js index fa6d573..0d9a956 100644 --- a/packages/filegrc/src/workflow-history-integrity.js +++ b/packages/filegrc/src/workflow-history-integrity.js @@ -1,3 +1,4 @@ +import { amendmentEvidenceObservationMatches } from "./nonmaterial-amendments.js"; import { readFile } from "node:fs/promises"; import { getDataRecordHistoryIndex, @@ -17,12 +18,13 @@ import { CALCULATED_REVISION_FIELDS, CALCULATED_REVISION_MAP_FIELDS, canonicalCa 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-occurrence", new Set(["reconciled", "superseded"])], ["audit-population", new Set(["reconciled", "not-applicable", "superseded"])], ["reporting-route", new Set(["active", "retired"])], - ["reporting-route-set", new Set(["approved", "canceled", "historical"])], + ["reporting-route-set", new Set(["approved", "canceled", "historical", "withdrawn"])], ["attestation", new Set(["completed"])], ["obligation-event", new Set(["complete", "canceled"])], ["action-item", new Set(["done", "canceled"])], @@ -261,6 +263,7 @@ export async function validateWorkflowHistoryIntegrity(loaded, diagnostics) { const byId = new Map(loaded.resources.map((record) => [record.id, record])); const proofOwners = loaded.resources.filter((record) => ( + record.type === "nonmaterial-amendment" || (record.type === "obligation-occurrence" && ["reconciled", "superseded"].includes(record.status)) || (record.type === "audit-population" && ["reconciled", "not-applicable", "superseded"].includes(record.status)) || (record.type === "attestation" && record.status === "completed") @@ -290,7 +293,9 @@ export async function validateWorkflowHistoryIntegrity(loaded, diagnostics) { diagnostics.push(integrityError(ownerEntry, `Proof record "${id}" must exist in the commit where ${owner.type} "${owner.id}" was first finalized.`)); continue; } - if (!sameJson(currentEntry.record, historical.record)) { + if (!(owner.type === "nonmaterial-amendment" && historical.record.type === "evidence" + ? amendmentEvidenceObservationMatches(historical.record, currentEntry.record, loaded, finalized.commit) + : sameJson(currentEntry.record, historical.record))) { diagnostics.push(integrityError(currentEntry, `Proof record "${id}" differs from the version used when ${owner.type} "${owner.id}" was first finalized. Create corrected proof and a superseding record.`)); continue; } @@ -845,6 +850,7 @@ function canonicalJson(value, field = null, revisionMap = false) { } function proofIds(occurrence, byId) { + if (occurrence.type === "nonmaterial-amendment") return new Set(occurrence.evidenceIds || []); const pending = [ ...(occurrence.evidenceIds || []), ...(occurrence.sourceEvidenceId ? [occurrence.sourceEvidenceId] : []), diff --git a/packages/filegrc/test/agent.test.js b/packages/filegrc/test/agent.test.js index cbc4ca9..0c0723c 100644 --- a/packages/filegrc/test/agent.test.js +++ b/packages/filegrc/test/agent.test.js @@ -95,6 +95,7 @@ test("agent guides and scaffolds cover every resource type from the model", asyn "search", "obligations", "programReadiness", + "nonmaterialAmendment", "programAmendment", "reviewBindings", "evidenceMap", diff --git a/packages/filegrc/test/fixtures.js b/packages/filegrc/test/fixtures.js index 692ed5c..77294ad 100644 --- a/packages/filegrc/test/fixtures.js +++ b/packages/filegrc/test/fixtures.js @@ -164,7 +164,7 @@ async function buildComprehensiveWorkspace(root, version) { const records = []; for (const [type, definition] of Object.entries(model.resources)) { - if (type === "reconciliation-dismissal") continue; + if (["reconciliation-dismissal", "nonmaterial-amendment"].includes(type)) continue; const record = { id: ids[type], type, diff --git a/packages/filegrc/test/model.test.js b/packages/filegrc/test/model.test.js index 1eae720..f8b39b0 100644 --- a/packages/filegrc/test/model.test.js +++ b/packages/filegrc/test/model.test.js @@ -29,7 +29,7 @@ test("active model exposes the complete resource registry", () => { "Operate the Program", "Audit" ]); - assert.equal(Object.keys(model.resources).length, 56); + assert.equal(Object.keys(model.resources).length, 57); assert.deepEqual(model.resources["reconciliation-dismissal"].required, [ "transitionFingerprint", "eventType", diff --git a/packages/filegrc/test/nonmaterial-amendments.test.js b/packages/filegrc/test/nonmaterial-amendments.test.js new file mode 100644 index 0000000..6fc42f1 --- /dev/null +++ b/packages/filegrc/test/nonmaterial-amendments.test.js @@ -0,0 +1,283 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import test from "node:test"; +import { makeComprehensiveWorkspace } from "./fixtures.js"; +import { loadWorkspace, proposeReportingRouteSet, approveReportingRouteSet, scaffoldNonmaterialAmendment, saveNonmaterialAmendment, amendedResourceView, validateWorkspace, updateResource, createResource, deleteResource, serveWorkspace, createAppState } from "../src/index.js"; +import { nonmaterialAmendmentIssues } from "../src/nonmaterial-amendments.js"; +import { calculateRevision } from "../src/revisions.js"; + +const git = (root, ...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); +const write = (root, collection, record) => writeFile(join(root, "data", collection, `${record.id}.json`), JSON.stringify(record, null, 2) + "\n"); +let base; +async function fixture(t) { + if (!base) { + base = await mkdtemp(join(tmpdir(), "filegrc-amendment-base-")); + const { records } = await makeComprehensiveWorkspace(base, "13"); + await writeFile(join(base, "package.json"), JSON.stringify({ private: true, dependencies: { filegrc: "0.19.0" } })); + await writeFile(join(base, "package-lock.json"), JSON.stringify({ lockfileVersion: 3, packages: { "node_modules/filegrc": { version: "0.19.0" } } })); + for (const record of records.filter((r) => ["policy", "document", "training"].includes(r.type) && r.status === "active")) { + record.status = "approved"; + for (const field of ["activatedOn", "activatedByIds", "activationBasis", "activatedContentRevisions", "effectiveOn"]) delete record[field]; + const collection = { policy: "policies", document: "documents", training: "training" }[record.type]; + await write(base, collection, record); + } + const route = records.find(({ type }) => type === "reporting-route-set"); + route.primaryLane.destination = "security@example.test"; + await write(base, "reporting-route-sets", route); + const attestation = records.find(({ type }) => type === "attestation"); + attestation.status = "pending"; + for (const field of ["completedOn", "attestationMethod", "contentRevisions"]) delete attestation[field]; + await write(base, "attestations", attestation); + await write(base, "appointments", { id: "appointment-route-reviewer", type: "appointment", title: "Independent route reviewer", status: "active", appointmentKind: "independent-policy-reviewer", holderId: "person-independent-approver-example", scopeResourceIds: [route.id], startsOn: "2020-01-01", responsibilities: "Independently review channel sets." }); + const proof = { ...records.find(({ type }) => type === "evidence"), id: "evidence-alias-proof", title: "Reporting configuration and decision", artifactKind: "business-record", sourceKind: "file", sourceResourceIds: [route.id], collectedOn: "2026-07-01", verifiedOn: "2026-07-01", coverage: { kind: "as-of", on: "2026-07-01" }, filePaths: ["evidence/evidence-alias-proof/configuration.txt"] }; + await mkdir(join(base, "data/evidence/evidence-alias-proof"), { recursive: true }); + await writeFile(join(base, "data", proof.filePaths[0]), "The security and report aliases deliver to the same monitored group. Same recipients, permissions, dependencies and fallback availability.\n"); + delete proof.sourceComponentId; + await writeFile(join(base, "data/evidence/evidence-alias-proof/evidence.json"), JSON.stringify(proof, null, 2) + "\n"); + git(base, "init", "--initial-branch=main"); + git(base, "config", "user.name", "Test User"); git(base, "config", "user.email", "test@example.test"); + git(base, "add", "."); git(base, "commit", "-m", "Initial source"); + await proposeReportingRouteSet(base, { routeSetId: route.id }); + git(base, "add", "."); git(base, "commit", "-m", "Propose reporting channels"); + const proposalCommit = git(base, "rev-parse", "HEAD"); + // Configuration proof may predate approval. Use actual current decision time. + const now = new Date().toISOString(); + const today = now.slice(0, 10); + proof.collectedOn = today; proof.verifiedOn = today; proof.coverage.on = today; + await writeFile(join(base, "data/evidence/evidence-alias-proof/evidence.json"), JSON.stringify(proof, null, 2) + "\n"); + await approveReportingRouteSet(base, { routeSetId: route.id, proposalCommit, approvalAppointmentId: "appointment-route-reviewer", approvedAt: now, effectiveAt: now, timezone: "UTC", evidenceIds: [proof.id] }); + git(base, "add", "."); git(base, "commit", "-m", "Approve reporting channels"); + } + const root = await mkdtemp(join(tmpdir(), "filegrc-amendment-test-")); + await cp(base, root, { recursive: true }); + t.after(() => rm(root, { recursive: true, force: true })); + return root; +} +async function payload(root, target = "reporting-route-set-example", options = {}) { + const result = await scaffoldNonmaterialAmendment(root, target, options); + result.record.replacementValue = options.category === "label-correction" || target !== "reporting-route-set-example" ? "Corrected administrative label" : "report@example.test"; + Object.assign(result.record, { amendedById: "person-example", authorityAppointmentId: "appointment-example", rationale: "Owner checked the retained configuration. The aliases reach the same group with unchanged recipients, access, dependencies and behavior." }); + if (result.record.confirmations) { + for (const key of Object.keys(result.record.confirmations)) result.record.confirmations[key] = true; + result.record.evidenceIds = ["evidence-alias-proof"]; + } + return result; +} + +test("alias amendment preserves original approval, requires commit, and records the owner separately", async (t) => { + const root = await fixture(t); + const originalPath = join(root, "data/reporting-route-sets/reporting-route-set-example.json"); + const original = await readFile(originalPath, "utf8"); + const p = await payload(root); + await saveNonmaterialAmendment(root, p, { preview: true }); + assert.equal(git(root, "status", "--porcelain"), ""); + await saveNonmaterialAmendment(root, p); + let loaded = await loadWorkspace(root); + let route = loaded.resources.find(({ type }) => type === "reporting-route-set"); + assert.deepEqual(amendedResourceView(loaded, route).amendmentIds, []); + git(root, "add", "."); git(root, "commit", "-m", "Record equivalent alias correction"); + loaded = await loadWorkspace(root); route = loaded.resources.find(({ type }) => type === "reporting-route-set"); + const view = amendedResourceView(loaded, route); + assert.equal(view.record.primaryLane.destination, "report@example.test"); + assert.equal(view.originalRecord.primaryLane.destination, "security@example.test"); + assert.equal(view.record.approval.approvedById, "person-independent-approver-example"); + assert.deepEqual(view.amendmentIds, [p.record.id]); + assert.equal((await amendedResourceView(loaded, route, { at: "2020-01-01T00:00:00Z" })).record.primaryLane.destination, "security@example.test"); + assert.equal(await readFile(originalPath, "utf8"), original); + const result = await validateWorkspace(root); + assert.equal(result.ok, true, JSON.stringify(result.diagnostics)); + const state = await createAppState(root); + assert.equal(state.resources.find(({ record }) => record.id === route.id).nonmaterialView.record.primaryLane.destination, "report@example.test"); + const next = await payload(root); next.record.replacementValue = "security@example.test"; + assert.equal(next.record.previousAmendmentId, p.record.id); + await saveNonmaterialAmendment(root, next); +}); + +test("bounded categories reject material fields, changed access, missing proof, wrong authority and stale writes", async (t) => { + const root = await fixture(t); + const p = await payload(root); + for (const field of ["approval.approvedById", "primaryLane.channelKind", "authorityAppointmentKind", "sourceResourceIds", "__proto__.polluted"]) { + const bad = structuredClone(p); bad.record.fieldKey = field; + await assert.rejects(() => saveNonmaterialAmendment(root, bad), /supported nonmaterial category and field/); + } + for (const mutate of [ + (r) => { r.confirmations.sameAccess = false; }, + (r) => { r.evidenceIds = []; }, + (r) => { r.amendedById = "person-independent-approver-example"; }, + (r) => { r.amendedAt = "2099-01-01T00:00:00Z"; }, + (r) => { r.previousValue = "someone@example.test"; } + ]) { const bad = structuredClone(p); mutate(bad.record); await assert.rejects(() => saveNonmaterialAmendment(root, bad)); } + const malformed = structuredClone(p); delete malformed.record.title; + await assert.rejects(() => saveNonmaterialAmendment(root, malformed, { preview: true }), /title/); + const stale = structuredClone(p); stale.expectedTargetRevision = "0".repeat(64); + await assert.rejects(() => saveNonmaterialAmendment(root, stale), /source record changed/); + await assert.rejects(() => createResource(root, p.record), /amendment scaffold/); + assert.equal(git(root, "status", "--porcelain"), ""); +}); + +test("a matching proposed successor can be withdrawn atomically without canceling the approved original", async (t) => { + const root = await fixture(t); + const loaded = await loadWorkspace(root); + const original = loaded.resources.find(({ type }) => type === "reporting-route-set"); + const successor = { ...original, id: "reporting-route-set-alias", title: "Equivalent alias proposal", status: "proposed", predecessorId: original.id, primaryLane: { ...original.primaryLane, destination: "report@example.test" } }; + delete successor.proposalCommit; delete successor.approval; + await write(root, "reporting-route-sets", successor); + await writeFile(join(root, "data/reporting-route-sets", `${successor.id}.md`), "# Instructions\n\nUse the same monitored inbox through the equivalent alias.\n"); + git(root, "add", "."); git(root, "commit", "-m", "Propose equivalent alias"); + const p = await payload(root); p.record.withdrawnProposalId = successor.id; + p.expectedProposalRevision = calculateRevision("content", await readFile(join(root, "data/reporting-route-sets", `${successor.id}.json`), "utf8")); + await saveNonmaterialAmendment(root, p); + const after = await loadWorkspace(root); + assert.equal(after.resources.find(({ id }) => id === successor.id).status, "withdrawn"); + assert.equal(after.resources.find(({ id }) => id === original.id).status, "approved"); + git(root, "add", "."); git(root, "commit", "-m", "Withdraw duplicate proposal with owner amendment"); + assert.equal((await validateWorkspace(root)).ok, true); +}); + +test("administrative labels preserve content bindings; committed decisions and proof are immutable", async (t) => { + const root = await fixture(t); + const p = await payload(root, "policy-example"); + const before = await readFile(join(root, "data/policies/policy-example.json"), "utf8"); + await saveNonmaterialAmendment(root, p); + git(root, "add", "."); git(root, "commit", "-m", "Correct policy display label"); + assert.equal(await readFile(join(root, "data/policies/policy-example.json"), "utf8"), before); + await assert.rejects(() => updateResource(root, "nonmaterial-amendment", p.record.id, { ...p.record, rationale: "Changed conclusion" }), /immutable owner decisions/); + await assert.rejects(() => deleteResource(root, "nonmaterial-amendment", p.record.id), /cannot be deleted/); + await write(root, "nonmaterial-amendments", { ...p.record, replacementValue: "Forged label" }); + let loaded = await loadWorkspace(root); + assert.equal(nonmaterialAmendmentIssues(loaded, loaded.resources.find(({ id }) => id === p.record.id)).length > 0, true); + assert.deepEqual(amendedResourceView(loaded, loaded.resources.find(({ id }) => id === "policy-example")).amendmentIds, []); + assert.equal((await validateWorkspace(root)).ok, false); +}); + +test("HTTP and CLI expose the same scaffold and preview", async (t) => { + const root = 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 = async (path, data) => fetch(endpoint + path, { method: "POST", headers: { "content-type": "application/json", origin: endpoint }, body: JSON.stringify(data) }); + const scaffold = await post("/api/nonmaterial-amendments/scaffold", { targetResourceId: "reporting-route-set-example" }); + assert.equal(scaffold.status, 200); + assert.equal((await scaffold.json()).record.fieldKey, "primaryLane.destination"); + const response = await post("/api/nonmaterial-amendments/preview", await payload(root)); + assert.equal(response.status, 200, await response.clone().text()); + assert.equal((await response.json()).preview, true); + const cli = execFileSync(process.execPath, [join(import.meta.dirname, "../bin/filegrc.js"), "amend-nonmaterial", "reporting-route-set-example", "--scaffold", "--root", root], { encoding: "utf8" }); + assert.equal(JSON.parse(cli).record.category, "reporting-email-alias"); +}); + +test("subsequent corrections survive ordinary source updates and a new source label", async (t) => { + const root = await fixture(t); + const first = await payload(root, "component-example"); + await saveNonmaterialAmendment(root, first); + git(root, "add", "."); git(root, "commit", "-m", "Correct component label"); + let loaded = await loadWorkspace(root); + let component = loaded.resources.find(({ id }) => id === "component-example"); + component.description = "Current provider retrieval instructions were clarified."; + await write(root, "components", component); + git(root, "add", "."); git(root, "commit", "-m", "Update component description"); + const second = await payload(root, component.id); + second.record.replacementValue = "Second administrative label"; + assert.equal(second.record.previousAmendmentId, first.record.id); + assert.notEqual(second.record.sourceCommit, first.record.sourceCommit); + await saveNonmaterialAmendment(root, second); + git(root, "add", "."); git(root, "commit", "-m", "Record another label correction"); + loaded = await loadWorkspace(root); + assert.equal(amendedResourceView(loaded, component).record.title, "Second administrative label"); + component.title = "New governed component name"; + await write(root, "components", component); + git(root, "add", "."); git(root, "commit", "-m", "Adopt new component name"); + const third = await payload(root, component.id); + assert.equal(third.record.previousAmendmentId, undefined); + assert.equal(third.record.previousValue, component.title); + await saveNonmaterialAmendment(root, third); + assert.equal((await validateWorkspace(root)).ok, true); + git(root, "add", "."); git(root, "commit", "-m", "Correct new component label"); + component.title = first.record.previousValue; + await write(root, "components", component); + git(root, "add", "."); git(root, "commit", "-m", "Restore an earlier name as a new decision"); + loaded = await loadWorkspace(root); + assert.equal(amendedResourceView(loaded, component).record.title, component.title); + const fresh = await payload(root, component.id); + assert.equal(fresh.record.previousAmendmentId, undefined); + await saveNonmaterialAmendment(root, fresh); +}); + +test("later independent evidence verification preserves the owner decision but altered captures do not", async (t) => { + const root = await fixture(t); + const proofPath = join(root, "data/evidence/evidence-alias-proof/evidence.json"); + const proof = JSON.parse(await readFile(proofPath, "utf8")); + proof.status = "collected"; + delete proof.verifiedOn; delete proof.verifierIds; + // This proof is separate from the original independently approved evidence. + proof.id = "evidence-owner-alias-observation"; + proof.collectedOn = "2026-07-01"; + proof.filePaths = ["evidence/evidence-owner-alias-observation/configuration.txt"]; + await mkdir(join(root, "data/evidence", proof.id)); + await writeFile(join(root, "data/evidence", proof.id, "evidence.json"), JSON.stringify(proof, null, 2) + "\n"); + await writeFile(join(root, "data", proof.filePaths[0]), "Both aliases reach the same monitored group.\n"); + git(root, "add", "."); git(root, "commit", "-m", "Retain owner configuration observation"); + const p = await payload(root); p.record.evidenceIds = [proof.id]; + await saveNonmaterialAmendment(root, p); + git(root, "add", "."); git(root, "commit", "-m", "Record owner alias decision"); + proof.status = "verified"; proof.verifierIds = ["person-independent-approver-example"]; proof.verifiedOn = new Date().toISOString().slice(0, 10); + await updateResource(root, "evidence", proof.id, proof); + assert.equal((await validateWorkspace(root)).ok, true); + let loaded = await loadWorkspace(root); + assert.equal(amendedResourceView(loaded, loaded.resources.find(({ id }) => id === p.record.targetResourceId)).amendmentIds.length, 1); + git(root, "add", "."); git(root, "commit", "-m", "Independently verify retained observation"); + await assert.rejects(() => updateResource(root, "evidence", proof.id, { ...proof, verifiedOn: "2026-07-02" }), /Proof record/); + const downgraded = { ...proof, status: "collected" }; delete downgraded.verifierIds; delete downgraded.verifiedOn; + await assert.rejects(() => updateResource(root, "evidence", proof.id, downgraded), /Proof record/); + assert.equal((await validateWorkspace(root)).ok, true); + const narrativePath = join(root, "data/evidence", proof.id, "evidence.md"); + await writeFile(narrativePath, "# Changed observation\n\nThe recipients are different.\n"); + loaded = await loadWorkspace(root); + assert.equal(amendedResourceView(loaded, loaded.resources.find(({ id }) => id === p.record.targetResourceId)).amendmentIds.length, 0); + assert.equal((await validateWorkspace(root)).ok, false); + await rm(narrativePath); + await writeFile(join(root, "data", proof.filePaths[0]), "Changed recipients.\n"); + assert.equal((await validateWorkspace(root)).ok, false); + loaded = await loadWorkspace(root); + assert.equal(amendedResourceView(loaded, loaded.resources.find(({ id }) => id === p.record.targetResourceId)).amendmentIds.length, 0); +}); + +test("backward reads do not authorize new records under an old repository pin", async (t) => { + const root = await fixture(t); + const p = await payload(root); + await writeFile(join(root, "package-lock.json"), JSON.stringify({ lockfileVersion: 3, packages: { "node_modules/filegrc": { resolved: "../linked-engine", link: true }, "../linked-engine": { version: "0.19.0" } } })); + await assert.rejects(() => saveNonmaterialAmendment(root, p), /Upgrade and commit/); + await writeFile(join(root, "package.json"), JSON.stringify({ private: true, dependencies: { filegrc: "0.18.16" } })); + await writeFile(join(root, "package-lock.json"), JSON.stringify({ lockfileVersion: 3, packages: { "node_modules/filegrc": { version: "0.18.16" } } })); + assert.equal((await validateWorkspace(root)).ok, true); + await assert.rejects(() => scaffoldNonmaterialAmendment(root, p.record.targetResourceId), /Upgrade and commit/); + await assert.rejects(() => saveNonmaterialAmendment(root, p), /Upgrade and commit/); + await mkdir(join(root, "data/nonmaterial-amendments"), { recursive: true }); + await write(root, "nonmaterial-amendments", p.record); + assert.equal((await validateWorkspace(root)).ok, false); + git(root, "add", "."); git(root, "commit", "-m", "Invalid legacy-pinned amendment fixture"); + await writeFile(join(root, "package.json"), JSON.stringify({ private: true, dependencies: { filegrc: "0.19.0" } })); + await writeFile(join(root, "package-lock.json"), JSON.stringify({ lockfileVersion: 3, packages: { "node_modules/filegrc": { version: "0.19.0" } } })); + const validation = await validateWorkspace(root); + assert.equal(validation.ok, false); + assert.equal(validation.diagnostics.some(({ message }) => message.includes("no later than its first amendment commit")), true); +}); + +test("reporting sets without email still offer administrative label corrections", async (t) => { + const root = await fixture(t); + const loaded = await loadWorkspace(root); + const route = structuredClone(loaded.resources.find(({ type }) => type === "reporting-route-set")); + Object.assign(route, { id: "reporting-route-set-web-phone", title: "Web and telephone reporting", status: "draft", purposeKey: "ethics-reporting", primaryLane: { channelKind: "web", destination: "https://example.test/report" }, alternateLane: { channelKind: "phone", destination: "+1 555 0100" } }); + delete route.approval; delete route.proposalCommit; + await createResource(root, route, { content: { instructions: "# Reporting instructions\n\nUse the reporting form or the telephone fallback to raise a concern.\n" } }); + git(root, "add", "."); git(root, "commit", "-m", "Define web and telephone reporting"); + const scaffold = await scaffoldNonmaterialAmendment(root, route.id); + assert.equal(scaffold.record.category, "label-correction"); + assert.equal(scaffold.record.fieldKey, "title"); + await assert.rejects(() => scaffoldNonmaterialAmendment(root, route.id, { category: "reporting-email-alias" }), /normal review/); +}); + +test.after(async () => { if (base) await rm(base, { recursive: true, force: true }); }); diff --git a/packages/filegrc/test/render.test.js b/packages/filegrc/test/render.test.js index e8a9ad6..b735c5e 100644 --- a/packages/filegrc/test/render.test.js +++ b/packages/filegrc/test/render.test.js @@ -2050,7 +2050,7 @@ test("keeps operation status explicit and stage instructions available on demand assert.match(APP_SCRIPT, /const findings = workflow\.findings\.filter\(\(item\) => matches\(item\) && activeStates\.has\(item\.state\)\)/); assert.match(APP_SCRIPT, /function controlApplicabilityPending\(record\)/); assert.match(APP_SCRIPT, /route\.name === "list" && \["requirement", "control", "commitment", "complementary-control"\]\.includes\(route\.type\)\) return \["program"\]/); - assert.match(detailSource, /

Record details<\/h3>/); + assert.match(detailSource, /entry\.nonmaterialView\?\.amendmentIds\.length \? "Originally recorded details" : "Record details"/); assert.match(detailSource, /detail-metadata-panel/); assert.match(APP_SCRIPT, /resourceReviewCriteria\(type, true\)/); assert.match(APP_SCRIPT, /detail-support-panel detail-review-panel/); diff --git a/packages/filegrc/test/runtime-version.test.js b/packages/filegrc/test/runtime-version.test.js index 0143e96..13bf019 100644 --- a/packages/filegrc/test/runtime-version.test.js +++ b/packages/filegrc/test/runtime-version.test.js @@ -70,7 +70,9 @@ test("stable runtime compatibility lines use minor before 1.0 and major thereaft ["0.17.0", "0.17.2", true], ["0.17.3", "0.17.0", true], ["0.17.3", "0.18.0", false], ["0.17.3", "0.16.9", false], ["1.0.0", "1.99.9", true], ["1.9.0", "1.0.0", true], ["1.0.0", "2.0.0", false], - ["0.17.0+build.1", "0.17.9", true] + ["0.17.0+build.1", "0.17.9", true], + ["0.19.0", "0.18.16", true], ["0.18.16", "0.19.0", false], + ["0.19.0", "0.19.2", true], ["0.19.0", "0.20.0", false] ]) assert.equal(assessRuntimeCompatibility(runtime, repository).compatible, compatible); for (const version of ["0.17.2-beta.1", "v0.17.2", "0.17", "00.17.2", "0.17.2 ", "^0.17.2", 17, "0.17.9007199254740992"]) { assert.equal(runtimeCompatibilityLine(version), null);