diff --git a/docs/architecture.md b/docs/architecture.md index 2d186ca..7da5687 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -386,7 +386,7 @@ The sidebar groups records by their job: Resource types are nested only when the extra grouping adds meaning. Organization settings remain anchored at the bottom. -The Controls page explains what the generated workspace already supplies and what management must tailor before a planned Control becomes implemented. Its operation-tracking column distinguishes Controls linked to recurring or event work in FileGRC from Controls supported by Evidence Artifacts, and it always shows authoritative evidence-source mappings. A Control owner records implementation without a separate per-Control approval, so that status change raises readiness immediately. The Step 2 overview links to separate Policies and Data Retention Schedule pages. The Policies page combines Policies, program Documents, and Training for individual content approval. The Data Retention Schedule page presents the governed schedule document, derived coverage gaps, structured rows, and one Git-bound schedule review whose collection revision includes the document Markdown and rows. Step 3 links, in order, to Controls, Complementary Controls, Evidence Sources, and Obligations. Evidence Sources is a derived coverage page whose fixes remain in canonical Control and Component records, and Step 3 retains derived checks that implemented sources follow the approved schedule. The Finish Step 3 row shows the next blocker, reveals one Git-bound Control collection review when implementation is ready, then reveals Program Content Activation as management's final cutover. Browser, HTTP API, and CLI use the same domain operations and readiness calculations. +The Controls page explains what the generated workspace already supplies and what management must tailor before a planned Control becomes implemented. Its operation-tracking column distinguishes Controls linked to recurring or event work in FileGRC from Controls supported by Evidence Artifacts, and it always shows authoritative evidence-source mappings. A Control owner records implementation without a separate per-Control approval, so that status change raises readiness immediately. The Step 2 overview links to separate Policies and Data Retention Schedule pages. The Policies page combines Policies, program Documents, and Training for individual content approval. The Data Retention Schedule page presents the governed schedule document, derived coverage gaps, structured rows, and one Git-bound schedule review whose collection revision includes the document Markdown and rows. Step 3 links, in order, to Controls, Complementary Controls, Evidence Sources, and Obligations. Evidence Sources is a derived coverage page whose fixes remain in canonical Control and Component records, and Step 3 retains derived checks that implemented sources follow the approved schedule. The Finish Step 3 row shows the next blocker, reveals one Git-bound Control collection review when implementation is ready, then reveals one Activation box as management's final cutover. Its review dialog selects eligible approved Policies, program Documents, and Training by default and applies the selection in one atomic batch. Unavailable records and readiness gaps remain visible in the content disclosure. Policy gaps remain disclosed, and Document and Training prerequisites and exact approval revisions still gate activation. Browser, HTTP API, and CLI use the same domain operations and readiness calculations. Evidence Artifacts belong in Step 4 because management should create one only when a real export, report, screenshot, signed file, or approved external reference exists. The standard page creates and filters these records. Users select the source Component, link Controls and source operating records, attach or reference the result, record collection facts, and obtain verification before audit use. Risk, governance, vendor review, training and acknowledgement, vulnerability testing, backup and recovery testing, exception, and finding evidence stays on the Step 4 operating records. Fixed artifacts remain behind Evidence Artifact records and are linked from those operating records. diff --git a/package-lock.json b/package-lock.json index 77d0bbb..52e2a8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "filegrc-monorepo", - "version": "0.16.33", + "version": "0.16.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "filegrc-monorepo", - "version": "0.16.33", + "version": "0.16.34", "workspaces": [ "packages/filegrc", "packages/create-filegrc" @@ -24,7 +24,7 @@ "link": true }, "packages/create-filegrc": { - "version": "0.16.33", + "version": "0.16.34", "license": "MIT", "bin": { "create-filegrc": "bin/create-filegrc.js" @@ -34,7 +34,7 @@ } }, "packages/filegrc": { - "version": "0.16.33", + "version": "0.16.34", "license": "MIT", "bin": { "filegrc": "bin/filegrc.js" diff --git a/package.json b/package.json index e06de0d..361badc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "filegrc-monorepo", - "version": "0.16.33", + "version": "0.16.34", "private": true, "workspaces": [ "packages/filegrc", diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json index f2dd385..10ab42a 100644 --- a/packages/create-filegrc/package.json +++ b/packages/create-filegrc/package.json @@ -1,6 +1,6 @@ { "name": "create-filegrc", - "version": "0.16.33", + "version": "0.16.34", "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 e7b5f68..f5e7db4 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.16.33", + version: "0.16.34", lockfileVersion: 3, requires: true, packages: { "": { name, - version: "0.16.33", + version: "0.16.34", dependencies: { filegrc: versionRange } } } diff --git a/packages/create-filegrc/template/README.md b/packages/create-filegrc/template/README.md index 382832b..9e2c4aa 100644 --- a/packages/create-filegrc/template/README.md +++ b/packages/create-filegrc/template/README.md @@ -43,7 +43,7 @@ Detached and feature-branch checkouts are read-only in the browser by default. D 1. **Define scope.** Set program ownership, choose the criteria, define the service, Systems, and providers in scope, review any supplemental requirement mappings, and commit the reporting-channel proposals needed by the planned program. 2. **Approve policies.** Review Policies, program Documents, and Training in one table. Have someone other than the owner approve each exact revision. Approval does not mean the linked Controls are implemented. -3. **Implement controls.** Implement the approved requirements, define how each Control works, connect its Evidence sources, and configure Obligations. Activate each unchanged approved Document or Training record with a separate activation date and revision, then activate the selected Policy cutover set. +3. **Implement controls.** Implement the approved requirements, define how each Control works, connect its Evidence sources, and configure Obligations. Use one Activation box to put eligible approved Policies, Documents, and Training into use together, with separate approval and activation revisions. 4. **Operate the program.** Run scheduled and event-driven work, maintain risk, and retain dated evidence. 5. **Audit.** Set up the CPA engagement, support fieldwork, and prepare the evidence packet. @@ -51,7 +51,7 @@ A Policy says what the company commits to do by the date it takes effect. Approv FileGRC does not infer technical implementation from Policy prose. Configuration facts belong in Controls, Components, Systems, Obligations, and Evidence. A Control may be implemented while its governing Policy, required program Document, or Training is approved but inactive. Enabled Obligations remain dormant until all of their governing content is active and effective. -Control implementation includes evidence-source and Obligation readiness. Use `npx filegrc program-readiness --json` to see each Control's launch state and next practical check. Implemented means the control works now for its named scope, with a real owner, procedure, usable evidence source, and enabled work rules. It does not claim a future cycle or event has already happened. When the five Step 3 work areas are ready, one eligible reviewer records the implemented Control collection review. Management then activates the unchanged approved program Documents and Training with `npx filegrc activate-content --scaffold`, followed by the approved Policies with `npx filegrc activate-policies --scaffold`. Evidence Readiness requires a current Control collection review and active required content before the candidate period can begin. Create Evidence during Step 4 only after operation produces a real record or artifact. Create engagement terms, management assertions, representation letters, and other Audit Documents in Step 5, link each to one Audit, approve it, then activate it with `npx filegrc activate-documents --audit AUDIT_ID --scaffold`. +Control implementation includes evidence-source and Obligation readiness. Use `npx filegrc program-readiness --json` to see each Control's launch state and next practical check. Implemented means the control works now for its named scope, with a real owner, procedure, usable evidence source, and enabled work rules. It does not claim a future cycle or event has already happened. When the five Step 3 work areas are ready, one eligible reviewer records the implemented Control collection review. Management then activates the unchanged approved Policies, program Documents, and Training together with `npx filegrc activate-content --scaffold`. The individual Policy activation command remains available. Evidence Readiness requires a current Control collection review and active required content before the candidate period can begin. Create Evidence during Step 4 only after operation produces a real record or artifact. Create engagement terms, management assertions, representation letters, and other Audit Documents in Step 5, link each to one Audit, approve it, then activate it with `npx filegrc activate-documents --audit AUDIT_ID --scaffold`. The Program Overview shows what is done, what is blocked, and what to do next. diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json index e16217d..5285640 100644 --- a/packages/create-filegrc/template/package.json +++ b/packages/create-filegrc/template/package.json @@ -1,6 +1,6 @@ { "name": "{{project_name}}", - "version": "0.16.33", + "version": "0.16.34", "private": true, "description": "filegrc workspace for a SOC 2 program", "type": "module", diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json index 5938cb2..f6f9a89 100644 --- a/packages/filegrc/package.json +++ b/packages/filegrc/package.json @@ -1,6 +1,6 @@ { "name": "filegrc", - "version": "0.16.33", + "version": "0.16.34", "description": "Zero-dependency Git-native GRC engine", "license": "MIT", "repository": { diff --git a/packages/filegrc/src/cli.js b/packages/filegrc/src/cli.js index 5f092a3..41b1a3d 100644 --- a/packages/filegrc/src/cli.js +++ b/packages/filegrc/src/cli.js @@ -1663,8 +1663,9 @@ Options: filegrc activate-content --scaffold [--program id] filegrc activate-content [--resource id] [--activated-by person-id] [--activated-on YYYY-MM-DD] [--effective-on YYYY-MM-DD] [--preview|--yes] [--json] -Activate approved program Documents and Training in Step 3 after their linked -Controls are implemented and Training has an enabled assignment Obligation. +Activate approved program Policies, Documents, and Training together in Step 3. +Documents require implemented linked Controls; Training also needs an enabled +assignment Obligation. Review disclosed Policy gaps before activation. Approval, activation, and effective dates remain separate, and activation binds the exact approved Markdown revision. diff --git a/packages/filegrc/src/document-activation.js b/packages/filegrc/src/document-activation.js index 2d0c8bf..f25f7c3 100644 --- a/packages/filegrc/src/document-activation.js +++ b/packages/filegrc/src/document-activation.js @@ -7,6 +7,7 @@ import { personWasActiveOn } from "./soc2.js"; import { currentCalendarDate } from "./time.js"; import { loadWorkspace } from "./workspace.js"; import { revisionDigest } from "./revisions.js"; +import { planPolicyActivation } from "./policy-activation.js"; export async function scaffoldDocumentActivation(input = process.cwd(), options = {}) { const loaded = await loadWorkspace(input); @@ -174,16 +175,22 @@ export async function scaffoldGovernedContentActivation(input = process.cwd(), o const candidates = oversightReady ? await activationCandidates(loaded, { ...options, auditId: undefined }) : []; + const policies = (oversightReady ? readiness.policyActivations : []).filter(({ state }) => ( + ["approved-implementation-pending", "ready-to-activate"].includes(state) + )); + const count = candidates.length + policies.length; const revisionById = new Map(loaded.entries.map((entry) => [entry.record.id, contentRevision(entry.source)])); const today = currentCalendarDate(loaded.workspace.timezone); return { - available: candidates.length > 0, + available: count > 0, message: !oversightReady ? "Complete the current Control collection review before Program Content Activation." - : candidates.length - ? `${candidates.length} approved governed-content ${candidates.length === 1 ? "record is" : "records are"} ready to activate.` - : "No program Document or Training record is ready to activate. Review the current readiness actions first.", - nextCommand: candidates.length ? null : "npx filegrc program-path --next --json", + : count + ? `${count} approved program content ${count === 1 ? "record is" : "records are"} available to activate. Review any Policy gaps before confirming.` + : "No approved program content is available to activate. Review the current readiness actions first.", + nextCommand: count ? null : "npx filegrc program-path --next --json", + programId: readiness.program.id, + policyIds: policies.map(({ policyId }) => policyId), resourceIds: candidates.map(({ resourceId }) => resourceId), documentIds: candidates.filter(({ resourceType }) => resourceType === "document").map(({ resourceId }) => resourceId), trainingIds: candidates.filter(({ resourceType }) => resourceType === "training").map(({ resourceId }) => resourceId), @@ -191,13 +198,58 @@ export async function scaffoldGovernedContentActivation(input = process.cwd(), o activatedByIds: [], activatedOn: today, effectiveOn: today, - expectedRevisions: Object.fromEntries(candidates.map(({ resourceId }) => [resourceId, revisionById.get(resourceId)])), + expectedRevisions: Object.fromEntries([ + ...candidates.map(({ resourceId }) => [resourceId, revisionById.get(resourceId)]), + ...policies.map(({ policyId }) => [policyId, revisionById.get(policyId)]) + ]), confirmed: false }; } -export const planGovernedContentActivation = planDocumentActivation; -export const activateGovernedContent = activateDocuments; +export async function planGovernedContentActivation(input = process.cwd(), options = {}) { + if (options.policyIds === undefined) return planDocumentActivation(input, options); + if (options.auditId) throw new Error("Program content activation cannot include Audit Documents. Use Step 5 activation."); + const policyIds = [...new Set((options.policyIds || []).map(String))]; + const resourceIds = [...new Set((options.resourceIds || options.documentIds || []).map(String))]; + if (!policyIds.length && !resourceIds.length) throw new Error("Select at least one approved program content record to activate."); + const plans = []; + if (policyIds.length) { + const loaded = await loadWorkspace(input); + const readiness = await assessProgramReadiness(loaded, { programId: options.programId }); + const eligible = new Set(readiness.policyActivations.filter(({ state }) => ( + ["approved-implementation-pending", "ready-to-activate"].includes(state) + )).map(({ policyId }) => policyId)); + if (policyIds.some((id) => !eligible.has(id))) throw new Error("Every selected Policy must be approved and available for this Program's activation."); + plans.push(await planPolicyActivation(input, { ...options, policyIds })); + } + if (resourceIds.length) plans.push(await planDocumentActivation(input, { ...options, resourceIds })); + return { + operation: "governed-content-activation", + workflowScope: "program", + policyIds, + resourceIds: [...policyIds, ...resourceIds], + documentIds: plans.flatMap((plan) => plan.documentIds || []), + trainingIds: plans.flatMap((plan) => plan.trainingIds || []), + activatedByIds: options.activatedByIds || [], + activatedOn: options.activatedOn, + effectiveOn: options.effectiveOn, + changes: { + update: plans.flatMap((plan) => plan.changes.update), + expectedRevisions: Object.assign({}, ...plans.map((plan) => plan.changes.expectedRevisions)), + validateWholeWorkspace: true + } + }; +} + +export async function activateGovernedContent(input = process.cwd(), options = {}) { + if (options.policyIds === undefined) return activateDocuments(input, options); + if (options.confirmed !== true) throw new Error("Review program content activation and confirm the write."); + return serializeWorkspaceMutation(input, async (root) => { + const plan = await planGovernedContentActivation(root, options); + const result = await applyGovernedContentActivationBatch(root, plan.changes); + return { ...plan, result }; + }); +} function requireDocumentLifecycle(loaded) { if (!modelSupports(loaded.model, "governed-document-activation")) { diff --git a/packages/filegrc/src/web.js b/packages/filegrc/src/web.js index 5e676af..9d10ea3 100644 --- a/packages/filegrc/src/web.js +++ b/packages/filegrc/src/web.js @@ -683,8 +683,7 @@ function renderStageOverview(main, stageId, params = new URLSearchParams()) { event.preventDefault(); main.querySelector("#program-content-cutover")?.scrollIntoView({ behavior: "smooth", block: "start" }); }); - main.querySelector("[data-review-policy-activation]")?.addEventListener("click", openPolicyActivationDialog); - main.querySelector("[data-review-document-activation]")?.addEventListener("click", () => openDocumentActivationDialog()); + main.querySelector("[data-review-program-activation]")?.addEventListener("click", openProgramActivationDialog); if (stage.id === "controls") { main.querySelector("[data-review-collection]")?.addEventListener("click", () => openCollectionReviewDialog("control")); } @@ -1004,13 +1003,12 @@ function policyContentWorkflowCell(entry) { return '' + esc(properCase(item.state)) + '' + esc(title) + '' + items.length + ' ' + pluralize('check', items.length) + ''; } -function renderDocumentActivationAssessments() { +function documentActivationCards() { const assessments = [ ...(state.programReadiness?.documentActivations || []).map((item) => ({ ...item, resourceType: "document", resourceId: item.documentId })), ...(state.programReadiness?.trainingActivations || []).map((item) => ({ ...item, resourceType: "training", resourceId: item.trainingId })) ]; if (!assessments.length) return ""; - const candidates = assessments.filter(({ state }) => state === "ready-to-activate"); const cards = assessments.map((assessment) => { const operating = assessment.state === "active-and-operating"; const controls = assessment.missingImplementationControlIds.length @@ -1022,10 +1020,7 @@ function renderDocumentActivationAssessments() { const detail = '#/resource/' + assessment.resourceType + '/' + encodeURIComponent(assessment.resourceId) + '?stage=controls' + (assessment.resourceType === "document" ? '&documentScope=program' : ''); return '
' + esc(assessment.label) + '

