From ead2aaeffe2d4f5c3910aa86a6c90c754cf972d8 Mon Sep 17 00:00:00 2001 From: Sourav Chandhok Date: Thu, 20 Aug 2026 17:22:59 -0400 Subject: [PATCH 1/8] docs: plan MVP architecture hardening --- .../2026-08-20-mvp-architecture-hardening.md | 148 ++++++++++++++++++ .../2026-08-20-mvp-architecture-hardening.md | 57 +++++++ 2 files changed, 205 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-20-mvp-architecture-hardening.md create mode 100644 docs/superpowers/specs/2026-08-20-mvp-architecture-hardening.md 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..2ad0e18 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-mvp-architecture-hardening.md @@ -0,0 +1,148 @@ +# 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..19d27c8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-20-mvp-architecture-hardening.md @@ -0,0 +1,57 @@ +# 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. + From b2a63f7ae1d40a854c2eeff8d9cf66dfa7ecf64a Mon Sep 17 00:00:00 2001 From: Sourav Chandhok Date: Thu, 20 Aug 2026 17:41:03 -0400 Subject: [PATCH 2/8] refactor: make continuity projection authoritative --- .../api/DraftContinuityRiskResource.java | 39 +++++ .../api/DraftImpactPreviewController.java | 14 +- .../DraftContinuityProjectionService.java | 152 +++++++++++++++++ .../workspace/WorkspaceIntegrationTest.java | 7 + .../DraftContinuityProjectionServiceTest.java | 161 ++++++++++++++++++ frontend/src/App.test.tsx | 45 ++++- frontend/src/api/draftImpact.ts | 31 ++++ .../workspaces/editor/DraftEditor.tsx | 16 +- .../workspaces/editor/DraftImpactTesting.tsx | 81 ++++----- .../editor/FullOrganizationImpactCanvas.tsx | 23 ++- .../workspaces/editor/OrganizationCanvas.tsx | 3 + .../editor/continuityRiskPresentation.test.ts | 43 +++++ .../editor/continuityRiskPresentation.ts | 44 +++++ .../workspaces/editor/workflowRisks.ts | 62 ------- 14 files changed, 585 insertions(+), 136 deletions(-) create mode 100644 backend/src/main/java/com/roleimpact/workspace/preview/api/DraftContinuityRiskResource.java create mode 100644 backend/src/main/java/com/roleimpact/workspace/preview/application/DraftContinuityProjectionService.java create mode 100644 backend/src/test/java/com/roleimpact/workspace/preview/application/DraftContinuityProjectionServiceTest.java create mode 100644 frontend/src/components/workspaces/editor/continuityRiskPresentation.test.ts create mode 100644 frontend/src/components/workspaces/editor/continuityRiskPresentation.ts delete mode 100644 frontend/src/components/workspaces/editor/workflowRisks.ts 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..08557ec --- /dev/null +++ b/backend/src/main/java/com/roleimpact/workspace/preview/application/DraftContinuityProjectionService.java @@ -0,0 +1,152 @@ +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.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) + 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..d700b29 100644 --- a/backend/src/test/java/com/roleimpact/workspace/WorkspaceIntegrationTest.java +++ b/backend/src/test/java/com/roleimpact/workspace/WorkspaceIntegrationTest.java @@ -344,6 +344,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/preview/application/DraftContinuityProjectionServiceTest.java b/backend/src/test/java/com/roleimpact/workspace/preview/application/DraftContinuityProjectionServiceTest.java new file mode 100644 index 0000000..34becf2 --- /dev/null +++ b/backend/src/test/java/com/roleimpact/workspace/preview/application/DraftContinuityProjectionServiceTest.java @@ -0,0 +1,161 @@ +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 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/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7e030f3..5d691a4 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(() => { @@ -158,11 +158,16 @@ describe('App', () => { 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 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 }] } @@ -252,8 +257,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() @@ -307,10 +314,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() @@ -733,6 +740,36 @@ const customDraftImpactFixture: DraftImpactResult = { excludedCandidateReasons: [], } +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/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..dbf4e9a 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,8 @@ 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')) function moveToStage(nextStage: EditorStage) { setStage(nextStage) @@ -86,7 +89,7 @@ export default function DraftEditor({ workspaceId, isTemplateClone }: { workspac ))} - {view === 'map' && singlePointRisks.length > 0 ? ( + {view === 'map' && !continuityQuery.isFetching && !continuityQuery.isError && singlePointRisks.length > 0 ? (
{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}.

@@ -97,7 +100,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')} />
) : ( <>