diff --git a/docs/architecture.md b/docs/architecture.md index bca16fe..542f9df 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -819,3 +819,27 @@ Operating ownership can use existing implemented Control procedures and approved Tokenized resource navigation projects the same snapshot-scoped workflow section used by root navigation. Previously, detail requests repeated full synchronous workflow and Git-history work on the HTTP thread after the shared worker had already calculated it. Detail now joins or reuses that worker calculation, retains each caller's deadline and verifies live file and Git signatures before responding. The 10-second request budgets remain unchanged. Tests cover warm computation bounds, concurrent cold requests, recovery after an earlier caller deadline, transient Git failures and invalidation after source changes. Finalized operating proof freezes explicit completion, Evidence, Exception and Attestation relationships at the owner's first finalized Git revision. Evidence source-resource links describe context, so current Controls, inventories and ongoing reviews can evolve without rewriting that proof. Source links to Evidence, Exceptions and Attestations still protect those proof records. Mutation guards and historical validation share the same traversal; historical validation resolves the traversal from committed records rather than current links. Evidence packets retain committed source versions alongside current context. + +## Retention rule applications + +The independent Data Retention Schedule review binds rule semantics and governing +sources. Its original committed revision remains verifiable after routine service +inventory growth. Owner-reviewed `retention-application` records separately bind +an approved rule to service data use, purpose and practical disposal. They name the +actual Policy Owner, authority and review date, retain the committed independent +approval, and confirm the full cutoff, disposition, holds and duties. + +The shared domain verifies the original approval before comparing rule semantics. +Changed rule notes, Markdown or governing decisions require independent review. +New or materially changed service uses require an explicit applicability decision; +provider or agreement changes invalidate prior disposal-fit review. Service labels +and unrelated metadata do not change the fit. Applications do not change original +approved rows and do not add approval requirements for equivalent service mappings. +Periodic independent schedule review exposes the current applications for oversight. + +Model v13 adds these optional records in FileGRC 0.19.2. Shared validation rejects +adoption until the consumer manifest and lockfile support them, including generic +CRUD and hand-edited source files. Hosted runtimes must upgrade before repositories +publish applications. Preview binds current service facts; save rejects changed +bindings. Retirement records the actual owner, date and reason, preserves the +original decision and cannot reactivate an old application. diff --git a/docs/data-model.md b/docs/data-model.md index 24f3fd9..fa46f87 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -728,6 +728,16 @@ Key format: `namespace`. | --- | --- | --- | --- | | `amendmentId` | id | Yes | References: `nonmaterial-amendment` | +### `retention-application-confirmations` + +| Property | Type | Required | Notes | +| --- | --- | --- | --- | +| `purposeMatches` | boolean | Yes | The record purpose and boundaries fit the approved rule. | +| `cutoffMatches` | boolean | Yes | The same cutoff event applies. | +| `dispositionFeasible` | boolean | Yes | The full disposition can be carried out in this service. | +| `holdsPreserved` | boolean | Yes | All approved holds and required records remain protected. | +| `noConflictingDuties` | boolean | Yes | No legal, contractual or other duty conflicts with this application. | + ## Common fields | Field | Type | Required | Meaning | @@ -1618,6 +1628,41 @@ Record Markdown: available when needed as an implicit companion file. | `confirmations` | object (`nonmaterial-amendment-confirmations`) | No | | | `withdrawnProposalId` | id | No | References: `reporting-route-set` | +#### `retention-application` + +An owner decision applying one unchanged independently approved retention rule to additional services. It preserves the original schedule approval and never changes the approved rule. + +Instructions: Apply the complete unchanged independently approved rule to actual services after owner review of purpose, cutoff, disposal, holds and duties. + +Policy basis: Apply the complete approved rule, including record purpose, cutoff, period, disposition and holds. Matching a duration alone is insufficient. New categories, conflicting duties, unsupported disposal or any changed rule require normal independent schedule approval. + +Timing: Review applicability when the service data use or disposal capability changes and include applications in periodic independent retention oversight. Use apply-retention-rule to scaffold exact bindings. Retire an obsolete application and record a new decision; preserve history. Requires a committed repository dependency and lockfile for FileGRC 0.19.2 or newer in the 0.19 line. + +Path: `data/retention-applications/.json` + +Record Markdown: available when needed as an implicit companion file. + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `status` | enum | Yes | Values: `active`, `retired` | +| `ruleId` | id | Yes | Approved rule References: `retention-schedule-item` | +| `ruleRevision` | string | Yes | Exact rule decision | +| `approvalId` | id | Yes | Independent schedule approval References: `collection-review` | +| `approvalRevision` | string | Yes | | +| `programId` | id | Yes | References: `program` | +| `scopeResourceIds` | array of id | Yes | Services covered References: `system`, `component`, `vendor` | +| `informationTypeIds` | array of id | Yes | References: `information-type` | +| `reviewedScopeRevisions` | object (`string-map`) | Yes | | +| `reviewedById` | id | Yes | References: `person` | +| `authorityAppointmentId` | id | Yes | References: `appointment` | +| `reviewedOn` | date | Yes | | +| `purpose` | string | Yes | Record purpose and boundaries | +| `rationale` | string | Yes | | +| `disposalImplementation` | string | Yes | How the complete rule will operate | +| `confirmations` | object (`retention-application-confirmations`) | Yes | | +| `approvalCommit` | string | Yes | Committed independent approval | +| `statusTransition` | object (`status-transition`) | Conditional | Required when `status` is `retired`. Allowed when `status` is `retired`. | + ### Risk #### `exception` diff --git a/package-lock.json b/package-lock.json index 642a1d0..cd3a012 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.19.1", + "version": "0.19.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.19.1", + "version": "0.19.2", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.19.1", + "version": "0.19.2", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.19.1", + "version": "0.19.2", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index d2dcc75..35dc0df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.19.1", + "version": "0.19.2", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index 699eb70..6ae600b 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.19.1", + "version": "0.19.2", "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 d96d2f5..7b1b3f6 100644 --- a/packages/create-filegrc/src/index.js +++ b/packages/create-filegrc/src/index.js @@ -524,13 +524,13 @@ async function runCombinedSetup(target, input) { async function writeMinimalLockfile(target, name, versionRange) { const lock = { name, - version: "0.19.1", + version: "0.19.2", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.19.1", + version: "0.19.2", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/AGENTS.md b/packages/create-filegrc/template/AGENTS.md index 539bc2e..e9779ac 100644 --- a/packages/create-filegrc/template/AGENTS.md +++ b/packages/create-filegrc/template/AGENTS.md @@ -264,3 +264,15 @@ Model v13 checks completed full-program risk assessment, current workforce train 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. + +## Applying approved retention rules to services + +The independent schedule review governs the complete retention rule. Adding a +service does not require another independent approval when the owner can apply +that exact rule without changing its category, purpose, cutoff, period, disposition, +holds or required duties. Use `filegrc apply-retention-rule RULE_ID --scaffold`, fill +an honest owner applicability decision, preview it, then save the preview with +`--yes`. The browser offers **Apply approved rule** on an active rule. Keep original +approved rows and approval metadata unchanged. Use normal independent review for +material changes or when no existing rule fully fits. Include service applications +in periodic independent retention oversight. diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index b812cb4..de13dc5 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.19.1", + "version": "0.19.2", "private": true, "description": "filegrc workspace for a SOC 2 program", "type": "module", diff --git a/packages/filegrc/README.md b/packages/filegrc/README.md index b6ecbff..838dff6 100644 --- a/packages/filegrc/README.md +++ b/packages/filegrc/README.md @@ -131,3 +131,29 @@ Choose a tested runtime in the repository's line that supports its model and the Patch releases must preserve readable records and generated records across their compatibility line. An incompatible record format, model requirement, or API contract needs a new compatibility line and an explicit upgrade path, rather than relaxed validation. Model v13 adds scoped source retrieval, proof preservation, baseline execution and operating ownership checks. Existing v12 programs explicitly preview `npx filegrc migrate --to-model 13 --preview --json` before applying the upgrade. Use `npx filegrc program-readiness --json` for the missing facts, existing records, tangible checks and read/scaffold/preview/save commands shared with the browser. Active programs repair current gaps using actual dates and preserve historical activation and Control oversight. Reuse unchanged approvals; new or changed governed content, schedules and retention decisions use their existing independent reviews. See the [model v13 upgrade guide](https://github.com/Alignbase/filegrc/blob/main/docs/upgrading-to-model-v13.md). + +### Apply an approved retention rule + +Independent review approves the retention decision: data category and purpose, +cutoff, period, disposition, holds and required duties. The Policy Owner can apply +that complete unchanged rule to another service without approving the schedule +again. Matching a duration alone does not establish a fit. + +```sh +npx filegrc apply-retention-rule RULE_ID --scaffold > application.json +# Fill the service scope, existing Information Types, actual owner authority, +# purpose, disposal implementation and every fit confirmation. +npx filegrc apply-retention-rule RULE_ID application.json --preview > reviewed-application.json +npx filegrc apply-retention-rule RULE_ID reviewed-application.json --yes +``` + +Preview binds the current service facts. Save rejects a changed service, rule or +approval instead of silently updating the decision. The browser's **Apply approved +rule** action uses the same checks. Original rule and approval records stay intact; +new applications retain the actual owner, date and exact approved source. Review +applications during periodic independent schedule oversight. + +Use normal independent schedule review for a new category or purpose, a changed +cutoff or period, conflicting duties, unsupported disposal, or a material exception. +An application cannot supply missing independent approval. Retire an obsolete +application and record a new owner decision when the service facts change. diff --git a/packages/filegrc/model/v13.json b/packages/filegrc/model/v13.json index 533007a..214f766 100644 --- a/packages/filegrc/model/v13.json +++ b/packages/filegrc/model/v13.json @@ -9645,6 +9645,165 @@ "confirmations", "withdrawnProposalId" ] + }, + "retention-application": { + "title": "Retention rule application", + "pluralTitle": "Retention rule applications", + "group": "governance", + "collection": "retention-applications", + "description": "An owner decision applying one unchanged independently approved retention rule to additional services. It preserves the original schedule approval and never changes the approved rule.", + "guidance": { + "policyBasis": "Apply the complete approved rule, including record purpose, cutoff, period, disposition and holds. Matching a duration alone is insufficient. New categories, conflicting duties, unsupported disposal or any changed rule require normal independent schedule approval.", + "cadence": "Review applicability when the service data use or disposal capability changes and include applications in periodic independent retention oversight. Use apply-retention-rule to scaffold exact bindings. Retire an obsolete application and record a new decision; preserve history. Requires a committed repository dependency and lockfile for FileGRC 0.19.2 or newer in the 0.19 line." + }, + "required": [ + "status", + "ruleId", + "ruleRevision", + "approvalId", + "approvalRevision", + "programId", + "scopeResourceIds", + "informationTypeIds", + "reviewedScopeRevisions", + "reviewedById", + "authorityAppointmentId", + "reviewedOn", + "purpose", + "rationale", + "disposalImplementation", + "confirmations", + "approvalCommit" + ], + "fields": { + "status": { + "type": "enum", + "values": [ + "active", + "retired" + ], + "filter": true + }, + "ruleId": { + "type": "id", + "relation": [ + "retention-schedule-item" + ], + "label": "Approved rule" + }, + "ruleRevision": { + "type": "string", + "label": "Exact rule decision" + }, + "approvalId": { + "type": "id", + "relation": [ + "collection-review" + ], + "label": "Independent schedule approval" + }, + "approvalRevision": { + "type": "string" + }, + "programId": { + "type": "id", + "relation": [ + "program" + ] + }, + "scopeResourceIds": { + "type": "array", + "items": "id", + "relation": [ + "system", + "component", + "vendor" + ], + "label": "Services covered" + }, + "informationTypeIds": { + "type": "array", + "items": "id", + "relation": [ + "information-type" + ] + }, + "reviewedScopeRevisions": { + "type": "object", + "objectType": "string-map" + }, + "reviewedById": { + "type": "id", + "relation": [ + "person" + ] + }, + "authorityAppointmentId": { + "type": "id", + "relation": [ + "appointment" + ] + }, + "reviewedOn": { + "type": "date" + }, + "purpose": { + "type": "string", + "label": "Record purpose and boundaries" + }, + "rationale": { + "type": "string" + }, + "disposalImplementation": { + "type": "string", + "label": "How the complete rule will operate" + }, + "confirmations": { + "type": "object", + "objectType": "retention-application-confirmations" + }, + "approvalCommit": { + "type": "string", + "label": "Committed independent approval" + }, + "statusTransition": { + "type": "object", + "objectType": "status-transition", + "requiredWhen": { + "status": "retired" + }, + "allowedWhen": { + "status": "retired" + } + } + }, + "listFields": [ + "title", + "status", + "ruleId", + "scopeResourceIds", + "reviewedOn" + ], + "formFields": [ + "status", + "ruleId", + "ruleRevision", + "approvalId", + "approvalRevision", + "programId", + "scopeResourceIds", + "informationTypeIds", + "reviewedScopeRevisions", + "reviewedById", + "authorityAppointmentId", + "reviewedOn", + "purpose", + "rationale", + "disposalImplementation", + "confirmations", + "approvalCommit", + "statusTransition" + ] } }, "primitives": { @@ -11555,6 +11714,38 @@ ] } } + }, + "retention-application-confirmations": { + "required": [ + "purposeMatches", + "cutoffMatches", + "dispositionFeasible", + "holdsPreserved", + "noConflictingDuties" + ], + "properties": { + "purposeMatches": { + "type": "boolean", + "label": "The record purpose and boundaries fit the approved rule." + }, + "cutoffMatches": { + "type": "boolean", + "label": "The same cutoff event applies." + }, + "dispositionFeasible": { + "type": "boolean", + "label": "The full disposition can be carried out in this service." + }, + "holdsPreserved": { + "type": "boolean", + "label": "All approved holds and required records remain protected." + }, + "noConflictingDuties": { + "type": "boolean", + "label": "No legal, contractual or other duty conflicts with this application." + } + }, + "additionalProperties": false } }, "obligationActivities": { diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index 4527184..8b35bfd 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.19.1", + "version": "0.19.2", "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 844b3cd..761bb20 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, + ...(type === "retention-schedule-item" && loaded.model.resources["retention-application"] ? { applyApprovedRule: "npx filegrc apply-retention-rule RULE_ID --scaffold" } : {}), nonmaterialAmendments: Object.fromEntries(Object.entries(loaded.model.nonmaterialAmendments || {}).filter(([, rule]) => rule.resourceTypes.includes(type))), ...(options.preparationChecks ? { preparationChecks: options.preparationChecks } : {}), title: definition.title, diff --git a/packages/filegrc/src/cli.js b/packages/filegrc/src/cli.js index e65340e..8f4f94a 100644 --- a/packages/filegrc/src/cli.js +++ b/packages/filegrc/src/cli.js @@ -1,3 +1,4 @@ +import { scaffoldRetentionApplication, saveRetentionApplication } from "./retention-applications.js"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { readFile, writeFile } from "node:fs/promises"; @@ -601,6 +602,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 === "apply-retention-rule") { + const ruleId = positionals[0]; + if (!ruleId) throw new Error("Pass an independently approved retention rule ID."); + if (flags.scaffold) { + const result = await scaffoldRetentionApplication(root, ruleId); + console.log(JSON.stringify(result, null, 2)); + return result; + } + if (!positionals[1] || !flags.preview && !flags.yes) throw new Error("Pass a completed applicability payload and --preview or --yes."); + const payload = JSON.parse(await readFile(resolve(positionals[1]), "utf8")); + if (payload.record?.ruleId !== ruleId) throw new Error("The application must name the requested rule."); + const result = flags.preview ? await saveRetentionApplication(root, payload, { preview: true }) + : await withWorkflowDelta(root, () => saveRetentionApplication(root, payload)); + console.log(JSON.stringify(result, null, 2)); + return result; + } if (command === "amend-nonmaterial") { const targetId = positionals[0]; if (!targetId) throw new Error("Pass the source resource ID."); @@ -1433,6 +1450,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 apply-retention-rule --scaffold + filegrc apply-retention-rule --preview|--yes [--json] filegrc amend-nonmaterial --scaffold [--category label-correction|reporting-email-alias] [--field field.path] filegrc amend-nonmaterial --preview|--yes [--json] filegrc program-amendment [--json] @@ -1786,6 +1805,7 @@ function agentOverview(model, connection) { search: "filegrc search --json", obligations: "filegrc obligations --json", programReadiness: "filegrc program-readiness --json", + applyRetentionRule: "filegrc apply-retention-rule RULE_ID --scaffold", nonmaterialAmendment: "filegrc amend-nonmaterial RESOURCE_ID --scaffold", programAmendment: "filegrc program-amendment --json", reviewBindings: "filegrc review-bindings --json", diff --git a/packages/filegrc/src/collection-review.js b/packages/filegrc/src/collection-review.js index 23c72ae..3043996 100644 --- a/packages/filegrc/src/collection-review.js +++ b/packages/filegrc/src/collection-review.js @@ -154,6 +154,7 @@ function assessCollectionReviewUncached(loaded, resourceType, options) { configuration, records, recordCount: records.length, + ...(resourceType === "retention-schedule-item" ? { retentionApplications: loaded.resources.filter((item) => item.type === "retention-application" && item.status === "active").map((item) => ({ id: item.id, title: item.title, ruleId: item.ruleId, scopeResourceIds: item.scopeResourceIds, reviewedById: item.reviewedById, reviewedOn: item.reviewedOn })) } : {}), review, reviewRevision: reviewEntry ? calculateRevision("content", reviewEntry.source) @@ -220,6 +221,7 @@ export async function scaffoldCollectionReview(input = process.cwd(), options = ...(resourceType === "obligation" ? { programId: program.id, firstOperatingTasks: firstOperatingTasks(loaded, program) } : {}), ...(resourceType === "obligation" ? { scheduleDecisions: assessment.records.map((record) => ({ obligationId: record.id, disposition: null, ...(record.activeRuleId ? { ruleId: record.activeRuleId } : {}), rationale: null })) } : {}), ...(assessment.changesSinceReview ? { changesSinceReview: assessment.changesSinceReview } : {}), + ...(resourceType === "retention-schedule-item" ? { retentionApplications: assessment.retentionApplications, applicationOversight: "Review current owner applications during periodic independent schedule oversight. These decisions preserve unchanged approved rules and do not create a new approval request for each service." } : {}), decision: priorDecisionStillValid ? priorReview.decision : (assessment.records.length ? "complete" : allowedDecisions.includes("zero-population") ? "zero-population" : null), diff --git a/packages/filegrc/src/collection-revision.js b/packages/filegrc/src/collection-revision.js index e4fc731..a1119d7 100644 --- a/packages/filegrc/src/collection-revision.js +++ b/packages/filegrc/src/collection-revision.js @@ -79,6 +79,14 @@ export function collectionReviewDecisionMatches(loaded, resourceType, review, re return revisionsMatch("collection", collectionRevision(loaded, "obligation", options), collectionRevision(historical, "obligation", { program, historicalCommit: review.scopeRevision })); } + if (resourceType === "retention-schedule-item" && loaded.model.resources["retention-application"]) { + if (collectionRevisionMatches(loaded, resourceType, review.collectionRevision, options)) return true; + if (!loaded.root || !review.scopeRevision) return false; + const historical = reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), review.scopeRevision); + if (!historical || !collectionRevisionMatches(historical, resourceType, review.collectionRevision, { ...options, historicalCommit: review.scopeRevision, currentRevision: undefined })) return false; + return revisionsMatch("collection", collectionRevision(loaded, resourceType, { ...options, retentionRuleSemantics: true }), + collectionRevision(historical, resourceType, { ...options, historicalCommit: review.scopeRevision, retentionRuleSemantics: true })); + } if (resourceType === "retention-schedule-item" || Number(loaded.model.modelVersion) < 11) { return collectionRevisionMatches(loaded, resourceType, review.collectionRevision, options); } @@ -231,6 +239,7 @@ function calculateCollectionRevisionUncached(loaded, resourceType, options, lega historicalCommit: options.historicalCommit, legacyRetentionSourceBasis: options.legacyRetentionSourceBasis, legacyGovernedLifecycleMetadata: options.legacyGovernedLifecycleMetadata, + retentionRuleSemantics: options.retentionRuleSemantics, legacyScheduleApprovalDates: options.legacyScheduleApprovalDates }) .map((input) => [input.record.id, input])); diff --git a/packages/filegrc/src/collection-scope.js b/packages/filegrc/src/collection-scope.js index 1c619fb..0f2b3ec 100644 --- a/packages/filegrc/src/collection-scope.js +++ b/packages/filegrc/src/collection-scope.js @@ -332,8 +332,10 @@ export function collectionRevisionInputs(loaded, resourceType, program, options ? [program] : loaded.resources.filter((record) => record.type === "program" && record.status !== "retired"); const uses = programs.flatMap((candidate) => retentionUses(loaded, candidate)); - addIds(uses.map(({ resource }) => resource.id)); - addIds(uses.map(({ informationTypeId }) => informationTypeId)); + if (!options.retentionRuleSemantics) { + addIds(uses.map(({ resource }) => resource.id)); + addIds(uses.map(({ informationTypeId }) => informationTypeId)); + } const schedules = loaded.resources .filter((record) => ( record.type === "document" @@ -376,7 +378,7 @@ export function collectionRevisionInputs(loaded, resourceType, program, options ? retentionScheduleDocumentProposalValue(record) : options.historicalReviewMetadata ? dependencyRevisionValue(resourceType, record, legacy, true, currentModel) - : substantiveRevisionValue(dependencyRevisionValue(resourceType, record, legacy, false, currentModel, scheduleDocumentIds, options.legacyRetentionSourceBasis), options.legacyGovernedLifecycleMetadata), + : substantiveRevisionValue((options.retentionRuleSemantics && ["component", "vendor", "system"].includes(record.type) ? { id: record.id, type: record.type } : dependencyRevisionValue(resourceType, record, legacy, false, currentModel, scheduleDocumentIds, options.legacyRetentionSourceBasis)), options.legacyGovernedLifecycleMetadata), includeContent: legacy || reviewedIds.has(record.id) || dependencyContentAffectsRevision(resourceType, record, options.historicalReviewMetadata, currentModel, scheduleDocumentIds, options.legacyRetentionSourceBasis) diff --git a/packages/filegrc/src/files.js b/packages/filegrc/src/files.js index cd11de1..c82e154 100644 --- a/packages/filegrc/src/files.js +++ b/packages/filegrc/src/files.js @@ -681,6 +681,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 === "retention-application" && (!preservesExisting(existing.status === "retired" ? [] : ["status", "statusTransition"]) || existing.status === "retired" && next.status !== "retired")) { + throw new Error("Preserve the applicability decision. Retire it and record a new owner review."); + } if (existing.type === "nonmaterial-amendment" && !preservesExisting()) { throw new Error("Nonmaterial amendments are immutable owner decisions. Record a new amendment instead."); } @@ -971,7 +974,7 @@ async function deleteResourceUnlocked(input, type, id, options) { assertRevision(source, options.expectedRevision, "The record"); const record = JSON.parse(source); if ( - ["reconciliation-dismissal", "nonmaterial-amendment"].includes(record.type) + ["reconciliation-dismissal", "nonmaterial-amendment", "retention-application"].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)) diff --git a/packages/filegrc/src/index.js b/packages/filegrc/src/index.js index fd3bc26..c9f52db 100644 --- a/packages/filegrc/src/index.js +++ b/packages/filegrc/src/index.js @@ -142,3 +142,5 @@ export { notificationContacts } from "./notifications.js"; export { assessHostedAutomation, githubRepository, hostedAutomationForConnection } from "./hosted-automation.js"; export { amendedResourceView, scaffoldNonmaterialAmendment, saveNonmaterialAmendment } from "./nonmaterial-amendments.js"; + +export { scaffoldRetentionApplication, saveRetentionApplication, retentionApplicationIssues } from "./retention-applications.js"; diff --git a/packages/filegrc/src/program-path.js b/packages/filegrc/src/program-path.js index 490690c..12d1c3e 100644 --- a/packages/filegrc/src/program-path.js +++ b/packages/filegrc/src/program-path.js @@ -24,6 +24,7 @@ export const RESOURCE_INSTRUCTIONS = { "risk-assessment": "Assess the in-scope service, record conclusions, and obtain an independent review.", risk: "Name the threat and impact, assign an owner, rate the risk, and record its response.", obligation: "Read the linked Policy and Control, fill the owner, cadence or trigger, deadline, and proof from those sources, then enable the rule when its Control is ready. Ask only for an unresolved management choice.", + "retention-application": "Apply the complete unchanged independently approved rule to actual services after owner review of purpose, cutoff, disposal, holds and duties.", "retention-schedule-item": "Set the Information Types, scope, cutoff, period, disposition, and sources for one retention rule.", "obligation-event": "Record the actual event date and subject, then complete its linked Work Queue actions.", "policy-review": "Record the review date, reviewer, decision, and any required follow-up.", @@ -74,6 +75,7 @@ export const RESOURCE_OUTPUTS = { "risk-assessment": "A completed, independently reviewed Risk Assessment with its conclusions.", risk: "A rated Risk with an owner, response, and linked treatment Controls.", obligation: "An enabled schedule or event rule with an owner, deadline, and required proof.", + "retention-application": "An exact owner applicability decision preserving the independently approved rule and recording actual service disposal fit.", "retention-schedule-item": "A reviewed retention row with its scope, cutoff, period, disposition, and sources.", "obligation-event": "A dated Policy Event with its linked Work Queue actions.", "policy-review": "A dated review with its decision, reviewer, and follow-up.", @@ -420,6 +422,7 @@ function agentCommand(command) { } export function resourceProgramContext(type, model) { + if (type === "retention-application" && model?.resources?.[type]) return resourceProgramContext("retention-schedule-item", model); const stage = programPathForModel(model).find((candidate) => ( candidate.resourceTypes.includes(type) || (candidate.supportingResourceTypes || []).includes(type) )); diff --git a/packages/filegrc/src/retention-applications.js b/packages/filegrc/src/retention-applications.js new file mode 100644 index 0000000..5f730ee --- /dev/null +++ b/packages/filegrc/src/retention-applications.js @@ -0,0 +1,204 @@ +import { calculationMemo } from "./calculation-cache.js"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { markdownEntries } from "./resource-markdown.js"; +import { resolveDataPath } from "./paths.js"; +import { previewWorkflowMutation } from "./workflow.js"; +import { loadWorkspace } from "./workspace.js"; +import { collectionRevisionMatches } from "./collection-revision.js"; +import { reviewHistoricalWorkspace, reviewHistoryContext } from "./historical-workspace.js"; +import { resourceReviewRevisionsSync, retentionReviewResourceIds, retentionRuleIsCurrent, retentionSystemUses, retentionUses } from "./retention.js"; +import { appointmentWasAuthorizedOn, personWasActiveOn } from "./soc2.js"; +import { currentPartyPeople } from "./parties.js"; +import { createResource } from "./files.js"; +import { getGitSummary, getFileAtRevision, runGitCommandSync, getDataRecordHistoryIndex, isDataHistoryAncestor } from "./git.js"; +import { currentCalendarDate } from "./time.js"; + +const CONFIRMATIONS = ["purposeMatches", "cutoffMatches", "dispositionFeasible", "holdsPreserved", "noConflictingDuties"]; +const RULE_FIELDS = ["id", "type", "status", "description", "informationTypeIds", "scopeResourceIds", "scheduleDocumentId", "sourceResourceIds", "sourceSectionHeadings", "cutoff", "retentionPeriod", "dispositionAction", "dispositionInstructions", "notes"]; +function canonical(value) { + if (Array.isArray(value)) return value.map(canonical).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])); + return value; +} +function digest(value) { return createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex"); } +function applicationError(message) { return Object.assign(new Error(message), { code: "FILEGRC_RETENTION_APPLICATION" }); } +export function retentionApplicationRuntimeSupported(loaded, commit = null) { + return calculationMemo(loaded, "retention-application-runtime", [commit], () => { + try { + const read = (name) => JSON.parse(commit ? runGitCommandSync(loaded.root, ["show", `${commit}:./${name}`], { timeout: 5000, maxBuffer: 2000000 }) : readFileSync(join(loaded.root, name), "utf8")); + const manifest = read("package.json"), lock = read("package-lock.json"); + const entry = lock.packages?.["node_modules/filegrc"]; + const requested = /^(?:\^|~)?0\.19\.(0|[1-9]\d*)$/.exec(manifest.dependencies?.filegrc || ""); + const match = /^0\.19\.(0|[1-9]\d*)$/.exec(entry?.version || ""); + return Boolean(requested && Number(requested[1]) >= 2 && lock.packages?.[""]?.dependencies?.filegrc === manifest.dependencies.filegrc && !entry?.link && match && Number(match[1]) >= Number(requested[1])); + } catch { return false; } + }); +} +function ruleMarkdown(loaded, rule) { + return markdownEntries(loaded.model, rule).map(({ path }) => { + try { return { path, source: loaded.historicalFiles ? loaded.historicalFiles.get(`data/${path}`) || null + : loaded.historicalCommit ? getFileAtRevision(loaded.root, loaded.historicalCommit, `data/${path}`) : readFileSync(resolveDataPath(loaded.root, path), "utf8") }; } + catch (error) { if (error.code === "ENOENT") return { path, source: null }; throw error; } + }); +} +export function retentionRuleRevision(rule, loaded) { + return calculationMemo(loaded, "retention-approved-rule-signature", [rule.id], () => { + const sourceIds = [rule.scheduleDocumentId, ...(rule.sourceResourceIds || []), ...(rule.informationTypeIds || [])].filter(Boolean); + return digest({ rule: Object.fromEntries(RULE_FIELDS.map((key) => [key, rule[key] ?? null])), + markdown: ruleMarkdown(loaded, rule), approvedSources: Object.fromEntries(resourceReviewRevisionsSync(loaded, sourceIds, "legacy", false, null, rule)) }); + }); +} +export function retentionApplicationScopeRevision(record, loaded = null) { + if (!record || typeof record !== "object") return null; + const provider = record.type === "component" && loaded?.resources.find((item) => item.type === "vendor" && item.id === record.vendorId); + return digest({ id: record.id, type: record.type, status: record.status, + markdown: loaded ? ruleMarkdown(loaded, record) : [], + vendorId: record.type === "component" ? record.vendorId || null : null, + providerDecision: provider ? retentionApplicationScopeRevision(provider, loaded) : null, + classificationId: record.classificationId || null, + description: record.description || null, purpose: record.purpose || null, boundary: record.boundary || null, exclusions: record.exclusions || null, servicesProvided: record.servicesProvided || null, + referencedDecisions: loaded ? Object.fromEntries(resourceReviewRevisionsSync(loaded, [record.agreementDocumentId, record.classificationId].filter(Boolean))) : {}, + informationTypeIds: [...(record.informationTypeIds || [])].sort(), + informationUses: record.informationUses || [], + systemUses: retentionSystemUses(record.systemUses), + // These fields can change the service's legal or operational fit. + agreementDocumentId: record.agreementDocumentId || null, standardAgreement: record.standardAgreement || null }); +} +export function retentionApplicationIssues(loaded, record, { includeStale = true } = {}) { + const issues = []; + const fail = (message) => issues.push({ severity: "error", code: "invalid-retention-application", resourceId: record.id, message }); + if (!retentionApplicationRuntimeSupported(loaded)) { + fail("Upgrade the repository package dependency and lockfile to FileGRC 0.19.2 or newer in the supported 0.19 line before adopting retention applications."); + return issues; + } + if (!Array.isArray(record.scopeResourceIds) || !Array.isArray(record.informationTypeIds) + || !record.reviewedScopeRevisions || typeof record.reviewedScopeRevisions !== "object" || Array.isArray(record.reviewedScopeRevisions)) { + fail("Service scope, Information Types and exact applicability bindings are required in the supported record shape."); + return issues; + } + const history = loaded.root ? getDataRecordHistoryIndex(loaded.root).historiesById.get(record.id) || [] : []; + const firstCommit = [...history].reverse()[0]?.commit; + if (firstCommit && !retentionApplicationRuntimeSupported(loaded, firstCommit)) fail("The repository must adopt a compatible package dependency and lockfile no later than its first retention application commit."); + const eventSnapshot = firstCommit ? reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), firstCommit) : loaded; + const eventRecord = eventSnapshot?.resources.find(({ id }) => id === record.id); + if (record.status === "active" && history.some(({ commit }) => reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), commit)?.resources.find(({ id }) => id === record.id)?.status === "retired")) fail("A retired applicability decision cannot be reactivated. Record a new owner review."); + const originalDecision = (item) => Object.fromEntries(Object.entries(item || {}).filter(([key]) => !["status", "statusTransition"].includes(key))); + if (firstCommit && JSON.stringify(originalDecision(eventRecord)) !== JSON.stringify(originalDecision(record))) fail("Preserve the committed applicability decision. Retire it and record a new owner review."); + const byId = new Map(loaded.resources.map((item) => [item.id, item])); + const eventById = new Map((eventSnapshot?.resources || loaded.resources).map((item) => [item.id, item])); + if (record.status === "retired") { + const transition = record.statusTransition; + if (!Array.isArray(transition?.changedByIds) || !transition.changedByIds.length || !String(transition.reason || "").trim() + || !transition.changedOn || transition.changedOn < record.reviewedOn || transition.changedOn > currentCalendarDate(loaded.workspace.timezone || "UTC") + || transition.changedByIds.some((id) => !loaded.resources.some((item) => item.type === "appointment" && item.appointmentKind === "policy-owner" && item.holderId === id + && (item.scopeResourceIds || []).some((scope) => [record.programId, loaded.workspace.id, record.ruleId].includes(scope)) && appointmentWasAuthorizedOn(item, transition.changedOn, byId)))) fail("Retirement needs the actual authorized owner, date and reason after the applicability review."); + const retiredCommit = [...history].reverse().find(({ commit }) => reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), commit)?.resources.find(({ id }) => id === record.id)?.status === "retired")?.commit; + const retiredRecord = retiredCommit ? reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), retiredCommit)?.resources.find(({ id }) => id === record.id) : null; + if (retiredRecord && JSON.stringify(retiredRecord.statusTransition) !== JSON.stringify(record.statusTransition)) fail("Preserve the committed retirement decision."); + } + const rule = byId.get(record.ruleId); + const approvalSnapshot = loaded.root && /^[a-f0-9]{40}$/.test(record.approvalCommit || "") && isDataHistoryAncestor(loaded, record.approvalCommit, getGitSummary(loaded.root).commit) + ? reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), record.approvalCommit) : null; + const review = approvalSnapshot?.resources.find(({ id }) => id === record.approvalId); + const historical = loaded.root && /^[a-f0-9]{40}$/.test(review?.scopeRevision || "") + ? reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), review.scopeRevision) : null; + const original = historical?.resources.find(({ id }) => id === rule?.id); + if (!rule || rule.type !== "retention-schedule-item" + || review?.type !== "collection-review" || review.resourceType !== "retention-schedule-item" || review.status !== "active" + || review.decision !== "complete" || review.collectionRevision !== record.approvalRevision + || !(review.populationResourceIds || []).includes(rule.id) || !original + || !calculationMemo(historical, "retention-application-independent-approval", [review.collectionRevision, review.scopeRevision], () => collectionRevisionMatches(historical, "retention-schedule-item", review.collectionRevision, { historicalCommit: review.scopeRevision })) + || retentionRuleRevision(original, historical) !== record.ruleRevision) { + fail("Choose a rule contained in an exact committed independent schedule approval. An owner application cannot create or replace that approval."); + return issues; + } + const originalOwners = currentPartyPeople([...(review.populationResourceIds || []).flatMap((id) => historical.resources.find((item) => item.id === id)?.ownerIds || []), ...(historical.resources.find(({ id }) => id === original.scheduleDocumentId)?.ownerIds || [])], new Map(historical.resources.map((item) => [item.id, item]))); + if (!review.reviewedByIds?.length || review.reviewedByIds.some((id) => originalOwners.has(id) + || !personWasActiveOn(historical.resources.find((item) => item.id === id), review.reviewedOn))) fail("The source schedule approval must have an eligible independent reviewer."); + const program = byId.get(record.programId); + const appointment = eventById.get(record.authorityAppointmentId); + if (program?.type !== "program" || !(review.scopeResourceIds || []).includes(program.id) + || appointment?.type !== "appointment" || appointment.appointmentKind !== "policy-owner" + || appointment.holderId !== record.reviewedById + || !appointmentWasAuthorizedOn(appointment, record.reviewedOn, eventById) + || !(appointment.scopeResourceIds || []).some((id) => [program.id, loaded.workspace.id, rule.id].includes(id))) fail("The actual reviewer must hold active Policy Owner authority for this Program on the review date."); + if (!record.reviewedOn || record.reviewedOn > currentCalendarDate(loaded.workspace.timezone || "UTC") || record.reviewedOn < review.reviewedOn) fail("Record the actual applicability review date after the independent approval, never a future date."); + if (!record.informationTypeIds?.length || record.informationTypeIds.some((id) => !(original.informationTypeIds || []).includes(id))) fail("Only Information Types already covered by the approved rule may be applied. A new category requires independent rule review."); + if (!record.scopeResourceIds?.length || record.scopeResourceIds.some((id) => !["system", "component", "vendor"].includes(byId.get(id)?.type))) fail("Select the actual services or systems receiving this rule."); + if (CONFIRMATIONS.some((key) => record.confirmations?.[key] !== true) + || ["purpose", "rationale", "disposalImplementation"].some((key) => !String(record[key] || "").trim())) fail("Review purpose, cutoff, complete disposition, holds and conflicting duties, and explain actual disposal before applying an approved rule. Use normal independent review when any condition does not fit."); + if (!firstCommit || includeStale && record.status !== "retired") { + const revisions = resourceReviewRevisionsSync(loaded, retentionReviewResourceIds(rule, loaded)); + if (rule.status !== "active" || retentionRuleRevision(rule, loaded) !== record.ruleRevision || !retentionRuleIsCurrent(rule, revisions, byId, loaded)) fail("The approved rule or a governing source changed. Review the material change independently before relying on this application."); + if (Object.keys(record.reviewedScopeRevisions || {}).sort().join() !== [...(record.scopeResourceIds || [])].sort().join() + || record.scopeResourceIds.some((id) => retentionApplicationScopeRevision(byId.get(id), loaded) !== record.reviewedScopeRevisions?.[id])) fail("Service data use, agreement or classification changed after the owner review. Retire and replace this applicability decision or request material rule review."); + } + return issues; +} +export function currentRetentionApplications(loaded) { + return calculationMemo(loaded, "current-retention-applications", [], () => loaded.resources.filter((record) => record.type === "retention-application" && record.status === "active" && !retentionApplicationIssues(loaded, record).length)); +} +export function retentionUseWasIndependentlyReviewed(loaded, use, programId) { + if (!loaded.model.resources["retention-application"]) return true; + return calculationMemo(loaded, "retention-original-use", [use.resource.id, use.informationTypeId, programId], () => { + const review = loaded.resources.find((item) => item.type === "collection-review" && item.resourceType === "retention-schedule-item" && item.status === "active" && item.scopeResourceIds?.includes(programId)); + if (!review) return false; + const historical = loaded.root && /^[a-f0-9]{40}$/.test(review.scopeRevision || "") ? reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), review.scopeRevision) : null; + const program = historical?.resources.find(({ id }) => id === programId); + return Boolean(program && retentionUses(historical, program).some((prior) => prior.resource.id === use.resource.id && prior.informationTypeId === use.informationTypeId + && retentionApplicationScopeRevision(prior.resource, historical) === retentionApplicationScopeRevision(use.resource, loaded))); + }); +} +export function applicationCoversUse(loaded, use, programId) { + return currentRetentionApplications(loaded).some((record) => record.programId === programId + && record.scopeResourceIds.includes(use.resource.id) && record.informationTypeIds.includes(use.informationTypeId)); +} +export async function scaffoldRetentionApplication(input, ruleId) { + const loaded = await loadWorkspace(input); + if (!loaded.model.resources["retention-application"]) throw applicationError("Apply approved rules requires model v13. Preview and complete the supported model migration first."); + if (!retentionApplicationRuntimeSupported(loaded)) throw applicationError("Upgrade the repository dependency and lockfile to FileGRC 0.19.2 before applying an approved rule."); + const rule = loaded.resources.find(({ id }) => id === ruleId); + if (rule?.type !== "retention-schedule-item" || rule.status !== "active") throw applicationError("Choose an active independently approved retention rule."); + const review = loaded.resources.find((item) => item.type === "collection-review" && item.resourceType === "retention-schedule-item" && item.status === "active" && item.populationResourceIds?.includes(rule.id)); + if (!review) throw applicationError("Approve this rule independently before applying it to another service."); + const head = getGitSummary(loaded.root).commit; + const committed = head ? reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), head) : null; + const committedReview = committed?.resources.find(({ id }) => id === review.id); + const originalWorkspace = review.scopeRevision ? reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), review.scopeRevision) : null; + const original = originalWorkspace?.resources.find(({ id }) => id === rule.id); + if (!original || retentionRuleRevision(original, originalWorkspace) !== retentionRuleRevision(rule, loaded) + || JSON.stringify(committedReview) !== JSON.stringify(review)) throw applicationError("Commit the exact independent schedule approval before applying its unchanged rule. Material rule changes need independent review."); + return { record: { type: "retention-application", id: "retention-application-" + rule.id.replace(/^retention-schedule-item-/, "") + "-service", title: "Apply " + rule.title, + status: "active", ruleId, ruleRevision: retentionRuleRevision(rule, loaded), approvalId: review.id, approvalRevision: review.collectionRevision, approvalCommit: getGitSummary(loaded.root).commit, + programId: review.scopeResourceIds?.length === 1 ? review.scopeResourceIds[0] : null, + scopeResourceIds: [], informationTypeIds: [], reviewedScopeRevisions: {}, reviewedById: null, authorityAppointmentId: null, + reviewedOn: currentCalendarDate(loaded.workspace.timezone || "UTC"), purpose: null, rationale: null, disposalImplementation: null, + confirmations: Object.fromEntries(CONFIRMATIONS.map((key) => [key, false])) }, + scopeCandidates: loaded.resources.filter((item) => ["system", "component", "vendor"].includes(item.type) && !["retired", "terminated"].includes(item.status)).map((item) => ({ id: item.id, title: item.title, revision: retentionApplicationScopeRevision(item, loaded) })), + guidance: "Choose actual service scope and existing rule Information Types. Complete every fit confirmation. Keep any proposed new semantic rule in independent review. apply-retention-rule preview computes exact service bindings; it never changes the original approval." }; +} +export async function saveRetentionApplication(input, payload, { preview = false } = {}) { + const loaded = await loadWorkspace(input); + if (!payload?.record || typeof payload.record !== "object" || Array.isArray(payload.record) + || payload.record.type !== "retention-application" || !Array.isArray(payload.record.scopeResourceIds)) throw applicationError("A retention application record and service scope array are required."); + const record = structuredClone(payload.record); + const supplied = record.reviewedScopeRevisions || {}; + record.reviewedScopeRevisions = Object.fromEntries((record.scopeResourceIds || []).map((id) => { + const scope = loaded.resources.find((item) => item.id === id); + if (!scope) throw applicationError("Unknown service scope: " + id); + return [id, retentionApplicationScopeRevision(scope, loaded)]; + })); + for (const [id, revision] of Object.entries(record.reviewedScopeRevisions)) { + if (supplied[id] && supplied[id] !== revision) throw applicationError("The selected service changed since the applicability preview. Reload and review its current facts."); + if (!preview && !supplied[id]) throw applicationError("Preview the selected service bindings and pass that preview record to --yes."); + } + const issues = retentionApplicationIssues(loaded, record); + if (issues.length) throw applicationError(issues.map(({ message }) => message).join("\n")); + if (preview) { + const projected = await previewWorkflowMutation(input, { operation: "create", record, content: payload.content }); + return { operation: "apply-retention-rule", record, issues, preservesIndependentApproval: true, workflowDelta: projected.workflowDelta }; + } + return createResource(input, record, { content: payload.content }); +} diff --git a/packages/filegrc/src/retention-schedule-approval.js b/packages/filegrc/src/retention-schedule-approval.js index 5fbe3ff..5434ea5 100644 --- a/packages/filegrc/src/retention-schedule-approval.js +++ b/packages/filegrc/src/retention-schedule-approval.js @@ -1,3 +1,4 @@ +import { applicationCoversUse, retentionUseWasIndependentlyReviewed } from "./retention-applications.js"; import { readFileSync } from "node:fs"; import { openPlaceholderCount, substantiveMarkdown } from "./content-readiness.js"; import { markdownEntries } from "./resource-markdown.js"; @@ -76,14 +77,14 @@ export function retentionScheduleApprovalIssues(loaded, program, rows, options = : [program]; const uncovered = scopedPrograms.flatMap((candidate) => ( retentionUses(loaded, candidate).map((use) => ({ ...use, programId: candidate.id })) - )).find((use) => !proposedRows.some((row) => ( + )).find((use) => !applicationCoversUse(loaded, use, use.programId) && (!proposedRows.some((row) => ( (row.informationTypeIds || []).includes(use.informationTypeId) && ((row.scopeResourceIds || []).includes(use.resource.id) || (row.scopeResourceIds || []).includes(use.programId)) - ))); + )) || options.approvalReview && !retentionUseWasIndependentlyReviewed(loaded, use, use.programId))); if (uncovered) { issues.push(issue( "uncovered-retention-information-use", - `${uncovered.resource.title} uses ${byId.get(uncovered.informationTypeId)?.title || uncovered.informationTypeId}, but no proposed retention schedule row covers both the type and scope.` + `${uncovered.resource.title} uses ${byId.get(uncovered.informationTypeId)?.title || uncovered.informationTypeId}, needs a reviewed retention decision for this type and scope. Apply an unchanged independently approved rule through apply-retention-rule, or propose a material rule change for independent approval.` )); } if (nearDuplicateInformationTypes(loaded.resources).length && !options.informationTypesReviewed) { diff --git a/packages/filegrc/src/retention.js b/packages/filegrc/src/retention.js index d20c22f..4052f8c 100644 --- a/packages/filegrc/src/retention.js +++ b/packages/filegrc/src/retention.js @@ -1,3 +1,4 @@ +import { applicationCoversUse, retentionApplicationIssues, retentionUseWasIndependentlyReviewed } from "./retention-applications.js"; import { calculationMemo, hasCalculationCache } from "./calculation-cache.js"; import { readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; @@ -22,6 +23,7 @@ export async function assessRetentionReadiness(loaded, program, options = {}) { const usableRules = activeRules.filter((rule) => retentionRuleIsCurrent(rule, revisions, byId, loaded)); const proposedRules = rules.filter(retentionRuleHasCoverageProposal); const uses = retentionUses(loaded, program); + const priorApproval = loaded.model.resources["retention-application"] && loaded.resources.some((item) => item.type === "collection-review" && item.resourceType === "retention-schedule-item" && item.status === "active"); const items = rules.map((rule) => readinessItem( `retention-rule-${rule.id}`, usableRules.includes(rule) ? "complete" : "action", @@ -52,10 +54,10 @@ export async function assessRetentionReadiness(loaded, program, options = {}) { } items.push(...[...usesByInformationType].map(([informationTypeId, informationTypeUses]) => { const absentUses = informationTypeUses.filter((use) => ( - !proposedRules.some((rule) => ruleCoversUse(rule, use, program)) + (!proposedRules.some((rule) => ruleCoversUse(rule, use, program)) || priorApproval && !retentionUseWasIndependentlyReviewed(loaded, use, program.id)) && !applicationCoversUse(loaded, use, program.id) )); const pendingUses = informationTypeUses.filter((use) => ( - !usableRules.some((rule) => ruleCoversUse(rule, use, program)) + !usableRules.some((rule) => ruleCoversUse(rule, use, program)) && !applicationCoversUse(loaded, use, program.id) )); const approvalPending = !options.scheduleApproved; const matches = usableRules.filter((rule) => ( @@ -89,6 +91,7 @@ export async function assessRetentionReadiness(loaded, program, options = {}) { retentionScheduleItemIds: matches.map(({ id }) => id), commands: [ "npx filegrc guide retention-schedule-item --json", + ...activeRules.filter((rule) => rule.informationTypeIds?.includes(informationTypeId)).map((rule) => `npx filegrc apply-retention-rule ${rule.id} --scaffold`), `npx filegrc scaffold retention-schedule-item --title ${shellArgument(`Retention for ${informationTypeTitle}`)}` ] } @@ -150,6 +153,13 @@ export async function assessRetentionReadiness(loaded, program, options = {}) { } )); } + for (const application of loaded.resources.filter((item) => item.type === "retention-application" && item.status === "active" && item.programId === program.id)) { + const issues = retentionApplicationIssues(loaded, application); + items.push(readinessItem(`retention-application-${application.id}`, issues.length ? "action" : "complete", + issues.length ? `Review ${application.title}` : `${application.title} applies an approved rule`, + issues.length ? issues[0].message : "The owner applied the complete unchanged approved rule. Include this decision in periodic independent schedule oversight.", application, + { commands: [`npx filegrc get ${application.id} --json`, `npx filegrc apply-retention-rule ${application.ruleId} --scaffold`] })); + } return items; } @@ -635,7 +645,7 @@ function retentionRuleHasCoverageProposal(rule) { export function retentionReviewResourceIds(rule, loaded) { const programUseIds = []; - if (loaded) { + if (loaded && !loaded.model.resources["retention-application"]) { const byId = new Map(loaded.resources.map((record) => [record.id, record])); const informationTypeIds = new Set(rule.informationTypeIds || []); for (const scopeId of rule.scopeResourceIds || []) { @@ -655,6 +665,27 @@ export function retentionReviewResourceIds(rule, loaded) { ].filter(Boolean))]; } +// Older program-wide rows captured the then-current inventory as source +// bindings. Preserve only those historically reviewed inventory keys; current +// service fit is separately assessed by owner applications. +export function retentionLegacyInventoryBindingIds(rule, loaded) { + if (!loaded?.model.resources["retention-application"] || !loaded.root) return []; + return calculationMemo(loaded, "retention-legacy-inventory-bindings", [rule.id], () => { + if (!(rule.scopeResourceIds || []).some((id) => loaded.resources.some((item) => item.id === id && item.type === "program"))) return []; + const review = loaded.resources.find((item) => item.type === "collection-review" && item.resourceType === "retention-schedule-item" && item.status === "active" && item.populationResourceIds?.includes(rule.id)); + if (!/^[a-f0-9]{40}$/.test(review?.scopeRevision || "")) return []; + const snapshot = reviewHistoricalWorkspace(reviewHistoryContext(loaded.root, loaded), review.scopeRevision); + const original = snapshot?.resources.find(({ id }) => id === rule.id); + if (!original) return []; + const originalScope = new Set((original.scopeResourceIds || []).flatMap((id) => { + const program = snapshot.resources.find((item) => item.id === id && item.type === "program"); + return program ? retentionUses(snapshot, program).filter((use) => (original.informationTypeIds || []).includes(use.informationTypeId)).map((use) => use.resource.id) : []; + })); + return Object.keys(original.reviewedSourceRevisions || {}).filter((id) => originalScope.has(id) + && ["system", "component", "vendor"].includes(snapshot.resources.find((item) => item.id === id)?.type)); + }); +} + export function retentionRuleIsCurrent(rule, revisions, byId = new Map(), loaded) { if (!String(rule.description || "").trim()) return false; if (!(rule.informationTypeIds || []).length || !(rule.scopeResourceIds || []).length || !rule.scheduleDocumentId) return false; @@ -666,7 +697,10 @@ export function retentionRuleIsCurrent(rule, revisions, byId = new Map(), loaded if (!["delete", "destroy", "erase", "anonymize", "transfer", "retain-permanently"].includes(rule.dispositionAction)) return false; if (!String(rule.dispositionInstructions || "").trim()) return false; const dependencyIds = retentionReviewResourceIds(rule, loaded); - if (Object.keys(rule.reviewedSourceRevisions).length !== dependencyIds.length) return false; + const legacyProgramInventoryBindings = new Set(retentionLegacyInventoryBindingIds(rule, loaded)); + const extraIds = Object.keys(rule.reviewedSourceRevisions).filter((id) => !dependencyIds.includes(id)); + if (extraIds.some((id) => !legacyProgramInventoryBindings.has(id))) return false; + if (dependencyIds.some((id) => !Object.hasOwn(rule.reviewedSourceRevisions, id))) return false; return dependencyIds.every((id) => ( resourceReviewRevisionMatches(loaded, revisions, id, rule.reviewedSourceRevisions?.[id], rule) )); diff --git a/packages/filegrc/src/server.js b/packages/filegrc/src/server.js index d734f4c..e67af50 100644 --- a/packages/filegrc/src/server.js +++ b/packages/filegrc/src/server.js @@ -1,3 +1,4 @@ +import { scaffoldRetentionApplication, saveRetentionApplication } from "./retention-applications.js"; import { firstOperatingTasks } from "./operating-preparation.js"; import { createStateCalculator } from "./state-calculator.js"; import { withCalculationCache } from "./calculation-cache.js"; @@ -381,6 +382,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/retention-applications/scaffold") { + const payload = await readJson(request); + return json(response, 200, await scaffoldRetentionApplication(input, payload.ruleId)); + } + if (request.method === "POST" && url.pathname === "/api/retention-applications/preview") { + return json(response, 200, await saveRetentionApplication(input, await readJson(request), { preview: true })); + } + if (request.method === "POST" && url.pathname === "/api/retention-applications") { + const payload = await readJson(request); + return json(response, 201, await browserMutation(input, requestOptions, { + message: () => `Apply approved retention rule: ${payload.record?.title}` + }, () => saveRetentionApplication(input, payload))); + } 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)); @@ -1407,6 +1421,7 @@ function urlHost(host) { } function statusFor(error) { + if (error?.code === "FILEGRC_RETENTION_APPLICATION") return 400; if (error?.code === "FILEGRC_ACTIVATION_NOT_READY") return 400; if (error?.code === "FILEGRC_STATE_EXPIRED") return 409; if (error?.code === "FILEGRC_STATE_CAPACITY") return 429; diff --git a/packages/filegrc/src/validate.js b/packages/filegrc/src/validate.js index 332c006..2e2a60c 100644 --- a/packages/filegrc/src/validate.js +++ b/packages/filegrc/src/validate.js @@ -1,3 +1,5 @@ +import { withAssessmentCache } from "./calculation-cache.js"; +import { retentionApplicationIssues } from "./retention-applications.js"; import { nonmaterialAmendmentIssues } from "./nonmaterial-amendments.js"; import { completionMatchesMember, assessCompletionForObligation, obligationScheduleRecurrence, calendarWindow } from "./obligations.js"; import { reportingCutoverIssues, scheduleCutoverIssues, historicalContentCutover } from "./activation-readiness.js"; @@ -22,7 +24,7 @@ import { parseCalendarDate, validCalendarRecurrence } from "./recurrence.js"; -import { resourceReviewRevisionMatches, resourceReviewRevisions, retentionReviewResourceIds } from "./retention.js"; +import { resourceReviewRevisionMatches, resourceReviewRevisions, retentionReviewResourceIds, retentionLegacyInventoryBindingIds } from "./retention.js"; import { retentionScheduleApprovalIssues } from "./retention-schedule-approval.js"; import { obligationIsEnabled, obligationRuleIsEnabled } from "./program-lifecycle.js"; import { currentPartyPeople, partyPeople } from "./parties.js"; @@ -81,7 +83,7 @@ const DEFERRED_VALIDATION_CONCURRENCY = 16; export async function validateWorkspace(input = process.cwd()) { const timingStarted = performance.now(); try { - return await validateWorkspaceUnmeasured(input); + return await withAssessmentCache(() => validateWorkspaceUnmeasured(input)); } finally { recordTiming("validation", performance.now() - timingStarted); } @@ -122,6 +124,9 @@ async function validateWorkspaceUnmeasured(input) { for (const entry of loaded.entries) { const { record } = entry; + if (record.type === "retention-application") { + diagnostics.push(...retentionApplicationIssues(loaded, record, { includeStale: false }).map((item) => ({ ...item, path: `data/${entry.relativePath}` }))); + } if (record.type === "nonmaterial-amendment") { diagnostics.push(...nonmaterialAmendmentIssues(loaded, record).map((item) => ({ ...item, path: `data/${entry.relativePath}` }))); } @@ -1412,7 +1417,9 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) { )), approvalReview: record })) { - diagnostics.push(error(issue.code, path, issue.message)); + if (issue.code === "uncovered-retention-information-use" && !originalPopulationCurrent && model.resources["retention-application"]) { + diagnostics.push(warning(issue.code, path, issue.message)); + } else diagnostics.push(error(issue.code, path, issue.message)); } const owners = new Set(); for (const candidate of loaded.resources.filter((candidate) => ( @@ -2083,9 +2090,10 @@ function validateRetentionScheduleItem(record, loaded, byId, currentReviewRevisi } const sources = retentionReviewResourceIds(record, loaded); const revisions = record.reviewedSourceRevisions || {}; + const legacyBindings = new Set(retentionLegacyInventoryBindingIds(record, loaded)); const missing = sources.filter((id) => ( !resourceReviewRevisionMatches(loaded, currentReviewRevisions, id, revisions[id], record) - )).concat(Object.keys(revisions).filter((id) => !sources.includes(id))); + )).concat(Object.keys(revisions).filter((id) => !sources.includes(id) && !legacyBindings.has(id))); if (missing.length) { diagnostics.push(error( "stale-retention-review", diff --git a/packages/filegrc/src/web.js b/packages/filegrc/src/web.js index 9c83697..cd4f17c 100644 --- a/packages/filegrc/src/web.js +++ b/packages/filegrc/src/web.js @@ -748,11 +748,13 @@ function renderRetentionSchedulePage(main, params = new URLSearchParams()) { const showHistory = params.get("history") === "1"; const retiredRows = rows.filter(({ record }) => ["superseded", "retired"].includes(record.status)).length; const tableTools = '
' + (retiredRows ? '' : '') + '
'; + const applications = state.resources.filter(({ record }) => record.type === "retention-application"); + const applicationsPanel = state.model.resources["retention-application"] ? '