' + esc(assessment.title) + '

' + assessment.gapCount + ' ' + pluralize("gap", assessment.gapCount) + '
' + controls + schedule + '
'; }).join(""); - const action = candidates.length && !state.readOnly - ? '
' - : ""; - return '

Program content cutover

Activate approved program content

After linked requirements are implemented and Training assignment Obligations are ready, activate each unchanged approved Document and Training record. FileGRC records a separate activation date and binds the exact activated revision.

' + action + '
' + cards + '
'; + return cards; } function renderAuditDocumentActivationAssessments() { @@ -1121,15 +1116,12 @@ function openDocumentActivationDialog(auditId = null) { dialog.showModal(); } -function renderPolicyActivationAssessments() { +function policyActivationCards() { const assessments = state.programReadiness?.policyActivations || []; - if (!assessments.length) return '

Implementation cutover

Approve required Policies first

Activation assessments appear here after independent approval binds each Policy to its reviewed content.

'; + if (!assessments.length) return ""; const gapGroup = (label, ids, type) => ids.length ? '
' + esc(label) + '
' + ids.map((id) => formatReference(id, type)).join("") + '
' : ""; - const activationCandidates = assessments.filter(({ state }) => ( - ["approved-implementation-pending", "ready-to-activate"].includes(state) - )); const cards = assessments.map((assessment) => { const operating = assessment.state === "active-and-operating"; const warnings = assessment.timingWarnings.map((warning) => '

