diff --git a/docs/architecture.md b/docs/architecture.md
index 374dce8..a5df74d 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -777,3 +777,5 @@ Reporting Channel Set proposals require committed independent approval during St
Each Program has an exact-revision Obligation Collection Review. Its complete population includes linked active schedules, unresolved proposals, and retired duplicates. Per-schedule decisions distinguish adopted rules, adopted replacements, retired schedules, and intentional inapplicability. Pending alternative rule proposals need explicit exclusion. Changes to schedules, rules, selected scope, or governing content invalidate this approval; the approved-to-active content transition preserves its design binding. Ordinary setup Collection Reviews retain their existing historical confirmation semantics.
Schedule-set and Control collection reviewer eligibility distinguish Obligation queue ownership from personal work assignments. Team membership and chairing a Team-owned review queue do not establish that a Person operates the Controls. Control ownership still resolves Team members and Appointment holders, and personal or Appointment assignments on Obligations remain conflicts. Governed content approval keeps its full owner separation checks, including Team membership. A reviewer who also owns implementation work remains ineligible.
+
+Readiness keeps shared prerequisites separate from individual implementation work. A Program schedule-set approval is one action with its selected Controls as dependents. Each Control retains the approval check and cannot pass the readiness gate until it clears, but otherwise complete implementation checks do not produce another action or reduce implementation progress. Mapped evidence-source setup is also one action per source; missing Control mappings remain individual gaps. Workflow findings, recommendations, progress, CLI output, and browser checklists share these bindings. Source-family items remain evidence-map rollups rather than duplicate setup actions.
diff --git a/package-lock.json b/package-lock.json
index 2517dbc..de42d4d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "filegrc-monorepo",
- "version": "0.17.2",
+ "version": "0.17.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "filegrc-monorepo",
- "version": "0.17.2",
+ "version": "0.17.3",
"workspaces": [
"packages/filegrc",
"packages/create-filegrc"
@@ -24,7 +24,7 @@
"link": true
},
"packages/create-filegrc": {
- "version": "0.17.2",
+ "version": "0.17.3",
"license": "MIT",
"bin": {
"create-filegrc": "bin/create-filegrc.js"
@@ -34,7 +34,7 @@
}
},
"packages/filegrc": {
- "version": "0.17.2",
+ "version": "0.17.3",
"license": "MIT",
"bin": {
"filegrc": "bin/filegrc.js"
diff --git a/package.json b/package.json
index 7169762..3b35992 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "filegrc-monorepo",
- "version": "0.17.2",
+ "version": "0.17.3",
"private": true,
"workspaces": [
"packages/filegrc",
diff --git a/packages/create-filegrc/package.json b/packages/create-filegrc/package.json
index 315ff03..457d815 100644
--- a/packages/create-filegrc/package.json
+++ b/packages/create-filegrc/package.json
@@ -1,6 +1,6 @@
{
"name": "create-filegrc",
- "version": "0.17.2",
+ "version": "0.17.3",
"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 ce6dc55..938fad5 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.17.2",
+ version: "0.17.3",
lockfileVersion: 3,
requires: true,
packages: {
"": {
name,
- version: "0.17.2",
+ version: "0.17.3",
dependencies: { filegrc: versionRange }
}
}
diff --git a/packages/create-filegrc/template/package.json b/packages/create-filegrc/template/package.json
index c075b67..44312fe 100644
--- a/packages/create-filegrc/template/package.json
+++ b/packages/create-filegrc/template/package.json
@@ -1,6 +1,6 @@
{
"name": "{{project_name}}",
- "version": "0.17.2",
+ "version": "0.17.3",
"private": true,
"description": "filegrc workspace for a SOC 2 program",
"type": "module",
diff --git a/packages/create-filegrc/test/create.test.js b/packages/create-filegrc/test/create.test.js
index e95cf06..ab25a0f 100644
--- a/packages/create-filegrc/test/create.test.js
+++ b/packages/create-filegrc/test/create.test.js
@@ -730,12 +730,15 @@ test("creates a complete generic repository with one dependency", async (context
const sourceFinding = workflow.findings.find(({ key }) => (
key === `program.controls.source-family-${familyId}`
));
- assert.equal(sourceFinding.state, "ready", familyId);
- assert.equal(
- sourceFinding.actions.some(({ command }) => /scaffold system/.test(command)),
- false,
- familyId
- );
+ assert.equal(sourceFinding, undefined, "source-family rollups must not duplicate mapping actions");
+ const sourceRollup = programReadiness.stages.find(({ id }) => id === "controls").items
+ .find(({ id }) => id === `source-family-${familyId}`);
+ assert.equal(sourceRollup.status, "action", familyId);
+ for (const mapping of sourceRollup.controlMappings.filter(({ complete }) => !complete)) {
+ const controlFinding = workflow.findings.find(({ key }) => key === `program.controls.control-${mapping.controlId}`);
+ assert.equal(controlFinding.state, "ready", mapping.controlId);
+ assert.equal(controlFinding.actions.some(({ command }) => /scaffold system/.test(command)), false);
+ }
assert.equal(
workflow.findings.find(({ key }) => key === `evidence-source.${familyId}.coverage`).state,
"ready",
diff --git a/packages/filegrc/package.json b/packages/filegrc/package.json
index 57eb89b..3351262 100644
--- a/packages/filegrc/package.json
+++ b/packages/filegrc/package.json
@@ -1,6 +1,6 @@
{
"name": "filegrc",
- "version": "0.17.2",
+ "version": "0.17.3",
"description": "Zero-dependency Git-native GRC engine",
"license": "MIT",
"repository": {
diff --git a/packages/filegrc/src/program-readiness.js b/packages/filegrc/src/program-readiness.js
index cc3a46c..1831a35 100644
--- a/packages/filegrc/src/program-readiness.js
+++ b/packages/filegrc/src/program-readiness.js
@@ -165,12 +165,22 @@ export async function assessProgramReadiness(input, options = {}) {
const policyStage = await policiesStage(scope, records, byId, readMarkdown, loaded.model);
const controlStage = await controlsStage(scope, byId, readMarkdown, asOf, loaded.model, loaded.root, asOfTime, collectionReviews.find(({ resourceType }) => resourceType === "obligation"));
- controlStage.items.unshift(...collectionReviews.filter(({ resourceType }) => resourceType === "obligation").map(collectionReviewReadinessItem));
+ controlStage.items.unshift(...collectionReviews.filter(({ resourceType }) => resourceType === "obligation").map((assessment) => ({
+ ...collectionReviewReadinessItem(assessment),
+ title: "Approve the Program schedule set",
+ programId: program.id,
+ commands: [
+ `npx filegrc review-collection obligation --program ${shellArgument(program.id)} --scaffold > REVIEW.json`,
+ `npx filegrc review-collection obligation --program ${shellArgument(program.id)} REVIEW.json --preview --json`,
+ `npx filegrc review-collection obligation --program ${shellArgument(program.id)} REVIEW.json --yes --json`
+ ]
+ })));
controlStage.items.unshift(...collectionReviews
.filter(({ resourceType }) => resourceType === "complementary-control")
.map(collectionReviewReadinessItem));
const sourceStage = await evidenceSourcesStage(scope, byId, loaded.model, readMarkdown);
controlStage.items.push(...sourceStage.items);
+ bindSharedReadinessPrerequisites(controlStage.items, program.id);
const retentionScheduleReview = collectionReviews.find(({ resourceType }) => resourceType === "retention-schedule-item");
const retentionItems = await assessRetentionReadiness(loaded, program, {
informationTypesReviewed: collectionReviews.find(({ resourceType }) => resourceType === "information-type")?.complete === true,
@@ -397,7 +407,7 @@ export async function assessEvidenceMap(input, options = {}) {
const evidenceItems = readiness.stages
.find((stage) => stage.id === "controls")
?.items.filter((current) => current.id.startsWith("source-family-")) || [];
- const counts = countStatuses(evidenceItems);
+ const counts = countStatuses(evidenceItems, true);
return {
schemaVersion: 1,
generatedAt: readiness.generatedAt,
@@ -1700,6 +1710,7 @@ function legacyPolicyLibraryProposals(records) {
async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null, asOfTime, scheduleReview = null) {
const items = [];
+ const sourcePrerequisites = new Map();
const families = selectedControlFamilies(scope.controls, model);
if (!scope.controls.length) {
items.push(item("control-scope", "action", "Select the program controls", "No controls are selected for the management program.", { type: "control" }));
@@ -1756,17 +1767,44 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null
&& queueSchedules.some((obligation) => obligationRuleIsEnabled(obligation, byId, asOfTime))
} : {})
};
+ const sharedChecks = modelSupports(model, "schedule-set-review") ? { scheduleSetReviewed: "collection-review-obligation" } : {};
+ const mappedMissingSourceFamilies = missingSourceFamilies.filter((family) => sourceDetails.some(({ record }) => (
+ !record.evidenceSourceKinds?.length
+ || !family.sourceKinds.length || family.sourceKinds.some((kind) => record.evidenceSourceKinds.includes(kind))
+ )));
+ const individualMissingSourceFamilies = missingSourceFamilies.filter((family) => !mappedMissingSourceFamilies.includes(family));
+ let sourcePrerequisiteIds = [];
+ // Mapping a Control to the right source remains an individual task. Once
+ // that mapping exists, missing source access or retrieval setup is one
+ // source task shared by all Controls that use it.
+ if (checks.evidenceSourceReady === false) {
+ sourcePrerequisiteIds = sourceDetails.filter(({ record, instructions }) => (
+ mappedMissingSourceFamilies.some((family) => !record.evidenceSourceKinds?.length || !family.sourceKinds.length || family.sourceKinds.some((kind) => record.evidenceSourceKinds.includes(kind)))
+ && (record.status !== "active" || !record.evidenceSourceKinds?.length || !record.evidenceOwnerIds?.length || !substantiveMarkdown(instructions) || openPlaceholderCount(instructions) > 0)
+ )).map(({ record }) => {
+ const id = `source-readiness-${record.id}`;
+ sourcePrerequisites.set(id, item(id, "action", `Prepare ${record.title} as an evidence source`,
+ "Make the source active, assign its evidence roles, name the people who can obtain evidence, and document complete retrieval instructions. This source setup is shared by its dependent Controls.", record,
+ { commands: [`npx filegrc get ${shellArgument(record.id)} --mutation`] }));
+ return id;
+ });
+ if (sourcePrerequisiteIds.length && !individualMissingSourceFamilies.length) sharedChecks.evidenceSourceReady = sourcePrerequisiteIds;
+ }
+ const individualChecks = Object.fromEntries(Object.entries(checks).filter(([name]) => !Object.hasOwn(sharedChecks, name)));
+ const sharedPrerequisiteIds = [...new Set([...Object.entries(sharedChecks).filter(([name]) => !checks[name]).flatMap(([, ids]) => ids), ...sourcePrerequisiteIds])];
const launchChecks = Object.fromEntries(Object.entries(checks).filter(([name]) => ![
"implemented", "implementationDate"
].includes(name)));
const launchReady = Object.values(launchChecks).every(Boolean);
const implementationState = control.status === "implemented"
- ? Object.values(checks).every(Boolean) ? "implemented" : "implemented-with-gaps"
+ ? Object.values(individualChecks).every(Boolean)
+ ? sharedPrerequisiteIds.length ? "implemented-awaiting-prerequisites" : "implemented"
+ : "implemented-with-gaps"
: control.status === "partially-implemented" ? "partial"
: launchReady ? "ready-to-implement" : "planned";
- const missing = Object.entries(checks).filter(([, value]) => !value).map(([name]) => controlCheckLabel(name));
- const nextSteps = controlImplementationSteps(control, checks, {
- missingSourceFamilies,
+ const missing = Object.entries(individualChecks).filter(([, value]) => !value).map(([name]) => controlCheckLabel(name));
+ const nextSteps = controlImplementationSteps(control, individualChecks, {
+ missingSourceFamilies: sourcePrerequisiteIds.length ? individualMissingSourceFamilies : missingSourceFamilies,
targets: [...new Set([
...(control.systemIds || []),
...(control.componentIds || [])
@@ -1774,14 +1812,21 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null
});
items.push(item(
`control-${control.id}`,
- missing.length ? "action" : "complete",
+ missing.length ? "action" : sharedPrerequisiteIds.length ? "blocked" : "complete",
`${control.code ? `${control.code}: ` : ""}${control.title}`,
missing.length
? nextSteps[0] || `Complete the remaining Control checks: ${missing.join(", ")}.`
+ : sharedPrerequisiteIds.length ? "Individual implementation checks pass. Resolve the shared Program prerequisites before treating this Control as ready."
: `Implemented ${control.effectiveOn}; owned, scoped, scheduled, documented, mapped, and tied to ${sourceSystems.length} authoritative ${sourceSystems.length === 1 ? "source" : "sources"}.`,
control,
{
checks,
+ programId: scope.program.id,
+ individualChecks,
+ sharedPrerequisiteIds,
+ prerequisiteIds: [...new Set([...Object.values(sharedChecks).flat(), ...sourcePrerequisiteIds])],
+ sharedOnly: !missing.length && sharedPrerequisiteIds.length > 0,
+ progressUnits: [{ id: `control-${control.id}`, status: missing.length ? "action" : "complete", title: control.title }],
launchChecks,
implementationState,
implementationAssertion: "Implemented means the stated control works for the named Systems, its owner and procedure match current practice, and its evidence sources can produce proof. Scheduled or event-driven Controls have at least one enabled linked Obligation. It does not assert that a scheduled cycle or event has already occurred. Record dated operating proof when it does.",
@@ -1795,6 +1840,7 @@ async function controlsStage(scope, byId, readMarkdown, asOf, model, root = null
}
));
}
+ items.push(...sourcePrerequisites.values());
return stage("controls", "Implement Controls", "Put each selected Control in place. Check its launch conditions, then mark it Implemented when it is working. Dated proof follows during operation.", items);
}
@@ -1875,6 +1921,7 @@ async function evidenceSourcesStage(scope, byId, model, readMarkdown) {
completeSources[0] || selectedSources[0] || { type: sourceType },
{
progressUnit: false,
+ rollupOnly: componentSources,
familyId: family.id,
sourceKinds: family.sourceKinds,
controlIds: family.controls.map((control) => control.id),
@@ -2253,6 +2300,16 @@ function stage(id, title, description, items) {
return { id, title, description, items };
}
+function bindSharedReadinessPrerequisites(items, programId) {
+ for (const prerequisite of items) {
+ const dependents = items.filter((candidate) => candidate.prerequisiteIds?.includes(prerequisite.id));
+ if (!dependents.length) continue;
+ prerequisite.programId = programId;
+ prerequisite.dependentResources = dependents.map(({ resourceType: type, resourceId: id, title }) => ({ type, id, title }));
+ prerequisite.message += ` ${dependents.length} selected ${dependents.length === 1 ? "Control depends" : "Controls depend"} on this shared prerequisite.`;
+ }
+}
+
function finalizeStage(current) {
current.counts = countStatuses(current.items);
current.status = current.counts.action ? "action" : current.counts.later ? "later" : "complete";
@@ -2321,9 +2378,12 @@ function shellArgument(value) {
: `'${text.replaceAll("'", "'\\''")}'`;
}
-function countStatuses(items) {
+function countStatuses(items, includeRollups = false) {
const counts = { complete: 0, action: 0, later: 0, info: 0 };
- for (const current of items) counts[current.status] = (counts[current.status] || 0) + 1;
+ for (const current of items) {
+ if (current.rollupOnly && current.status !== "complete" && !includeRollups) continue;
+ counts[current.status] = (counts[current.status] || 0) + 1;
+ }
return counts;
}
diff --git a/packages/filegrc/src/web.js b/packages/filegrc/src/web.js
index 8cacdf0..83f980b 100644
--- a/packages/filegrc/src/web.js
+++ b/packages/filegrc/src/web.js
@@ -1231,14 +1231,15 @@ function workflowGuidance(options = {}) {
const matches = (item) => {
if (options.type === "audit" && options.id && item.auditId === options.id) return true;
if (options.stageId && item.stage !== options.stageId) return false;
- if (options.type && item.subject?.type !== options.type) return false;
- if (options.id && item.subject?.id !== options.id) return false;
+ const dependent = item.dependentResources?.some((resource) => resource.type === options.type && (!options.id || resource.id === options.id));
+ if (options.type && item.subject?.type !== options.type && !dependent) return false;
+ if (options.id && item.subject?.id !== options.id && !dependent) return false;
return true;
};
const activeStates = new Set(["blocked", "due", "open", "overdue", "ready", "scheduled", "upcoming", "waiting-external"]);
const findings = workflow.findings.filter((item) => matches(item) && activeStates.has(item.state));
const workItems = workflow.workItems.filter((item) => matches(item) && activeStates.has(item.state));
- const items = [...findings, ...workItems].sort((left, right) => (
+ const items = distinctPageActionItems([...findings, ...workItems]).sort((left, right) => (
workflowItemStatePriority(left) - workflowItemStatePriority(right)
|| (left.priority ?? workflowItemPriority(left)) - (right.priority ?? workflowItemPriority(right))
|| left.key.localeCompare(right.key)
@@ -1257,7 +1258,11 @@ function workflowGuidance(options = {}) {
? '
' + esc(controlLaunchLabel(item.implementationState)) + '
' + item.nextSteps.map((step) => '- ' + esc(step) + '
').join("") + '
' + esc(item.implementationAssertion || "") + ''
: '' + esc(item.message || workflowItemDetail(item)) + '';
const body = '' + esc(options.type === "control" && item.implementationState ? controlLaunchLabel(item.implementationState).split(":")[0] : properCase(item.state)) + '' + esc(item.title) + '' + guidance + '';
- return href ? '' + body + '' : '' + body + '
';
+ const dependents = item.dependentResources?.length
+ ? '' + item.dependentResources.length + ' dependent ' + pluralize("Control", item.dependentResources.length) + '
'
+ + personParticipationGroup("Dependent Controls", item.dependentResources.map((resource) => ({ record: resource, detail: "Requires this shared prerequisite" })), item.dependentResources.length, "Controls") + ' '
+ : "";
+ return (href ? '' + body + '' : '' + body + '
') + dependents;
};
const rows = visible.map(renderRow).join("");
const remaining = items.slice(visible.length);
@@ -1274,13 +1279,15 @@ function controlLaunchLabel(state) {
partial: "Partially implemented: launch conditions remain",
"ready-to-implement": "Launch checks ready: check the live configuration, then record its start date",
"implemented-with-gaps": "Implemented with gaps: fix the launch checks",
+ "implemented-awaiting-prerequisites": "Implementation checks pass: awaiting Program prerequisites",
implemented: "Implemented"
})[state] || "Control launch";
}
function controlLaunchState(id) {
- return state.programReadiness?.stages?.find(({ id: stageId }) => stageId === "controls")?.items
+ const value = state.programReadiness?.stages?.find(({ id: stageId }) => stageId === "controls")?.items
.find(({ id: itemId }) => itemId === "control-" + id)?.implementationState;
+ return value === "implemented-awaiting-prerequisites" ? "implemented" : value;
}
function recordCompletionState(record) {
@@ -1474,6 +1481,7 @@ function recordWorkflowItems(type, id) {
&& (
item.subject?.type === type && item.subject?.id === id
|| item.source?.type === type && item.source?.id === id
+ || item.dependentResources?.some((resource) => resource.type === type && resource.id === id)
)
)).sort((left, right) => (
workflowItemStatePriority(left) - workflowItemStatePriority(right)
@@ -1490,8 +1498,11 @@ function recordWorkflowCell(type, entry, detailContext = "") {
if (!items.length) return 'No calculated action';
const item = items[0];
let href = workflowItemHref(item) || "#/resource/" + encodeURIComponent(type) + "/" + encodeURIComponent(entry.record.id);
+ if (type === "control" && item.dependentResources?.some(({ id }) => id === entry.record.id)) {
+ return 'DependentShared prerequisite' + esc(item.title) + '';
+ }
if (detailContext && href.startsWith("#/resource/" + encodeURIComponent(type) + "/") && !href.includes("?")) href += detailContext;
- const title = type === "control"
+ const title = type === "control" && item.implementationState
? item.implementationState === "implemented-with-gaps" ? "Fix Control launch gaps" : "Finish implementation"
: item.title;
const statusLabel = type === "control" && item.implementationState
@@ -2088,7 +2099,7 @@ function stagePageItems(stage, destination) {
&& item.key?.startsWith("evidence-source.")
) return false;
const href = workflowItemHref(item);
- return workflowHrefBelongsToPage(destination, href)
+ return (workflowHrefBelongsToPage(destination, href) || item.dependentResources?.some((resource) => resource.type === destination.type))
&& !(destination.href === "#/policies" && isRetentionScheduleWorkflowItem(item));
})).sort((left, right) => (
workflowItemStatePriority(left) - workflowItemStatePriority(right)
@@ -3344,6 +3355,7 @@ function renderList(main, type, params = new URLSearchParams()) {
const pageSummary = relatedGuidance?.summary || STAGE_PAGE_SUMMARIES[type] || definition.description;
main.innerHTML = '' + esc(listStage?.title || groupTitle(definition.group)) + '
' + esc(titleCase(listTitle)) + '
' + guideTrigger + '' + esc(pageSummary) + '
' + listTools + '
' + resourceGuide(type, relatedGuidance) +
collectionReviewPanel(type) +
+ (type === "control" ? workflowGuidance({ type, title: "Next steps" }) : "") +
'
| ' + esc(fieldLabel(type, "title")) + ' | ' + fields.map((name) => '' + esc(fieldLabel(type, name)) + ' | ').join("") + 'Next action | Git file |
|---|
' +
'
';
resourceGuideCleanup = setupResourceGuide(main);
diff --git a/packages/filegrc/src/workflow.js b/packages/filegrc/src/workflow.js
index f892415..15e3087 100644
--- a/packages/filegrc/src/workflow.js
+++ b/packages/filegrc/src/workflow.js
@@ -237,8 +237,8 @@ export function workflowForResource(workflow, type, id, loaded = null) {
};
const matches = (item) => item.subject?.type === type && item.subject?.id === id;
const related = (item) => (
- item.source?.type === type
- && item.source?.id === id
+ (item.source?.type === type && item.source?.id === id
+ || item.dependentResources?.some((resource) => resource.type === type && resource.id === id))
&& !matches(item)
);
const directFindings = workflow.findings.filter(matches);
@@ -362,13 +362,15 @@ function mutationTarget(operation, mutation, record) {
function programFindings(program) {
return program.stages.flatMap((stage) => stage.items
+ .filter((item) => !item.sharedOnly && !item.rollupOnly)
.filter(({ id }) => !["required-appointments", "independent-reviewer"].includes(id))
.map((item) => normalizeFinding(
- `program.${stage.id}.${item.id}`,
+ item.dependentResources?.length ? `program.${item.programId}.${stage.id}.${item.id}` : `program.${stage.id}.${item.id}`,
item,
{
assessment: stage.id === "operation" ? "period-health" : "program-configuration",
- stage: stage.id
+ stage: stage.id,
+ programId: program.program?.id
}
)));
}
@@ -441,6 +443,8 @@ function recordFinalizationFindings(loaded, program) {
}
function removeRedundantRecordFindings(recordFindings, guidedFindings) {
+ const guidedChecks = new Map(guidedFindings.filter(({ subject, unmetChecks }) => subject?.id && unmetChecks?.length)
+ .map((finding) => [subjectKey(finding.subject), new Set(finding.unmetChecks)]));
const guidedSubjects = new Set(guidedFindings
.filter(({ subject }) => subject?.type && subject?.id)
.map(({ subject }) => subjectKey(subject)));
@@ -468,6 +472,9 @@ function removeRedundantRecordFindings(recordFindings, guidedFindings) {
return recordFindings.filter((finding) => {
const key = finding.subject ? subjectKey(finding.subject) : null;
if (!key) return true;
+ // A readiness check already provides the action for this exact field.
+ // Keep fields that have no matching check rather than hiding their work.
+ if (guidedChecks.get(key)?.has(finding.fieldPath)) return false;
if (finding.code.endsWith(".finalize")) {
if (finding.subject?.type === "collection-review" && guidedSubjectIds.has(finding.subject.id)) {
return false;
@@ -1166,6 +1173,7 @@ function normalizeFinding(code, item, context) {
id: assignment.resourceId,
reasons: assignment.reasons || []
}));
+ dependencies.push(...(item.sharedPrerequisiteIds || []).map((id) => ({ key: `program.${item.programId || context.programId}.${context.stage}.${id}`, id })));
return {
key: code,
code,
@@ -1176,6 +1184,7 @@ function normalizeFinding(code, item, context) {
severity: findingSeverity(state, context.assessment),
title: item.title,
message: item.message,
+ ...(item.dependentResources ? { dependentResources: item.dependentResources, programId: item.programId } : {}),
...(item.nextSteps?.length ? { nextSteps: item.nextSteps } : {}),
...(item.implementationState ? {
implementationState: item.implementationState,
diff --git a/packages/filegrc/test/render.test.js b/packages/filegrc/test/render.test.js
index d4022c3..a1dfab8 100644
--- a/packages/filegrc/test/render.test.js
+++ b/packages/filegrc/test/render.test.js
@@ -1984,7 +1984,7 @@ test("keeps operation status explicit and stage instructions available on demand
assert.match(APP_SCRIPT, /scheduled or external/);
assert.match(APP_SCRIPT, /missingType[\s\S]*destinationHref\(\{ kind: "collection", resourceType: missingType, create: !state\.readOnly \}\)/);
assert.match(APP_SCRIPT, /params\.get\("review"\) === "1"/);
- assert.doesNotMatch(listSource, /workflowGuidance\(/);
+ assert.match(listSource, /type === "control" \? workflowGuidance\(\{ type, title: "Next steps" \}\)/);
assert.match(listSource, /collectionReviewPanel\(type\)/);
assert.match(listSource, /Next action<\/th>/);
assert.match(APP_SCRIPT, /function recordWorkflowCell\(type, entry, detailContext = ""\)/);
diff --git a/packages/filegrc/test/shared-control-readiness.test.js b/packages/filegrc/test/shared-control-readiness.test.js
new file mode 100644
index 0000000..d1bc9e5
--- /dev/null
+++ b/packages/filegrc/test/shared-control-readiness.test.js
@@ -0,0 +1,181 @@
+import assert from "node:assert/strict";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import test from "node:test";
+import { makeComprehensiveWorkspace } from "./fixtures.js";
+import { writeJson, executeCli } from "./helpers.js";
+import { loadWorkspace } from "../src/workspace.js";
+import { applicabilityScopeRevision } from "../src/applicability-scope.js";
+import { assessProgramReadiness } from "../src/program-readiness.js";
+import { assessCollectionReviews } from "../src/collection-review.js";
+import { assessWorkflow, workflowForResource } from "../src/workflow.js";
+import { createAppStateSection } from "../src/state.js";
+import { runCli } from "../src/cli.js";
+import { fileURLToPath } from "node:url";
+
+async function fixture(context, count = 28) {
+ const root = await mkdtemp(join(tmpdir(), "filegrc-shared-controls-"));
+ context.after(() => rm(root, { recursive: true, force: true }));
+ await makeComprehensiveWorkspace(root, "12");
+ const initial = await loadWorkspace(root);
+ const programEntry = initial.entries.find(({ record }) => record.type === "program");
+ const entry = initial.entries.find(({ record }) => record.type === "control");
+ const markdown = await readFile(entry.path.replace(/\.json$/, ".md"), "utf8");
+ const controls = Array.from({ length: count }, (_, index) => ({
+ ...structuredClone(entry.record),
+ id: index ? `control-shared-${index}` : entry.record.id,
+ title: `Shared prerequisite Control ${index + 1}`,
+ procedureRevision: "a".repeat(40), procedureEffectiveOn: "2026-01-01"
+ }));
+ programEntry.record.controlIds = controls.map(({ id }) => id);
+ for (const control of controls) {
+ control.applicabilityReview = { decision: "applicable", rationale: "Selected service requirement.", reviewedByIds: ["person-independent-approver-example"], reviewedOn: "2026-01-01", scopeRevision: applicabilityScopeRevision(control, programEntry.record, initial.resources, initial.model) };
+ await writeJson(join(root, "data", "controls", `${control.id}.json`), control);
+ await writeFile(join(root, "data", "controls", `${control.id}.md`), markdown);
+ }
+ await writeJson(programEntry.path, programEntry.record);
+ return { root, loaded: await loadWorkspace(root), controls };
+}
+const controlItems = (readiness) => readiness.stages.find(({ id }) => id === "controls").items.filter(({ id }) => id.startsWith("control-"));
+const scheduleItem = (readiness) => readiness.stages.find(({ id }) => id === "controls").items.find(({ id }) => id === "collection-review-obligation");
+async function focusedWorkflow(loaded, readiness) {
+ const controls = controlItems(readiness);
+ const groups = readiness.stages.find(({ id }) => id === "controls").items.filter(({ dependentResources }) => dependentResources?.length);
+ return assessWorkflow(loaded, { programId: readiness.program.id, programReadiness: { ...readiness, stages: [{ id: "controls", items: [...groups, ...controls] }], firstAction: groups.find(({ status }) => status === "action") }, validation: { ok: true, diagnostics: [] }, auditPreparations: {} });
+}
+
+test("28 shared-only Control blockers produce one approval action and keep every gate", async (context) => {
+ const { loaded } = await fixture(context);
+ const readiness = await assessProgramReadiness(loaded);
+ const group = scheduleItem(readiness);
+ assert.equal(group.title, "Approve the Program schedule set");
+ assert.equal(group.dependentResources.length, 28);
+ assert.ok(group.commands.every((command) => command.includes("--program program-example")));
+ for (const item of controlItems(readiness)) {
+ assert.equal(item.checks.scheduleSetReviewed, false);
+ assert.equal(item.launchChecks.scheduleSetReviewed, false);
+ assert.equal(item.status, "blocked");
+ assert.equal(item.implementationState, "implemented-awaiting-prerequisites");
+ assert.equal(item.progressUnits[0].status, "complete");
+ }
+ assert.equal(readiness.canStartCandidatePeriod, false);
+ const workflow = await focusedWorkflow(loaded, readiness);
+ const findings = workflow.findings.filter(({ dependentResources, subject }) => dependentResources?.some(({ type }) => type === "control") || subject?.type === "control");
+ assert.equal(findings.length, 1);
+ assert.equal(findings[0].state, "ready");
+ assert.equal(workflow.recommended.key, findings[0].key);
+ assert.equal(workflowForResource(workflow, "control", controlItems(readiness)[0].resourceId).related.findings[0].key, findings[0].key);
+});
+
+test("individual gaps stay separate from their shared schedule prerequisite", async (context) => {
+ const { loaded } = await fixture(context, 3);
+ const records = loaded.resources.filter(({ type }) => type === "control");
+ records[0].procedureRevision = null;
+ records[1].ownerIds = [];
+ const readiness = await assessProgramReadiness(loaded);
+ assert.deepEqual(controlItems(readiness).map(({ status }) => status), ["action", "action", "blocked"]);
+ const workflow = await focusedWorkflow(loaded, readiness);
+ const own = workflow.findings.filter(({ subject }) => subject?.type === "control");
+ assert.equal(own.length, 2, own.map(({ key }) => key).join(", "));
+ assert.ok(own.every(({ dependencies }) => dependencies.some(({ id }) => id === "collection-review-obligation")));
+ assert.equal(workflow.findings.filter(({ dependentResources }) => dependentResources?.length).length, 1);
+});
+
+test("schedule groups are Program-scoped and approval clears only that Program's dependencies", async (context) => {
+ const { loaded } = await fixture(context, 2);
+ const first = loaded.resources.find(({ type }) => type === "program");
+ const second = { ...structuredClone(first), id: "program-second", controlIds: [first.controlIds[1]] };
+ first.controlIds = [first.controlIds[0]];
+ loaded.resources.push(second);
+ const secondControl = loaded.resources.find(({ id }) => id === second.controlIds[0]);
+ secondControl.applicabilityReview.scopeRevision = applicabilityScopeRevision(secondControl, second, loaded.resources, loaded.model);
+ const approvedReviews = assessCollectionReviews(loaded, { programId: first.id }).map((review) => review.resourceType === "obligation" ? { ...review, complete: true, status: "current" } : review);
+ const one = await assessProgramReadiness(loaded, { programId: first.id, collectionReviews: approvedReviews });
+ const two = await assessProgramReadiness(loaded, { programId: second.id });
+ assert.equal(controlItems(one)[0].status, "complete");
+ assert.equal(controlItems(one)[0].checks.scheduleSetReviewed, true);
+ assert.deepEqual(controlItems(one)[0].sharedPrerequisiteIds, []);
+ assert.equal(controlItems(two)[0].status, "blocked");
+ assert.equal(scheduleItem(two).dependentResources[0].id, second.controlIds[0]);
+ assert.ok(scheduleItem(two).commands.every((command) => command.includes("--program program-second")));
+ const beforeOne = await assessProgramReadiness(loaded, { programId: first.id });
+ const f1 = (await focusedWorkflow(loaded, beforeOne)).findings.find(({ dependentResources }) => dependentResources?.length);
+ const f2 = (await focusedWorkflow(loaded, two)).findings.find(({ dependentResources }) => dependentResources?.length);
+ assert.notEqual(f1.key, f2.key);
+});
+
+test("shared evidence-source setup is one task while unmapped Controls keep their own gap", async (context) => {
+ const { loaded } = await fixture(context, 3);
+ const source = loaded.resources.find(({ type }) => type === "component");
+ const originalKinds = [...source.evidenceSourceKinds];
+ source.evidenceOwnerIds = [];
+ const controls = loaded.resources.filter(({ type }) => type === "control");
+ controls[2].evidenceSourceComponentIds = [];
+ const readiness = await assessProgramReadiness(loaded);
+ const groups = readiness.stages.find(({ id }) => id === "controls").items.filter(({ id }) => id.startsWith("source-readiness-"));
+ assert.equal(groups.length, 1);
+ assert.equal(groups[0].dependentResources.length, 2);
+ assert.deepEqual(controlItems(readiness).map(({ status }) => status), ["blocked", "blocked", "action"]);
+ const workflow = await focusedWorkflow(loaded, readiness);
+ assert.equal(workflow.findings.filter(({ subject }) => subject?.type === "control").length, 1);
+ assert.equal(workflow.findings.filter(({ dependentResources }) => dependentResources?.length).length, 2);
+ source.evidenceSourceKinds = [];
+ source.evidenceOwnerIds = ["person-example"];
+ const missingRole = await assessProgramReadiness(loaded);
+ assert.equal(missingRole.stages.find(({ id }) => id === "controls").items.filter(({ id }) => id.startsWith("source-readiness-")).length, 1);
+ assert.equal(controlItems(missingRole)[0].status, "blocked");
+ source.evidenceSourceKinds = originalKinds;
+ source.evidenceOwnerIds = ["person-example"];
+ const cleared = await assessProgramReadiness(loaded, { collectionReviews: assessCollectionReviews(loaded).map((review) => review.resourceType === "obligation" ? { ...review, complete: true } : review) });
+ assert.deepEqual(controlItems(cleared).map(({ status }) => status), ["complete", "complete", "action"]);
+});
+
+test("CLI and browser state expose identical grouped readiness", async (context) => {
+ const { root, loaded } = await fixture(context);
+ const browser = await createAppStateSection(loaded, "program");
+ const cli = fileURLToPath(new URL("../bin/filegrc.js", import.meta.url));
+ const output = await executeCli(runCli, process.execPath, [cli, "program-readiness", "--json", "--root", root]);
+ const { generatedAt: a, ...fromCli } = JSON.parse(output.stdout);
+ const { generatedAt: b, ...fromBrowser } = browser.programReadiness;
+ assert.ok(a && b);
+ assert.deepEqual(fromCli, fromBrowser);
+});
+
+test("mixed evidence families group the mapped source even when other mappings are missing", async (context) => {
+ const { loaded } = await fixture(context, 2);
+ const program = loaded.resources.find(({ type }) => type === "program");
+ const controls = loaded.resources.filter(({ type }) => type === "control");
+ const source = loaded.resources.find(({ type }) => type === "component");
+ source.evidenceSourceKinds = [loaded.model.evidenceSourceFamilies.find(({ id }) => id === "workforce").sourceKinds[0]];
+ source.evidenceOwnerIds = [];
+ for (const [index, control] of controls.entries()) {
+ control.code = ["HR-01", "IAM-03"][index];
+ control.applicabilityReview.scopeRevision = applicabilityScopeRevision(control, program, loaded.resources, loaded.model);
+ }
+ const readiness = await assessProgramReadiness(loaded);
+ const groups = readiness.stages.find(({ id }) => id === "controls").items.filter(({ id }) => id.startsWith("source-readiness-"));
+ assert.equal(groups.length, 1);
+ assert.equal(groups[0].dependentResources.length, 2);
+ for (const item of controlItems(readiness)) {
+ assert.equal(item.status, "action");
+ assert.equal(item.individualChecks.evidenceSourceReady, false);
+ assert.ok(item.sharedPrerequisiteIds.includes(groups[0].id));
+ assert.doesNotMatch(item.nextSteps.join(" "), /Workforce source/);
+ }
+});
+
+test("legacy models retain actionable source-family gates", async (context) => {
+ const root = await mkdtemp(join(tmpdir(), "filegrc-shared-legacy-"));
+ context.after(() => rm(root, { recursive: true, force: true }));
+ await makeComprehensiveWorkspace(root, "3");
+ const loaded = await loadWorkspace(root);
+ const source = loaded.resources.find(({ type }) => type === "system");
+ source.evidenceOwnerIds = [];
+ const readiness = await assessProgramReadiness(loaded);
+ const sourceItem = readiness.stages.find(({ id }) => id === "controls").items.find(({ id }) => id.startsWith("source-family-"));
+ assert.equal(sourceItem.status, "action");
+ assert.equal(sourceItem.rollupOnly, false);
+ const workflow = await assessWorkflow(loaded, { programReadiness: readiness });
+ assert.ok(workflow.findings.some(({ key, state }) => key.endsWith(sourceItem.id) && state === "ready"));
+});
|