diff --git a/backend/src/main/java/com/roleimpact/workspace/editor/api/RoleRequest.java b/backend/src/main/java/com/roleimpact/workspace/editor/api/RoleRequest.java
index d70f11f..3d3142f 100644
--- a/backend/src/main/java/com/roleimpact/workspace/editor/api/RoleRequest.java
+++ b/backend/src/main/java/com/roleimpact/workspace/editor/api/RoleRequest.java
@@ -1,5 +1,6 @@
package com.roleimpact.workspace.editor.api;
+import java.util.Set;
import java.util.UUID;
import com.roleimpact.shared.model.Sensitivity;
@@ -12,5 +13,9 @@ public record RoleRequest(
@NotBlank @Size(max = 120) String name,
@NotBlank @Size(max = 2000) String description,
@NotNull Sensitivity sensitivity,
- UUID ownerMemberId) {
+ UUID ownerMemberId,
+ Set holderMemberIds) {
+ public RoleRequest {
+ if (holderMemberIds != null) holderMemberIds = Set.copyOf(holderMemberIds);
+ }
}
diff --git a/backend/src/main/java/com/roleimpact/workspace/editor/application/DraftCatalogService.java b/backend/src/main/java/com/roleimpact/workspace/editor/application/DraftCatalogService.java
index c70a4dc..a1d01e1 100644
--- a/backend/src/main/java/com/roleimpact/workspace/editor/application/DraftCatalogService.java
+++ b/backend/src/main/java/com/roleimpact/workspace/editor/application/DraftCatalogService.java
@@ -97,7 +97,9 @@ public DraftCatalogResource updateMember(UUID workspaceId, UUID memberId, Member
public DraftCatalogResource createRole(UUID workspaceId, RoleRequest request) {
requireDraft(workspaceId);
if (request.ownerMemberId() != null) requireMember(workspaceId, request.ownerMemberId());
- mutate(() -> repository.insertRole(workspaceId, request), "A role with this name already exists");
+ requireHolders(workspaceId, request.holderMemberIds());
+ UUID roleId = mutate(() -> repository.insertRole(workspaceId, request), "A role with this name already exists");
+ if (request.holderMemberIds() != null) repository.replaceRoleHolders(roleId, request.holderMemberIds());
touch(workspaceId);
return repository.findCatalog(workspaceId);
}
@@ -119,8 +121,10 @@ public DraftCatalogResource updateRole(UUID workspaceId, UUID roleId, RoleReques
requireDraft(workspaceId);
requireRole(workspaceId, roleId);
if (request.ownerMemberId() != null) requireMember(workspaceId, request.ownerMemberId());
+ requireHolders(workspaceId, request.holderMemberIds());
mutate(() -> { repository.updateRole(workspaceId, roleId, request); return roleId; },
"A role with this name already exists");
+ if (request.holderMemberIds() != null) repository.replaceRoleHolders(roleId, request.holderMemberIds());
touch(workspaceId);
return repository.findCatalog(workspaceId);
}
@@ -214,15 +218,24 @@ private void requireRole(UUID workspaceId, UUID roleId) {
}
}
+ private void requireHolders(UUID workspaceId, Set holderMemberIds) {
+ if (holderMemberIds == null || repository.countMatchingMembers(workspaceId, holderMemberIds) == holderMemberIds.size()) return;
+ UUID unknownMemberId = holderMemberIds.stream()
+ .filter(memberId -> !repository.memberExists(workspaceId, memberId))
+ .findFirst()
+ .orElseThrow();
+ throw new DraftCatalogNotFoundException("Member", unknownMemberId);
+ }
+
private void requireCoverage(int minimumActors, int resilienceTarget) {
if (resilienceTarget < minimumActors) {
throw new DraftCatalogConflictException("Healthy coverage cannot be lower than the minimum people required");
}
}
- private void mutate(Supplier mutation, String conflictMessage) {
+ private UUID mutate(Supplier mutation, String conflictMessage) {
try {
- mutation.get();
+ return mutation.get();
}
catch (DataIntegrityViolationException exception) {
throw new DraftCatalogConflictException(conflictMessage);
diff --git a/backend/src/main/java/com/roleimpact/workspace/editor/persistence/DraftCatalogRepository.java b/backend/src/main/java/com/roleimpact/workspace/editor/persistence/DraftCatalogRepository.java
index 69f915e..9d77afb 100644
--- a/backend/src/main/java/com/roleimpact/workspace/editor/persistence/DraftCatalogRepository.java
+++ b/backend/src/main/java/com/roleimpact/workspace/editor/persistence/DraftCatalogRepository.java
@@ -280,6 +280,23 @@ public int countMatchingRoles(UUID workspaceId, Set roleIds) {
.param("workspaceId", workspaceId).param("roleIds", roleIds).query(Integer.class).single();
}
+ public int countMatchingMembers(UUID workspaceId, Set memberIds) {
+ if (memberIds.isEmpty()) return 0;
+ return jdbcClient.sql("SELECT COUNT(*) FROM employees WHERE organization_id = :workspaceId AND id IN (:memberIds)")
+ .param("workspaceId", workspaceId).param("memberIds", memberIds).query(Integer.class).single();
+ }
+
+ public void replaceRoleHolders(UUID roleId, Set memberIds) {
+ jdbcClient.sql("DELETE FROM employee_roles WHERE role_id = :roleId")
+ .param("roleId", roleId).update();
+ for (UUID memberId : memberIds) {
+ jdbcClient.sql("""
+ INSERT INTO employee_roles (employee_id, role_id, assigned_at, assigned_by)
+ VALUES (:memberId, :roleId, CURRENT_TIMESTAMP, 'workspace editor')
+ """).param("memberId", memberId).param("roleId", roleId).update();
+ }
+ }
+
public void replaceAssignments(UUID memberId, Set roleIds) {
jdbcClient.sql("DELETE FROM employee_roles WHERE employee_id = :memberId").param("memberId", memberId).update();
for (UUID roleId : roleIds) {
diff --git a/backend/src/main/java/com/roleimpact/workspace/preview/api/DraftContinuityRiskResource.java b/backend/src/main/java/com/roleimpact/workspace/preview/api/DraftContinuityRiskResource.java
new file mode 100644
index 0000000..654b5c1
--- /dev/null
+++ b/backend/src/main/java/com/roleimpact/workspace/preview/api/DraftContinuityRiskResource.java
@@ -0,0 +1,39 @@
+package com.roleimpact.workspace.preview.api;
+
+import java.util.List;
+import java.util.UUID;
+
+import com.roleimpact.impactengine.ImpactResult.WorkflowStatus;
+import com.roleimpact.shared.model.WorkflowCriticality;
+
+public record DraftContinuityRiskResource(
+ String key,
+ UUID workflowId,
+ String workflowName,
+ WorkflowCriticality criticality,
+ UUID requirementId,
+ String requirementName,
+ int minimumActors,
+ int resilienceTarget,
+ UUID roleId,
+ String roleName,
+ List eligibleMembers,
+ List members) {
+
+ public DraftContinuityRiskResource {
+ eligibleMembers = List.copyOf(eligibleMembers);
+ members = List.copyOf(members);
+ }
+
+ public record EligibleMember(UUID id, String name) {
+ }
+
+ public record MemberScenario(
+ UUID id,
+ String name,
+ boolean eligible,
+ boolean losesCoverage,
+ int remainingEligibleActorCount,
+ WorkflowStatus scenarioStatus) {
+ }
+}
diff --git a/backend/src/main/java/com/roleimpact/workspace/preview/api/DraftImpactPreviewController.java b/backend/src/main/java/com/roleimpact/workspace/preview/api/DraftImpactPreviewController.java
index e54685c..96e2b13 100644
--- a/backend/src/main/java/com/roleimpact/workspace/preview/api/DraftImpactPreviewController.java
+++ b/backend/src/main/java/com/roleimpact/workspace/preview/api/DraftImpactPreviewController.java
@@ -1,13 +1,16 @@
package com.roleimpact.workspace.preview.api;
import java.util.UUID;
+import java.util.List;
import com.roleimpact.impactengine.ImpactResult;
+import com.roleimpact.workspace.preview.application.DraftContinuityProjectionService;
import com.roleimpact.workspace.preview.application.DraftImpactPreviewService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -18,9 +21,18 @@
public class DraftImpactPreviewController {
private final DraftImpactPreviewService service;
+ private final DraftContinuityProjectionService continuityProjectionService;
- public DraftImpactPreviewController(DraftImpactPreviewService service) {
+ public DraftImpactPreviewController(
+ DraftImpactPreviewService service,
+ DraftContinuityProjectionService continuityProjectionService) {
this.service = service;
+ this.continuityProjectionService = continuityProjectionService;
+ }
+
+ @GetMapping("/continuity")
+ public List continuity(@PathVariable UUID workspaceId) {
+ return continuityProjectionService.project(workspaceId);
}
@PostMapping
diff --git a/backend/src/main/java/com/roleimpact/workspace/preview/application/DraftContinuityProjectionService.java b/backend/src/main/java/com/roleimpact/workspace/preview/application/DraftContinuityProjectionService.java
new file mode 100644
index 0000000..267b4f1
--- /dev/null
+++ b/backend/src/main/java/com/roleimpact/workspace/preview/application/DraftContinuityProjectionService.java
@@ -0,0 +1,153 @@
+package com.roleimpact.workspace.preview.application;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.UUID;
+
+import com.roleimpact.catalog.snapshot.OrganizationSnapshot;
+import com.roleimpact.catalog.snapshot.OrganizationSnapshotAssembler;
+import com.roleimpact.impactengine.ImpactEngine;
+import com.roleimpact.impactengine.ImpactResult;
+import com.roleimpact.impactengine.RevokeEmployeeRole;
+import com.roleimpact.shared.model.WorkflowCriticality;
+import com.roleimpact.workspace.application.WorkspaceNotFoundException;
+import com.roleimpact.workspace.editor.api.DraftCatalogResource;
+import com.roleimpact.workspace.editor.application.PublishedWorkspaceMutationException;
+import com.roleimpact.workspace.editor.persistence.DraftCatalogRepository;
+import com.roleimpact.workspace.preview.api.DraftContinuityRiskResource;
+
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Isolation;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+public class DraftContinuityProjectionService {
+
+ private static final Comparator TEXT_ORDER = Comparator.nullsFirst(String::compareTo);
+ private static final Comparator ID_ORDER = Comparator.nullsFirst(UUID::compareTo);
+
+ private final DraftCatalogRepository catalogRepository;
+ private final OrganizationSnapshotAssembler snapshotAssembler;
+ private final ImpactEngine impactEngine;
+
+ public DraftContinuityProjectionService(
+ DraftCatalogRepository catalogRepository,
+ OrganizationSnapshotAssembler snapshotAssembler,
+ ImpactEngine impactEngine) {
+ this.catalogRepository = catalogRepository;
+ this.snapshotAssembler = snapshotAssembler;
+ this.impactEngine = impactEngine;
+ }
+
+ @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
+ public List project(UUID workspaceId) {
+ validateDraftWorkspace(workspaceId);
+ DraftCatalogResource catalog = catalogRepository.findCatalog(workspaceId);
+ OrganizationSnapshot snapshot = snapshotAssembler.assemble(workspaceId);
+ return catalog.workflows().stream()
+ .sorted(Comparator.comparing(DraftCatalogResource.WorkflowItem::name, TEXT_ORDER)
+ .thenComparing(DraftCatalogResource.WorkflowItem::id, ID_ORDER))
+ .flatMap(workflow -> projectWorkflow(catalog, snapshot, workflow).stream())
+ .toList();
+ }
+
+ private List projectWorkflow(
+ DraftCatalogResource catalog,
+ OrganizationSnapshot snapshot,
+ DraftCatalogResource.WorkflowItem workflow) {
+ return workflow.requirements().stream()
+ .sorted(Comparator.comparing(DraftCatalogResource.WorkflowRequirementItem::name, TEXT_ORDER)
+ .thenComparing(DraftCatalogResource.WorkflowRequirementItem::id, ID_ORDER))
+ .flatMap(requirement -> requirement.roleIds().stream()
+ .map(roleId -> new RoleReference(roleId, findRole(catalog, roleId)))
+ .filter(role -> role.item() != null)
+ .sorted(Comparator.comparing((RoleReference role) -> role.item().name(), TEXT_ORDER)
+ .thenComparing(RoleReference::id, ID_ORDER))
+ .map(role -> projectRole(catalog, snapshot, workflow, requirement, role)))
+ .filter(java.util.Optional::isPresent)
+ .map(java.util.Optional::get)
+ .toList();
+ }
+
+ private java.util.Optional projectRole(
+ DraftCatalogResource catalog,
+ OrganizationSnapshot snapshot,
+ DraftCatalogResource.WorkflowItem workflow,
+ DraftCatalogResource.WorkflowRequirementItem requirement,
+ RoleReference role) {
+ List holders = catalog.members().stream()
+ .filter(member -> member.roleIds().contains(role.id()))
+ .sorted(Comparator.comparing(DraftCatalogResource.MemberItem::name, TEXT_ORDER)
+ .thenComparing(DraftCatalogResource.MemberItem::id, ID_ORDER))
+ .toList();
+ if (holders.isEmpty()) return java.util.Optional.empty();
+
+ List scenarios = holders.stream()
+ .map(holder -> scenarioFor(snapshot, workflow.id(), requirement.id(), holder, role.id()))
+ .toList();
+ ImpactResult.StepImpact baseline = scenarios.getFirst().step();
+ return java.util.Optional.of(new DraftContinuityRiskResource(
+ workflow.id() + ":" + requirement.id() + ":" + role.id(),
+ workflow.id(),
+ workflow.name(),
+ WorkflowCriticality.valueOf(workflow.criticality()),
+ requirement.id(),
+ requirement.name(),
+ requirement.minimumActors(),
+ requirement.resilienceTarget(),
+ role.id(),
+ role.item().name(),
+ baseline.baselineEligibleActors().stream()
+ .sorted(Comparator.comparing(ImpactResult.ActorRef::name, TEXT_ORDER)
+ .thenComparing(ImpactResult.ActorRef::id, ID_ORDER))
+ .map(actor -> new DraftContinuityRiskResource.EligibleMember(actor.id(), actor.name()))
+ .toList(),
+ scenarios.stream().map(Scenario::toMemberScenario).toList()));
+ }
+
+ private Scenario scenarioFor(
+ OrganizationSnapshot snapshot,
+ UUID workflowId,
+ UUID stepId,
+ DraftCatalogResource.MemberItem holder,
+ UUID roleId) {
+ ImpactResult result = impactEngine.analyze(snapshot, new RevokeEmployeeRole(holder.id(), roleId));
+ ImpactResult.WorkflowImpact workflow = result.workflowImpacts().stream()
+ .filter(candidate -> candidate.workflowId().equals(workflowId))
+ .findFirst()
+ .orElseThrow(() -> new IllegalStateException("Impact result did not include workflow " + workflowId));
+ ImpactResult.StepImpact step = workflow.steps().stream()
+ .filter(candidate -> candidate.stepId().equals(stepId))
+ .findFirst()
+ .orElseThrow(() -> new IllegalStateException("Impact result did not include workflow step " + stepId));
+ return new Scenario(holder, workflow.scenarioStatus(), step);
+ }
+
+ private DraftCatalogResource.RoleItem findRole(DraftCatalogResource catalog, UUID roleId) {
+ return catalog.roles().stream().filter(role -> role.id().equals(roleId)).findFirst().orElse(null);
+ }
+
+ private void validateDraftWorkspace(UUID workspaceId) {
+ String status = catalogRepository.findWorkspaceStatus(workspaceId)
+ .orElseThrow(() -> new WorkspaceNotFoundException(workspaceId));
+ if (!"DRAFT".equals(status)) throw new PublishedWorkspaceMutationException();
+ }
+
+ private record RoleReference(UUID id, DraftCatalogResource.RoleItem item) {
+ }
+
+ private record Scenario(
+ DraftCatalogResource.MemberItem holder,
+ ImpactResult.WorkflowStatus workflowStatus,
+ ImpactResult.StepImpact step) {
+
+ DraftContinuityRiskResource.MemberScenario toMemberScenario() {
+ boolean eligible = step.baselineEligibleActors().stream().anyMatch(actor -> actor.id().equals(holder.id()));
+ boolean losesCoverage = eligible && step.scenarioEligibleActors().stream()
+ .noneMatch(actor -> actor.id().equals(holder.id()));
+ return new DraftContinuityRiskResource.MemberScenario(
+ holder.id(), holder.name(), eligible, losesCoverage,
+ step.scenarioEligibleActors().size(), workflowStatus);
+ }
+ }
+}
diff --git a/backend/src/test/java/com/roleimpact/workspace/WorkspaceIntegrationTest.java b/backend/src/test/java/com/roleimpact/workspace/WorkspaceIntegrationTest.java
index ab65842..b2927ef 100644
--- a/backend/src/test/java/com/roleimpact/workspace/WorkspaceIntegrationTest.java
+++ b/backend/src/test/java/com/roleimpact/workspace/WorkspaceIntegrationTest.java
@@ -210,23 +210,18 @@ void buildsTeamsMembersAndRolesInsideADraftOnly() throws Exception {
.content("""
{
"name":"Release Manager", "description":"Approves production releases",
- "sensitivity":"HIGH", "ownerMemberId":"%s"
+ "sensitivity":"HIGH", "ownerMemberId":"%s",
+ "holderMemberIds":["%s"]
}
- """.formatted(memberId)))
- .andExpect(status().isOk())
- .andExpect(jsonPath("$.roles[0].name").value("Release Manager"))
- .andReturn();
+ """.formatted(memberId, memberId)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.roles[0].name").value("Release Manager"))
+ .andExpect(jsonPath("$.roles[0].memberCount").value(1))
+ .andReturn();
UUID roleId = UUID.fromString(objectMapper.readTree(roleResponse.getResponse().getContentAsString())
.path("roles").get(0).path("id").asText());
-
- var assignmentResponse = mockMvc.perform(put("/api/v1/workspaces/{workspaceId}/catalog/members/{memberId}/roles", workspaceId, memberId)
- .contentType(MediaType.APPLICATION_JSON)
- .content("{\"roleIds\":[\"" + roleId + "\"]}"))
- .andExpect(status().isOk())
- .andExpect(jsonPath("$.roles[0].memberCount").value(1))
- .andReturn();
assertThat(findById(
- objectMapper.readTree(assignmentResponse.getResponse().getContentAsString()),
+ objectMapper.readTree(roleResponse.getResponse().getContentAsString()),
"members",
memberId).path("roleIds").get(0).asText()).isEqualTo(roleId.toString());
@@ -255,12 +250,46 @@ void buildsTeamsMembersAndRolesInsideADraftOnly() throws Exception {
.content("""
{
"name":"Production Release Manager", "description":"Approves production releases",
- "sensitivity":"CRITICAL", "ownerMemberId":"%s"
+ "sensitivity":"CRITICAL", "ownerMemberId":"%s",
+ "holderMemberIds":["%s","%s"]
}
- """.formatted(memberId)))
- .andExpect(status().isOk())
- .andExpect(jsonPath("$.roles[0].name").value("Production Release Manager"))
- .andExpect(jsonPath("$.roles[0].sensitivity").value("CRITICAL"));
+ """.formatted(memberId, memberId, candidateId)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.roles[0].name").value("Production Release Manager"))
+ .andExpect(jsonPath("$.roles[0].sensitivity").value("CRITICAL"))
+ .andExpect(jsonPath("$.roles[0].memberCount").value(2));
+
+ UUID unknownHolderId = UUID.fromString("00000000-0000-0000-0000-000000000999");
+ mockMvc.perform(put("/api/v1/workspaces/{workspaceId}/catalog/roles/{roleId}", workspaceId, roleId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "name":"Should Not Persist", "description":"Should not persist",
+ "sensitivity":"LOW", "ownerMemberId":null,
+ "holderMemberIds":["%s","%s"]
+ }
+ """.formatted(memberId, unknownHolderId)))
+ .andExpect(status().isNotFound());
+ var catalogAfterRejectedRoleUpdate = objectMapper.readTree(mockMvc.perform(get("/api/v1/workspaces/{workspaceId}/catalog", workspaceId))
+ .andExpect(status().isOk()).andReturn().getResponse().getContentAsString());
+ assertThat(findById(catalogAfterRejectedRoleUpdate, "roles", roleId).path("name").asText())
+ .isEqualTo("Production Release Manager");
+ assertThat(findById(catalogAfterRejectedRoleUpdate, "members", memberId).path("roleIds"))
+ .extracting(com.fasterxml.jackson.databind.JsonNode::asText).containsExactly(roleId.toString());
+ assertThat(findById(catalogAfterRejectedRoleUpdate, "members", candidateId).path("roleIds"))
+ .extracting(com.fasterxml.jackson.databind.JsonNode::asText).containsExactly(roleId.toString());
+
+ mockMvc.perform(put("/api/v1/workspaces/{workspaceId}/catalog/roles/{roleId}", workspaceId, roleId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "name":"Production Release Manager", "description":"Approves production releases",
+ "sensitivity":"CRITICAL", "ownerMemberId":"%s",
+ "holderMemberIds":["%s"]
+ }
+ """.formatted(memberId, memberId)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.roles[0].memberCount").value(1));
var workflowResponse = mockMvc.perform(post("/api/v1/workspaces/{workspaceId}/catalog/workflows/quick", workspaceId)
.contentType(MediaType.APPLICATION_JSON)
@@ -344,6 +373,13 @@ void buildsTeamsMembersAndRolesInsideADraftOnly() throws Exception {
.andExpect(jsonPath("$.mitigation.overallSeverity").value("CRITICAL"))
.andExpect(jsonPath("$.mitigation.executiveSummary.workflowsBlocked").value(1));
+ mockMvc.perform(get("/api/v1/workspaces/{workspaceId}/impact-previews/continuity", workspaceId))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$[?(@.workflowName == 'Production Deployment' && @.requirementName == 'Approve production release')].eligibleMembers[0].id")
+ .value(memberId.toString()))
+ .andExpect(jsonPath("$[?(@.workflowName == 'Production Deployment' && @.requirementName == 'Approve production release')].members[0].scenarioStatus")
+ .value("BLOCKED"));
+
var catalogAfterMitigation = mockMvc.perform(get("/api/v1/workspaces/{workspaceId}/catalog", workspaceId))
.andExpect(status().isOk())
.andReturn();
diff --git a/backend/src/test/java/com/roleimpact/workspace/editor/application/DraftCatalogServiceTest.java b/backend/src/test/java/com/roleimpact/workspace/editor/application/DraftCatalogServiceTest.java
new file mode 100644
index 0000000..9893b03
--- /dev/null
+++ b/backend/src/test/java/com/roleimpact/workspace/editor/application/DraftCatalogServiceTest.java
@@ -0,0 +1,124 @@
+package com.roleimpact.workspace.editor.application;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.LinkedHashSet;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+import com.roleimpact.shared.model.Sensitivity;
+import com.roleimpact.workspace.editor.api.RoleRequest;
+import com.roleimpact.workspace.editor.persistence.DraftCatalogRepository;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class DraftCatalogServiceTest {
+
+ @Mock
+ private DraftCatalogRepository repository;
+
+ @Test
+ void createsRoleAfterValidatingEveryHolderAndReplacesTheCompleteHolderSetOnce() {
+ UUID workspaceId = UUID.fromString("00000000-0000-0000-0000-000000000101");
+ UUID roleId = UUID.fromString("00000000-0000-0000-0000-000000000102");
+ Set holderIds = Set.of(
+ UUID.fromString("00000000-0000-0000-0000-000000000103"),
+ UUID.fromString("00000000-0000-0000-0000-000000000104"));
+ RoleRequest request = roleRequest(holderIds);
+ DraftCatalogService service = serviceForDraft(workspaceId);
+ when(repository.countMatchingMembers(workspaceId, holderIds)).thenReturn(2);
+ when(repository.insertRole(workspaceId, request)).thenReturn(roleId);
+
+ service.createRole(workspaceId, request);
+
+ InOrder order = inOrder(repository);
+ order.verify(repository).countMatchingMembers(workspaceId, holderIds);
+ order.verify(repository).insertRole(workspaceId, request);
+ order.verify(repository).replaceRoleHolders(roleId, holderIds);
+ order.verify(repository).touchWorkspace(workspaceId);
+ }
+
+ @Test
+ void updatesRoleWithAnExplicitEmptyHolderSetByClearingAllHolders() {
+ UUID workspaceId = UUID.fromString("00000000-0000-0000-0000-000000000111");
+ UUID roleId = UUID.fromString("00000000-0000-0000-0000-000000000112");
+ Set holderIds = Set.of();
+ RoleRequest request = roleRequest(holderIds);
+ DraftCatalogService service = serviceForDraft(workspaceId);
+ when(repository.roleExists(workspaceId, roleId)).thenReturn(true);
+ when(repository.countMatchingMembers(workspaceId, holderIds)).thenReturn(0);
+
+ service.updateRole(workspaceId, roleId, request);
+
+ verify(repository).updateRole(workspaceId, roleId, request);
+ verify(repository).replaceRoleHolders(roleId, holderIds);
+ }
+
+ @Test
+ void updatesRoleWithoutHolderIdsWithoutChangingCurrentHolders() {
+ UUID workspaceId = UUID.fromString("00000000-0000-0000-0000-000000000121");
+ UUID roleId = UUID.fromString("00000000-0000-0000-0000-000000000122");
+ RoleRequest request = roleRequest(null);
+ DraftCatalogService service = serviceForDraft(workspaceId);
+ when(repository.roleExists(workspaceId, roleId)).thenReturn(true);
+
+ service.updateRole(workspaceId, roleId, request);
+
+ verify(repository).updateRole(workspaceId, roleId, request);
+ verify(repository, never()).countMatchingMembers(eq(workspaceId), any());
+ verify(repository, never()).replaceRoleHolders(any(), any());
+ }
+
+ @Test
+ void rejectsUnknownHolderBeforeChangingRoleMetadataOrAssignments() {
+ UUID workspaceId = UUID.fromString("00000000-0000-0000-0000-000000000131");
+ UUID roleId = UUID.fromString("00000000-0000-0000-0000-000000000132");
+ Set holderIds = Set.of(
+ UUID.fromString("00000000-0000-0000-0000-000000000133"),
+ UUID.fromString("00000000-0000-0000-0000-000000000134"));
+ RoleRequest request = roleRequest(holderIds);
+ DraftCatalogService service = serviceForDraft(workspaceId);
+ when(repository.roleExists(workspaceId, roleId)).thenReturn(true);
+ when(repository.countMatchingMembers(workspaceId, holderIds)).thenReturn(1);
+
+ assertThatThrownBy(() -> service.updateRole(workspaceId, roleId, request))
+ .isInstanceOf(DraftCatalogNotFoundException.class);
+
+ verify(repository, never()).updateRole(workspaceId, roleId, request);
+ verify(repository, never()).replaceRoleHolders(any(), any());
+ verify(repository, never()).touchWorkspace(workspaceId);
+ }
+
+ @Test
+ void copiesSuppliedHolderIdsSoCallersCannotMutateTheRequestAfterConstruction() {
+ Set suppliedIds = new LinkedHashSet<>();
+ suppliedIds.add(UUID.fromString("00000000-0000-0000-0000-000000000141"));
+ RoleRequest request = roleRequest(suppliedIds);
+
+ suppliedIds.add(UUID.fromString("00000000-0000-0000-0000-000000000142"));
+
+ assertThat(request.holderMemberIds()).containsExactly(UUID.fromString("00000000-0000-0000-0000-000000000141"));
+ }
+
+ private DraftCatalogService serviceForDraft(UUID workspaceId) {
+ when(repository.findWorkspaceStatus(workspaceId)).thenReturn(Optional.of("DRAFT"));
+ return new DraftCatalogService(repository);
+ }
+
+ private RoleRequest roleRequest(Set holderMemberIds) {
+ return new RoleRequest("Release Manager", "Approves production releases", Sensitivity.HIGH, null, holderMemberIds);
+ }
+}
diff --git a/backend/src/test/java/com/roleimpact/workspace/preview/application/DraftContinuityProjectionConcurrencyIntegrationTest.java b/backend/src/test/java/com/roleimpact/workspace/preview/application/DraftContinuityProjectionConcurrencyIntegrationTest.java
new file mode 100644
index 0000000..ceb505d
--- /dev/null
+++ b/backend/src/test/java/com/roleimpact/workspace/preview/application/DraftContinuityProjectionConcurrencyIntegrationTest.java
@@ -0,0 +1,188 @@
+package com.roleimpact.workspace.preview.application;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.doAnswer;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.roleimpact.workspace.editor.api.DraftCatalogResource;
+import com.roleimpact.workspace.editor.persistence.DraftCatalogRepository;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
+import org.springframework.test.web.servlet.MockMvc;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+@SpringBootTest
+@Testcontainers
+@AutoConfigureMockMvc
+class DraftContinuityProjectionConcurrencyIntegrationTest {
+
+ @Container
+ @ServiceConnection
+ static final PostgreSQLContainer> postgres = new PostgreSQLContainer<>("postgres:17-alpine")
+ .withDatabaseName("roleimpact")
+ .withUsername("roleimpact")
+ .withPassword("roleimpact");
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Autowired
+ private DraftContinuityProjectionService projectionService;
+
+ @MockitoSpyBean
+ private DraftCatalogRepository catalogRepository;
+
+ @Test
+ void projectsOneCommittedCatalogStateWhenRoleHoldersChangeBetweenItsReads() throws Exception {
+ UUID workspaceId = createWorkspace("Concurrent Projection Workspace");
+ UUID teamId = createTeam(workspaceId);
+ UUID originalHolderId = createMember(workspaceId, teamId, "Original Holder");
+ UUID replacementHolderId = createMember(workspaceId, teamId, "Replacement Holder");
+ UUID roleId = createRole(workspaceId, originalHolderId);
+ createWorkflow(workspaceId, roleId);
+
+ var pauseProjection = new AtomicBoolean(true);
+ var catalogRead = new CountDownLatch(1);
+ var continueProjection = new CountDownLatch(1);
+ doAnswer(invocation -> {
+ DraftCatalogResource catalog = (DraftCatalogResource) invocation.callRealMethod();
+ if (pauseProjection.compareAndSet(true, false)) {
+ catalogRead.countDown();
+ if (!continueProjection.await(10, SECONDS)) {
+ throw new AssertionError("Timed out waiting for the concurrent holder replacement");
+ }
+ }
+ return catalog;
+ }).when(catalogRepository).findCatalog(workspaceId);
+
+ var executor = Executors.newSingleThreadExecutor();
+ try {
+ CompletableFuture> projection =
+ CompletableFuture.supplyAsync(() -> projectionService.project(workspaceId), executor);
+ assertThat(catalogRead.await(10, SECONDS)).as("projection reached the catalog/snapshot boundary").isTrue();
+
+ mockMvc.perform(put("/api/v1/workspaces/{workspaceId}/catalog/roles/{roleId}", workspaceId, roleId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "name":"Release Manager", "description":"Approves production releases",
+ "sensitivity":"HIGH", "ownerMemberId":"%s",
+ "holderMemberIds":["%s"]
+ }
+ """.formatted(originalHolderId, replacementHolderId)))
+ .andExpect(status().isOk());
+ continueProjection.countDown();
+
+ var risks = projection.get(10, SECONDS);
+
+ assertThat(risks).hasSize(1);
+ assertThat(risks.getFirst().members())
+ .extracting(member -> member.id(), member -> member.name())
+ .containsExactly(org.assertj.core.groups.Tuple.tuple(originalHolderId, "Original Holder"));
+ assertThat(risks.getFirst().eligibleMembers())
+ .extracting(member -> member.id(), member -> member.name())
+ .containsExactly(org.assertj.core.groups.Tuple.tuple(originalHolderId, "Original Holder"));
+ assertThat(catalogRepository.findCatalog(workspaceId).members().stream()
+ .filter(member -> member.roleIds().contains(roleId))
+ .map(DraftCatalogResource.MemberItem::id))
+ .containsExactly(replacementHolderId);
+ }
+ finally {
+ continueProjection.countDown();
+ executor.shutdownNow();
+ }
+ }
+
+ private UUID createWorkspace(String name) throws Exception {
+ var response = mockMvc.perform(post("/api/v1/workspaces")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"name\":\"%s\"}".formatted(name)))
+ .andExpect(status().isCreated())
+ .andReturn();
+ return UUID.fromString(objectMapper.readTree(response.getResponse().getContentAsString()).path("id").asText());
+ }
+
+ private UUID createTeam(UUID workspaceId) throws Exception {
+ JsonNode catalog = responseJson(mockMvc.perform(post("/api/v1/workspaces/{workspaceId}/catalog/teams", workspaceId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"name\":\"Platform\",\"department\":\"Technology\"}"))
+ .andExpect(status().isOk())
+ .andReturn());
+ return findId(catalog, "teams", "Platform");
+ }
+
+ private UUID createMember(UUID workspaceId, UUID teamId, String name) throws Exception {
+ JsonNode catalog = responseJson(mockMvc.perform(post("/api/v1/workspaces/{workspaceId}/catalog/members", workspaceId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "teamId":"%s", "name":"%s", "status":"ACTIVE",
+ "region":"NORTH_AMERICA", "shift":"DAY"
+ }
+ """.formatted(teamId, name)))
+ .andExpect(status().isOk())
+ .andReturn());
+ return findId(catalog, "members", name);
+ }
+
+ private UUID createRole(UUID workspaceId, UUID originalHolderId) throws Exception {
+ JsonNode catalog = responseJson(mockMvc.perform(post("/api/v1/workspaces/{workspaceId}/catalog/roles", workspaceId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "name":"Release Manager", "description":"Approves production releases",
+ "sensitivity":"HIGH", "ownerMemberId":"%s",
+ "holderMemberIds":["%s"]
+ }
+ """.formatted(originalHolderId, originalHolderId)))
+ .andExpect(status().isOk())
+ .andReturn());
+ return findId(catalog, "roles", "Release Manager");
+ }
+
+ private void createWorkflow(UUID workspaceId, UUID roleId) throws Exception {
+ mockMvc.perform(post("/api/v1/workspaces/{workspaceId}/catalog/workflows/quick", workspaceId)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "name":"Production Deployment", "criticality":"CRITICAL",
+ "requirementName":"Approve production release", "roleId":"%s",
+ "minimumActors":1, "resilienceTarget":1
+ }
+ """.formatted(roleId)))
+ .andExpect(status().isOk());
+ }
+
+ private JsonNode responseJson(org.springframework.test.web.servlet.MvcResult response) throws Exception {
+ return objectMapper.readTree(response.getResponse().getContentAsString());
+ }
+
+ private UUID findId(JsonNode catalog, String collection, String name) {
+ for (JsonNode item : catalog.path(collection)) {
+ if (name.equals(item.path("name").asText())) return UUID.fromString(item.path("id").asText());
+ }
+ throw new AssertionError("Missing " + name + " in " + collection);
+ }
+}
diff --git a/backend/src/test/java/com/roleimpact/workspace/preview/application/DraftContinuityProjectionServiceTest.java b/backend/src/test/java/com/roleimpact/workspace/preview/application/DraftContinuityProjectionServiceTest.java
new file mode 100644
index 0000000..f3c1420
--- /dev/null
+++ b/backend/src/test/java/com/roleimpact/workspace/preview/application/DraftContinuityProjectionServiceTest.java
@@ -0,0 +1,207 @@
+package com.roleimpact.workspace.preview.application;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import com.roleimpact.catalog.snapshot.OrganizationSnapshot;
+import com.roleimpact.catalog.snapshot.OrganizationSnapshotAssembler;
+import com.roleimpact.impactengine.ImpactEngine;
+import com.roleimpact.impactengine.ImpactResult;
+import com.roleimpact.impactengine.ImpactResult.ActorRef;
+import com.roleimpact.impactengine.ImpactResult.StepImpact;
+import com.roleimpact.impactengine.ImpactResult.WorkflowImpact;
+import com.roleimpact.impactengine.RevokeEmployeeRole;
+import com.roleimpact.shared.model.WorkflowCriticality;
+import com.roleimpact.workspace.application.WorkspaceNotFoundException;
+import com.roleimpact.workspace.editor.api.DraftCatalogResource;
+import com.roleimpact.workspace.editor.application.PublishedWorkspaceMutationException;
+import com.roleimpact.workspace.editor.persistence.DraftCatalogRepository;
+
+import org.junit.jupiter.api.Test;
+
+import static org.mockito.Mockito.mock;
+
+class DraftContinuityProjectionServiceTest {
+
+ private static final UUID WORKSPACE_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
+ private static final UUID ALPHA_WORKFLOW_ID = UUID.fromString("00000000-0000-0000-0000-000000000010");
+ private static final UUID ZULU_WORKFLOW_ID = UUID.fromString("00000000-0000-0000-0000-000000000020");
+ private static final UUID ALPHA_STEP_ID = UUID.fromString("00000000-0000-0000-0000-000000000011");
+ private static final UUID ZULU_STEP_ID = UUID.fromString("00000000-0000-0000-0000-000000000021");
+ private static final UUID ALPHA_ROLE_ID = UUID.fromString("00000000-0000-0000-0000-000000000012");
+ private static final UUID ZULU_ROLE_ID = UUID.fromString("00000000-0000-0000-0000-000000000022");
+ private static final UUID AARON_ID = UUID.fromString("00000000-0000-0000-0000-000000000101");
+ private static final UUID ZOE_ID = UUID.fromString("00000000-0000-0000-0000-000000000102");
+ private static final UUID ENGINE_BACKUP_ID = UUID.fromString("00000000-0000-0000-0000-000000000103");
+ private static final UUID ALPHA_HOLDER_ID = UUID.fromString("00000000-0000-0000-0000-000000000104");
+
+ @Test
+ void projectsEngineEligibilityAndWorkflowVerdictsInDeterministicOrder() {
+ var repository = mock(DraftCatalogRepository.class);
+ var snapshots = mock(OrganizationSnapshotAssembler.class);
+ var snapshot = mock(OrganizationSnapshot.class);
+ when(repository.findWorkspaceStatus(WORKSPACE_ID)).thenReturn(java.util.Optional.of("DRAFT"));
+ when(repository.findCatalog(WORKSPACE_ID)).thenReturn(catalog());
+ when(snapshots.assemble(WORKSPACE_ID)).thenReturn(snapshot);
+ ImpactEngine engine = (baseline, change) -> resultFor((RevokeEmployeeRole) change);
+
+ var service = new DraftContinuityProjectionService(repository, snapshots, engine);
+
+ var risks = service.project(WORKSPACE_ID);
+
+ assertThat(risks).extracting(risk -> risk.workflowName() + ":" + risk.roleName())
+ .containsExactly("Alpha workflow:Alpha role", "Zulu workflow:Zulu role");
+ var zuluRisk = risks.get(1);
+ assertThat(zuluRisk.eligibleMembers())
+ .extracting(member -> member.id(), member -> member.name())
+ .containsExactly(
+ tuple(ENGINE_BACKUP_ID, "Engine Backup"),
+ tuple(ZOE_ID, "Zoe Holder"));
+ assertThat(zuluRisk.members()).extracting(member -> member.name())
+ .containsExactly("Aaron Holder", "Zoe Holder");
+ assertThat(zuluRisk.members()).extracting(
+ member -> member.eligible(),
+ member -> member.losesCoverage(),
+ member -> member.remainingEligibleActorCount(),
+ member -> member.scenarioStatus())
+ .containsExactly(
+ tuple(false, false, 2, ImpactResult.WorkflowStatus.OPERATIONAL),
+ tuple(true, true, 1, ImpactResult.WorkflowStatus.BLOCKED));
+ assertThat(zuluRisk.members().get(1).scenarioStatus()).isEqualTo(ImpactResult.WorkflowStatus.BLOCKED);
+ }
+
+ @Test
+ void ordersEveryRequirementAndRoleWithinAWorkflow() {
+ var repository = mock(DraftCatalogRepository.class);
+ var snapshots = mock(OrganizationSnapshotAssembler.class);
+ var snapshot = mock(OrganizationSnapshot.class);
+ when(repository.findWorkspaceStatus(WORKSPACE_ID)).thenReturn(java.util.Optional.of("DRAFT"));
+ when(repository.findCatalog(WORKSPACE_ID)).thenReturn(new DraftCatalogResource(
+ WORKSPACE_ID,
+ List.of(),
+ List.of(
+ member(ZOE_ID, "Zoe Holder", ZULU_ROLE_ID),
+ member(ALPHA_HOLDER_ID, "Alpha Holder", ALPHA_ROLE_ID)),
+ List.of(
+ role(ZULU_ROLE_ID, "Zulu role"),
+ role(ALPHA_ROLE_ID, "Alpha role")),
+ List.of(new DraftCatalogResource.WorkflowItem(
+ ALPHA_WORKFLOW_ID,
+ "Alpha workflow",
+ "HIGH",
+ List.of(
+ new DraftCatalogResource.WorkflowRequirementItem(
+ ZULU_STEP_ID, "Zulu requirement", 2, 1, 2,
+ "Finance", "EUROPE", "NIGHT", Set.of(ZULU_ROLE_ID, ALPHA_ROLE_ID)),
+ new DraftCatalogResource.WorkflowRequirementItem(
+ ALPHA_STEP_ID, "Alpha requirement", 1, 1, 2,
+ "Finance", "EUROPE", "NIGHT", Set.of(ZULU_ROLE_ID, ALPHA_ROLE_ID))),
+ true))));
+ when(snapshots.assemble(WORKSPACE_ID)).thenReturn(snapshot);
+ ImpactEngine engine = (baseline, change) -> result(new WorkflowImpact(
+ ALPHA_WORKFLOW_ID, "Alpha workflow", WorkflowCriticality.HIGH,
+ ImpactResult.WorkflowStatus.OPERATIONAL, ImpactResult.WorkflowStatus.OPERATIONAL,
+ List.of(
+ step(ZULU_STEP_ID, "Zulu requirement", List.of(), List.of()),
+ step(ALPHA_STEP_ID, "Alpha requirement", List.of(), List.of())),
+ List.of()));
+
+ var risks = new DraftContinuityProjectionService(repository, snapshots, engine).project(WORKSPACE_ID);
+
+ assertThat(risks).extracting(risk -> risk.requirementName() + ":" + risk.roleName())
+ .containsExactly(
+ "Alpha requirement:Alpha role",
+ "Alpha requirement:Zulu role",
+ "Zulu requirement:Alpha role",
+ "Zulu requirement:Zulu role");
+ }
+
+ @Test
+ void rejectsPublishedAndUnknownWorkspacesBeforeProjecting() {
+ var repository = mock(DraftCatalogRepository.class);
+ var snapshots = mock(OrganizationSnapshotAssembler.class);
+ ImpactEngine engine = (baseline, change) -> null;
+ var service = new DraftContinuityProjectionService(repository, snapshots, engine);
+
+ when(repository.findWorkspaceStatus(WORKSPACE_ID)).thenReturn(java.util.Optional.of("PUBLISHED"));
+ assertThatThrownBy(() -> service.project(WORKSPACE_ID))
+ .isInstanceOf(PublishedWorkspaceMutationException.class);
+
+ UUID unknownWorkspaceId = UUID.fromString("00000000-0000-0000-0000-000000000099");
+ when(repository.findWorkspaceStatus(unknownWorkspaceId)).thenReturn(java.util.Optional.empty());
+ assertThatThrownBy(() -> service.project(unknownWorkspaceId))
+ .isInstanceOf(WorkspaceNotFoundException.class);
+ }
+
+ private static DraftCatalogResource catalog() {
+ return new DraftCatalogResource(
+ WORKSPACE_ID,
+ List.of(),
+ List.of(
+ member(ZOE_ID, "Zoe Holder", ZULU_ROLE_ID),
+ member(AARON_ID, "Aaron Holder", ZULU_ROLE_ID),
+ member(ALPHA_HOLDER_ID, "Alpha Holder", ALPHA_ROLE_ID)),
+ List.of(
+ role(ZULU_ROLE_ID, "Zulu role"),
+ role(ALPHA_ROLE_ID, "Alpha role")),
+ List.of(
+ workflow(ZULU_WORKFLOW_ID, "Zulu workflow", ZULU_STEP_ID, "Zulu requirement", ZULU_ROLE_ID),
+ workflow(ALPHA_WORKFLOW_ID, "Alpha workflow", ALPHA_STEP_ID, "Alpha requirement", ALPHA_ROLE_ID)));
+ }
+
+ private static DraftCatalogResource.MemberItem member(UUID id, String name, UUID roleId) {
+ return new DraftCatalogResource.MemberItem(id, null, null, name, null, "INACTIVE", "EUROPE", "NIGHT", Set.of(roleId));
+ }
+
+ private static DraftCatalogResource.RoleItem role(UUID id, String name) {
+ return new DraftCatalogResource.RoleItem(id, name, "fixture", "HIGH", null, 2);
+ }
+
+ private static DraftCatalogResource.WorkflowItem workflow(
+ UUID workflowId, String workflowName, UUID requirementId, String requirementName, UUID roleId) {
+ return new DraftCatalogResource.WorkflowItem(
+ workflowId,
+ workflowName,
+ "HIGH",
+ List.of(new DraftCatalogResource.WorkflowRequirementItem(
+ requirementId, requirementName, 1, 1, 2, "Finance", "EUROPE", "NIGHT", Set.of(roleId))),
+ true);
+ }
+
+ private static ImpactResult resultFor(RevokeEmployeeRole change) {
+ if (change.roleId().equals(ALPHA_ROLE_ID)) {
+ return result(new WorkflowImpact(
+ ALPHA_WORKFLOW_ID, "Alpha workflow", WorkflowCriticality.HIGH,
+ ImpactResult.WorkflowStatus.OPERATIONAL, ImpactResult.WorkflowStatus.OPERATIONAL,
+ List.of(step(ALPHA_STEP_ID, "Alpha requirement", List.of(), List.of())), List.of()));
+ }
+ var baselineActors = List.of(new ActorRef(ZOE_ID, "Zoe Holder"), new ActorRef(ENGINE_BACKUP_ID, "Engine Backup"));
+ var scenarioActors = change.employeeId().equals(ZOE_ID)
+ ? List.of(new ActorRef(ENGINE_BACKUP_ID, "Engine Backup"))
+ : baselineActors;
+ return result(new WorkflowImpact(
+ ZULU_WORKFLOW_ID, "Zulu workflow", WorkflowCriticality.HIGH,
+ ImpactResult.WorkflowStatus.OPERATIONAL,
+ change.employeeId().equals(ZOE_ID) ? ImpactResult.WorkflowStatus.BLOCKED : ImpactResult.WorkflowStatus.OPERATIONAL,
+ List.of(step(ZULU_STEP_ID, "Zulu requirement", baselineActors, scenarioActors)), List.of()));
+ }
+
+ private static StepImpact step(UUID id, String name, List baseline, List scenario) {
+ return new StepImpact(id, name.toUpperCase().replace(' ', '_'), name, null, 1, 2,
+ ImpactResult.StepStatus.OPERATIONAL, ImpactResult.StepStatus.OPERATIONAL, baseline, scenario, "fixture");
+ }
+
+ private static ImpactResult result(WorkflowImpact workflow) {
+ return new ImpactResult("1.0", WORKSPACE_ID, 0, ImpactResult.ResultStatus.COMPLETE,
+ ImpactResult.Severity.NONE, null, null, null, List.of(workflow), List.of(), List.of(), List.of(), null);
+ }
+
+ private static org.assertj.core.groups.Tuple tuple(Object... values) {
+ return org.assertj.core.groups.Tuple.tuple(values);
+ }
+}
diff --git a/docs/superpowers/plans/2026-08-20-mvp-architecture-hardening.md b/docs/superpowers/plans/2026-08-20-mvp-architecture-hardening.md
new file mode 100644
index 0000000..a41ce5e
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-20-mvp-architecture-hardening.md
@@ -0,0 +1,147 @@
+# MVP Architecture Hardening Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make draft continuity verdicts authoritative and role-holder changes atomic before the MVP is finalized.
+
+**Architecture:** Preserve the deep `ImpactEngine.analyze` interface. A draft continuity projection module maps engine-produced `ImpactResult` values into a compact frontend read model, while role mutations move exact holder-set synchronization into the existing backend transaction.
+
+**Tech Stack:** Java 21, Spring Boot 3.5, Spring JDBC, PostgreSQL/Testcontainers, React 19, TypeScript, TanStack Query, Vitest/Testing Library.
+
+**Spec:** `docs/superpowers/specs/2026-08-20-mvp-architecture-hardening.md`
+
+## Global Constraints
+
+- `ImpactEngine.analyze(OrganizationSnapshot, SimulationChange)` remains the only public impact-engine interface.
+- The browser must not calculate employee eligibility or continuity status from catalog fields.
+- Existing impact and mitigation preview response contracts remain unchanged.
+- Role metadata and a supplied exact holder set commit in one Spring transaction.
+- Omitted `holderMemberIds` preserves existing update assignments; an explicitly empty set clears assignments.
+- No database migration or new frontend dependency.
+- Strict RED-GREEN-REFACTOR evidence is required in every task report.
+- Do not restructure the graph projection or split `DeterministicImpactEngine`.
+
+---
+
+### Task 1: Authoritative Draft Continuity Projection
+
+**Files:**
+- Create: `backend/src/main/java/com/roleimpact/workspace/preview/api/DraftContinuityRiskResource.java`
+- Create: `backend/src/main/java/com/roleimpact/workspace/preview/application/DraftContinuityProjectionService.java`
+- Create: `backend/src/test/java/com/roleimpact/workspace/preview/application/DraftContinuityProjectionServiceTest.java`
+- Modify: `backend/src/main/java/com/roleimpact/workspace/preview/api/DraftImpactPreviewController.java`
+- Modify: `backend/src/test/java/com/roleimpact/workspace/WorkspaceIntegrationTest.java`
+- Modify: `frontend/src/api/draftImpact.ts`
+- Create: `frontend/src/components/workspaces/editor/continuityRiskPresentation.ts`
+- Create: `frontend/src/components/workspaces/editor/continuityRiskPresentation.test.ts`
+- Modify: `frontend/src/components/workspaces/editor/DraftEditor.tsx`
+- Modify: `frontend/src/components/workspaces/editor/DraftImpactTesting.tsx`
+- Modify: `frontend/src/components/workspaces/editor/OrganizationCanvas.tsx`
+- Modify: `frontend/src/App.test.tsx`
+- Delete: `frontend/src/components/workspaces/editor/workflowRisks.ts`
+
+**Interfaces:**
+- Consumes: `ImpactEngine.analyze(snapshot, new RevokeEmployeeRole(memberId, roleId))` and matching `ImpactResult.WorkflowImpact` / `ImpactResult.StepImpact` records.
+- Produces: `GET /api/v1/workspaces/{workspaceId}/impact-previews/continuity` returning `List`.
+- Produces frontend `DraftContinuityRisk` with `key`, workflow/requirement/role facts, `eligibleMembers`, and `members`; each member carries `eligible`, `losesCoverage`, `remainingEligibleActorCount`, and `scenarioStatus` (`OPERATIONAL | DEGRADED | BLOCKED`).
+
+- [ ] **Step 1: Write failing backend projection tests**
+
+Create `DraftContinuityProjectionServiceTest` with hand-built catalog and engine-result fixtures. Prove that the projection:
+
+1. uses `StepImpact.baselineEligibleActors` rather than team/region/shift catalog guesses;
+2. exposes the engine's workflow `scenarioStatus`, including `BLOCKED` when the step itself remains operational (the `DIFFERENT_ACTORS` case);
+3. marks `losesCoverage` by comparing the selected holder against `scenarioEligibleActors`;
+4. sorts workflows, requirements, roles, and holders deterministically;
+5. rejects a non-draft or unknown workspace consistently with existing preview behavior.
+
+- [ ] **Step 2: Run the focused backend test and verify RED**
+
+Run the Maven unit test selector for `DraftContinuityProjectionServiceTest`. Expected: compilation/test failure because the projection records and module do not exist.
+
+- [ ] **Step 3: Implement the minimal backend projection**
+
+Add immutable records under `DraftContinuityRiskResource` and a read-only transactional `DraftContinuityProjectionService`. Load one catalog and one immutable snapshot. For every workflow requirement/role/current-holder tuple, call the existing impact engine with `RevokeEmployeeRole`, locate the matching workflow and step, and map only engine-produced eligibility and outcome facts. Add `GET /continuity` to `DraftImpactPreviewController`. Do not copy `DeterministicImpactEngine.isEligible`, `workflowStatus`, or constraint logic.
+
+- [ ] **Step 4: Verify backend GREEN and add endpoint integration coverage**
+
+Run `DraftContinuityProjectionServiceTest` to PASS. Add `WorkspaceIntegrationTest` coverage asserting the continuity endpoint returns engine-derived baseline actors and scenario status for the custom release workflow. Compile integration tests; run them when Docker is available.
+
+- [ ] **Step 5: Write failing frontend presentation and UI tests**
+
+Create literal server-projection fixtures where catalog demographics suggest safety but the supplied status is `BLOCKED`. Prove display-only ranking and labels follow `scenarioStatus`. Extend the app/editor test routing for the continuity GET and assert the risk callout and impact controls use the supplied verdict. Add an assertion that a successful catalog mutation causes the continuity query to be fetched again.
+
+- [ ] **Step 6: Run focused frontend tests and verify RED**
+
+Run the new presentation test and focused editor/App test. Expected: failure because the continuity client/query does not exist and the UI still imports `findWorkflowRisks`.
+
+- [ ] **Step 7: Implement the frontend projection consumer**
+
+Add `fetchDraftContinuityRisks` and exact TypeScript response types to `draftImpact.ts`. Query it from `DraftEditor`, pass authoritative risks to `DraftImpactTesting`, and keep only sorting/wording helpers in `continuityRiskPresentation.ts`. Make loading/error states explicit. Invalidate `['draft-continuity', workspaceId]` after every successful catalog mutation in both editor and map mutation paths. Delete `workflowRisks.ts` and all catalog-based eligibility calculations.
+
+- [ ] **Step 8: Verify frontend GREEN and refactor**
+
+Run focused tests, the full frontend suite, lint, and build. The already-recorded unrelated `Focused impact graph` baseline assertion may be updated only if the current production heading intentionally changed; record that separately in the task report.
+
+- [ ] **Step 9: Commit Task 1**
+
+Commit with subject `refactor: make continuity projection authoritative`.
+
+### Task 2: Atomic Role-Holder Synchronization
+
+**Files:**
+- Modify: `backend/src/main/java/com/roleimpact/workspace/editor/api/RoleRequest.java`
+- Modify: `backend/src/main/java/com/roleimpact/workspace/editor/application/DraftCatalogService.java`
+- Modify: `backend/src/main/java/com/roleimpact/workspace/editor/persistence/DraftCatalogRepository.java`
+- Create: `backend/src/test/java/com/roleimpact/workspace/editor/application/DraftCatalogServiceTest.java`
+- Modify: `backend/src/test/java/com/roleimpact/workspace/WorkspaceIntegrationTest.java`
+- Modify: `frontend/src/api/draftCatalog.ts`
+- Modify: `frontend/src/components/workspaces/editor/DraftEditor.tsx`
+- Modify: `frontend/src/components/workspaces/editor/OrganizationCanvas.tsx`
+- Modify: `frontend/src/App.test.tsx`
+
+**Interfaces:**
+- Consumes: existing `POST /catalog/roles` and `PUT /catalog/roles/{roleId}`.
+- Extends request: optional `holderMemberIds: Set | null`; omitted update preserves holders, present set replaces holders exactly.
+- Produces: `DraftCatalogRepository.replaceRoleHolders(UUID roleId, Set memberIds)` invoked once inside the role mutation transaction.
+
+- [ ] **Step 1: Write failing backend service tests**
+
+Create `DraftCatalogServiceTest` with controlled repository behavior. Prove:
+
+1. create with holder IDs validates the complete member set and performs one role-centric replacement;
+2. update with an explicit empty set clears holders;
+3. update with omitted holder IDs does not touch holders;
+4. an unknown holder is rejected before role metadata or assignments are mutated.
+
+- [ ] **Step 2: Run the focused backend test and verify RED**
+
+Run the Maven unit test selector for `DraftCatalogServiceTest`. Expected: compilation/test failure because `RoleRequest.holderMemberIds`, member-set validation, and `replaceRoleHolders` do not exist.
+
+- [ ] **Step 3: Implement the atomic backend mutation**
+
+Extend `RoleRequest` with nullable `holderMemberIds` and defensively copy non-null sets. Add repository member-count validation and a role-centric replace method that deletes assignments for the role and inserts the exact desired members. In `createRole` and `updateRole`, validate all holder IDs first, then mutate metadata and holders inside the existing `@Transactional` method. Generalize the existing mutation helper only as needed to return the created role ID.
+
+- [ ] **Step 4: Verify backend GREEN and transactional integration behavior**
+
+Run `DraftCatalogServiceTest` to PASS. Extend `WorkspaceIntegrationTest` to assert create/update exact holder sets and that a mixed valid/unknown holder request leaves prior role metadata and holders unchanged. Compile integration tests; run them when Docker is available.
+
+- [ ] **Step 5: Write failing frontend role-flow tests**
+
+Use request-body assertions against the real frontend mutation functions and role flows. Prove inventory create/edit and organization-map quick-create/reuse send exactly one role request with the complete `holderMemberIds` set and do not issue sequential `/members/{id}/roles` calls.
+
+- [ ] **Step 6: Run focused frontend tests and verify RED**
+
+Run the relevant App tests. Expected: failure because role input has no holder set and both role flows still synchronize members sequentially.
+
+- [ ] **Step 7: Implement the single role mutation in the frontend**
+
+Extend `RoleInput` with optional `holderMemberIds`. Inventory create/edit/reuse and map create/reuse must call `createDraftRole` or `updateDraftRole` once with the exact selected holders. Delete `syncRoleHolders` and `assignRoleToMembers`. Leave member editing and one-off graph connections on `replaceMemberRoles`.
+
+- [ ] **Step 8: Verify frontend GREEN and all available checks**
+
+Run focused tests, full frontend tests, lint, build, backend unit tests, and backend test compilation. Run Testcontainers integration tests only if Docker is available.
+
+- [ ] **Step 9: Commit Task 2**
+
+Commit with subject `refactor: synchronize role holders atomically`.
diff --git a/docs/superpowers/specs/2026-08-20-mvp-architecture-hardening.md b/docs/superpowers/specs/2026-08-20-mvp-architecture-hardening.md
new file mode 100644
index 0000000..bcb9aa8
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-20-mvp-architecture-hardening.md
@@ -0,0 +1,56 @@
+# MVP Architecture Hardening Specification
+
+## Objective
+
+Remove the two correctness risks approved before MVP completion:
+
+1. Browser-generated workflow continuity verdicts can disagree with the deterministic impact engine.
+2. Role-holder edits are applied as a sequence of member mutations and can leave a partial result.
+
+## Authoritative continuity projection
+
+- `ImpactEngine.analyze(OrganizationSnapshot, SimulationChange)` remains the only public impact-engine interface.
+- The backend exposes a read-only continuity projection for draft workspaces at
+ `GET /api/v1/workspaces/{workspaceId}/impact-previews/continuity`.
+- The projection is derived from `ImpactResult` values produced by the existing engine. It must not reimplement employee eligibility, effective-permission, capability, application-access, coverage, or `DIFFERENT_ACTORS` rules.
+- Each projection item identifies one workflow requirement and one supporting role and contains:
+ - workflow and requirement identity and display names;
+ - workflow criticality;
+ - minimum actors and resilience target;
+ - role identity and display name;
+ - baseline eligible members from the engine's matching `StepImpact`;
+ - one scenario per current holder of that role, including whether that holder was baseline-eligible, whether the holder loses coverage after revocation, the remaining eligible-actor count, and the engine's workflow scenario status.
+- Items and nested members use deterministic ordering.
+- The frontend uses this projection for workflow ordering, status labels, continuity callouts, member eligibility text, and post-run explanatory copy.
+- The frontend may keep display-only sorting and wording. It must not recompute domain eligibility or coverage from catalog fields.
+- If the projection is loading or unavailable, the organization editor remains usable but does not invent a verdict. The impact view presents an explicit loading or retry state.
+- Any successful catalog mutation invalidates the continuity projection so it cannot remain stale.
+- Delete `frontend/src/components/workspaces/editor/workflowRisks.ts` after all callers consume the server projection.
+
+## Atomic role-holder synchronization
+
+- Role create and role update requests accept optional `holderMemberIds`.
+- Omission preserves compatibility: create means no holders; update leaves the current holders unchanged.
+- When `holderMemberIds` is present, it is the exact desired holder set, including an explicitly empty set.
+- The backend validates that every requested holder belongs to the draft workspace before mutating anything.
+- Role metadata and the supplied holder set are committed in the same Spring transaction.
+- Persistence replaces the role's complete holder set with one role-centric repository operation. It must not call the member-centric replacement operation in a loop.
+- The inventory role form and organization-map quick-create/reuse paths send one role mutation containing the exact holder set.
+- Member editing and one-off graph connections may retain the member-centric role-assignment interface because those user intents are member-centric and individually atomic.
+- A failed holder validation or persistence operation must leave role metadata and existing assignments unchanged.
+
+## Compatibility and scope
+
+- No database migration is required.
+- Existing published-workspace mutation protection remains in force.
+- Existing impact and mitigation preview response contracts remain unchanged.
+- No new frontend state-management or graph libraries are introduced.
+- Do not restructure the graph projection or split `DeterministicImpactEngine` in this work.
+
+## Verification
+
+- Follow strict RED-GREEN-REFACTOR for every production change.
+- Backend unit tests must remain runnable without Docker.
+- Add PostgreSQL integration coverage for the endpoint and transactional holder replacement; compilation must succeed even if Docker is unavailable locally.
+- Frontend tests must prove that server-supplied continuity statuses drive labels and that catalog mutations invalidate the projection.
+- Run frontend tests, lint, and build; run backend unit tests and test compilation. Run PostgreSQL integration tests when Docker is available.
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 5aea09d..31cad50 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -1691,8 +1691,10 @@
}
.continuity-signal.critical { border-color: #704352; background: #27171e; }
+.continuity-signal.warning { border-color: #7b6434; background: #2b2414; }
.continuity-signal strong { color: #9cddc1; font-size: 0.75rem; }
.continuity-signal.critical strong { color: #f6aebb; }
+.continuity-signal.warning strong { color: #efc46a; }
.continuity-signal span { color: #89958f; font-size: 0.67rem; }
.impact-test-result {
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 7e030f3..4785b37 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import App from './App'
import type { DraftCatalog } from './api/draftCatalog'
-import type { DraftImpactResult, DraftMitigationPreview } from './api/draftImpact'
+import type { DraftContinuityRisk, DraftImpactResult, DraftMitigationPreview } from './api/draftImpact'
import type { Simulation } from './api/simulations'
afterEach(() => {
@@ -155,14 +155,186 @@ describe('App', () => {
expect(screen.getByRole('button', { name: /Teams/ })).toHaveAttribute('aria-current', 'step')
})
+ it('creates an inventory role with its complete holder set in one role request', async () => {
+ const user = userEvent.setup()
+ const requests = mockEditableClone(clonedCatalogFixture)
+
+ renderApp()
+ await user.click(await screen.findByRole('button', { name: 'Clone and customize' }))
+ await user.click(await screen.findByRole('button', { name: 'Detailed inventory' }))
+ await user.click(screen.getByRole('button', { name: /Roles/ }))
+ await user.type(screen.getByLabelText('Role name'), 'Release Manager')
+ await user.type(screen.getByLabelText('Description'), 'Approves production releases')
+ await user.click(screen.getByRole('checkbox', { name: 'Priya Sharma' }))
+ await user.click(screen.getByRole('button', { name: 'Add shared role & continue' }))
+
+ expect((await screen.findAllByText(/Release Manager/)).length).toBeGreaterThan(0)
+ expect(requests.roleRequests.filter((request) => request.method === 'POST')).toHaveLength(1)
+ expect(requests.roleRequests.filter((request) => request.method === 'POST')[0].body.holderMemberIds).toEqual(['member-priya'])
+ expect(requests.memberRoleRequests).toEqual([])
+ })
+
+ it('updates an inventory role with its complete holder set in one role request', async () => {
+ const user = userEvent.setup()
+ const requests = mockEditableClone(clonedCatalogFixture)
+
+ renderApp()
+ await user.click(await screen.findByRole('button', { name: 'Clone and customize' }))
+ await user.click(await screen.findByRole('button', { name: 'Detailed inventory' }))
+ await user.click(screen.getByRole('button', { name: /Roles/ }))
+ const financeRole = screen.getByText('Finance Approver', { selector: 'strong' }).closest('article')
+ expect(financeRole).not.toBeNull()
+ await user.click(within(financeRole!).getByRole('button', { name: 'Edit' }))
+ await user.selectOptions(screen.getByLabelText('Sensitivity'), 'HIGH')
+ await user.click(screen.getByRole('button', { name: 'Save role and holders' }))
+
+ expect(await screen.findByText(/HIGH · 1 holders/)).toBeInTheDocument()
+ expect(requests.roleRequests.filter((request) => request.method === 'PUT')).toHaveLength(1)
+ expect(requests.roleRequests.filter((request) => request.method === 'PUT')[0].body.holderMemberIds).toEqual(['member-priya'])
+ expect(requests.memberRoleRequests).toEqual([])
+ })
+
+ it('quick-creates a map role with its complete holder set in one role request', async () => {
+ const user = userEvent.setup()
+ const requests = mockEditableClone(clonedCatalogFixture)
+
+ renderApp()
+ await user.click(await screen.findByRole('button', { name: 'Clone and customize' }))
+ await user.click(await screen.findByRole('button', { name: 'Add Role' }))
+ await user.type(screen.getByLabelText('Role name'), 'Release Manager')
+ await user.click(screen.getByRole('checkbox', { name: 'Priya Sharma' }))
+ await user.click(screen.getByRole('button', { name: 'Create role' }))
+
+ expect(await screen.findByText('Role created')).toBeInTheDocument()
+ expect(requests.roleRequests.filter((request) => request.method === 'POST')).toHaveLength(1)
+ expect(requests.roleRequests.filter((request) => request.method === 'POST')[0].body.holderMemberIds).toEqual(['member-priya'])
+ expect(requests.memberRoleRequests).toEqual([])
+ })
+
+ it('reuses a map role with its complete holder set in one role request', async () => {
+ const user = userEvent.setup()
+ const requests = mockEditableClone(clonedCatalogFixture)
+
+ renderApp()
+ await user.click(await screen.findByRole('button', { name: 'Clone and customize' }))
+ await user.click(await screen.findByRole('button', { name: 'Add Role' }))
+ await user.type(screen.getByLabelText('Role name'), 'Finance Approver')
+ expect(screen.getByRole('checkbox', { name: 'Priya Sharma' })).toBeChecked()
+ await user.click(screen.getByRole('button', { name: 'Assign existing role' }))
+
+ expect(await screen.findByText('Role assigned')).toBeInTheDocument()
+ expect(requests.roleRequests.filter((request) => request.method === 'PUT')).toHaveLength(1)
+ expect(requests.roleRequests.filter((request) => request.method === 'PUT')[0].body.holderMemberIds).toEqual(['member-priya'])
+ expect(requests.memberRoleRequests).toEqual([])
+ })
+
+ it('preselects current holders before reusing a map role', async () => {
+ const user = userEvent.setup()
+ mockEditableClone(clonedCatalogFixture)
+
+ renderApp()
+ await user.click(await screen.findByRole('button', { name: 'Clone and customize' }))
+ await user.click(await screen.findByRole('button', { name: 'Add Role' }))
+ await user.type(screen.getByLabelText('Role name'), 'Finance Approver')
+
+ expect(screen.getByRole('checkbox', { name: 'Priya Sharma' })).toBeChecked()
+ })
+
+ it('opens impact testing after a deferred continuity projection resolves', async () => {
+ const user = userEvent.setup()
+ const projection = deferred()
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
+ const url = input.toString()
+ if (url.endsWith('/api/v1/workspaces') && !init?.method) return jsonResponse(workspaceFixture)
+ if (url.endsWith('/clones') && init?.method === 'POST') return jsonResponse(clonedWorkspaceFixture, 201)
+ if (url.endsWith('/catalog')) return jsonResponse(clonedCatalogFixture)
+ if (url.endsWith('/impact-previews/continuity') && !init?.method) return projection.promise
+ return new Response(null, { status: 404 })
+ })
+
+ renderApp()
+ await user.click(await screen.findByRole('button', { name: 'Clone and customize' }))
+ await user.click(await screen.findByRole('button', { name: 'Test impact' }))
+ expect(await screen.findByRole('heading', { name: 'Loading continuity analysis' })).toBeInTheDocument()
+
+ projection.resolve(jsonResponse(clonedContinuityFixture))
+
+ expect(await screen.findByRole('heading', { name: 'Test impact map' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /Vendor Payment.*would block/ })).toBeInTheDocument()
+ })
+
+ it('opens impact testing after retrying a failed continuity projection', async () => {
+ const user = userEvent.setup()
+ let continuityAttempts = 0
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
+ const url = input.toString()
+ if (url.endsWith('/api/v1/workspaces') && !init?.method) return jsonResponse(workspaceFixture)
+ if (url.endsWith('/clones') && init?.method === 'POST') return jsonResponse(clonedWorkspaceFixture, 201)
+ if (url.endsWith('/catalog')) return jsonResponse(clonedCatalogFixture)
+ if (url.endsWith('/impact-previews/continuity') && !init?.method) {
+ continuityAttempts += 1
+ return continuityAttempts === 1
+ ? jsonResponse({ message: 'Continuity service is unavailable' }, 503)
+ : jsonResponse(clonedContinuityFixture)
+ }
+ return new Response(null, { status: 404 })
+ })
+
+ renderApp()
+ await user.click(await screen.findByRole('button', { name: 'Clone and customize' }))
+ await user.click(await screen.findByRole('button', { name: 'Test impact' }))
+ expect(await screen.findByRole('heading', { name: 'Continuity analysis is unavailable' })).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Retry continuity analysis' }))
+
+ expect(await screen.findByRole('heading', { name: 'Test impact map' })).toBeInTheDocument()
+ expect(continuityAttempts).toBe(2)
+ })
+
+ it('renders a degraded engine verdict with the warning continuity treatment', async () => {
+ const user = userEvent.setup()
+ const degradedContinuity = clonedContinuityFixture.map((risk) => ({
+ ...risk,
+ members: risk.members.map((member) => ({
+ ...member,
+ remainingEligibleActorCount: 1,
+ scenarioStatus: 'DEGRADED' as const,
+ })),
+ }))
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
+ const url = input.toString()
+ if (url.endsWith('/api/v1/workspaces') && !init?.method) return jsonResponse(workspaceFixture)
+ if (url.endsWith('/clones') && init?.method === 'POST') return jsonResponse(clonedWorkspaceFixture, 201)
+ if (url.endsWith('/catalog')) return jsonResponse(clonedCatalogFixture)
+ if (url.endsWith('/impact-previews/continuity') && !init?.method) return jsonResponse(degradedContinuity)
+ return new Response(null, { status: 404 })
+ })
+
+ renderApp()
+ await user.click(await screen.findByRole('button', { name: 'Clone and customize' }))
+ await user.click(await screen.findByRole('button', { name: 'Test impact' }))
+
+ const heading = await screen.findByText('This workflow would lose resilience')
+ const signal = heading.closest('.continuity-signal')
+ expect(signal).toHaveClass('warning')
+ expect(getComputedStyle(signal!).backgroundColor).toBe('rgb(43, 36, 20)')
+ })
+
it('builds a blank draft through teams, members, workflows, impact testing, and shared roles', async () => {
const user = userEvent.setup()
let catalog: DraftCatalog = structuredClone(blankCatalogFixture)
+ let continuityRequests = 0
+ const roleRequests: Array<{ method: string; body: { holderMemberIds?: string[] } }> = []
+ const memberRoleRequests: string[] = []
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = input.toString()
if (url.endsWith('/api/v1/workspaces') && !init?.method) return jsonResponse(workspaceFixture)
if (url.endsWith('/api/v1/workspaces') && init?.method === 'POST') return jsonResponse(blankWorkspaceFixture, 201)
if (url.endsWith('/catalog') && !init?.method) return jsonResponse(catalog)
+ if (url.endsWith('/impact-previews/continuity') && !init?.method) {
+ continuityRequests += 1
+ return jsonResponse(catalog.workflows.length === 0 ? [] : customDraftContinuityFixture)
+ }
if (url.endsWith('/catalog/teams') && init?.method === 'POST') {
const input = JSON.parse(String(init.body)) as { name: string; department: string }
catalog = { ...catalog, teams: [{ id: 'team-1', name: input.name, department: input.department, memberCount: 0 }] }
@@ -176,8 +348,9 @@ describe('App', () => {
return jsonResponse(catalog)
}
if (url.endsWith('/catalog/roles') && init?.method === 'POST') {
- const input = JSON.parse(String(init.body)) as { name: string; description: string; sensitivity: DraftCatalog['roles'][number]['sensitivity'] }
- catalog = { ...catalog, roles: [{ id: 'role-1', name: input.name, description: input.description, sensitivity: input.sensitivity, ownerMemberId: null, memberCount: 0 }] }
+ const input = JSON.parse(String(init.body)) as { name: string; description: string; sensitivity: DraftCatalog['roles'][number]['sensitivity']; ownerMemberId: string | null; holderMemberIds?: string[] }
+ roleRequests.push({ method: 'POST', body: input })
+ catalog = applyRoleHolders({ ...catalog, roles: [{ id: 'role-1', name: input.name, description: input.description, sensitivity: input.sensitivity, ownerMemberId: input.ownerMemberId, memberCount: 0 }] }, 'role-1', input.holderMemberIds)
return jsonResponse(catalog)
}
if (url.endsWith('/catalog/members/member-1') && init?.method === 'PUT') {
@@ -186,12 +359,14 @@ describe('App', () => {
return jsonResponse(catalog)
}
if (url.endsWith('/catalog/roles/role-1') && init?.method === 'PUT') {
- const input = JSON.parse(String(init.body)) as { name: string; description: string; sensitivity: DraftCatalog['roles'][number]['sensitivity']; ownerMemberId: string | null }
- catalog = { ...catalog, roles: [{ ...catalog.roles[0], ...input }] }
+ const input = JSON.parse(String(init.body)) as { name: string; description: string; sensitivity: DraftCatalog['roles'][number]['sensitivity']; ownerMemberId: string | null; holderMemberIds?: string[] }
+ roleRequests.push({ method: 'PUT', body: input })
+ catalog = applyRoleHolders({ ...catalog, roles: [{ ...catalog.roles[0], ...input }] }, 'role-1', input.holderMemberIds)
return jsonResponse(catalog)
}
if (url.match(/\/members\/[^/]+\/roles$/) && init?.method === 'PUT') {
const memberId = url.match(/\/members\/([^/]+)\/roles$/)![1]
+ memberRoleRequests.push(memberId)
const input = JSON.parse(String(init.body)) as { roleIds: string[] }
const members = catalog.members.map((member) => member.id === memberId ? { ...member, roleIds: input.roleIds } : member)
const roles = catalog.roles.map((role) => ({ ...role, memberCount: members.filter((member) => member.roleIds.includes(role.id)).length }))
@@ -216,7 +391,7 @@ describe('App', () => {
await user.type(await screen.findByLabelText('Organization name'), 'Atlas Systems')
await user.click(screen.getByRole('button', { name: 'Start blank' }))
expect(await screen.findByRole('heading', { name: 'Map how your organization works' })).toBeInTheDocument()
- expect(screen.getByRole('button', { name: 'Add Member' })).toBeDisabled()
+ expect(await screen.findByRole('button', { name: 'Add Member' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'Add Team' }))
await user.type(screen.getByLabelText('Team name'), 'Platform')
@@ -244,6 +419,9 @@ describe('App', () => {
await user.click(screen.getByRole('checkbox', { name: 'Maya Singh' }))
await user.click(screen.getByRole('button', { name: 'Create role' }))
expect(await screen.findByText('Role created')).toBeInTheDocument()
+ expect(roleRequests.filter((request) => request.method === 'POST')).toHaveLength(1)
+ expect(roleRequests.filter((request) => request.method === 'POST')[0].body.holderMemberIds).toEqual(['member-1'])
+ expect(memberRoleRequests).toHaveLength(0)
expect(within(screen.getByLabelText('Organization inventory')).queryByRole('button', { name: 'Delete object' })).not.toBeInTheDocument()
@@ -252,8 +430,10 @@ describe('App', () => {
await user.type(screen.getByLabelText('Workflow name'), 'Production Deployment')
await user.selectOptions(screen.getByLabelText('Business criticality'), 'CRITICAL')
await user.type(screen.getByLabelText('Responsibility or step'), 'Release Manager responsibility')
+ const continuityRequestsBeforeWorkflow = continuityRequests
await user.click(screen.getByRole('button', { name: 'Create workflow' }))
expect(await screen.findByText(/CRITICAL · 1 role responsibilities/)).toBeInTheDocument()
+ expect(continuityRequests).toBeGreaterThan(continuityRequestsBeforeWorkflow)
await user.click(screen.getByRole('button', { name: 'Organization map' }))
expect(screen.getByLabelText('Organization inventory')).toHaveTextContent('Production Deployment')
expect(screen.getByLabelText('Organization relationship canvas')).toBeInTheDocument()
@@ -264,7 +444,10 @@ describe('App', () => {
expect(screen.getByLabelText('Role Release Manager')).toBeInTheDocument()
expect(screen.getByLabelText('Responsibility Release Manager responsibility')).toBeInTheDocument()
expect(screen.getByLabelText('Workflow Production Deployment')).toBeInTheDocument()
- expect(screen.getByLabelText('Continuity risks found')).toHaveTextContent('critical coverage gap')
+ const continuityCallout = screen.getByLabelText('Continuity risks found')
+ expect(continuityCallout).toHaveTextContent('critical coverage gap')
+ expect(continuityCallout).toHaveTextContent('According to the impact engine, removing Release Manager from Maya Singh would block Production Deployment.')
+ expect(continuityCallout).not.toHaveTextContent('provide the minimum coverage')
const graphInventory = screen.getByLabelText('Organization inventory')
await user.click(within(graphInventory).getByRole('button', { name: 'Role' }))
@@ -307,10 +490,10 @@ describe('App', () => {
const selectedMaya = screen.getByLabelText('Selected Member Maya Singh')
expect(selectedMaya).toHaveTextContent('Connected workflows')
await user.click(within(selectedMaya).getByRole('button', { name: 'Test losing Release Manager' }))
- expect(await screen.findByText('This responsibility is now blocked')).toBeInTheDocument()
- expect(screen.getByText(/Maya Singh was the only eligible holder/)).toBeInTheDocument()
+ expect(await screen.findAllByText('This workflow would be blocked')).not.toHaveLength(0)
+ expect(screen.getByText(/the impact engine finds 2 eligible actors/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /Production Deployment.*would block/ }))
- expect(screen.getByText('This responsibility is now blocked')).toBeInTheDocument()
+ expect(screen.getAllByText('This workflow would be blocked')).not.toHaveLength(0)
expect(await screen.findByRole('heading', { name: 'A workflow would be blocked' })).toBeInTheDocument()
expect(screen.getByLabelText('Complete organization impact map')).toBeInTheDocument()
expect(screen.getByLabelText(/member Arjun Mehta.*Recommended #1/i)).toBeInTheDocument()
@@ -339,6 +522,9 @@ describe('App', () => {
await user.click(screen.getByRole('checkbox', { name: 'Arjun Mehta' }))
await user.click(screen.getByRole('button', { name: 'Assign existing role' }))
expect(await screen.findByText('Role assigned')).toBeInTheDocument()
+ expect(roleRequests.filter((request) => request.method === 'PUT')).toHaveLength(1)
+ expect(roleRequests.filter((request) => request.method === 'PUT')[0].body.holderMemberIds).toEqual(['member-1', 'member-2'])
+ expect(memberRoleRequests).toHaveLength(0)
await user.click(screen.getByRole('button', { name: 'Detailed inventory' }))
await user.click(screen.getByRole('button', { name: /Members/ }))
@@ -352,6 +538,7 @@ describe('App', () => {
const mayaNode = screen.getByLabelText('Member Maya Singh').closest('.react-flow__node')
const arjunNode = screen.getByLabelText('Member Arjun Mehta').closest('.react-flow__node')
expect(mayaNode?.getAttribute('style')).not.toEqual(arjunNode?.getAttribute('style'))
+ const memberRoleRequestsBeforeRoleEdit = memberRoleRequests.length
await user.click(screen.getByRole('button', { name: 'Detailed inventory' }))
await user.click(screen.getByRole('button', { name: /Roles/ }))
expect(await screen.findByText(/2 holders/)).toBeInTheDocument()
@@ -360,6 +547,9 @@ describe('App', () => {
await user.selectOptions(screen.getByLabelText('Sensitivity'), 'CRITICAL')
await user.click(screen.getByRole('button', { name: 'Save role and holders' }))
expect(await screen.findByText(/CRITICAL · 2 holders/)).toBeInTheDocument()
+ expect(roleRequests.filter((request) => request.method === 'PUT')).toHaveLength(2)
+ expect(roleRequests.at(-1)).toEqual({ method: 'PUT', body: expect.objectContaining({ holderMemberIds: ['member-1', 'member-2'] }) })
+ expect(memberRoleRequests).toHaveLength(memberRoleRequestsBeforeRoleEdit)
}, 15000)
})
@@ -370,6 +560,78 @@ function jsonResponse(value: unknown, status = 200) {
})
}
+function applyRoleHolders(catalog: DraftCatalog, roleId: string, holderMemberIds?: string[]) {
+ if (holderMemberIds === undefined) return catalog
+ const holderIds = new Set(holderMemberIds)
+ const members = catalog.members.map((member) => ({
+ ...member,
+ roleIds: holderIds.has(member.id)
+ ? [...new Set([...member.roleIds, roleId])]
+ : member.roleIds.filter((id) => id !== roleId),
+ }))
+ return {
+ ...catalog,
+ members,
+ roles: catalog.roles.map((role) => ({ ...role, memberCount: members.filter((member) => member.roleIds.includes(role.id)).length })),
+ }
+}
+
+type RoleMutationRequest = {
+ method: 'POST' | 'PUT'
+ body: {
+ name: string
+ description: string
+ sensitivity: DraftCatalog['roles'][number]['sensitivity']
+ ownerMemberId: string | null
+ holderMemberIds?: string[]
+ }
+}
+
+function mockEditableClone(initialCatalog: DraftCatalog) {
+ let catalog = structuredClone(initialCatalog)
+ const roleRequests: RoleMutationRequest[] = []
+ const memberRoleRequests: string[] = []
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
+ const url = input.toString()
+ if (url.endsWith('/api/v1/workspaces') && !init?.method) return jsonResponse(workspaceFixture)
+ if (url.endsWith('/clones') && init?.method === 'POST') return jsonResponse(clonedWorkspaceFixture, 201)
+ if (url.endsWith('/catalog') && !init?.method) return jsonResponse(catalog)
+ if (url.endsWith('/impact-previews/continuity') && !init?.method) return jsonResponse([])
+ if (url.endsWith('/catalog/roles') && init?.method === 'POST') {
+ const body = JSON.parse(String(init.body)) as RoleMutationRequest['body']
+ roleRequests.push({ method: 'POST', body })
+ catalog = applyRoleHolders({
+ ...catalog,
+ roles: [...catalog.roles, { id: 'role-release', name: body.name, description: body.description, sensitivity: body.sensitivity, ownerMemberId: body.ownerMemberId, memberCount: 0 }],
+ }, 'role-release', body.holderMemberIds)
+ return jsonResponse(catalog)
+ }
+ const roleMatch = url.match(/\/catalog\/roles\/([^/]+)$/)
+ if (roleMatch && init?.method === 'PUT') {
+ const body = JSON.parse(String(init.body)) as RoleMutationRequest['body']
+ const roleId = roleMatch[1]
+ roleRequests.push({ method: 'PUT', body })
+ catalog = applyRoleHolders({
+ ...catalog,
+ roles: catalog.roles.map((role) => role.id === roleId ? {
+ ...role,
+ name: body.name,
+ description: body.description,
+ sensitivity: body.sensitivity,
+ ownerMemberId: body.ownerMemberId,
+ } : role),
+ }, roleId, body.holderMemberIds)
+ return jsonResponse(catalog)
+ }
+ if (url.match(/\/members\/[^/]+\/roles$/) && init?.method === 'PUT') {
+ memberRoleRequests.push(url)
+ return jsonResponse(catalog)
+ }
+ return new Response(null, { status: 404 })
+ })
+ return { roleRequests, memberRoleRequests }
+}
+
const workspaceFixture = [
{
id: '00000000-0000-0000-0000-000000000001',
@@ -429,6 +691,30 @@ const clonedCatalogFixture: DraftCatalog = {
],
}
+const clonedContinuityFixture: DraftContinuityRisk[] = [
+ {
+ key: 'workflow-payment:requirement-payment:role-approver',
+ workflowId: 'workflow-payment',
+ workflowName: 'Vendor Payment',
+ criticality: 'CRITICAL',
+ requirementId: 'requirement-payment',
+ requirementName: 'Approve payment',
+ minimumActors: 1,
+ resilienceTarget: 1,
+ roleId: 'role-approver',
+ roleName: 'Finance Approver',
+ eligibleMembers: [{ id: 'member-priya', name: 'Priya Sharma' }],
+ members: [{
+ id: 'member-priya',
+ name: 'Priya Sharma',
+ eligible: true,
+ losesCoverage: true,
+ remainingEligibleActorCount: 0,
+ scenarioStatus: 'BLOCKED',
+ }],
+ },
+]
+
const dashboardFixture = {
organization: {
id: '00000000-0000-0000-0000-000000000001',
@@ -733,6 +1019,42 @@ const customDraftImpactFixture: DraftImpactResult = {
excludedCandidateReasons: [],
}
+function deferred() {
+ let resolve!: (value: T) => void
+ const promise = new Promise((resolvePromise) => { resolve = resolvePromise })
+ return { promise, resolve }
+}
+
+const customDraftContinuityFixture: DraftContinuityRisk[] = [
+ {
+ key: 'workflow-1:requirement-1:role-1',
+ workflowId: 'workflow-1',
+ workflowName: 'Production Deployment',
+ criticality: 'CRITICAL',
+ requirementId: 'requirement-1',
+ requirementName: 'Release Manager responsibility',
+ minimumActors: 1,
+ resilienceTarget: 1,
+ roleId: 'role-1',
+ roleName: 'Release Manager',
+ // The supplied BLOCKED verdict is authoritative even with two baseline actors.
+ eligibleMembers: [
+ { id: 'member-1', name: 'Maya Singh' },
+ { id: 'member-2', name: 'Arjun Mehta' },
+ ],
+ members: [
+ {
+ id: 'member-1',
+ name: 'Maya Singh',
+ eligible: true,
+ losesCoverage: false,
+ remainingEligibleActorCount: 2,
+ scenarioStatus: 'BLOCKED',
+ },
+ ],
+ },
+]
+
const customDraftMitigationPreviewFixture: DraftMitigationPreview = {
original: customDraftImpactFixture,
mitigation: {
diff --git a/frontend/src/api/draftCatalog.ts b/frontend/src/api/draftCatalog.ts
index 0501a8f..87865a3 100644
--- a/frontend/src/api/draftCatalog.ts
+++ b/frontend/src/api/draftCatalog.ts
@@ -56,7 +56,7 @@ export type DraftCatalog = {
export type TeamInput = { name: string; department: string }
export type MemberInput = Omit
-export type RoleInput = Omit
+export type RoleInput = Omit & { holderMemberIds?: string[] }
export type WorkflowRequirementInput = {
name: string
roleId: string
diff --git a/frontend/src/api/draftImpact.ts b/frontend/src/api/draftImpact.ts
index 2ee6d0f..63fbd27 100644
--- a/frontend/src/api/draftImpact.ts
+++ b/frontend/src/api/draftImpact.ts
@@ -6,8 +6,39 @@ export type DraftMitigationPreview = {
mitigation: DraftImpactResult
}
+export type DraftContinuityEligibleMember = { id: string; name: string }
+export type DraftContinuityRiskMember = DraftContinuityEligibleMember & {
+ eligible: boolean
+ losesCoverage: boolean
+ remainingEligibleActorCount: number
+ scenarioStatus: 'OPERATIONAL' | 'DEGRADED' | 'BLOCKED'
+}
+export type DraftContinuityRisk = {
+ key: string
+ workflowId: string
+ workflowName: string
+ criticality: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
+ requirementId: string
+ requirementName: string
+ minimumActors: number
+ resilienceTarget: number
+ roleId: string
+ roleName: string
+ eligibleMembers: DraftContinuityEligibleMember[]
+ members: DraftContinuityRiskMember[]
+}
+
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? ''
+export async function fetchDraftContinuityRisks(workspaceId: string, signal?: AbortSignal): Promise {
+ const response = await fetch(`${apiBaseUrl}/api/v1/workspaces/${workspaceId}/impact-previews/continuity`, { signal })
+ if (!response.ok) {
+ const error = await response.json().catch(() => null) as { message?: string } | null
+ throw new Error(error?.message ?? 'The continuity projection could not be loaded')
+ }
+ return response.json() as Promise
+}
+
export async function runDraftImpactPreview(
workspaceId: string,
memberId: string,
diff --git a/frontend/src/components/workspaces/editor/DraftEditor.tsx b/frontend/src/components/workspaces/editor/DraftEditor.tsx
index 1535569..99d1413 100644
--- a/frontend/src/components/workspaces/editor/DraftEditor.tsx
+++ b/frontend/src/components/workspaces/editor/DraftEditor.tsx
@@ -18,7 +18,7 @@ import {
type DraftCatalog,
type DraftMember,
} from '../../../api/draftCatalog'
-import { findWorkflowRisks } from './workflowRisks'
+import { fetchDraftContinuityRisks } from '../../../api/draftImpact'
const OrganizationCanvas = lazy(() => import('./OrganizationCanvas'))
const DraftImpactTesting = lazy(() => import('./DraftImpactTesting'))
@@ -33,6 +33,10 @@ export default function DraftEditor({ workspaceId, isTemplateClone }: { workspac
queryKey: ['draft-catalog', workspaceId],
queryFn: ({ signal }) => fetchDraftCatalog(workspaceId, signal),
})
+ const continuityQuery = useQuery({
+ queryKey: ['draft-continuity', workspaceId],
+ queryFn: ({ signal }) => fetchDraftContinuityRisks(workspaceId, signal),
+ })
if (catalogQuery.isPending) return Loading the draft catalog…
if (catalogQuery.isError) {
@@ -51,9 +55,10 @@ export default function DraftEditor({ workspaceId, isTemplateClone }: { workspac
{ label: 'roles', value: catalog.roles.length },
{ label: 'workflows', value: catalog.workflows.length },
]
- const singlePointRisks = findWorkflowRisks(catalog).filter((risk) =>
- risk.eligibleMembers.length > 0 && risk.eligibleMembers.length === risk.minimumActors,
- )
+ const continuityRisks = continuityQuery.data ?? []
+ const singlePointRisks = continuityRisks.filter((risk) => risk.members.some((member) => member.scenarioStatus === 'BLOCKED'))
+ const firstBlockedRisk = singlePointRisks[0]
+ const firstBlockedMember = firstBlockedRisk?.members.find((member) => member.scenarioStatus === 'BLOCKED')
function moveToStage(nextStage: EditorStage) {
setStage(nextStage)
@@ -86,10 +91,10 @@ export default function DraftEditor({ workspaceId, isTemplateClone }: { workspac
))}
- {view === 'map' && singlePointRisks.length > 0 ? (
+ {view === 'map' && !continuityQuery.isFetching && !continuityQuery.isError && firstBlockedRisk && firstBlockedMember ? (
!
- {singlePointRisks.length} critical coverage gap{singlePointRisks.length === 1 ? '' : 's'} found {singlePointRisks[0].eligibleMembers.map((member) => member.name).join(', ')} provide the minimum coverage for {singlePointRisks[0].requirementName} in {singlePointRisks[0].workflowName}.
+ {singlePointRisks.length} critical coverage gap{singlePointRisks.length === 1 ? '' : 's'} found According to the impact engine, removing {firstBlockedRisk.roleName} from {firstBlockedMember.name} would block {firstBlockedRisk.workflowName}.
setView('impact')}>Test this risk
) : null}
@@ -97,7 +102,7 @@ export default function DraftEditor({ workspaceId, isTemplateClone }: { workspac
{view === 'map' ? (
Opening the organization map…
}> openInventory()} onOpenWorkflows={() => openInventory('workflows')} onTestImpact={() => setView('impact')} />
) : view === 'impact' ? (
- Preparing impact testing…}> setView('map')} />
+ Preparing impact testing…}> void continuityQuery.refetch()} onBackToMap={() => setView('map')} />
) : (
<>
@@ -242,13 +247,8 @@ function RolesStage({ workspaceId, catalog, onContinue }: EditorStageProps) {
if (existingRole) setSelectedHolderIds(catalog.members.filter((member) => member.roleIds.includes(existingRole.id)).map((member) => member.id))
}, [catalog.members, existingRole])
const mutation = useCatalogMutation(workspaceId, async (input: Parameters[1]) => {
- if (editingId) {
- const updated = await updateDraftRole(workspaceId, editingId, input)
- return syncRoleHolders(workspaceId, updated, editingId, selectedHolderIds)
- }
- const created = await createDraftRole(workspaceId, input)
- const role = created.roles.find((candidate) => !catalog.roles.some((existing) => existing.id === candidate.id))
- return role ? syncRoleHolders(workspaceId, created, role.id, selectedHolderIds) : created
+ if (editingId) return updateDraftRole(workspaceId, editingId, input)
+ return createDraftRole(workspaceId, input)
}, () => {
const wasCreatingFirst = !editingId && catalog.roles.length === 0
setName(''); setDescription(''); setOwnerMemberId(''); setSelectedHolderIds([]); setEditingId(null)
@@ -256,7 +256,13 @@ function RolesStage({ workspaceId, catalog, onContinue }: EditorStageProps) {
})
const reuseMutation = useCatalogMutation(workspaceId, async () => {
if (!existingRole) return catalog
- return syncRoleHolders(workspaceId, catalog, existingRole.id, selectedHolderIds)
+ return updateDraftRole(workspaceId, existingRole.id, {
+ name: existingRole.name,
+ description: existingRole.description,
+ sensitivity: existingRole.sensitivity,
+ ownerMemberId: existingRole.ownerMemberId,
+ holderMemberIds: selectedHolderIds,
+ })
}, () => { setName(''); setDescription(''); setSelectedHolderIds([]) })
const deleteMutation = useCatalogMutation(workspaceId, (roleId: string) => deleteDraftRole(workspaceId, roleId))
@@ -266,7 +272,7 @@ function RolesStage({ workspaceId, catalog, onContinue }: EditorStageProps) {
reuseMutation.mutate(undefined)
return
}
- mutation.mutate({ name: name.trim(), description: description.trim(), sensitivity, ownerMemberId: ownerMemberId || null })
+ mutation.mutate({ name: name.trim(), description: description.trim(), sensitivity, ownerMemberId: ownerMemberId || null, holderMemberIds: selectedHolderIds })
}
return (
@@ -377,19 +383,6 @@ function MemberMultiSelect({ legend, members, selectedIds, onChange }: {
)
}
-async function syncRoleHolders(workspaceId: string, catalog: DraftCatalog, roleId: string, selectedHolderIds: string[]) {
- const selected = new Set(selectedHolderIds)
- let latest = catalog
- for (const member of catalog.members) {
- const currentlyAssigned = member.roleIds.includes(roleId)
- const shouldBeAssigned = selected.has(member.id)
- if (currentlyAssigned === shouldBeAssigned) continue
- const roleIds = shouldBeAssigned ? [...member.roleIds, roleId] : member.roleIds.filter((id) => id !== roleId)
- latest = await replaceMemberRoles(workspaceId, member.id, roleIds)
- }
- return latest
-}
-
type EditorProps = { workspaceId: string; catalog: DraftCatalog }
type EditorStageProps = EditorProps & { onContinue: () => void }
@@ -399,6 +392,7 @@ function useCatalogMutation(workspaceId: string, mutationFn: (input: T) => Pr
mutationFn,
onSuccess: (catalog) => {
queryClient.setQueryData(['draft-catalog', workspaceId], catalog)
+ void queryClient.invalidateQueries({ queryKey: ['draft-continuity', workspaceId] })
onSuccess?.()
},
})
diff --git a/frontend/src/components/workspaces/editor/DraftImpactTesting.tsx b/frontend/src/components/workspaces/editor/DraftImpactTesting.tsx
index d0551ab..3fab01f 100644
--- a/frontend/src/components/workspaces/editor/DraftImpactTesting.tsx
+++ b/frontend/src/components/workspaces/editor/DraftImpactTesting.tsx
@@ -3,32 +3,50 @@ import { useMutation } from '@tanstack/react-query'
import {
runDraftImpactPreview,
runDraftMitigationPreview,
+ type DraftContinuityRisk,
type DraftImpactResult,
} from '../../../api/draftImpact'
import type { DraftCatalog } from '../../../api/draftCatalog'
-import { findWorkflowRisks, type WorkflowRisk } from './workflowRisks'
+import { coverageHeading, coverageTone, outcomeTone, predictedOutcome, riskRank } from './continuityRiskPresentation'
const FullOrganizationImpactCanvas = lazy(() => import('./OrganizationCanvas').then((module) => ({ default: module.OrganizationImpactCanvas })))
-export default function DraftImpactTesting({ workspaceId, catalog, onBackToMap }: {
+export default function DraftImpactTesting({ workspaceId, catalog, risks, isContinuityLoading, continuityError, onRetryContinuity, onBackToMap }: {
workspaceId: string
catalog: DraftCatalog
+ risks: DraftContinuityRisk[]
+ isContinuityLoading: boolean
+ continuityError: Error | null
+ onRetryContinuity: () => void
onBackToMap: () => void
}) {
const testableRisks = useMemo(
- () => findWorkflowRisks(catalog).filter((risk) => risk.members.length > 0).toSorted((left, right) => riskRank(left) - riskRank(right)),
- [catalog],
+ () => risks.filter((risk) => risk.members.length > 0).toSorted((left, right) => riskRank(left) - riskRank(right)),
+ [risks],
)
const initialRisk = testableRisks[0]
- const [selectedWorkflowId, setSelectedWorkflowId] = useState(initialRisk?.workflowId ?? '')
+ const [selectedWorkflowIdState, setSelectedWorkflowId] = useState(initialRisk?.workflowId ?? '')
+ const selectedWorkflowId = testableRisks.some((risk) => risk.workflowId === selectedWorkflowIdState)
+ ? selectedWorkflowIdState
+ : initialRisk?.workflowId ?? ''
const workflowRisks = testableRisks.filter((risk) => risk.workflowId === selectedWorkflowId)
- const [selectedRiskKey, setSelectedRiskKey] = useState(initialRisk?.key ?? '')
+ const [selectedRiskKeyState, setSelectedRiskKey] = useState(initialRisk?.key ?? '')
+ const selectedRiskKey = workflowRisks.some((risk) => risk.key === selectedRiskKeyState)
+ ? selectedRiskKeyState
+ : workflowRisks[0]?.key ?? ''
const selectedRisk = workflowRisks.find((risk) => risk.key === selectedRiskKey) ?? workflowRisks[0]
- const [selectedMemberId, setSelectedMemberId] = useState(selectedRisk?.members[0]?.id ?? '')
+ const [selectedMemberIdState, setSelectedMemberId] = useState(selectedRisk?.members[0]?.id ?? '')
+ const selectedMemberId = selectedRisk?.members.some((member) => member.id === selectedMemberIdState)
+ ? selectedMemberIdState
+ : selectedRisk?.members[0]?.id ?? ''
const [showOutcomeExplanation, setShowOutcomeExplanation] = useState(false)
const [comparisonView, setComparisonView] = useState<'original' | 'mitigation'>('original')
const selectedMember = selectedRisk?.members.find((member) => member.id === selectedMemberId) ?? selectedRisk?.members[0]
- const testableWorkflows = catalog.workflows.filter((workflow) => testableRisks.some((risk) => risk.workflowId === workflow.id))
+ const testableWorkflows = Array.from(new Map(testableRisks.map((risk) => [risk.workflowId, {
+ id: risk.workflowId,
+ name: risk.workflowName,
+ responsibilityCount: new Set(testableRisks.filter((candidate) => candidate.workflowId === risk.workflowId).map((candidate) => candidate.requirementId)).size,
+ }])).values())
const mitigationMutation = useMutation({
mutationFn: ({ memberId, roleId, replacementMemberId }: { memberId: string; roleId: string; replacementMemberId: string }) =>
runDraftMitigationPreview(workspaceId, memberId, roleId, replacementMemberId),
@@ -90,6 +108,12 @@ export default function DraftImpactTesting({ workspaceId, catalog, onBackToMap }
if (catalog.workflows.length === 0) {
return
}
+ if (isContinuityLoading) {
+ return
+ }
+ if (continuityError) {
+ return ! Impact testing
Continuity analysis is unavailable {continuityError.message}
Retry continuity analysis Return to organization map
+ }
if (testableRisks.length === 0) {
return
}
@@ -102,7 +126,7 @@ export default function DraftImpactTesting({ workspaceId, catalog, onBackToMap }
{testableWorkflows.map((workflow) => {
const risks = testableRisks.filter((risk) => risk.workflowId === workflow.id)
const outcome = workflowOutcome(risks)
- return chooseWorkflow(workflow.id)}>{workflow.name} {workflow.requirements.length} responsibilities {outcome.label}
+ return chooseWorkflow(workflow.id)}>{workflow.name} {workflow.responsibilityCount} responsibilities {outcome.label}
})}
Back to organization map
@@ -138,7 +162,7 @@ export default function DraftImpactTesting({ workspaceId, catalog, onBackToMap }
Step 2
Choose what to test Use these controls as a precise alternative to selecting people directly on the full organization map.
Responsibility {workflowRisks.map((risk) => chooseRisk(risk.key)}>{risk.requirementName} {risk.roleName} {predictedOutcome(risk)} )}
Role holder {selectedRisk.members.map((member) => { if (member.id === selectedMemberId) return; setSelectedMemberId(member.id); resetResults() }}>{member.name} {member.eligible ? 'Eligible for this responsibility' : 'Not eligible under this responsibility’s conditions'} )}
- {coverageHeading(selectedRisk, selectedMember.losesCoverage)} {selectedRisk.eligibleMembers.length} eligible now · minimum {selectedRisk.minimumActors} · healthy target {selectedRisk.resilienceTarget}.
+ {coverageHeading(selectedRisk, selectedMember)} {selectedRisk.eligibleMembers.length} eligible now · {selectedMember.remainingEligibleActorCount} eligible after this change · minimum {selectedRisk.minimumActors} · healthy target {selectedRisk.resilienceTarget}.
What if {selectedMember.name} loses {selectedRisk.roleName}? No organization data will be changed.
runScenario(selectedRisk.key, selectedMember.id)}>{mutation.isPending ? 'Calculating impact…' : 'Run this what-if test'} {originalResult ? Reset to baseline : null}
{mutation.isError ? {mutation.error.message}
: null}
@@ -236,18 +260,17 @@ function CandidateExclusions({ result }: { result: DraftImpactResult }) {
)
}
-function OutcomeExplanation({ risk, member, onDismiss }: { risk: WorkflowRisk; member: WorkflowRisk['members'][number]; onDismiss: () => void }) {
- const survivors = risk.eligibleMembers.filter((candidate) => candidate.id !== member.id || !member.losesCoverage)
- const tone = survivors.length < risk.minimumActors ? 'blocked' : survivors.length < risk.resilienceTarget ? 'degraded' : 'safe'
- const heading = tone === 'blocked' ? 'This responsibility is now blocked' : tone === 'degraded' ? 'It still works, but resilience is reduced' : 'Coverage remains healthy'
- const coverageText = survivors.length === 0
- ? `${member.name} was the only eligible holder. No one can now perform ${risk.requirementName}.`
- : `${member.name} loses ${risk.roleName}, but ${formatNames(survivors.map((survivor) => survivor.name))} ${survivors.length === 1 ? 'still provides' : 'still provide'} coverage.`
+function OutcomeExplanation({ risk, member, onDismiss }: { risk: DraftContinuityRisk; member: DraftContinuityRisk['members'][number]; onDismiss: () => void }) {
+ const tone = member.scenarioStatus === 'BLOCKED' ? 'blocked' : member.scenarioStatus === 'DEGRADED' ? 'degraded' : 'safe'
+ const heading = member.scenarioStatus === 'BLOCKED' ? 'This workflow would be blocked' : member.scenarioStatus === 'DEGRADED' ? 'It still works, but resilience is reduced' : 'The workflow remains operational'
+ const coverageText = member.remainingEligibleActorCount === 0
+ ? `After ${member.name} loses ${risk.roleName}, the impact engine finds no eligible actor for ${risk.requirementName}.`
+ : `After ${member.name} loses ${risk.roleName}, the impact engine finds ${member.remainingEligibleActorCount} eligible actor${member.remainingEligibleActorCount === 1 ? '' : 's'} for ${risk.requirementName}.`
return (
{tone === 'blocked' ? '!' : tone === 'degraded' ? '△' : '✓'}
- {heading} {coverageText}
Rule: {survivors.length} eligible after change · minimum {risk.minimumActors} to operate · {risk.resilienceTarget} for healthy coverage.
+ {heading} {coverageText}
Engine verdict: {member.scenarioStatus} · {member.remainingEligibleActorCount} eligible after change · minimum {risk.minimumActors} to operate · {risk.resilienceTarget} for healthy coverage.
×
)
@@ -307,42 +330,7 @@ function resultExplanation(result: DraftImpactResult, requirementName: string) {
return `If ${change.employee.name} loses ${change.role.name}, RoleImpact recalculates ${requirementName}: ${result.executiveSummary.workflowsBlocked} workflow(s) are blocked and ${result.executiveSummary.workflowsDegraded} are degraded.`
}
-function workflowOutcome(risks: WorkflowRisk[]) {
+function workflowOutcome(risks: DraftContinuityRisk[]) {
const best = risks.toSorted((left, right) => riskRank(left) - riskRank(right))[0]
return best ? { label: predictedOutcome(best), tone: outcomeTone(best) } : { label: 'not testable', tone: 'neutral' }
}
-
-function riskRank(risk: WorkflowRisk) {
- const remaining = remainingCoverage(risk)
- if (remaining < risk.minimumActors) return 0
- if (remaining < risk.resilienceTarget) return 1
- return 2
-}
-
-function predictedOutcome(risk: WorkflowRisk) {
- const remaining = remainingCoverage(risk)
- if (remaining < risk.minimumActors) return 'would block'
- if (remaining < risk.resilienceTarget) return 'would degrade'
- return 'coverage remains safe'
-}
-
-function outcomeTone(risk: WorkflowRisk) {
- return ['danger', 'warning', 'safe'][riskRank(risk)]
-}
-
-function remainingCoverage(risk: WorkflowRisk) {
- return risk.eligibleMembers.length - (risk.members.some((member) => member.losesCoverage) ? 1 : 0)
-}
-
-function coverageHeading(risk: WorkflowRisk, losesCoverage: boolean) {
- if (!losesCoverage) return 'Other access still covers this step'
- const remaining = risk.eligibleMembers.length - 1
- if (remaining < risk.minimumActors) return 'This change would block the step'
- if (remaining < risk.resilienceTarget) return 'This change would reduce resilience'
- return 'Coverage would remain healthy'
-}
-
-function coverageTone(risk: WorkflowRisk, losesCoverage: boolean) {
- if (!losesCoverage) return 'ready'
- return risk.eligibleMembers.length - 1 < risk.resilienceTarget ? 'critical' : 'ready'
-}
diff --git a/frontend/src/components/workspaces/editor/FullOrganizationImpactCanvas.tsx b/frontend/src/components/workspaces/editor/FullOrganizationImpactCanvas.tsx
index 1f4e219..a6efec4 100644
--- a/frontend/src/components/workspaces/editor/FullOrganizationImpactCanvas.tsx
+++ b/frontend/src/components/workspaces/editor/FullOrganizationImpactCanvas.tsx
@@ -10,9 +10,8 @@ import {
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import type { DraftCatalog } from '../../../api/draftCatalog'
-import type { DraftImpactResult } from '../../../api/draftImpact'
+import type { DraftContinuityRisk, DraftImpactResult } from '../../../api/draftImpact'
import type { CanvasNode, OrganizationSimulationState } from './OrganizationCanvas'
-import type { WorkflowRisk } from './workflowRisks'
type ImpactContextMenu = { x: number; y: number; memberId: string }
type ImpactEdgeState = 'removed' | 'blocked' | 'degraded' | 'restored' | 'candidate'
@@ -21,7 +20,7 @@ export type FullOrganizationImpactCanvasProps = {
workspaceId: string
catalog: DraftCatalog
workflowId: string
- risks: WorkflowRisk[]
+ risks: DraftContinuityRisk[]
selectedRiskKey: string
selectedMemberId: string
originalResult?: DraftImpactResult
@@ -173,8 +172,8 @@ export default function FullOrganizationImpactCanvas({
function ImpactSelectionPanel({ node, catalog, risks, selectedRisk, originalResult, isPending, onClose, onRunScenario, onTryReplacement }: {
node: CanvasNode
catalog: DraftCatalog
- risks: WorkflowRisk[]
- selectedRisk: WorkflowRisk
+ risks: DraftContinuityRisk[]
+ selectedRisk: DraftContinuityRisk
originalResult?: DraftImpactResult
isPending: boolean
onClose: () => void
@@ -240,7 +239,7 @@ function ImpactSelectionPanel({ node, catalog, risks, selectedRisk, originalResu
}
function ImpactRiskActions({ actions, isPending, onRun, empty }: {
- actions: Array<{ risk: WorkflowRisk; member: WorkflowRisk['members'][number] }>
+ actions: Array<{ risk: DraftContinuityRisk; member: DraftContinuityRisk['members'][number] }>
isPending: boolean
onRun: (riskKey: string, memberId: string) => void
empty: string
@@ -254,8 +253,8 @@ function ImpactMemberMenu({ x, y, memberId, catalog, risks, selectedRisk, origin
y: number
memberId: string
catalog: DraftCatalog
- risks: WorkflowRisk[]
- selectedRisk: WorkflowRisk
+ risks: DraftContinuityRisk[]
+ selectedRisk: DraftContinuityRisk
originalResult?: DraftImpactResult
isPending: boolean
onClose: () => void
@@ -288,7 +287,7 @@ function buildImpactModel(
baseNodes: CanvasNode[],
baseEdges: Edge[],
baseFocusIds: Set,
- selectedRisk: WorkflowRisk | undefined,
+ selectedRisk: DraftContinuityRisk | undefined,
selectedMemberId: string,
originalResult?: DraftImpactResult,
displayedResult?: DraftImpactResult,
@@ -421,12 +420,12 @@ function emphasizeSelectedEdges(edges: Edge[], selectedPathIds: Set | nu
})
}
-function uniqueRisksByRole(risks: WorkflowRisk[]) {
+function uniqueRisksByRole(risks: DraftContinuityRisk[]) {
return Array.from(new Map(risks.map((risk) => [risk.roleId, risk])).values())
}
-function uniqueRiskActions(risks: WorkflowRisk[]) {
- const actions = new Map()
+function uniqueRiskActions(risks: DraftContinuityRisk[]) {
+ const actions = new Map()
risks.forEach((risk) => risk.members.forEach((member) => {
const key = `${risk.roleId}:${member.id}`
if (!actions.has(key)) actions.set(key, { risk, member })
diff --git a/frontend/src/components/workspaces/editor/OrganizationCanvas.tsx b/frontend/src/components/workspaces/editor/OrganizationCanvas.tsx
index f0e3e7a..7f13dcb 100644
--- a/frontend/src/components/workspaces/editor/OrganizationCanvas.tsx
+++ b/frontend/src/components/workspaces/editor/OrganizationCanvas.tsx
@@ -29,6 +29,7 @@ import {
deleteQuickWorkflow,
replaceMemberRoles,
updateDraftMember,
+ updateDraftRole,
type DraftCatalog,
type DraftMember,
} from '../../../api/draftCatalog'
@@ -157,6 +158,7 @@ function OrganizationCanvasInner({ workspaceId, catalog, initialFocus, onOpenInv
mutationFn: async (connection: Connection) => connectEntities(workspaceId, catalog, connection),
onSuccess: (nextCatalog) => {
queryClient.setQueryData(['draft-catalog', workspaceId], nextCatalog)
+ void queryClient.invalidateQueries({ queryKey: ['draft-continuity', workspaceId] })
setNotice('Relationship saved')
},
onError: (error: Error) => setNotice(error.message),
@@ -165,6 +167,7 @@ function OrganizationCanvasInner({ workspaceId, catalog, initialFocus, onOpenInv
mutationFn: (node: CanvasNode) => deleteEntity(workspaceId, node, catalog),
onSuccess: (nextCatalog, node) => {
queryClient.setQueryData(['draft-catalog', workspaceId], nextCatalog)
+ void queryClient.invalidateQueries({ queryKey: ['draft-continuity', workspaceId] })
removeSavedPosition(workspaceId, node.id)
setSelectedNodeId(null)
setPendingDelete(null)
@@ -343,6 +346,7 @@ function OrganizationCanvasInner({ workspaceId, catalog, initialFocus, onOpenInv
localStorage.setItem(positionKey(workspaceId), JSON.stringify(positions))
}
queryClient.setQueryData(['draft-catalog', workspaceId], nextCatalog)
+ void queryClient.invalidateQueries({ queryKey: ['draft-continuity', workspaceId] })
setSelectedNodeId(null)
setPendingCreate(null)
setNotice(successMessage ?? `${typeLabels[pendingCreate.entityType]} created`)
@@ -393,6 +397,10 @@ function QuickCreate({ workspaceId, catalog, pending, onCancel, onCreated }: {
const relationshipOptions = parentOptions(pending.entityType, catalog)
const existingEntity = findExistingEntity(pending.entityType, name, catalog)
+ useEffect(() => {
+ if (pending.entityType !== 'role' || !existingEntity || !('memberCount' in existingEntity)) return
+ setSelectedMemberIds(catalog.members.filter((member) => member.roleIds.includes(existingEntity.id)).map((member) => member.id))
+ }, [catalog.members, existingEntity, pending.entityType])
const roleAlreadyRequired = pending.entityType === 'workflow' && existingEntity && 'requirements' in existingEntity
? existingEntity.requirements.some((requirement) => requirement.roleIds.includes(parentId))
: false
@@ -722,13 +730,18 @@ async function createEntity(workspaceId: string, entityType: EntityType, name: s
if (entityType === 'role') {
const existingRole = catalog.roles.find((role) => normalizedName(role.name) === normalizedName(name))
if (existingRole) {
- const next = await assignRoleToMembers(workspaceId, catalog, existingRole.id, selectedMemberIds)
+ const next = await updateDraftRole(workspaceId, existingRole.id, {
+ name: existingRole.name,
+ description: existingRole.description,
+ sensitivity: existingRole.sensitivity,
+ ownerMemberId: existingRole.ownerMemberId,
+ holderMemberIds: selectedMemberIds,
+ })
return { catalog: next, nodeId: nodeId('role', existingRole.id) }
}
- const created = await createDraftRole(workspaceId, { name, description: `${name} responsibilities`, sensitivity: options.sensitivity, ownerMemberId: null })
+ const created = await createDraftRole(workspaceId, { name, description: `${name} responsibilities`, sensitivity: options.sensitivity, ownerMemberId: null, holderMemberIds: selectedMemberIds })
const roleId = findCreatedId(catalog.roles, created.roles)
- const next = await assignRoleToMembers(workspaceId, created, roleId, selectedMemberIds)
- return { catalog: next, nodeId: nodeId('role', roleId) }
+ return { catalog: created, nodeId: nodeId('role', roleId) }
}
const existingWorkflow = catalog.workflows.find((workflow) => normalizedName(workflow.name) === normalizedName(name))
const role = catalog.roles.find((candidate) => candidate.id === parentId)
@@ -742,16 +755,6 @@ async function createEntity(workspaceId: string, entityType: EntityType, name: s
return { catalog: next, nodeId: nodeId('workflow', findCreatedId(catalog.workflows, next.workflows)) }
}
-async function assignRoleToMembers(workspaceId: string, catalog: DraftCatalog, roleId: string, memberIds: string[]) {
- const selected = new Set(memberIds)
- let latest = catalog
- for (const member of catalog.members) {
- if (!selected.has(member.id) || member.roleIds.includes(roleId)) continue
- latest = await replaceMemberRoles(workspaceId, member.id, [...member.roleIds, roleId])
- }
- return latest
-}
-
function findExistingEntity(entityType: EntityType, name: string, catalog: DraftCatalog) {
if (!name.trim()) return undefined
if (entityType === 'role') return catalog.roles.find((role) => normalizedName(role.name) === normalizedName(name))
diff --git a/frontend/src/components/workspaces/editor/continuityRiskPresentation.test.ts b/frontend/src/components/workspaces/editor/continuityRiskPresentation.test.ts
new file mode 100644
index 0000000..3b48f4b
--- /dev/null
+++ b/frontend/src/components/workspaces/editor/continuityRiskPresentation.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest'
+import type { DraftContinuityRisk } from '../../../api/draftImpact'
+import { coverageHeading, coverageTone, outcomeTone, predictedOutcome, riskRank } from './continuityRiskPresentation'
+
+const serverBlockedRisk: DraftContinuityRisk = {
+ key: 'workflow-release:step-approve:role-release',
+ workflowId: 'workflow-release',
+ workflowName: 'Release workflow',
+ criticality: 'HIGH',
+ requirementId: 'step-approve',
+ requirementName: 'Approve release',
+ minimumActors: 1,
+ resilienceTarget: 2,
+ roleId: 'role-release',
+ roleName: 'Release Manager',
+ // These deliberately look safe by catalog demographics; the engine verdict wins.
+ eligibleMembers: [
+ { id: 'member-a', name: 'Alex Holder' },
+ { id: 'member-b', name: 'Blair Backup' },
+ ],
+ members: [
+ {
+ id: 'member-a',
+ name: 'Alex Holder',
+ eligible: true,
+ losesCoverage: false,
+ remainingEligibleActorCount: 2,
+ scenarioStatus: 'BLOCKED',
+ },
+ ],
+}
+
+describe('continuity risk presentation', () => {
+ it('ranks and labels a supplied BLOCKED verdict without recalculating catalog coverage', () => {
+ const member = serverBlockedRisk.members[0]
+
+ expect(riskRank(serverBlockedRisk)).toBe(0)
+ expect(predictedOutcome(serverBlockedRisk)).toBe('would block')
+ expect(outcomeTone(serverBlockedRisk)).toBe('danger')
+ expect(coverageHeading(serverBlockedRisk, member)).toBe('This workflow would be blocked')
+ expect(coverageTone(member)).toBe('critical')
+ })
+})
diff --git a/frontend/src/components/workspaces/editor/continuityRiskPresentation.ts b/frontend/src/components/workspaces/editor/continuityRiskPresentation.ts
new file mode 100644
index 0000000..e25e918
--- /dev/null
+++ b/frontend/src/components/workspaces/editor/continuityRiskPresentation.ts
@@ -0,0 +1,44 @@
+import type { DraftContinuityRisk, DraftContinuityRiskMember } from '../../../api/draftImpact'
+
+export function riskRank(risk: DraftContinuityRisk) {
+ return statusRank(worstScenarioStatus(risk))
+}
+
+export function predictedOutcome(risk: DraftContinuityRisk) {
+ return outcomeLabel(worstScenarioStatus(risk))
+}
+
+export function outcomeTone(risk: DraftContinuityRisk) {
+ return outcomeToneForStatus(worstScenarioStatus(risk))
+}
+
+export function coverageHeading(_risk: DraftContinuityRisk, member: DraftContinuityRiskMember) {
+ return member.scenarioStatus === 'BLOCKED'
+ ? 'This workflow would be blocked'
+ : member.scenarioStatus === 'DEGRADED'
+ ? 'This workflow would lose resilience'
+ : 'This workflow remains operational'
+}
+
+export function coverageTone(member: DraftContinuityRiskMember) {
+ return member.scenarioStatus === 'BLOCKED' ? 'critical' : member.scenarioStatus === 'DEGRADED' ? 'warning' : 'ready'
+}
+
+export function worstScenarioStatus(risk: DraftContinuityRisk) {
+ return risk.members.reduce<'OPERATIONAL' | 'DEGRADED' | 'BLOCKED'>(
+ (worst, member) => statusRank(member.scenarioStatus) < statusRank(worst) ? member.scenarioStatus : worst,
+ 'OPERATIONAL',
+ )
+}
+
+export function outcomeLabel(status: DraftContinuityRiskMember['scenarioStatus']) {
+ return status === 'BLOCKED' ? 'would block' : status === 'DEGRADED' ? 'would degrade' : 'coverage remains safe'
+}
+
+export function outcomeToneForStatus(status: DraftContinuityRiskMember['scenarioStatus']) {
+ return status === 'BLOCKED' ? 'danger' : status === 'DEGRADED' ? 'warning' : 'safe'
+}
+
+function statusRank(status: DraftContinuityRiskMember['scenarioStatus']) {
+ return status === 'BLOCKED' ? 0 : status === 'DEGRADED' ? 1 : 2
+}
diff --git a/frontend/src/components/workspaces/editor/workflowRisks.ts b/frontend/src/components/workspaces/editor/workflowRisks.ts
deleted file mode 100644
index 5f4987c..0000000
--- a/frontend/src/components/workspaces/editor/workflowRisks.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import type { DraftCatalog, DraftMember, DraftWorkflowRequirement } from '../../../api/draftCatalog'
-
-export type WorkflowRiskMember = { id: string; name: string; eligible: boolean; losesCoverage: boolean }
-
-export type WorkflowRisk = {
- key: string
- workflowId: string
- workflowName: string
- criticality: string
- requirementId: string
- requirementName: string
- minimumActors: number
- resilienceTarget: number
- roleId: string
- roleName: string
- members: WorkflowRiskMember[]
- eligibleMembers: WorkflowRiskMember[]
-}
-
-export function findWorkflowRisks(catalog: DraftCatalog): WorkflowRisk[] {
- const rolesById = new Map(catalog.roles.map((role) => [role.id, role]))
- return catalog.workflows.flatMap((workflow) => workflow.requirements.flatMap((requirement) =>
- requirement.roleIds.flatMap((roleId) => {
- const role = rolesById.get(roleId)
- if (!role) return []
- const eligibleMembers = catalog.members
- .filter((member) => member.roleIds.some((id) => requirement.roleIds.includes(id)) && isEligible(member, requirement, catalog))
- .map((member) => ({ id: member.id, name: member.name, eligible: true, losesCoverage: false }))
- const members = catalog.members
- .filter((member) => member.roleIds.includes(roleId))
- .map((member) => {
- const eligible = isEligible(member, requirement, catalog)
- const hasAlternativeRole = member.roleIds.some((id) => id !== roleId && requirement.roleIds.includes(id))
- return { id: member.id, name: member.name, eligible, losesCoverage: eligible && !hasAlternativeRole }
- })
- return [{
- key: `${workflow.id}:${requirement.id}:${roleId}`,
- workflowId: workflow.id,
- workflowName: workflow.name,
- criticality: workflow.criticality,
- requirementId: requirement.id,
- requirementName: requirement.name,
- minimumActors: requirement.minimumActors,
- resilienceTarget: requirement.resilienceTarget,
- roleId,
- roleName: role.name,
- members,
- eligibleMembers,
- }]
- }),
- ))
-}
-
-function isEligible(member: DraftMember, requirement: DraftWorkflowRequirement, catalog: DraftCatalog) {
- if (member.status !== 'ACTIVE') return false
- const team = catalog.teams.find((candidate) => candidate.id === member.teamId)
- if (!team) return false
- if (requirement.requiredDepartment && team.department !== requirement.requiredDepartment) return false
- if (requirement.requiredRegion && member.region !== requirement.requiredRegion) return false
- if (requirement.requiredShift && member.shift !== requirement.requiredShift) return false
- return true
-}