' + esc(warning) + '

').join(""); @@ -1146,76 +1138,87 @@ function renderPolicyActivationAssessments() { (assessment.activationWarning ? '

' + esc(assessment.activationWarning) + '

' : "") + '
View Policy
'; }).join(""); - const action = activationCandidates.length && !state.readOnly - ? '
' - : ""; - return '

Step 3 review

Review the gaps and turn the Policies on

At the end of Step 3, review what is still missing, choose the approved Policies you want to put into effect, and set their effective date. You do not need to go back to Step 2.

' + action + '
' + cards + '
'; + return cards; +} + +function programActivationAssessments() { + return [ + ...(state.programReadiness?.policyActivations || []).map((item) => ({ ...item, resourceType: "policy", resourceId: item.policyId })), + ...(state.programReadiness?.documentActivations || []).map((item) => ({ ...item, resourceType: "document", resourceId: item.documentId })), + ...(state.programReadiness?.trainingActivations || []).map((item) => ({ ...item, resourceType: "training", resourceId: item.trainingId })) + ]; } -function openPolicyActivationDialog() { - const assessments = (state.programReadiness?.policyActivations || []).filter(({ state }) => ( - ["approved-implementation-pending", "ready-to-activate"].includes(state) +function programActivationCandidates() { + return programActivationAssessments().filter((item) => ( + (item.state === "ready-to-activate" || item.resourceType === "policy" && item.state === "approved-implementation-pending") + && state.resources.some(({ record }) => record.id === item.resourceId && record.status === "approved") )); - const entryById = new Map(state.resources - .filter(({ record }) => record.type === "policy" && record.status === "approved") - .map((entry) => [entry.record.id, entry])); - const candidates = assessments.filter(({ policyId }) => entryById.has(policyId)); +} + +function renderProgramActivation(complete = false) { + const assessments = programActivationAssessments(); + const candidates = programActivationCandidates(); + const active = assessments.filter(({ state }) => state.startsWith("active-")).length; + const unavailable = assessments.length - candidates.length - active; + const gaps = assessments.reduce((total, item) => total + (item.gapCount || 0), 0); + const action = complete + ? 'Continue to Step 4' + : state.readOnly ? '' : ''; + return '