Owner applicability decisions

Applications of approved rules

Apply an unchanged approved rule from its record page after confirming the complete rule fits. New categories, duties or retention rules need independent review.

' + (applications.length ? '
' + applications.map((item) => '').join("") + '
ApplicationApproved ruleServicesOwner reviewStatusNext action
' + esc(item.record.title) + '' + esc(referenceTitle(item.record.ruleId)) + '' + esc((item.record.scopeResourceIds || []).map(referenceTitle).join(", ")) + '' + esc(referenceTitle(item.record.reviewedById)) + '' + esc(item.record.reviewedOn) + '' + formatValue(item.record.status, "status", item.record.type, true) + '' + recordWorkflowCell("retention-application", item) + '
' : '

No separate service applications recorded.

') + '
' : ""; main.innerHTML = '
' + - '

Step 2

Data Retention Schedule

' + guideTrigger + '

Owners finish each proposed retention row first. A separate reviewer then approves the complete document and all proposed rows once.

' + documentAction + add + '
' + resourceGuide("retention-schedule-item") + + '

Step 2

Data Retention Schedule

' + guideTrigger + '

Independent review approves the complete retention rules. Owners can apply an unchanged approved rule to additional services after reviewing its fit.

' + documentAction + add + '
' + resourceGuide("retention-schedule-item") + '
Governing document' + documentSummary + '
Schedule revision' + proposedRows + ' completed ' + pluralize("proposal", proposedRows) + 'Final approval follows every row proposal' + (state.collectionReviews?.["retention-schedule-item"]?.status === "current" ? "Approved" : "Approval needed") + '
' + '