Finish Step 3

Activation

' + (complete + ? 'The Control collection review is current and required program content is active.' + : 'Put approved Policies, Documents, and Training into use together. Review the selection, activation Person, and effective date before confirming.') + '

' + action + '

' + assessments.length + ' content ' + pluralize('record', assessments.length) + ' · ' + active + ' active' + (unavailable ? ' · ' + unavailable + ' cannot activate yet' : '') + (gaps ? ' · ' + gaps + ' ' + pluralize('gap', gaps) + ' to review' : '') + '

' + (assessments.length + ? '
Review content and readiness gaps
' + documentActivationCards() + policyActivationCards() + '
' + : '

Approve the required program content in Step 2 before activation.

') + '
'; +} + +function openProgramActivationDialog() { + const candidates = programActivationCandidates(); if (!candidates.length) return; + const eligible = new Set(candidates.map(({ resourceId }) => resourceId)); + const pending = programActivationAssessments().filter(({ state }) => !state.startsWith("active-")); + const entryById = new Map(state.resources.map((entry) => [entry.record.id, entry])); + const activators = resourcesOfType("person").filter(({ record }) => record.status === "active"); const today = currentDate(); - const candidateRows = candidates.map((assessment) => { - const details = [ - [assessment.plannedOrPartialControlIds.length, "planned or partial Controls"], - [assessment.missingComponentControlIds.length, "missing Components"], - [assessment.missingEvidenceSourceControlIds.length, "missing evidence sources"], - [assessment.missingScheduleControlIds.length, "missing schedules"], - [(assessment.missingGovernedDocumentIds || []).length, "inactive governed Documents"], - [assessment.unresolvedExceptionIds.length, "unresolved Exceptions"] - ].filter(([count]) => count).map(([count, label]) => count + " " + label).join(" · "); - return ''; - }).join(""); const dialog = document.createElement("dialog"); dialog.className = "commit-dialog event-dialog policy-activation-dialog"; - dialog.setAttribute("aria-labelledby", "policy-activation-dialog-title"); - dialog.innerHTML = '

Step 3 cutover

Review policy activation

' + - '

Choose which approved Policies should take effect. Enabled schedules stay dormant until the effective date, their linked Controls are implemented, and required governed Documents are active and effective.

' + - '
' + candidateRows + '
' + - '
Activation with gaps

You can activate a Policy with documented gaps or Exceptions. The gaps stay open, Controls keep their current status, and Evidence Readiness stays incomplete.

' + + dialog.setAttribute("aria-labelledby", "program-activation-dialog-title"); + dialog.innerHTML = '

Step 3

Activate approved content

All eligible approved content is selected. Policies take effect on the chosen date. Documents and Training also record who activates their exact approved revisions.

' + pending.map((item) => '').join('') + '
' + + '
Policy gaps remain visible

You can activate a Policy with documented gaps or Exceptions. The gaps stay open, Controls keep their current status, and Evidence Readiness stays incomplete. Documents and Training must meet their activation prerequisites.

' + + '' + + '' + '' + - '
'; + '
'; document.body.append(dialog); const repositoryPrefetch = prefetchRepositoryForReview(dialog.querySelector(".save-status")); - const close = () => dialog.close(); - dialog.querySelector(".icon-button").addEventListener("click", close); - dialog.querySelector('[data-event="cancel"]').addEventListener("click", close); + dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close()); + dialog.querySelector('[data-event="cancel"]').addEventListener("click", () => dialog.close()); dialog.addEventListener("close", () => dialog.remove(), { once: true }); dialog.querySelector("form").addEventListener("submit", async (event) => { event.preventDefault(); - if (!event.currentTarget.reportValidity() || dialog.dataset.mutationBusy === "true") return; - const policyIds = [...event.currentTarget.querySelectorAll('[name="policyId"]:checked')].map(({ value }) => value); - if (!policyIds.length) { - dialog.querySelector(".dialog-error").textContent = "Select at least one approved Policy to activate."; - return; - } - setMutationBusy(dialog, true, "Activating…", "Activate selected Policies"); + const form = event.currentTarget; + if (!form.reportValidity() || dialog.dataset.mutationBusy === "true") return; + const selected = [...form.querySelectorAll('[name="resourceId"]:checked:not(:disabled)')].map(({ value }) => value); + if (!selected.length) { dialog.querySelector(".dialog-error").textContent = "Select at least one approved content record to activate."; return; } + const policyIds = selected.filter((id) => entryById.get(id).record.type === "policy"); + const resourceIds = selected.filter((id) => entryById.get(id).record.type !== "policy"); + setMutationBusy(dialog, true, "Activating…", "Activate selected content"); try { const prefetch = await repositoryPrefetch; if (prefetch?.error) throw prefetch.error; - const response = await localFetch("/api/policy-activations", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - policyIds, - effectiveOn: event.currentTarget.elements.effectiveOn.value, - prefetchToken: prefetch?.token, - expectedRevisions: Object.fromEntries(policyIds.map((policyId) => [policyId, entryById.get(policyId).revision])) - }) + const response = await localFetch("/api/governed-content-activations", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ policyIds, resourceIds, programId: state.selectedProgramId || undefined, + activatedByIds: [form.elements.activatedById.value], activatedOn: form.elements.activatedOn.value, + effectiveOn: form.elements.effectiveOn.value, prefetchToken: prefetch?.token, + expectedRevisions: Object.fromEntries(selected.map((id) => [id, entryById.get(id).revision])) }) }); if (!response.ok) throw new Error(await responseMessage(response)); applyMutationState(await response.json()); dialog.close(); render(); } catch (error) { - setMutationBusy(dialog, false, "", "Activate selected Policies"); + setMutationBusy(dialog, false, "", "Activate selected content"); dialog.querySelector(".dialog-error").textContent = error.message; } }); @@ -1407,9 +1410,9 @@ function renderFinishStepThree() { return '

Finish Step 3

Review the implemented Control collection

One eligible reviewer confirms the implemented Controls as a batch. Management activates the approved program content after this review.

' + collectionReviewPanel("control", true); } if (activationItems.some(({ status }) => status !== "complete")) { - return renderHostedAutomation() + '

Finish Step 3

Program Content Activation

The Control collection review is current. Activate the unchanged approved program content to complete the implementation cutover.

' + renderDocumentActivationAssessments() + renderPolicyActivationAssessments(); + return renderHostedAutomation() + renderProgramActivation(); } - return '

Step 3 complete

Implementation cutover complete

The Control collection review is current and required program content is active.