Structured schedule

Schedule rows

Add a row when an Information Type and its operational scope need a distinct cutoff, period, or disposition rule.

' + tableTools + '
' + rowHtml + '
RuleInformation typesScopeRetentionDispositionStatusNext action

' + - renderRetentionScheduleIssues() + collectionReviewPanel("retention-schedule-item", true) + '
'; + applicationsPanel + renderRetentionScheduleIssues() + collectionReviewPanel("retention-schedule-item", true) + ''; resourceGuideCleanup = setupResourceGuide(main); wireRetentionScheduleTable(rows.length, Number(params.get("page")) || 1); main.querySelector("[data-review-collection]")?.addEventListener("click", () => openCollectionReviewDialog("retention-schedule-item")); @@ -3604,6 +3606,7 @@ function renderDetail(main, type, id, params = new URLSearchParams()) { ? 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 retentionApplicationAction = !state.readOnly && type === "retention-schedule-item" && entry.record.status === "active" && state.model.resources["retention-application"] ? '' : ""; 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(" · ") + '

' : ""; @@ -3629,11 +3632,18 @@ function renderDetail(main, type, id, params = new URLSearchParams()) { ? '' + (applicabilityStatus === "stale" ? "Reconfirm" : "Review") + ' applicability' : ""; const recordMenu = '
' + (mayDelete ? '
' : '') + '
'; - 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.innerHTML = '

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

' + reportingRouteSetActions + amendmentAction + retentionApplicationAction + 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-apply-retention-rule]")?.addEventListener("click", async () => { + try { + const scaffold = await fetchJson("/api/retention-applications/scaffold", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ruleId: entry.record.id }) }); + scaffold.record.reviewedScopeRevisions = Object.fromEntries(scaffold.scopeCandidates.map(({ id, revision }) => [id, revision])); + openEditor("retention-application", null, { seed: scaffold.record, retentionApplication: true, saveLabel: "Apply approved rule", description: "Apply the complete approved rule “" + entry.record.title + "”. Its independent approval stays unchanged. Confirm purpose, cutoff, disposition, holds and conflicting duties. If any condition does not fit, cancel and request a material rule review." }); + } catch (error) { showError(error.message); } + }); 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", () => { @@ -5173,7 +5183,7 @@ function openEditor(type, entry = null, options = {}) { const oneOf = new Set(oneOfGroups.flatMap((group) => group.fields || []).filter((name) => !name.startsWith("$markdown:"))); const hiddenFields = type === "retention-schedule-item" && modelSupports("retention-schedule-approval") ? new Set(["approvedByIds", "approvedOn"]) - : new Set(); + : type === "retention-application" ? new Set(["ruleRevision", "approvalRevision", "approvalCommit", "reviewedScopeRevisions", ...(options.retentionApplication ? ["ruleId", "approvalId"] : [])]) : new Set(); const activeOneOf = new Set(oneOfGroups .filter((group) => !group.when || conditionMatches(record, group.when)) .flatMap((group) => group.fields || [])); @@ -5285,7 +5295,7 @@ function openEditor(type, entry = null, options = {}) { content[path] = recordContentSource.value; } } - const url = options.occurrenceReconciliation + const url = options.retentionApplication ? "/api/retention-applications" : options.occurrenceReconciliation ? "/api/obligation-occurrences" : options.auditPopulationCorrection ? "/api/audit-population-corrections" diff --git a/packages/filegrc/test/agent.test.js b/packages/filegrc/test/agent.test.js index 0c0723c..dc5b350 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", + "applyRetentionRule", "nonmaterialAmendment", "programAmendment", "reviewBindings", diff --git a/packages/filegrc/test/fixtures.js b/packages/filegrc/test/fixtures.js index 77294ad..1bbcd7d 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 (["reconciliation-dismissal", "nonmaterial-amendment"].includes(type)) continue; + if (["reconciliation-dismissal", "nonmaterial-amendment", "retention-application"].includes(type)) continue; const record = { id: ids[type], type, diff --git a/packages/filegrc/test/fixtures/retention-applications.js b/packages/filegrc/test/fixtures/retention-applications.js new file mode 100644 index 0000000..9d9c9da --- /dev/null +++ b/packages/filegrc/test/fixtures/retention-applications.js @@ -0,0 +1,46 @@ +import { writeJson, initializeGitWorkspace, commitWorkspaceFiles } from "../helpers.js"; +import { makeComprehensiveWorkspace } from "../fixtures.js"; +import { loadWorkspace } from "../../src/workspace.js"; +import { resourceReviewRevisionsSync, retentionReviewResourceIds } from "../../src/retention.js"; +import { applyCollectionReview, scaffoldCollectionReview } from "../../src/collection-review.js"; +import { currentCalendarDate } from "../../src/time.js"; + +export async function makeRetentionApplicationWorkspace(root, { programWide = false } = {}) { + // Historical approvals remain valid when a workspace adopts the new action. + await makeComprehensiveWorkspace(root, "11"); + let loaded = await loadWorkspace(root); + const document = loaded.entries.find(({ record }) => record.id === "document-example"); + await writeJson(document.path, { ...document.record, documentKind: "schedule", reportingRouteRequirements: undefined }); + const rowEntry = loaded.entries.find(({ record }) => record.type === "retention-schedule-item"); + const row = { + ...rowEntry.record, status: "active", + scopeResourceIds: programWide ? ["program-example"] : ["system-example", "component-example", "vendor-example"], + scheduleDocumentId: document.record.id, informationTypeIds: ["information-type-example"], + cutoff: { basis: "creation" }, retentionPeriod: { basis: "fixed", amount: 3, unit: "year" }, + dispositionAction: "delete", dispositionInstructions: "Delete after the approved cutoff and period unless a hold applies." + }; + if (programWide) { + const system = loaded.entries.find(({ record }) => record.id === "system-example"); + await writeJson(system.path, { ...system.record, informationTypeIds: ["information-type-example"] }); + const vendor = loaded.entries.find(({ record }) => record.id === "vendor-example"); + await writeJson(vendor.path, { ...vendor.record, informationTypeIds: ["information-type-example"] }); + } + loaded = await loadWorkspace(root); + row.reviewedSourceRevisions = Object.fromEntries(resourceReviewRevisionsSync( + loaded, retentionReviewResourceIds(row, loaded), "legacy", false, null, row + )); + await writeJson(rowEntry.path, row); + await initializeGitWorkspace(root); + const scaffold = await scaffoldCollectionReview(root, { resourceType: "retention-schedule-item" }); + await applyCollectionReview(root, { + ...scaffold, decision: "complete", rationale: "Reviewed the complete schedule and all approved rule requirements.", + reviewedByIds: ["person-independent-approver-example"], reviewedOn: currentCalendarDate("UTC"), confirmed: true + }); + await commitWorkspaceFiles(root, "Approve retention schedule"); + loaded = await loadWorkspace(root); + await writeJson(loaded.entries.find(({ record }) => record.type === "workspace").path, { ...loaded.workspace, dataModelVersion: "13" }); + await writeJson(`${root}/package.json`, { private: true, dependencies: { filegrc: "^0.19.2" } }); + await writeJson(`${root}/package-lock.json`, { lockfileVersion: 3, packages: { "": { dependencies: { filegrc: "^0.19.2" } }, "node_modules/filegrc": { version: "0.19.2" } } }); + await commitWorkspaceFiles(root, "Upgrade workspace model"); + return root; +} diff --git a/packages/filegrc/test/git.test.js b/packages/filegrc/test/git.test.js index 7907a1a..f5beee3 100644 --- a/packages/filegrc/test/git.test.js +++ b/packages/filegrc/test/git.test.js @@ -3229,7 +3229,8 @@ test("validation and static build report malformed history without repeated full assert.ok(build.state.validation.diagnostics.some(({ message }) => /unreadable data record.*raw.json/.test(message))); assert.equal(calls.filter((args) => args[0] === "log" && args.includes("--name-status")).length, 1); assert.equal(calls.filter((args) => args[0] === "ls-tree").length, 0); - assert.ok(calls.length < 150, `unexpected repeated Git calculations: ${calls.length}`); + // Allow bounded per-resource work as the model gains an optional collection. + assert.ok(calls.length < 160, `unexpected repeated Git calculations: ${calls.length}`); }); test("opaque evidence-like JSON cannot declare attachment ownership", async (context) => { diff --git a/packages/filegrc/test/model.test.js b/packages/filegrc/test/model.test.js index f8b39b0..ec3bef6 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, 57); + assert.equal(Object.keys(model.resources).length, 58); assert.deepEqual(model.resources["reconciliation-dismissal"].required, [ "transitionFingerprint", "eventType", diff --git a/packages/filegrc/test/retention-applications.test.js b/packages/filegrc/test/retention-applications.test.js new file mode 100644 index 0000000..76f52c9 --- /dev/null +++ b/packages/filegrc/test/retention-applications.test.js @@ -0,0 +1,310 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { makeRetentionApplicationWorkspace } from "./fixtures/retention-applications.js"; +import { commitWorkspaceFiles, writeJson, captureCli } from "./helpers.js"; +import { scaffoldRetentionApplication, saveRetentionApplication, retentionApplicationIssues, retentionApplicationScopeRevision } from "../src/retention-applications.js"; +import { assessCollectionReview } from "../src/collection-review.js"; +import { assessRetentionReadiness, resourceReviewRevisionsSync, retentionReviewResourceIds } from "../src/retention.js"; +import { loadWorkspace } from "../src/workspace.js"; +import { validateWorkspace } from "../src/validate.js"; +import { createResource, updateResource } from "../src/files.js"; +import { setGitSubprocessObserverForTests } from "../src/git.js"; +import { runCli } from "../src/cli.js"; + +async function fixture(context) { + const root = await mkdtemp(join(tmpdir(), "filegrc-retention-application-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeRetentionApplicationWorkspace(root); + let loaded = await loadWorkspace(root); + const vendor = loaded.resources.find(({ id }) => id === "vendor-example"); + await writeJson(join(root, "data/vendors/vendor-additional.json"), { ...vendor, id: "vendor-additional", title: "Additional working-record service", informationTypeIds: ["information-type-example"] }); + await commitWorkspaceFiles(root, "Catalog additional service"); + return root; +} +async function decision(root) { + const payload = await scaffoldRetentionApplication(root, "retention-schedule-item-example"); + Object.assign(payload.record, { scopeResourceIds: ["vendor-additional"], informationTypeIds: ["information-type-example"], reviewedById: "person-example", authorityAppointmentId: "appointment-example", purpose: "Working business records. Required authoritative history remains in its approved source system.", rationale: "Same data category, purpose, cutoff, period and holds, with no conflicting duty.", disposalImplementation: "Owner deletes expired working records through the service and checks holds before deletion." }); + Object.keys(payload.record.confirmations).forEach((key) => payload.record.confirmations[key] = true); + payload.record.reviewedScopeRevisions = Object.fromEntries(payload.scopeCandidates.map(({ id, revision }) => [id, revision])); + return payload; +} + +test("owner applies an exact approved rule through CLI without changing independent approval", async (context) => { + const root = await fixture(context); + const before = await loadWorkspace(root); + const reviewEntry = before.entries.find(({ record }) => record.type === "collection-review" && record.resourceType === "retention-schedule-item"); + const approved = await readFile(reviewEntry.path, "utf8"); + assert.equal(assessCollectionReview(before, "retention-schedule-item").complete, false); + const payload = await decision(root); + const payloadPath = join(root, "application.json"); + await writeJson(payloadPath, payload); + const preview = await captureCli(runCli, ["apply-retention-rule", payload.record.ruleId, payloadPath, "--preview", "--json", "--root", root]); + assert.equal(preview.result.preservesIndependentApproval, true); + assert.ok(preview.result.workflowDelta.findings.removed.length > 0, "preview projects resolved applicability work without changing source records"); + assert.ok(preview.result.record.reviewedScopeRevisions["vendor-additional"]); + await captureCli(runCli, ["apply-retention-rule", payload.record.ruleId, payloadPath, "--yes", "--json", "--root", root]); + const after = await loadWorkspace(root); + assert.equal(assessCollectionReview(after, "retention-schedule-item").complete, true); + assert.equal(await readFile(reviewEntry.path, "utf8"), approved); + const checks = await assessRetentionReadiness(after, after.resources.find(({ type }) => type === "program"), { scheduleApproved: true }); + assert.equal(checks.find(({ id }) => id.startsWith("retention-application-")).status, "complete"); + assert.equal(checks.find(({ id }) => id.startsWith("retention-use-")).status, "complete"); + assert.equal((await validateWorkspace(root)).ok, true, JSON.stringify((await validateWorkspace(root)).diagnostics)); +}); + +test("same duration cannot bypass changed cutoff, categories, purpose confirmations or authority", async (context) => { + const root = await fixture(context); + const original = await decision(root); + for (const edit of [ + (record) => record.confirmations.purposeMatches = false, + (record) => record.confirmations.dispositionFeasible = false, + (record) => record.confirmations.noConflictingDuties = false, + (record) => record.reviewedById = "person-independent-approver-example", + (record) => record.informationTypeIds = ["information-type-unapproved"], + (record) => record.approvalCommit = "a".repeat(40), + (record) => record.ruleRevision = "same-duration-does-not-prove-same-rule" + ]) { + const payload = structuredClone(original); edit(payload.record); + await assert.rejects(saveRetentionApplication(root, payload, { preview: true }), /approval|approved|review|authority|purpose|Information Types|actual/i); + } + let loaded = await loadWorkspace(root); + const rule = loaded.entries.find(({ record }) => record.id === original.record.ruleId); + await writeJson(rule.path, { ...rule.record, cutoff: { basis: "event", event: "Service retirement" } }); + await assert.rejects(saveRetentionApplication(root, original, { preview: true }), /rule|governing source|approval/i); + assert.equal(assessCollectionReview(await loadWorkspace(root), "retention-schedule-item").complete, false); +}); + +test("service-data changes stale applications; unrelated vendor metadata does not", async (context) => { + const root = await fixture(context); + await saveRetentionApplication(root, await decision(root)); + await commitWorkspaceFiles(root, "Apply existing rule to service"); + let loaded = await loadWorkspace(root); + const entry = loaded.entries.find(({ record }) => record.id === "vendor-additional"); + await writeJson(entry.path, { ...entry.record, title: "Renamed supplier", website: "https://example.test" }); + loaded = await loadWorkspace(root); + let application = loaded.resources.find(({ type }) => type === "retention-application"); + assert.deepEqual(retentionApplicationIssues(loaded, application), []); + await writeJson(entry.path, { ...entry.record, informationTypeIds: [] }); + loaded = await loadWorkspace(root); + assert.ok(retentionApplicationIssues(loaded, application).some(({ message }) => /changed after the owner review/.test(message))); + const checks = await assessRetentionReadiness(loaded, loaded.resources.find(({ type }) => type === "program")); + assert.equal(checks.find(({ id }) => id.startsWith("retention-application-")).status, "action"); + assert.equal((await validateWorkspace(root)).ok, true, JSON.stringify((await validateWorkspace(root)).diagnostics)); + const applicationEntry = loaded.entries.find(({ record }) => record.type === "retention-application"); + await assert.rejects(updateResource(root, application.type, application.id, { ...application, rationale: "Rewritten" }, { expectedRevision: applicationEntry.revision }), /Preserve|immutable/i); + await writeJson(applicationEntry.path, { ...application, rationale: "Hand-edited historical decision" }); + assert.ok((await validateWorkspace(root)).diagnostics.some(({ code }) => code === "invalid-retention-application")); +}); + + +test("previous program-wide service uses need a fresh owner decision after material service changes", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-retention-existing-use-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeRetentionApplicationWorkspace(root, { programWide: true }); + let loaded = await loadWorkspace(root); + assert.equal(assessCollectionReview(loaded, "retention-schedule-item").complete, true); + assert.equal((await validateWorkspace(root)).ok, true, "unchanged legacy inventory bindings remain valid after adoption"); + const vendor = loaded.entries.find(({ record }) => record.id === "vendor-example"); + await writeJson(vendor.path, { ...vendor.record, description: "An authoritative statutory archive requiring permanent preservation." }); + loaded = await loadWorkspace(root); + assert.equal(assessCollectionReview(loaded, "retention-schedule-item").complete, false); + const checks = await assessRetentionReadiness(loaded, loaded.resources.find(({ type }) => type === "program"), { scheduleApproved: false }); + const use = checks.find(({ id }) => id.startsWith("retention-use-")); + assert.equal(use.status, "action"); + assert.ok(use.affectedResourceIds.includes(vendor.record.id)); +}); + +test("refreshing owner bindings cannot reuse independent approval after governing category changes", async (context) => { + const root = await fixture(context); + let loaded = await loadWorkspace(root); + const information = loaded.entries.find(({ record }) => record.type === "information-type"); + await writeJson(information.path, { ...information.record, description: "Statutory records requiring permanent preservation." }); + loaded = await loadWorkspace(root); + const rule = loaded.entries.find(({ record }) => record.type === "retention-schedule-item"); + await writeJson(rule.path, { ...rule.record, reviewedSourceRevisions: Object.fromEntries(resourceReviewRevisionsSync(loaded, retentionReviewResourceIds(rule.record, loaded))) }); + await commitWorkspaceFiles(root, "Change category and review owner bindings"); + await assert.rejects(scaffoldRetentionApplication(root, rule.record.id), /Material rule changes need independent review/); + assert.equal(assessCollectionReview(await loadWorkspace(root), "retention-schedule-item").complete, false); +}); + +test("adopting new application records requires a compatible consumer pin even through generic CRUD", async (context) => { + const root = await fixture(context); + const payload = await decision(root); + const preview = await saveRetentionApplication(root, payload, { preview: true }); + await writeJson(join(root, "package.json"), { private: true, dependencies: { filegrc: "^0.19.1" } }); + await writeJson(join(root, "package-lock.json"), { lockfileVersion: 3, packages: { "": { dependencies: { filegrc: "^0.19.1" } }, "node_modules/filegrc": { version: "0.19.1" } } }); + await assert.rejects(saveRetentionApplication(root, payload), /Upgrade.*0\.19\.2/); + await assert.rejects(createResource(root, preview.record), /Upgrade.*0\.19\.2/); + assert.equal((await loadWorkspace(root)).resources.some(({ type }) => type === "retention-application"), false); +}); + +test("save never silently rebinds a service changed after preview", async (context) => { + const root = await fixture(context); + const preview = await saveRetentionApplication(root, await decision(root), { preview: true }); + const loaded = await loadWorkspace(root); + const vendor = loaded.entries.find(({ record }) => record.id === "vendor-additional"); + await writeJson(vendor.path, { ...vendor.record, description: "Different record purpose under review." }); + await assert.rejects(saveRetentionApplication(root, preview), /changed since the applicability preview/); +}); + +test("substantive rule notes and Markdown remain bound to independent approval", async (context) => { + const root = await fixture(context); + let loaded = await loadWorkspace(root); + const rule = loaded.entries.find(({ record }) => record.type === "retention-schedule-item"); + await writeJson(rule.path, { ...rule.record, notes: "Retain indefinitely during litigation; legal clearance is required for disposal." }); + assert.equal(assessCollectionReview(await loadWorkspace(root), "retention-schedule-item").complete, false); + await assert.rejects(scaffoldRetentionApplication(root, rule.record.id), /Material rule changes/); + await writeJson(rule.path, rule.record); + await writeFile(rule.path.replace(/\.json$/, ".md"), "# Retention conditions\n\nRetain indefinitely pending legal clearance.\n"); + assert.equal(assessCollectionReview(await loadWorkspace(root), "retention-schedule-item").complete, false); + await assert.rejects(scaffoldRetentionApplication(root, rule.record.id), /Material rule changes/); +}); + +test("changing a component provider or provider agreement invalidates disposal-fit review", async (context) => { + const root = await fixture(context); + let loaded = await loadWorkspace(root); + const component = loaded.resources.find(({ id }) => id === "component-example"); + const vendor = loaded.resources.find(({ id }) => id === "vendor-example"); + await writeJson(join(root, "data/components/component-additional.json"), { ...component, id: "component-additional", vendorId: vendor.id }); + await writeJson(join(root, "data/vendors/vendor-replacement.json"), { ...vendor, id: "vendor-replacement", description: "Provider with different preservation duties." }); + await commitWorkspaceFiles(root, "Catalog service provider scope"); + const payload = await decision(root); + payload.record.scopeResourceIds = ["component-additional"]; + payload.record.reviewedScopeRevisions = {}; + const preview = await saveRetentionApplication(root, payload, { preview: true }); + await saveRetentionApplication(root, preview); + await commitWorkspaceFiles(root, "Review component rule applicability"); + loaded = await loadWorkspace(root); + const app = loaded.resources.find(({ type }) => type === "retention-application"); + const entry = loaded.entries.find(({ record }) => record.id === "component-additional"); + await writeJson(entry.path, { ...entry.record, vendorId: "vendor-replacement" }); + assert.ok(retentionApplicationIssues(await loadWorkspace(root), app).some(({ message }) => /changed after the owner review/.test(message))); + await writeJson(entry.path, entry.record); + const provider = loaded.entries.find(({ record }) => record.id === vendor.id); + await writeJson(provider.path, { ...provider.record, description: "Provider now requires ten-year preservation." }); + assert.ok(retentionApplicationIssues(await loadWorkspace(root), app).some(({ message }) => /changed after the owner review/.test(message))); + await writeJson(provider.path, provider.record); + await writeFile(provider.path.replace(/\.json$/, ".md"), "# Contract duties\n\nPreserve the authoritative statutory archive permanently.\n"); + assert.ok(retentionApplicationIssues(await loadWorkspace(root), app).some(({ message }) => /changed after the owner review/.test(message))); +}); + +test("service Markdown duties invalidate owner applicability", async (context) => { + const root = await fixture(context); + const preview = await saveRetentionApplication(root, await decision(root), { preview: true }); + await saveRetentionApplication(root, preview); + await commitWorkspaceFiles(root, "Apply approved rule"); + let loaded = await loadWorkspace(root); + const app = loaded.resources.find(({ type }) => type === "retention-application"); + const vendor = loaded.entries.find(({ record }) => record.id === "vendor-additional"); + await writeFile(vendor.path.replace(/\.json$/, ".md"), "# Preservation duties\n\nThe authoritative statutory archive must be retained permanently.\n"); + assert.ok(retentionApplicationIssues(await loadWorkspace(root), app).some(({ message }) => /changed after the owner review/.test(message))); +}); + +test("retiring an application records dated owner work and cannot rewrite or revive it", async (context) => { + const root = await fixture(context); + await saveRetentionApplication(root, await decision(root)); + await commitWorkspaceFiles(root, "Apply approved rule"); + let loaded = await loadWorkspace(root); + let entry = loaded.entries.find(({ record }) => record.type === "retention-application"); + const vendor = loaded.entries.find(({ record }) => record.id === "vendor-additional"); + await writeJson(vendor.path, { ...vendor.record, informationTypeIds: [] }); + const retired = { ...entry.record, status: "retired", statusTransition: { changedByIds: ["person-example"], changedOn: entry.record.reviewedOn, reason: "The service no longer processes the covered working records." } }; + await updateResource(root, retired.type, retired.id, retired); + await commitWorkspaceFiles(root, "Retire obsolete applicability decision"); + loaded = await loadWorkspace(root); + assert.deepEqual(retentionApplicationIssues(loaded, loaded.resources.find(({ id }) => id === retired.id)), []); + await assert.rejects(updateResource(root, retired.type, retired.id, entry.record), /Preserve/); + await writeJson(entry.path, entry.record); + assert.ok((await validateWorkspace(root)).diagnostics.some(({ message }) => /cannot be reactivated/.test(message))); +}); + + +test("new service cataloging stays writable while its owner applicability remains due", async (context) => { + const root = await fixture(context); + const loaded = await loadWorkspace(root); + const vendor = loaded.resources.find(({ id }) => id === "vendor-example"); + await createResource(root, { ...vendor, id: "vendor-needs-application", title: "Service awaiting applicability", informationTypeIds: ["information-type-example"] }); + const result = await validateWorkspace(root); + assert.equal(result.ok, true, JSON.stringify(result.diagnostics)); + assert.ok(result.diagnostics.some(({ code, severity }) => code === "uncovered-retention-information-use" && severity === "warning")); + assert.equal(assessCollectionReview(await loadWorkspace(root), "retention-schedule-item").complete, false); +}); + + +test("changed System boundary exclusions require a fresh owner applicability decision", async (context) => { + const root = await mkdtemp(join(tmpdir(), "filegrc-retention-boundary-")); + context.after(() => rm(root, { recursive: true, force: true })); + await makeRetentionApplicationWorkspace(root, { programWide: true }); + let loaded = await loadWorkspace(root); + const system = loaded.entries.find(({ record }) => record.id === "system-example"); + await writeJson(system.path, { ...system.record, exclusions: "The permanent statutory archive is now inside this service boundary." }); + loaded = await loadWorkspace(root); + assert.equal(assessCollectionReview(loaded, "retention-schedule-item").complete, false); + const checks = await assessRetentionReadiness(loaded, loaded.resources.find(({ type }) => type === "program")); + assert.ok(checks.find(({ id }) => id.startsWith("retention-use-")).affectedResourceIds.includes(system.record.id)); +}); + +test("validation reuses one bounded calculation snapshot for many owner mappings", async (context) => { + const root = await fixture(context); + const preview = await saveRetentionApplication(root, await decision(root), { preview: true }); + await mkdir(join(root, "data/retention-applications"), { recursive: true }); + for (let index = 0; index < 12; index++) await writeJson(join(root, "data/retention-applications", `${preview.record.id}-${index}.json`), { ...preview.record, id: `${preview.record.id}-${index}`, title: `Reviewed service application ${index + 1}` }); + const calls = []; + const restoreObserver = setGitSubprocessObserverForTests(({ args }) => calls.push(args.join(" "))); + let result; + try { result = await validateWorkspace(root); } finally { restoreObserver(); } + assert.equal(result.ok, true, JSON.stringify(result.diagnostics)); + // Every use shares the original approval calculation. Runtime/approval checks + // must not spawn a fresh history traversal for every application/use pair. + const manyCalls = calls.length; + for (let index = 1; index < 12; index++) await rm(join(root, "data/retention-applications", `${preview.record.id}-${index}.json`)); + const oneCalls = []; + const restoreOneObserver = setGitSubprocessObserverForTests(({ args }) => oneCalls.push(args.join(" "))); + try { assert.equal((await validateWorkspace(root)).ok, true); } finally { restoreOneObserver(); } + assert.ok(manyCalls <= oneCalls.length * 4 + 30, `12 applications used ${manyCalls} Git calls vs ${oneCalls.length} for one application`); + await commitWorkspaceFiles(root, "Record the reviewed service applicability"); + const loaded = await loadWorkspace(root); + const entry = loaded.entries.find(({ record }) => record.id === "vendor-additional"); + await writeJson(entry.path, { ...entry.record, description: "New purpose requires another applicability decision." }); + const next = await validateWorkspace(root); + assert.equal(next.ok, true, JSON.stringify(next.diagnostics)); + assert.ok(next.diagnostics.some(({ code }) => code === "uncovered-retention-information-use"), "a new validation sees changed facts rather than a prior invocation's cache"); +}); + + +test("malformed applicability scope produces diagnostics instead of aborting validation", async (context) => { + const root = await fixture(context); + const preview = await saveRetentionApplication(root, await decision(root), { preview: true }); + const loaded = await loadWorkspace(root); + assert.doesNotThrow(() => retentionApplicationIssues(loaded, { ...preview.record, scopeResourceIds: ["vendor-missing"], reviewedScopeRevisions: { "vendor-missing": "unknown" } })); + await assert.rejects(saveRetentionApplication(root, { record: { type: "retention-application", scopeResourceIds: "not-an-array" } }), { code: "FILEGRC_RETENTION_APPLICATION" }); +}); + + +test("malformed retirement actors produce diagnostics instead of aborting validation", async (context) => { + const root = await fixture(context); + const preview = await saveRetentionApplication(root, await decision(root), { preview: true }); + await saveRetentionApplication(root, preview); + await commitWorkspaceFiles(root, "Apply approved rule"); + const loaded = await loadWorkspace(root); + const entry = loaded.entries.find(({ record }) => record.type === "retention-application"); + await writeJson(entry.path, { ...entry.record, status: "retired", statusTransition: { changedByIds: "person-example", changedOn: entry.record.reviewedOn, reason: "Service retired." } }); + const result = await validateWorkspace(root); + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some(({ message }) => /Retirement needs/.test(message))); +}); + + +test("generic creation and direct-file validation reject uncaptured applicability bindings", async (context) => { + const root = await fixture(context); + const preview = await saveRetentionApplication(root, await decision(root), { preview: true }); + const forged = { ...preview.record, reviewedScopeRevisions: Object.fromEntries(preview.record.scopeResourceIds.map((id) => [id, "forged"])) }; + await assert.rejects(createResource(root, forged), /changed after the owner review/); + await writeJson(join(root, "data/retention-applications", forged.id + ".json"), forged); + const result = await validateWorkspace(root); + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some(({ message }) => /changed after the owner review/.test(message))); +});