Continue to Step 4
'; + return renderProgramActivation(true); } function collectionNeedsFirstRecord(type) { @@ -6745,6 +6748,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax( .policy-stage-pages{margin-top:30px}.retention-schedule-page .page-intro{margin-bottom:18px}.retention-schedule-page .page-intro .actions{flex-wrap:wrap;justify-content:flex-end}.retention-summary{margin-bottom:28px;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.retention-summary>div{display:grid;grid-template-columns:150px minmax(0,1fr) auto;gap:16px;align-items:center;padding:13px 0}.retention-summary>div+div{border-top:1px solid var(--line)}.retention-summary-label{color:var(--muted);font-size:10.8px;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.retention-summary a{text-decoration:none}.retention-summary strong,.retention-summary small{display:block}.retention-summary strong{font-size:13.2px}.retention-summary small{margin-top:3px;color:var(--muted);font-size:10.8px}.retention-rows h2,.retention-issues h2{font:500 24px Georgia,serif;margin:5px 0 7px}.retention-rows .section-head{align-items:end}.retention-rows .section-head p:not(.kicker){max-width:760px;margin:0;color:var(--muted);font-size:12px;line-height:1.55}.retention-table-tools{display:flex;align-items:center;justify-content:flex-end;gap:9px;flex-wrap:wrap}.retention-table-tools input[type="search"]{min-width:210px;border:1px solid var(--line);border-radius:7px;background:var(--field);padding:9px 11px}.retention-rows .record-table-wrap{margin-top:12px}.retention-rows .record-title+small{display:block;max-width:380px;margin-top:4px;color:var(--muted);font-size:9.6px;line-height:1.4}.retention-filter-empty{margin:10px 0 0;padding:16px;border:1px dashed var(--line);border-radius:8px;color:var(--muted);font-size:12px;text-align:center}.retention-row-count{margin:9px 2px 0;color:var(--muted);font-size:10.8px;text-align:right}.retention-issues{margin-top:28px}.retention-issue-list{border-top:1px solid var(--line)}.retention-issue-list>a{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:12px 2px;border-bottom:1px solid var(--line);text-decoration:none}.retention-issue-list strong,.retention-issue-list small{display:block}.retention-issue-list strong{font-size:12px}.retention-issue-list small{margin-top:3px;color:var(--muted);font-size:10.4px;line-height:1.45}.retention-issue-list b{color:var(--accent);font-size:11px}.retention-schedule-page>.collection-review-panel{margin-top:28px;margin-bottom:0} @media(max-width:760px){.retention-summary>div{grid-template-columns:1fr;gap:7px}.retention-summary>div>.badge{justify-self:start}.retention-schedule-page .page-intro .actions{justify-content:flex-start}} .policy-library-proposals{min-width:0}.policy-library-proposals article,.policy-library-proposals details{min-width:0}.policy-library-proposals pre{box-sizing:border-box;max-width:100%;overflow:auto;padding:9px;border:1px solid var(--line);border-radius:6px;background:var(--paper);font-size:9px;line-height:1.45} +.program-activation{padding:20px 22px;background:var(--accent-soft);border:1px solid #cbd3ff;border-radius:11px}.program-activation>.evidence-map-head{padding:0;border:0;background:none}.program-activation>.activation-summary{margin:0}.activation-summary{font-size:12px;color:var(--muted);margin:14px 0}.activation-details>summary{cursor:pointer;font-size:12px;font-weight:600}.activation-details>.policy-activation-grid{margin-top:14px} .policy-activation-actions{display:flex;justify-content:flex-end;margin-top:14px;padding-top:12px;border-top:1px solid var(--line)} .policy-activation-review-warning{margin-top:14px;padding:12px 14px;border:1px solid #d8bd78;border-radius:8px;background:#fff8e8}.policy-activation-review-warning>strong{font-size:12px}.policy-activation-review-warning ul{display:grid;gap:5px;margin:9px 0;padding-left:20px;font-size:10.8px}.policy-activation-review-warning p{margin:9px 0 0;color:#6d4917;font-size:11px;line-height:1.5} .policy-activation-selections{display:grid;gap:7px;margin:15px 0}.policy-activation-selection{display:grid!important;grid-template-columns:auto minmax(0,1fr);gap:10px;align-items:start;padding:11px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.event-dialog .policy-activation-selection input{width:18px;min-height:18px;height:18px;margin:1px 0 0}.policy-activation-selection strong,.policy-activation-selection small{display:block}.policy-activation-selection strong{font-size:11.5px}.policy-activation-selection small{margin-top:3px;color:var(--muted);font-size:10px;line-height:1.45} diff --git a/packages/filegrc/test/document-activation.test.js b/packages/filegrc/test/document-activation.test.js index 4763994..c387500 100644 --- a/packages/filegrc/test/document-activation.test.js +++ b/packages/filegrc/test/document-activation.test.js @@ -430,3 +430,77 @@ test("rejects engagement Documents that govern reusable program work", async (co assert.ok(validation.diagnostics.some(({ code }) => code === "engagement-document-in-program-workflow")); assert.equal(validation.diagnostics.some(({ code }) => code === "invalid-engagement-document-audit-count"), false); }); + +test("activates Policies, Documents, and Training atomically through CLI and HTTP", async (context) => { + const root = await mkdtemp(`${tmpdir()}/filegrc-combined-activation-`); + context.after(() => import("node:fs/promises").then(({ rm }) => rm(root, { recursive: true, force: true }))); + await makeComprehensiveWorkspace(root, "6"); + let loaded = await loadWorkspace(root); + const selected = ["policy", "document", "training"].map((type) => loaded.resources.find((record) => ( + record.type === type && (type !== "document" || record.workflowScope === "program") + ))); + const [policy, document, training] = selected; + for (const record of selected) { + const draft = { ...record, status: "draft" }; + for (const field of ["approverIds", "approvedOn", "approvedContentRevisions", "activationBasis", "activatedByIds", "activatedOn", "activatedContentRevisions", "effectiveOn"]) delete draft[field]; + if (record.type === "document") draft.programRole = "required"; + await updateResource(root, record.type, record.id, draft); + await updateResource(root, record.type, record.id, { + ...draft, status: "approved", approverIds: ["person-independent-approver-example"], approvedOn: "2026-08-20" + }); + } + loaded = await loadWorkspace(root); + const obligation = loaded.resources.find(({ type }) => type === "obligation"); + await updateResource(root, "obligation", obligation.id, { + ...obligation, activityType: "training", scopeResourceIds: [training.id], templateResourceId: training.id, + recurrence: { mode: "calendar", unit: "month", interval: 1, anchorDate: "2026-08-21" } + }); + const scaffold = await scaffoldGovernedContentActivation(root); + assert.ok(scaffold.policyIds.includes(policy.id)); + assert.ok(scaffold.documentIds.includes(document.id)); + assert.ok(scaffold.trainingIds.includes(training.id)); + const payload = { ...scaffold, policyIds: [policy.id], resourceIds: [document.id, training.id], activatedByIds: ["person-example"] }; + const { writeFile } = await import("node:fs/promises"); + const payloadPath = `${root}/activation.json`; + await writeJson(payloadPath, payload); + const preview = JSON.parse((await execute(process.execPath, [cli, "activate-content", payloadPath, "--preview", "--json", "--root", root])).stdout); + assert.deepEqual(preview.changes.update.map(({ type }) => type), ["policy", "document", "training"]); + await assert.rejects(activateGovernedContent(root, { ...payload, confirmed: true, expectedRevisions: { ...payload.expectedRevisions, [training.id]: "0".repeat(64) } }), /changed|revision/i); + assert.ok((await loadWorkspace(root)).resources.filter(({ id }) => selected.some((item) => item.id === id)).every(({ status }) => status === "approved")); + await assert.rejects(activateGovernedContent(root, { ...payload, confirmed: true, activatedByIds: ["missing-person"] }), /active People/); + // A changed companion cannot silently activate either the Policy or the other content. + loaded = await loadWorkspace(root); + const documentEntry = loaded.entries.find(({ record }) => record.id === document.id); + const markdownPath = documentEntry.path.replace(/\.json$/, ".md"); + const { readFile } = await import("node:fs/promises"); + const markdown = await readFile(markdownPath, "utf8"); + await writeFile(markdownPath, `${markdown}\nChanged after approval.\n`); + await assert.rejects(activateGovernedContent(root, { ...payload, confirmed: true }), /ready|approv|invalid/i); + assert.ok((await loadWorkspace(root)).resources.filter(({ id }) => selected.some((item) => item.id === id)).every(({ status }) => status === "approved")); + await writeFile(markdownPath, markdown); + const { cp, rm } = await import("node:fs/promises"); + const cliRoot = await mkdtemp(`${tmpdir()}/filegrc-combined-activation-cli-`); + context.after(() => rm(cliRoot, { recursive: true, force: true })); + await cp(root, cliRoot, { recursive: true }); + const cliResult = JSON.parse((await execute(process.execPath, [cli, "activate-content", `${cliRoot}/activation.json`, "--yes", "--json", "--root", cliRoot])).stdout); + assert.deepEqual(cliResult.policyIds, [policy.id]); + assert.ok((await loadWorkspace(cliRoot)).resources.filter(({ id }) => selected.some((item) => item.id === id)).every(({ status }) => status === "active")); + const { serveWorkspace } = await import("../src/server.js"); + const served = await serveWorkspace(root, { port: 0, allowNonAuthoritativeWrites: true }); + context.after(() => new Promise((resolve) => served.server.close(resolve))); + const response = await fetch(`${served.url}/api/governed-content-activations`, { + method: "POST", headers: { "content-type": "application/json", prefer: "return=minimal" }, body: JSON.stringify(payload) + }); + assert.equal(response.status, 200, await response.clone().text()); + const result = await response.json(); + assert.deepEqual(result.policyIds, [policy.id]); + assert.equal(result.resourceIds.length, 3); + loaded = await loadWorkspace(root); + const active = selected.map(({ id }) => loaded.resources.find((record) => record.id === id)); + assert.ok(active.every(({ status, effectiveOn }) => status === "active" && effectiveOn === payload.effectiveOn)); + for (const record of active.filter(({ type }) => type !== "policy")) { + assert.deepEqual(record.activatedContentRevisions, record.approvedContentRevisions); + assert.deepEqual(record.activatedByIds, ["person-example"]); + } + assert.equal((await validateWorkspace(root)).ok, true); +}); diff --git a/packages/filegrc/test/hosted-automation.test.js b/packages/filegrc/test/hosted-automation.test.js index e9862a9..6dac0d4 100644 --- a/packages/filegrc/test/hosted-automation.test.js +++ b/packages/filegrc/test/hosted-automation.test.js @@ -46,7 +46,7 @@ test("renderer consumes shared placement and offers setup and direct cutover wit assert.match(html, /USD \/ repo \/ month/); assert.match(html, /Run your SOC 2 program automatically\./); assert.doesNotMatch(html, /Operated by|Opening setup|production approval/); - assert.ok(APP_SCRIPT.indexOf('return renderHostedAutomation()') < APP_SCRIPT.indexOf('id="program-content-cutover"')); + assert.match(APP_SCRIPT, /return renderHostedAutomation\(\) \+ renderProgramActivation\(\)/); assert.match(APP_SCRIPT, /event.preventDefault\(\);\n main.querySelector\("#program-content-cutover"\)/); state.programReadiness.hostedAutomation.status = "available"; assert.equal(render(), ""); diff --git a/packages/filegrc/test/render.test.js b/packages/filegrc/test/render.test.js index 5b9503b..c9a4ffc 100644 --- a/packages/filegrc/test/render.test.js +++ b/packages/filegrc/test/render.test.js @@ -82,8 +82,7 @@ test("Step 3 review action follows the CLI prerequisites and lists each implemen pluralize: (word, count) => count === 1 ? word : word + "s", collectionReviewPanel: () => panel, renderHostedAutomation: () => "", - renderDocumentActivationAssessments: () => "Document activation", - renderPolicyActivationAssessments: () => "Policy activation" + renderProgramActivation: () => "Activation" }); const finish = vm.runInNewContext(finishSource + "\nrenderFinishStepThree", context); const blocked = finish(); @@ -97,7 +96,7 @@ test("Step 3 review action follows the CLI prerequisites and lists each implemen assert.match(finish(), /Review and confirm/); context.state.collectionReviews.control.complete = true; context.state.programReadiness.stages[0].items.push({ id: "training-training-one", status: "blocked" }); - assert.match(finish(), /Program Content Activation/); + assert.match(finish(), /Activation/); assert.doesNotMatch(finish(), /Step 3 complete/); }); @@ -397,8 +396,8 @@ test("renders the shared Policy lifecycle and activation assessment states", () assert.match(APP_SCRIPT, /--proposal-revision/); assert.match(APP_STYLES, /\.policy-library-proposals\{min-width:0\}/); assert.match(APP_STYLES, /\.policy-library-proposals pre\{[^}]*overflow:auto/); - assert.match(APP_SCRIPT, /function renderPolicyActivationAssessments\(\)/); - assert.match(APP_SCRIPT, /function renderDocumentActivationAssessments\(\)/); + assert.match(APP_SCRIPT, /function policyActivationCards\(\)/); + assert.match(APP_SCRIPT, /function documentActivationCards\(\)/); assert.match(APP_SCRIPT, /function openDocumentActivationDialog\(auditId = null\)/); assert.match(APP_SCRIPT, /function renderAuditDocumentActivationAssessments\(\)/); assert.match(APP_SCRIPT, /\.filter\(\(record\) => !state\.selectedProgramId \|\| record\.programId === state\.selectedProgramId\)/); @@ -406,12 +405,10 @@ test("renders the shared Policy lifecycle and activation assessment states", () assert.match(APP_SCRIPT, /esc\(assessment\.label\)/); assert.match(APP_SCRIPT, /Missing ready evidence sources/); assert.match(APP_SCRIPT, /Unresolved Exceptions/); - assert.match(APP_SCRIPT, /You do not need to go back to Step 2/); - assert.match(APP_SCRIPT, /Review the gaps and turn the Policies on/); - assert.match(APP_SCRIPT, /data-review-policy-activation/); - assert.match(APP_SCRIPT, /function openPolicyActivationDialog\(\)/); - assert.match(APP_SCRIPT, /Activate selected Policies/); - assert.match(APP_SCRIPT, /\/api\/policy-activations/); + assert.match(APP_SCRIPT, /data-review-program-activation/); + assert.match(APP_SCRIPT, /function openProgramActivationDialog\(\)/); + assert.match(APP_SCRIPT, /Activate selected content/); + assert.match(APP_SCRIPT, /\/api\/governed-content-activations/); assert.match(APP_SCRIPT, /Step 2 ends at Approved/); assert.match(APP_SCRIPT, /activationManagedType = \["policy", "document"\]\.includes\(type\)/); assert.match(APP_SCRIPT, /Activate program Documents from Step 3 after implementation/); @@ -2209,7 +2206,7 @@ test("renders five navigable stage pages with progressive guidance and honest pr assert.match(APP_SCRIPT, /Review and approve Data Retention Schedule/); assert.match(APP_SCRIPT, /Approve schedule/); assert.match(APP_SCRIPT, /function renderFinishStepThree\(\)/); - assert.match(APP_SCRIPT, /Program Content Activation/); + assert.match(APP_SCRIPT, /function renderProgramActivation\(complete = false\)/); assert.equal(PROGRAM_PATH.some(({ sections }) => sections.some(({ id }) => id === "service-description")), false); assert.doesNotMatch(APP_SCRIPT, /Working areas/); assert.doesNotMatch(APP_SCRIPT, /Complete This Step/); @@ -2919,3 +2916,32 @@ test("warm bootstrap caches reject changed governed Markdown and still report ap assert.equal(response.status, 200); assert.ok((await response.json()).state.validation.diagnostics.some(({ code }) => code === "approval-content-changed")); }); + + +test("Step 3 combines activation into one box while preserving unavailable content and gaps", () => { + const source = APP_SCRIPT.slice(APP_SCRIPT.indexOf("function programActivationAssessments"), APP_SCRIPT.indexOf("function openProgramActivationDialog")); + const state = { + resources: ["policy-ready", "document-ready", "training-blocked"].map((id) => ({ record: { id, status: "approved" } })), + programReadiness: { + policyActivations: [{ policyId: "policy-ready", state: "approved-implementation-pending", gapCount: 2 }], + documentActivations: [{ documentId: "document-ready", state: "ready-to-activate" }], + trainingActivations: [{ trainingId: "training-blocked", state: "approved-implementation-pending", gapCount: 1 }] + } + }; + const context = vm.createContext({ state, pluralize: (word, count) => word + (count === 1 ? "" : "s"), documentActivationCards: () => "Document and Training details", policyActivationCards: () => "Policy gaps" }); + vm.runInContext(source, context); + assert.deepEqual(Array.from(context.programActivationCandidates(), ({ resourceId }) => resourceId), ["policy-ready", "document-ready"]); + const html = context.renderProgramActivation(); + assert.equal((html.match(/

Activation<\/h2>/g) || []).length, 1); + assert.equal((html.match(/data-review-program-activation/g) || []).length, 1); + assert.match(html, /1 cannot activate yet/); + assert.match(html, /3 gaps to review/); + assert.match(html, /
/); + assert.doesNotMatch(html, /activation-details" open/); + state.readOnly = true; + assert.doesNotMatch(context.renderProgramActivation(), /data-review-program-activation/); + assert.match(context.renderProgramActivation(true), /Continue to Step 4/); + state.readOnly = false; + state.resources = []; + assert.match(context.renderProgramActivation(), /data-review-program-activation disabled/); +});