From 6e00a48e00da3b2cfbd976a53254275fa63583a6 Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 8 Mar 2026 01:35:18 +0200 Subject: [PATCH] feat(ui): ship policy decisioning studio --- ...E_policy_vex_release_decisioning_studio.md | 72 ++- .../web/policy-decisioning-studio-ui.md | 67 ++ docs/modules/ui/TASKS.md | 15 +- docs/modules/ui/implementation_plan.md | 4 +- .../ui/policy-decisioning-studio/README.md | 218 +++---- .../src/app/core/api/search.models.ts | 2 +- .../app/core/navigation/navigation.config.ts | 34 +- .../core/services/search-context.registry.ts | 2 +- .../approval-detail-page.component.ts | 36 +- .../policy-hit-annotation.component.ts | 3 +- .../evidence-audit-overview.component.ts | 4 +- .../features/home/home-dashboard.component.ts | 4 +- ...olicy-decisioning-audit-shell.component.ts | 163 +++++ ...policy-decisioning-gates-page.component.ts | 592 ++++++++++++++++++ ...icy-decisioning-overview-page.component.ts | 254 ++++++++ .../policy-decisioning-shell.component.ts | 437 +++++++++++++ .../policy-decisioning-vex-shell.component.ts | 221 +++++++ .../policy-decisioning.routes.ts | 317 ++++++++++ .../policy-pack-shell.component.ts | 229 +++++++ .../impact-preview.component.ts | 2 +- .../policy-audit-log.component.ts | 2 +- .../promotion-gate.component.ts | 2 +- .../simulation-dashboard.component.ts | 4 +- .../simulation-history.component.ts | 2 +- .../workspace/policy-workspace.component.ts | 8 +- .../policy/policy-studio.component.ts | 10 +- .../promotion-request.component.ts | 22 + .../environment-detail.component.ts | 2 +- .../evidence-detail.component.ts | 31 +- .../release-detail.component.ts | 57 +- .../workflow-editor.component.ts | 22 + .../releases/release-detail-page.component.ts | 26 +- .../exception-detail-page.component.ts | 2 +- .../security/exceptions-page.component.ts | 2 +- .../security-overview-page.component.ts | 4 +- .../vulnerability-detail-page.component.ts | 4 +- ...licy-governance-settings-page.component.ts | 8 +- .../evidence-links.component.ts | 4 +- .../vex-conflict-resolution.component.ts | 7 +- .../vex-hub/vex-statement-search.component.ts | 7 + .../policy-baseline-chip.component.ts | 3 +- .../global-search/search-route-matrix.ts | 6 +- .../src/app/routes/administration.routes.ts | 124 +++- .../src/app/routes/legacy-redirects.routes.ts | 195 ++++++ .../src/app/routes/ops.routes.ts | 39 +- .../src/app/routes/security-risk.routes.ts | 51 +- .../administration-routes.spec.ts | 4 +- .../global_search/search-route-matrix.spec.ts | 4 +- .../tests/navigation/legacy-redirects.spec.ts | 12 + .../policy-decisioning-routes.spec.ts | 74 +++ ...policy-decisioning-shell.component.spec.ts | 117 ++++ .../evidence-detail.behavior.spec.ts | 135 ++++ .../visual-workflow-editor.behavior.spec.ts | 25 +- ...oute-migration-framework.component.spec.ts | 19 + .../security-risk-routes.spec.ts | 19 +- ...curity-overview-dashboard.behavior.spec.ts | 9 + .../e2e/policy-decisioning-studio.spec.ts | 232 +++++++ 57 files changed, 3637 insertions(+), 333 deletions(-) rename {docs => docs-archived}/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md (65%) create mode 100644 docs/features/checked/web/policy-decisioning-studio-ui.md create mode 100644 src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-audit-shell.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-gates-page.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-overview-page.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-shell.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-vex-shell.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning.routes.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-pack-shell.component.ts create mode 100644 src/Web/StellaOps.Web/src/tests/policy_decisioning/policy-decisioning-routes.spec.ts create mode 100644 src/Web/StellaOps.Web/src/tests/policy_decisioning/policy-decisioning-shell.component.spec.ts create mode 100644 src/Web/StellaOps.Web/src/tests/release_orchestrator/evidence-detail.behavior.spec.ts create mode 100644 src/Web/StellaOps.Web/tests/e2e/policy-decisioning-studio.spec.ts diff --git a/docs/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md b/docs-archived/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md similarity index 65% rename from docs/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md rename to docs-archived/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md index dafe8a1b8..02138d1a7 100644 --- a/docs/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md +++ b/docs-archived/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md @@ -33,7 +33,7 @@ ## Delivery Tracker ### FE-PD-001 - Build the canonical `/ops/policy` shell -Status: TODO +Status: DONE Dependency: none Owners: Product Manager, FE Architect Task description: @@ -41,12 +41,12 @@ Task description: - Make the shell usable in global, pack, and release-context modes from the first shipped route. Completion criteria: -- [ ] `/ops/policy` renders as the canonical shell with working top-level navigation. -- [ ] Primary tabs and shared context header are wired in code. -- [ ] Release-context mode can be entered without creating a separate product shell. +- [x] `/ops/policy` renders as the canonical shell with working top-level navigation. +- [x] Primary tabs and shared context header are wired in code. +- [x] Release-context mode can be entered without creating a separate product shell. ### FE-PD-002 - Migrate routes and legacy aliases into the new tree -Status: TODO +Status: DONE Dependency: FE-PD-001 Owners: FE Architect, Documentation author Task description: @@ -54,12 +54,12 @@ Task description: - Wire redirects from `/policy-studio/*`, `/admin/policy/*`, and `/admin/vex-hub/*` so old entry points land on usable new pages. Completion criteria: -- [ ] Canonical child routes exist in the active router. -- [ ] Legacy aliases redirect into working `/ops/policy` subviews. -- [ ] No mutable policy or VEX workflow remains dependent on an orphan route. +- [x] Canonical child routes exist in the active router. +- [x] Legacy aliases redirect into working `/ops/policy` subviews. +- [x] No mutable policy or VEX workflow remains dependent on an orphan route. ### FE-PD-003 - Ship Packs and Governance functionality -Status: TODO +Status: DONE Dependency: FE-PD-002 Owners: FE Architect, Documentation author Task description: @@ -67,12 +67,12 @@ Task description: - Ensure these flows remain usable, not just reachable, after the shell cutover. Completion criteria: -- [ ] Packs and Governance tabs are functional under `/ops/policy`. -- [ ] Editing, approvals, governance settings, and explain flows are usable from the new shell. -- [ ] Superseded pack and governance shells can be retired or redirected after cutover. +- [x] Packs and Governance tabs are functional under `/ops/policy`. +- [x] Editing, approvals, governance settings, and explain flows are usable from the new shell. +- [x] Superseded pack and governance shells can be retired or redirected after cutover. ### FE-PD-004 - Ship Simulation, VEX, Exceptions, Gates, and Audit functionality -Status: TODO +Status: DONE Dependency: FE-PD-001 Owners: Product Manager, FE Architect Task description: @@ -80,12 +80,12 @@ Task description: - Ensure operators can complete the key workflows from the new tabs without falling back to dead or duplicate screens. Completion criteria: -- [ ] Simulation, VEX, Exceptions, Release Gates, and Audit tabs are functional under `/ops/policy`. -- [ ] Conflict resolution, exception handling, and gate review are usable from the new shell. -- [ ] Old mutable VEX and policy action pages are no longer required for those workflows. +- [x] Simulation, VEX, Exceptions, Release Gates, and Audit tabs are functional under `/ops/policy`. +- [x] Conflict resolution, exception handling, and gate review are usable from the new shell. +- [x] Old mutable VEX and policy action pages are no longer required for those workflows. ### FE-PD-005 - Wire Release Orchestrator into Decisioning Studio -Status: TODO +Status: DONE Dependency: FE-PD-002 Owners: Developer, FE Architect Task description: @@ -93,12 +93,12 @@ Task description: - Keep Release Orchestrator as the owner of release state while Decisioning Studio owns policy and VEX actions. Completion criteria: -- [ ] Release-context entry points are wired from active release surfaces. -- [ ] Release-context header shows the required release, environment, artifact, and gate state. -- [ ] Operators can return to the release workflow after taking policy or VEX actions. +- [x] Release-context entry points are wired from active release surfaces. +- [x] Release-context header shows the required release, environment, artifact, and gate state. +- [x] Operators can return to the release workflow after taking policy or VEX actions. ### FE-PD-006 - Verify cutover, redirects, and core operator journeys -Status: TODO +Status: DONE Dependency: FE-PD-005 Owners: QA, Test Automation Task description: @@ -106,12 +106,12 @@ Task description: - Validate that the new shell is the working owner for the core operator journeys, not just a shell around dead components. Completion criteria: -- [ ] Playwright scenarios cover all three shell modes. -- [ ] Legacy aliases and old bookmarks land on usable new pages. -- [ ] Scope-based visibility and the main policy/VEX operator journeys are explicitly verified. +- [x] Playwright scenarios cover all three shell modes. +- [x] Legacy aliases and old bookmarks land on usable new pages. +- [x] Scope-based visibility and the main policy/VEX operator journeys are explicitly verified. ### FE-PD-007 - Complete docs sync and retire superseded shells -Status: TODO +Status: DONE Dependency: FE-PD-003 Owners: Documentation author, Project Manager Task description: @@ -119,14 +119,18 @@ Task description: - Record which legacy names remain as temporary aliases and which old product shells are fully retired after the move. Completion criteria: -- [ ] Cross-doc references are updated for the shipped shell. -- [ ] User-facing naming and alias lifetimes are documented. -- [ ] Retired sibling-product labels and routes are explicitly listed after cutover. +- [x] Cross-doc references are updated for the shipped shell. +- [x] User-facing naming and alias lifetimes are documented. +- [x] Retired sibling-product labels and routes are explicitly listed after cutover. ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | | 2026-03-07 | Sprint created to ship a single Decisioning Studio shell spanning policy authoring, governance, simulation, actionable VEX resolution, and release-context gate explanation. | Project Manager | +| 2026-03-07 | Implementation started. Route inventory confirmed: `/ops/policy` is still fragmented across Policy Studio, Governance, Simulation, VEX, Exceptions, and Gate leaves, so the sprint is moving into shell and alias cutover. | FE | +| 2026-03-07 | Shipped the canonical `Decisioning Studio` shell at `/ops/policy` with primary tabs, pack and release-context modes, canonical VEX and audit ownership, and legacy redirects from `policy-studio`, `policy/*`, `admin/policy/*`, `admin/vex-hub/*`, and `security/vex*`. | FE | +| 2026-03-07 | Wired active release, approval, promotion, workflow-editor, evidence, security, home, settings, search, and timeline entry points into the shared shell so mutable policy and VEX actions no longer depend on orphan routes. | FE | +| 2026-03-07 | Verification passed: `npm test -- --watch=false --include ...` (10 targeted spec files, 94 tests), `npx playwright test tests/e2e/policy-decisioning-studio.spec.ts` (4/4), and `npm run build` (production build pass; existing size-budget warnings remain). | QA | ## Decisions & Risks - Decision: the preferred product shape is one dynamic shell with deep-linkable tabs, not one giant page and not separate sibling products. @@ -138,6 +142,18 @@ Completion criteria: - Mitigation: freeze redirects and verification scenarios before implementation starts. - Risk: Release Orchestrator could grow duplicate gate/policy UI while this consolidation is in flight. - Mitigation: require release-facing FE work to deep-link into the shared shell rather than add new standalone policy/VEX pages. +- Decision: canonical mutable VEX ownership is now `/ops/policy/vex`; Security keeps read-only exploratory entry points only where needed. +- Decision: legacy `policy-studio` and `policy/*` bookmarks are preserved through redirect coverage rather than leaving parallel writable routes mounted. +- Documentation sync: + - `docs/modules/ui/policy-decisioning-studio/README.md` + - `docs/features/checked/web/policy-decisioning-studio-ui.md` + - `docs/modules/ui/TASKS.md` + - `docs/modules/ui/implementation_plan.md` +- Verification commands: + - `npm test -- --watch=false --include src/tests/policy_decisioning/policy-decisioning-shell.component.spec.ts --include src/tests/policy_decisioning/policy-decisioning-routes.spec.ts --include src/tests/release_orchestrator/evidence-detail.behavior.spec.ts --include src/tests/release_orchestrator/visual-workflow-editor.behavior.spec.ts --include src/tests/security/security-overview-dashboard.behavior.spec.ts --include src/tests/global_search/search-route-matrix.spec.ts --include src/tests/navigation/legacy-redirects.spec.ts --include src/tests/routes/legacy-route-migration-framework.component.spec.ts --include src/tests/administration/administration-routes.spec.ts --include src/tests/security-risk/security-risk-routes.spec.ts` + - `npx playwright test tests/e2e/policy-decisioning-studio.spec.ts` + - `npm run build` +- Residual risk: the production build still reports pre-existing bundle-budget warnings unrelated to this sprint. - Delivery rule: this sprint is only complete when the canonical shell is routable, usable, verified, and old mutable policy or VEX action paths are no longer required. - Reference design note: `docs/modules/ui/policy-decisioning-studio/README.md`. diff --git a/docs/features/checked/web/policy-decisioning-studio-ui.md b/docs/features/checked/web/policy-decisioning-studio-ui.md new file mode 100644 index 000000000..6cb8a5184 --- /dev/null +++ b/docs/features/checked/web/policy-decisioning-studio-ui.md @@ -0,0 +1,67 @@ +# Policy Decisioning Studio UI + +## Module +Web + +## Status +VERIFIED + +## Description +Shipped the canonical `Policy Decisioning Studio` shell at `/ops/policy` and made it the mutable owner for policy packs, governance, simulation, VEX, exceptions, release gates, and policy/VEX audit. Legacy `policy-studio`, `policy/*`, `admin/policy/*`, `admin/vex-hub/*`, and security VEX aliases now resolve into the same routed shell instead of leaving writable sibling products active. + +## Implementation Details +- **Feature directory**: `src/Web/StellaOps.Web/src/app/features/policy-decisioning/` +- **Primary components**: + - `policy-decisioning-shell.component.ts` + - `policy-decisioning-overview-page.component.ts` + - `policy-pack-shell.component.ts` + - `policy-decisioning-vex-shell.component.ts` + - `policy-decisioning-gates-page.component.ts` + - `policy-decisioning-audit-shell.component.ts` +- **Canonical routes**: + - `/ops/policy/overview` + - `/ops/policy/packs/*` + - `/ops/policy/governance/*` + - `/ops/policy/simulation/*` + - `/ops/policy/vex/*` + - `/ops/policy/gates/*` + - `/ops/policy/audit/*` +- **Legacy aliases**: + - `/policy-studio/*` + - `/policy/*` + - `/admin/policy/*` + - `/admin/vex-hub/*` + - `/security/vex*` + - `/security/exceptions*` +- **Release-context entry points**: + - approvals detail + - promotion request + - release detail + - workflow editor + - evidence detail + +## E2E Test Plan +- **Setup**: + - [x] Log in with a user that has policy, VEX, exception, and release read scopes. + - [x] Seed pack dashboard data or route fixtures for `/api/policy/packs` and `/api/policy/packs/:packId/dashboard`. + - [x] Start the local UI harness on `https://127.0.0.1:4400`. +- **Core verification**: + - [x] Verify `/ops/policy/overview` renders the canonical shell and primary tabs. + - [x] Verify a legacy pack bookmark lands inside pack-mode decisioning. + - [x] Verify a release-context gate URL renders context chips and return-to-source affordance. + - [x] Verify `/security/vex` redirects into the canonical VEX shell. +- **Regression verification**: + - [x] Verify targeted Angular route and redirect specs cover the alias contract. + - [x] Verify workflow-editor and evidence-detail deep links open the shared shell. + - [x] Verify global search VEX normalization lands in the canonical shell. + +## Verification +- Run: + - `npm test -- --watch=false --include src/tests/policy_decisioning/policy-decisioning-shell.component.spec.ts --include src/tests/policy_decisioning/policy-decisioning-routes.spec.ts --include src/tests/release_orchestrator/evidence-detail.behavior.spec.ts --include src/tests/release_orchestrator/visual-workflow-editor.behavior.spec.ts --include src/tests/security/security-overview-dashboard.behavior.spec.ts --include src/tests/global_search/search-route-matrix.spec.ts --include src/tests/navigation/legacy-redirects.spec.ts --include src/tests/routes/legacy-route-migration-framework.component.spec.ts --include src/tests/administration/administration-routes.spec.ts --include src/tests/security-risk/security-risk-routes.spec.ts` + - `npx playwright test tests/e2e/policy-decisioning-studio.spec.ts` + - `npm run build` +- Tier 0 (source): pass +- Tier 1 (build/tests): pass +- Tier 2 (behavior): pass +- Note: the production build still emits existing bundle-budget warnings outside this feature scope; the build itself completes successfully. +- Verified on (UTC): 2026-03-07T23:22:53Z diff --git a/docs/modules/ui/TASKS.md b/docs/modules/ui/TASKS.md index 1336413da..b0df6adfd 100644 --- a/docs/modules/ui/TASKS.md +++ b/docs/modules/ui/TASKS.md @@ -5,7 +5,6 @@ - `docs/implplan/SPRINT_20260307_004_FE_self_serve_search_answer_first.md` - `docs/implplan/SPRINT_20260307_006_FE_self_serve_rollout_and_gap_closure.md` - `docs/implplan/SPRINT_20260307_009_DOCS_ui_component_preservation_map.md` -- `docs/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md` - `docs/implplan/SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md` - `docs/implplan/SPRINT_20260307_038_FE_live_search_supported_route_execution_matrix.md` @@ -65,13 +64,13 @@ - [DONE] DOCS-RTS-005 Triage explainability restoration placement note - [DONE] DOCS-RTS-006 Workflow visualization and replay placement note - [DONE] DOCS-RTS-007 Deeper corroboration and implementation-sprint cutover for restoration topics -- [DOING] FE-PD-001 Freeze Policy Decisioning Studio shell shape and ownership -- [DOING] FE-PD-002 Canonical route and alias contract for policy / VEX / release decisioning -- [DOING] FE-PD-003 Component merge matrix for Policy Studio, Governance, Simulation, and VEX -- [DOING] FE-PD-004 Release-context UX contract for Release Orchestrator deep links -- [DOING] FE-PD-005 FE implementation slices for Decisioning Studio shell and cutover -- [TODO] FE-PD-006 QA and rollout contract for Decisioning Studio -- [TODO] FE-PD-007 Docs and deprecation plan for legacy policy / VEX product labels +- [DONE] FE-PD-001 Freeze Policy Decisioning Studio shell shape and ownership +- [DONE] FE-PD-002 Canonical route and alias contract for policy / VEX / release decisioning +- [DONE] FE-PD-003 Component merge matrix for Policy Studio, Governance, Simulation, and VEX +- [DONE] FE-PD-004 Release-context UX contract for Release Orchestrator deep links +- [DONE] FE-PD-005 FE implementation slices for Decisioning Studio shell and cutover +- [DONE] FE-PD-006 QA and rollout contract for Decisioning Studio +- [DONE] FE-PD-007 Docs and deprecation plan for legacy policy / VEX product labels - [DONE] FE-WL-001 Freeze Watchlist shell ownership and route contract - [DONE] FE-WL-002 Entries tab list-detail implementation slice - [DONE] FE-WL-003 Alerts tab and alert-detail drill-in diff --git a/docs/modules/ui/implementation_plan.md b/docs/modules/ui/implementation_plan.md index 67bf709de..b800346c7 100644 --- a/docs/modules/ui/implementation_plan.md +++ b/docs/modules/ui/implementation_plan.md @@ -11,19 +11,19 @@ Provide a living plan for UI deliverables, dependencies, and evidence. - `SPRINT_20260307_004_FE_self_serve_search_answer_first.md` - answer-first search shell, page-owned self-serve questions, and explicit fallback states. - `SPRINT_20260307_006_FE_self_serve_rollout_and_gap_closure.md` - page rollout, guided handoffs, and telemetry-driven gap closure. - `SPRINT_20260307_009_DOCS_ui_component_preservation_map.md` - per-component preservation dossiers for unused and weakly surfaced console UI components. -- `SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md` - canonical Decisioning Studio shell to unify policy, simulation, VEX decisioning, and release-context gate explanation. - `SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md` - documentation prerequisite for shell/menu/tab placements; not a product-delivery sprint by itself. ## Latest evidence - `docs/modules/ui/component-preservation-map/README.md` - root index for the first-pass preservation map. - `docs/modules/ui/component-preservation-map/SUMMARY_TREE.md` - branch-level keep / merge / wire / archive guidance. - `docs/modules/ui/component-preservation-map/inventory.json` - deterministic machine-readable inventory for 303 candidate components. -- `docs/modules/ui/policy-decisioning-studio/README.md` - proposed Decisioning Studio product shape, tab model, route contract, and Release Orchestrator integration boundary. +- `docs/modules/ui/policy-decisioning-studio/README.md` - shipped Decisioning Studio product shape, canonical routes, alias coverage, and release-context entry-point contract. - `docs/modules/ui/restoration-topics/README.md` - detailed placement notes for the next restoration topics after Decisioning Studio. - `docs/modules/ui/watchlist-operations/README.md` - detailed watchlist UX dossier and owner-shell contract. - `docs/features/checked/web/reachability-witnessing-ui.md` - shipped verification note for the canonical Reachability witness and PoE shell. - `docs/features/checked/web/identity-watchlist-management-ui.md` - shipped verification note for the Trust & Signing watchlist shell and its Mission Control / Notifications handoffs. - `docs/features/checked/web/operations-consolidation-ui.md` - shipped verification note for the canonical Operations shell, overview grouping, and legacy alias cutover. +- `docs/features/checked/web/policy-decisioning-studio-ui.md` - shipped verification note for the canonical Decisioning Studio shell, redirect cutover, release-context deep links, and VEX ownership merge. - `docs/features/checked/web/triage-explainability-workspace-ui.md` - shipped verification note for the canonical triage artifact workspace, explainability rail, audit bundles, and security alias cutover. - `docs/features/checked/web/workflow-visualization-replay-ui.md` - shipped verification note for the canonical run-detail graph, timeline, replay, evidence tabs, and workflow-editor preview reuse boundary. - `docs/features/checked/web/contextual-actions-patterns-ui.md` - shipped verification note for the shared contextual route-state, headers, drawers, list-detail shells, grouped overview cards, and first adopted restoration surfaces. diff --git a/docs/modules/ui/policy-decisioning-studio/README.md b/docs/modules/ui/policy-decisioning-studio/README.md index 15fda3c33..a2e525659 100644 --- a/docs/modules/ui/policy-decisioning-studio/README.md +++ b/docs/modules/ui/policy-decisioning-studio/README.md @@ -1,172 +1,132 @@ # Policy Decisioning Studio -## Recommendation - -Create one dynamic sub-product shell, not one giant page and not three separate sibling products. +## Status +Shipped on 2026-03-07. +## Product Shape - Canonical mount: `/ops/policy` -- Suggested user-facing title: `Decisioning Studio` -- Suggested nav label for now: keep `Policy` to avoid unnecessary IA churn during rollout +- User-facing title: `Policy Decisioning Studio` +- Active primary tabs: `Overview`, `Packs`, `Governance`, `Simulation`, `VEX & Exceptions`, `Release Gates`, `Audit` +- Supported modes: `global`, `pack`, `release-context`, plus non-owning `approval`, `workflow`, and `evidence` context chips -This shell should unify the current policy workspace, policy governance, policy simulation, and the actionable parts of VEX conflict resolution into one product surface with deep-linkable tabs and a shared context model. +This is now the canonical mutable owner for policy packs, governance controls, policy simulation, VEX resolution, exception handling, release-gate review, and policy/VEX audit. -## Why This Is The Right Shape - -- Policy authoring, governance, simulation, VEX overrides, exceptions, and release gates are all parts of one decisioning workflow. -- Release Orchestrator is a consumer of policy/VEX decisions, not a second owner of those UIs. -- The current split creates duplicated mental models: packs live in one branch, governance in another, simulation in another, and VEX conflicts off to the side. -- A single shell allows one shared context header for tenant, pack, environment, artifact digest, and release. -- Deep-linkable child routes preserve auditability and operator workflows better than a modal-heavy or single-scroll design. - -## Product Modes - -The shell should support three modes without forking the UI into separate apps. - -### 1. Global Mode -- Used by policy admins and security operators. -- Focus: pack inventory, governance controls, simulations, trust/VEX posture, gate policy defaults. - -### 2. Pack Mode -- Used when the user is working on a specific policy pack. -- Focus: editing, YAML, rule builder, approvals, explain traces, simulation for one pack. - -### 3. Release Context Mode -- Entered from Release Orchestrator deep links. -- Context is pinned in the shell header: `releaseId`, `environment`, `artifactDigest`, `approvalId`, and effective policy bundle. -- Focus: "why is this promotion blocked / allowed?" rather than general administration. - -## Recommended IA - -### Primary tabs -- `Overview` - - decision board for active packs, pending approvals, open conflicts, shadow-mode readiness, and gate health -- `Packs` - - policy pack inventory and pack-scoped workspace -- `Governance` - - risk budgets, trust weights, staleness, sealed mode, profiles, validator, governance audit -- `Simulation` - - shadow mode, console, coverage, effective policy, diff, merge preview, history, batch evaluation -- `VEX & Exceptions` - - VEX conflicts, consensus review, overrides, exception queue, exception detail, rationale capture -- `Release Gates` - - gate catalog, environment-specific gate policy, release-context gate evaluation, promotion readiness -- `Audit` - - immutable policy/VEX decision history, evidence pointers, exports, explain traces - -### Secondary navigation -- Use child tabs inside the active primary tab. -- Use a contextual right rail for evidence, explain traces, or release summary. -- Never hide critical release-decision screens behind modal-only flows. - -## Route Contract - -Keep `/ops/policy` as the canonical root and move the product to a single route tree. +## Shipped Route Contract ### Canonical routes -- `/ops/policy` +- `/ops/policy/overview` - `/ops/policy/packs` - `/ops/policy/packs/:packId` - `/ops/policy/packs/:packId/edit` - `/ops/policy/packs/:packId/rules` - `/ops/policy/packs/:packId/yaml` - `/ops/policy/packs/:packId/approvals` +- `/ops/policy/packs/:packId/simulate` - `/ops/policy/packs/:packId/explain/:runId` - `/ops/policy/governance/...` - `/ops/policy/simulation/...` - `/ops/policy/vex` +- `/ops/policy/vex/search` +- `/ops/policy/vex/search/detail/:id` +- `/ops/policy/vex/create` +- `/ops/policy/vex/stats` +- `/ops/policy/vex/consensus` +- `/ops/policy/vex/explorer` - `/ops/policy/vex/conflicts` -- `/ops/policy/vex/conflicts/:conflictId` -- `/ops/policy/exceptions` -- `/ops/policy/exceptions/:exceptionId` +- `/ops/policy/vex/exceptions` +- `/ops/policy/vex/exceptions/approvals` +- `/ops/policy/vex/exceptions/:exceptionId` - `/ops/policy/gates` +- `/ops/policy/gates/catalog` +- `/ops/policy/gates/simulate/:promotionId` - `/ops/policy/gates/environments/:environment` - `/ops/policy/gates/releases/:releaseId` -- `/ops/policy/audit` +- `/ops/policy/gates/approvals/:approvalId` +- `/ops/policy/audit/policy` +- `/ops/policy/audit/vex` +- `/ops/policy/audit/log` +- `/ops/policy/audit/log/events` -### Alias and migration rules -- Legacy `/policy-studio/*` routes redirect into `/ops/policy/packs/*` -- `/admin/policy/governance` and `/admin/policy/simulation` redirect into `/ops/policy/governance/*` and `/ops/policy/simulation/*` -- `/admin/vex-hub/*` should redirect into `/ops/policy/vex/*` for mutating and conflict-resolution flows -- If Analyze keeps a VEX entry point, it should deep-link into the same shell in read-only context instead of owning a separate VEX product +### Legacy aliases kept live +- `/policy-studio/*` +- `/policy/*` +- `/admin/policy/governance*` +- `/admin/policy/simulation*` +- `/admin/vex-hub*` +- `/security/vex*` +- `/security/exceptions*` +- `/administration/policy*` +- `/administration/policy-governance*` -## What To Merge +## Shipped Merge Boundary -### Merge into `Packs` +### Packs - `PolicyWorkspaceComponent` +- `PolicyDashboardComponent` - `PolicyEditorComponent` -- `PolicyYamlEditorComponent` - `PolicyRuleBuilderComponent` +- `PolicyYamlEditorComponent` - `PolicyApprovalsComponent` - `PolicyExplainComponent` -- `PolicyDashboardComponent` -### Merge into `Governance` -- `PolicyGovernanceComponent` shell -- risk budget, trust weighting, staleness, sealed mode, profiles, validator, audit, conflicts, schema tools +### Governance +- Existing `policy-governance.routes.ts` subtree mounted under `/ops/policy/governance` +- Settings, impact-preview, profile, trust-weight, and schema surfaces now point to the canonical shell -### Merge into `Simulation` -- `SimulationDashboardComponent` shell -- shadow mode, console, lint, coverage, effective policy, audit, diff, promotion, merge preview, history, batch +### Simulation +- Existing `policy-simulation.routes.ts` subtree mounted under `/ops/policy/simulation` +- Internal simulation navigation updated to stay inside the canonical route family -### Merge into `VEX & Exceptions` -- `VexConflictResolutionComponent` -- preserved ideas from `VexConflictStudioComponent` -- exception queue and exception detail flows -- VEX consensus and trust-weighted decision support +### VEX and exceptions +- Existing `vex-hub` components mounted under `/ops/policy/vex` +- Security VEX and exception aliases now redirect into the canonical VEX subtree +- Mutable VEX actions are no longer owned by a separate Security shell -### Merge into `Release Gates` -- promotion gate surfaces from policy simulation -- environment gate policy editors -- release-context verdict page used by Release Orchestrator +### Gates and audit +- Canonical release-gate page at `/ops/policy/gates*` +- Canonical policy/VEX audit owner under `/ops/policy/audit*` ## Release Orchestrator Integration -Release Orchestrator should link into this shell instead of growing a parallel policy UI. +### Shipped entry points +- approvals detail +- promotion request +- release detail +- workflow editor +- evidence detail -### Entry points from releases -- approval detail -> open gate verdict in release context mode -- promotion request -> open readiness checklist in release context mode -- release detail -> open effective policy + VEX posture for this artifact -- workflow editor -> deep link to gate catalog / policy pack used by the workflow -- evidence detail -> deep link to policy and VEX rationale bound to the promotion +### Shipped context fields +- `releaseId` +- `approvalId` +- `environment` +- `artifact` / `bundleDigest` +- `workflowId` +- `evidenceId` +- `returnTo` -### Required release-context panel -- active release / approval identifier -- environment lane -- artifact digest / subject digest -- effective policy pack and version -- gate verdict summary -- open conflicts or missing evidence -- CTA back to release flow +Release Orchestrator still owns promotion state and workflow execution. Decisioning Studio owns policy and VEX authoring, mutation, and explanation. -### Ownership rule -- Release Orchestrator owns promotion state and workflow execution -- Decisioning Studio owns policy authoring, governance, VEX resolution, exceptions, and gate explanation +## Secondary Entry Points Updated +- `Security Overview` +- `Security Exceptions` +- `Vulnerability Detail` +- `Home Dashboard` +- `Policy Governance Settings` +- `Evidence Audit` +- `Timeline Evidence Links` +- `Policy baseline chip` +- global search VEX normalization -## UI Standards For Implementation +## Retired Or Superseded Writable Owners +- standalone `Policy Studio` product label +- standalone `VEX Hub` mutable owner +- mutable `policy/*` writable paths +- mutable `security/vex*` owner paths -- One shell component with child router outlets, not duplicated top-level pages -- Page-owned context and self-serve guidance -- Stable deep links for every tab and subview -- Scope-aware tabs that hide or disable only what the operator cannot act on -- Shared evidence and explain cards reused across policy, VEX, and release contexts -- Deterministic loading order and route aliases so legacy bookmarks remain functional during rollout +These names survive only as temporary redirect aliases where needed for bookmark continuity. -## Non-Goals - -- Do not move all security exploration into this shell; read-only security analytics can remain elsewhere if they deep-link into the same canonical decisioning routes when action is required. -- Do not let Release Orchestrator fork its own policy editor or VEX conflict UI. -- Do not collapse everything into one scroll page; operators need stable, bookmarkable subviews. - -## Source Inputs - -- `docs/contracts/policy-studio.md` -- `docs/security/policy-governance.md` -- `docs/modules/release-orchestrator/ui/overview.md` -- `docs/modules/release-orchestrator/workflow/evidence-based-release-gates.md` -- `docs/modules/ui/component-preservation-map/README.md` -- `src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.routes.ts` -- `src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-simulation.routes.ts` -- `src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub.routes.ts` -- `src/Web/StellaOps.Web/src/app/routes/ops.routes.ts` -- `src/Web/StellaOps.Web/src/app/routes/administration.routes.ts` +## Verification Evidence +- feature verification note: `docs/features/checked/web/policy-decisioning-studio-ui.md` +- targeted Angular tests: `94` passing assertions across route, shell, redirect, workflow, evidence, and search coverage +- Playwright: `4/4` passing scenarios for global mode, pack mode, release-context mode, and security VEX alias redirect +- production build: pass, with existing unrelated bundle-budget warnings diff --git a/src/Web/StellaOps.Web/src/app/core/api/search.models.ts b/src/Web/StellaOps.Web/src/app/core/api/search.models.ts index 8979aed17..0a2ade94a 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/search.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/search.models.ts @@ -146,7 +146,7 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [ shortcut: '>policy', description: 'Create new policy pack', icon: 'shield', - route: '/ops/policy/baselines', + route: '/ops/policy/packs', keywords: ['policy', 'new', 'pack', 'create'], }, { diff --git a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts index b066235c8..135be6dea 100644 --- a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts +++ b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts @@ -68,10 +68,10 @@ export const NAVIGATION_GROUPS: NavGroup[] = [ }, { id: 'vex-hub', - label: 'VEX Hub', - route: '/admin/vex-hub', + label: 'VEX & Exceptions', + route: '/ops/policy/vex', icon: 'shield-check', - tooltip: 'Explore VEX statements and consensus', + tooltip: 'Resolve VEX statements, conflicts, and exceptions in Decisioning Studio', }, { id: 'unknowns', @@ -157,37 +157,37 @@ export const NAVIGATION_GROUPS: NavGroup[] = [ icon: 'policy', items: [ { - id: 'policy-studio', - label: 'Policy Studio', + id: 'policy-decisioning', + label: 'Policy Decisioning', icon: 'edit', children: [ { id: 'policy-editor', - label: 'Editor', - route: '/policy-studio/packs', + label: 'Packs', + route: '/ops/policy/packs', requiredScopes: ['policy:author'], - tooltip: 'Author and edit policies', + tooltip: 'Author and edit policy packs', }, { id: 'policy-simulate', label: 'Simulate', - route: '/policy-studio/simulate', + route: '/ops/policy/simulation', requiredScopes: ['policy:simulate'], tooltip: 'Test policies with simulations', }, { id: 'policy-approvals', - label: 'Approvals', - route: '/policy-studio/approvals', + label: 'VEX & Exceptions', + route: '/ops/policy/vex/exceptions', requireAnyScope: ['policy:review', 'policy:approve'], - tooltip: 'Review and approve policy changes', + tooltip: 'Review and resolve policy exceptions', }, { id: 'policy-dashboard', - label: 'Dashboard', - route: '/policy-studio/dashboard', + label: 'Overview', + route: '/ops/policy/overview', requiredScopes: ['policy:read'], - tooltip: 'Policy metrics and status', + tooltip: 'Policy metrics, packs, gates, and VEX status', }, ], }, @@ -596,14 +596,14 @@ export const NAVIGATION_GROUPS: NavGroup[] = [ { id: 'policy-governance', label: 'Policy Governance', - route: '/admin/policy/governance', + route: '/ops/policy/governance', icon: 'policy-config', tooltip: 'Risk budgets, trust weights, and sealed mode', }, { id: 'policy-simulation', label: 'Policy Simulation', - route: '/admin/policy/simulation', + route: '/ops/policy/simulation', icon: 'test-tube', tooltip: 'Shadow mode and policy simulation studio', }, diff --git a/src/Web/StellaOps.Web/src/app/core/services/search-context.registry.ts b/src/Web/StellaOps.Web/src/app/core/services/search-context.registry.ts index 9b8c5d20a..03e457ee4 100644 --- a/src/Web/StellaOps.Web/src/app/core/services/search-context.registry.ts +++ b/src/Web/StellaOps.Web/src/app/core/services/search-context.registry.ts @@ -264,7 +264,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [ }, { id: 'vex', - routePrefixes: ['/security/advisories-vex', '/vex-hub'], + routePrefixes: ['/ops/policy/vex', '/security/advisories-vex', '/vex-hub'], presentation: { titleKey: 'ui.search.context.vex.title', titleFallback: 'VEX intelligence', diff --git a/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts index 39f7415af..6d3803bde 100644 --- a/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts @@ -1,7 +1,9 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { ActivatedRoute, RouterLink } from '@angular/router'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; + +import { buildContextReturnTo } from '../../shared/ui/context-route-state/context-route-state'; type GateResult = 'PASS' | 'WARN' | 'BLOCK'; type HealthStatus = 'OK' | 'WARN' | 'FAIL'; @@ -43,7 +45,7 @@ interface GateTraceRow { inputs: string[]; timestamp: string; evidenceAge: string; - fixLinks: Array<{ label: string; route: string }>; + fixLinks: Array<{ label: string; route: string; queryParams?: Record }>; } interface SecurityFindingRow { @@ -229,7 +231,7 @@ interface HistoryEvent { @if (row.result === 'BLOCK') { } @@ -296,8 +298,8 @@ interface HistoryEvent { } @@ -785,6 +787,7 @@ interface HistoryEvent { }) export class ApprovalDetailPageComponent implements OnInit { private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); readonly minDecisionReasonLength = 10; readonly activeTab = signal('overview'); @@ -843,7 +846,11 @@ export class ApprovalDetailPageComponent implements OnInit { fixLinks: [ { label: 'Trigger SBOM Scan', route: '/platform-ops/data-integrity/scan-pipeline' }, { label: 'Open Finding', route: '/security/findings' }, - { label: 'Request Exception', route: '/administration/policy-governance/exceptions' }, + { + label: 'Request Exception', + route: '/ops/policy/vex/exceptions', + queryParams: this.decisioningContextParams({ create: '1' }), + }, { label: 'Open Data Integrity', route: '/platform-ops/data-integrity' }, ], }, @@ -1002,6 +1009,9 @@ export class ApprovalDetailPageComponent implements OnInit { requestExceptionAction(): void { this.requestException = true; + void this.router.navigate(['/ops/policy/vex/exceptions'], { + queryParams: this.decisioningContextParams({ create: '1' }), + }); } exportPacket(): void { @@ -1037,4 +1047,18 @@ export class ApprovalDetailPageComponent implements OnInit { decidedAt: 'Just now', })); } + + decisioningContextParams(extra: Record = {}): Record { + const approval = this.approval(); + const returnTo = buildContextReturnTo(this.router, ['/releases/approvals', approval.id]); + + return { + approvalId: approval.id, + releaseId: approval.bundleVersion, + environment: approval.targetEnvironment, + artifact: approval.bundleDigest, + returnTo, + ...extra, + }; + } } diff --git a/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/policy-hit-annotation/policy-hit-annotation.component.ts b/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/policy-hit-annotation/policy-hit-annotation.component.ts index a713cf0ba..0d23c2ba2 100644 --- a/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/policy-hit-annotation/policy-hit-annotation.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/policy-hit-annotation/policy-hit-annotation.component.ts @@ -97,7 +97,7 @@ import { {{ hit.result | uppercase }} @@ -367,4 +367,3 @@ export class PolicyHitAnnotationComponent { } } } - diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-audit/evidence-audit-overview.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-audit/evidence-audit-overview.component.ts index 0b40ba390..6afce74d2 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-audit/evidence-audit-overview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-audit/evidence-audit-overview.component.ts @@ -166,10 +166,10 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty'; - + diff --git a/src/Web/StellaOps.Web/src/app/features/home/home-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/home/home-dashboard.component.ts index b09f751da..637d15d78 100644 --- a/src/Web/StellaOps.Web/src/app/features/home/home-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/home/home-dashboard.component.ts @@ -275,12 +275,12 @@ import { SkeletonComponent } from '../../shared/components/skeleton/skeleton.com Exception Queue - + - Policy Studio + Policy Decisioning diff --git a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-audit-shell.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-audit-shell.component.ts new file mode 100644 index 000000000..4a6e96387 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-audit-shell.component.ts @@ -0,0 +1,163 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + inject, + signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + ActivatedRoute, + NavigationEnd, + Router, + RouterOutlet, +} from '@angular/router'; +import { filter, startWith } from 'rxjs'; + +import { + buildContextRouteParams, +} from '../../shared/ui/context-route-state/context-route-state'; +import { + TabItem, + TabbedNavComponent, +} from '../../shared/ui'; + +type AuditSubview = 'policy' | 'vex' | 'log'; + +@Component({ + selector: 'app-policy-decisioning-audit-shell', + imports: [CommonModule, RouterOutlet, TabbedNavComponent], + template: ` +
+
+
+

Audit

+

Policy and VEX audit now share the same owner shell

+

+ Review mutable policy and VEX actions after the cutover without routing operators back + into retired sibling products. +

+
+ + + + +
+ +
+ + `, + styles: [` + .policy-audit-shell { + display: grid; + gap: 0.85rem; + } + + .section-header { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + padding: 1rem; + } + + .section-header__eyebrow { + margin: 0 0 0.25rem; + color: var(--color-status-info); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .section-header h2 { + margin: 0; + color: var(--color-text-heading); + } + + .section-header p { + margin: 0.35rem 0 0; + color: var(--color-text-secondary); + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PolicyDecisioningAuditShellComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + + readonly activeSubview = signal(this.readSubview()); + + readonly tabItems = computed(() => { + const queryParams = buildContextRouteParams({ + releaseId: coerceString(this.route.snapshot.root.queryParams['releaseId']), + approvalId: coerceString(this.route.snapshot.root.queryParams['approvalId']), + environment: coerceString(this.route.snapshot.root.queryParams['environment']), + artifact: coerceString(this.route.snapshot.root.queryParams['artifact']), + returnTo: coerceString(this.route.snapshot.root.queryParams['returnTo']), + }); + + return [ + { + id: 'policy', + label: 'Policy Audit', + route: ['/ops/policy/audit/policy'], + queryParams, + testId: 'policy-audit-tab-policy', + }, + { + id: 'vex', + label: 'VEX Audit', + route: ['/ops/policy/audit/vex'], + queryParams, + testId: 'policy-audit-tab-vex', + }, + { + id: 'log', + label: 'Unified Log', + route: ['/ops/policy/audit/log'], + queryParams, + testId: 'policy-audit-tab-log', + }, + ]; + }); + + constructor() { + this.router.events + .pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + startWith(null), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => { + this.activeSubview.set(this.readSubview()); + }); + } + + private readSubview(): AuditSubview { + const url = this.router.url.split('?')[0] ?? ''; + + if (url.includes('/audit/vex')) { + return 'vex'; + } + if (url.includes('/audit/log')) { + return 'log'; + } + + return 'policy'; + } +} + +function coerceString(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} diff --git a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-gates-page.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-gates-page.component.ts new file mode 100644 index 000000000..296b7d4b2 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-gates-page.component.ts @@ -0,0 +1,592 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + signal, +} from '@angular/core'; +import { + ActivatedRoute, + Router, + RouterLink, +} from '@angular/router'; + +import { + GateSummaryPanelComponent, + type GateResult, +} from '../../shared/domain/gate-summary-panel/gate-summary-panel.component'; +import { + GateEvaluation, + GateExplainDrawerComponent, +} from '../../shared/overlays/gate-explain-drawer/gate-explain-drawer.component'; +import { + buildContextRouteParams, +} from '../../shared/ui/context-route-state/context-route-state'; + +type DecisioningGateScope = 'global' | 'environment' | 'release' | 'approval'; + +@Component({ + selector: 'app-policy-decisioning-gates-page', + imports: [ + CommonModule, + RouterLink, + GateSummaryPanelComponent, + GateExplainDrawerComponent, + ], + template: ` +
+ + +
+ + + +
+ +
+
+

Blocking reasons

+
    + @for (reason of blockingReasons(); track reason) { +
  • {{ reason }}
  • + } +
+
+ +
+

Recommended next actions

+
    + @for (action of recommendedActions(); track action) { +
  • {{ action }}
  • + } +
+
+
+ + +
+ `, + styles: [` + .policy-gates-page { + display: grid; + gap: 1rem; + } + + .page-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + padding: 1rem; + } + + .page-header__eyebrow { + margin: 0 0 0.25rem; + color: var(--color-status-error); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .page-header h2 { + margin: 0; + color: var(--color-text-heading); + } + + .page-header p { + margin: 0.35rem 0 0; + color: var(--color-text-secondary); + max-width: 56rem; + } + + .page-actions { + display: flex; + gap: 0.6rem; + flex-wrap: wrap; + justify-content: flex-end; + } + + .page-action, + .return-button { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border-primary); + border-radius: 0.75rem; + background: var(--color-surface-secondary); + color: var(--color-text-primary); + font-weight: 600; + min-height: 2.6rem; + padding: 0.6rem 0.95rem; + text-decoration: none; + } + + .page-action--primary { + border-color: var(--color-brand-primary); + background: var(--color-brand-primary); + color: var(--color-text-heading); + } + + .gates-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); + gap: 1rem; + } + + .context-panel, + .details-card { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + padding: 1rem; + } + + .context-panel h3, + .details-card h3 { + margin: 0 0 0.75rem; + color: var(--color-text-heading); + } + + .context-panel dl { + display: grid; + gap: 0.65rem; + margin: 0; + } + + .context-panel dt { + color: var(--color-text-secondary); + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + } + + .context-panel dd { + margin: 0.12rem 0 0; + color: var(--color-text-primary); + } + + .context-links { + display: grid; + gap: 0.5rem; + margin-top: 1rem; + } + + .context-links a { + color: var(--color-brand-primary); + text-decoration: none; + } + + .details-panel { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; + } + + .details-card ul { + margin: 0; + padding-left: 1.1rem; + color: var(--color-text-secondary); + display: grid; + gap: 0.35rem; + } + + @media (max-width: 980px) { + .page-header, + .gates-grid { + grid-template-columns: 1fr; + } + + .page-actions { + justify-content: flex-start; + } + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PolicyDecisioningGatesPageComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + readonly drawerOpen = signal(false); + readonly selectedGateId = signal(null); + + readonly releaseId = computed(() => + coerceString(this.route.snapshot.root.queryParams['releaseId']) + ?? coerceString(this.route.snapshot.paramMap.get('releaseId')) + ); + readonly approvalId = computed(() => + coerceString(this.route.snapshot.root.queryParams['approvalId']) + ?? coerceString(this.route.snapshot.paramMap.get('approvalId')) + ); + readonly environmentId = computed(() => + coerceString(this.route.snapshot.root.queryParams['environment']) + ?? coerceString(this.route.snapshot.paramMap.get('environment')) + ); + readonly artifactDigest = computed(() => + coerceString(this.route.snapshot.root.queryParams['artifact']) + ?? coerceString(this.route.snapshot.root.queryParams['artifactDigest']) + ?? coerceString(this.route.snapshot.root.queryParams['bundleDigest']) + ); + readonly returnTo = computed(() => + coerceString(this.route.snapshot.root.queryParams['returnTo']) + ); + + readonly scope = computed(() => { + if (this.approvalId()) { + return 'approval'; + } + if (this.releaseId()) { + return 'release'; + } + if (this.environmentId()) { + return 'environment'; + } + + return 'global'; + }); + + readonly pageTitle = computed(() => { + switch (this.scope()) { + case 'approval': + return `Approval ${this.approvalId()} gate review`; + case 'release': + return `Release ${this.releaseId()} gate review`; + case 'environment': + return `${this.environmentId()} environment gate posture`; + default: + return 'Policy gate catalog and release posture'; + } + }); + + readonly pageSubtitle = computed(() => { + switch (this.scope()) { + case 'approval': + return 'Inspect why the approval is blocked or warned, then move directly into exceptions, VEX, simulation, or audit.'; + case 'release': + return 'Review release-specific blockers and explanation before returning to the release workflow.'; + case 'environment': + return 'Check how a target environment is constrained by policy, VEX, feed freshness, and witness confidence.'; + default: + return 'Use this route to inspect gate policy, promotion simulation, and the supporting evidence before release actions.'; + } + }); + + readonly scopeLabel = computed(() => { + switch (this.scope()) { + case 'approval': + return 'Approval-context decision'; + case 'release': + return 'Release-context decision'; + case 'environment': + return 'Environment review'; + default: + return 'Global gate catalog'; + } + }); + + readonly gateResults = computed(() => { + const blocking = this.scope() === 'global' ? 'WARN' : 'BLOCK'; + const artifact = this.artifactDigest(); + + return [ + { + id: 'policy', + name: 'Policy Pack', + state: 'PASS', + reason: 'Baseline policy and attestations satisfy the selected promotion contract.', + ruleHits: 6, + }, + { + id: 'reachability', + name: 'Reachability Confidence', + state: blocking, + reason: this.scope() === 'global' + ? 'Runtime witness coverage needs review before strict promotion rules are enabled.' + : 'Runtime witness coverage is below the threshold required for this promotion.', + witnessMetrics: { + totalPaths: 12, + witnessedPaths: this.scope() === 'global' ? 9 : 7, + unwitnessedPaths: this.scope() === 'global' ? 3 : 5, + stalePaths: this.scope() === 'global' ? 1 : 2, + unwitnessedPathDetails: [ + { + pathId: 'path-1', + entrypoint: 'gateway.verifyRelease', + sink: 'runtime.exec.promote', + severity: 'critical', + vulnId: 'CVE-2026-1234', + }, + { + pathId: 'path-2', + entrypoint: 'worker.issueDecision', + sink: 'prod-rollout.apply', + severity: 'high', + vulnId: 'CVE-2026-3101', + }, + ], + }, + gateType: 'witness', + }, + { + id: 'vex', + name: 'VEX Consensus', + state: this.scope() === 'global' ? 'WARN' : 'PASS', + reason: this.scope() === 'global' + ? 'Two statements still need explicit consensus before release promotion is hardened.' + : 'VEX posture is consistent with the current release snapshot.', + ruleHits: 2, + }, + { + id: 'freshness', + name: 'Feed Freshness', + state: 'WARN', + reason: artifact + ? `Snapshot for ${truncateDigest(artifact)} uses stale NVD data beyond the preferred SLO.` + : 'Feed freshness is warning-only but should be reviewed before approval.', + ruleHits: 1, + }, + ]; + }); + + readonly policyReference = computed(() => { + if (this.releaseId()) { + return `release-${this.releaseId()}-baseline`; + } + if (this.approvalId()) { + return `approval-${this.approvalId()}-baseline`; + } + if (this.environmentId()) { + return `${this.environmentId()}-baseline`; + } + + return 'global-release-baseline'; + }); + + readonly snapshotReference = computed(() => { + if (this.releaseId()) { + return `snapshot-release-${this.releaseId()}`; + } + if (this.approvalId()) { + return `snapshot-approval-${this.approvalId()}`; + } + return 'snapshot-2026-03-07T00:00:00Z'; + }); + + readonly selectedEvaluation = computed(() => { + const gateId = this.selectedGateId(); + if (!gateId) { + return null; + } + + const gate = this.gateResults().find((item) => item.id === gateId); + if (!gate) { + return null; + } + + return { + gateId, + gateName: gate.name, + description: gate.reason ?? 'Gate evaluation details', + result: gate.state === 'PASS' + ? 'passed' + : gate.state === 'WARN' + ? 'warning' + : 'failed', + evaluatedAt: '2026-03-07T14:00:00Z', + triggeredBy: this.scope() === 'global' ? 'catalog-preview' : 'release-context', + gateType: gate.gateType ?? 'standard', + policyVersion: this.policyReference(), + summary: { + totalRules: 3, + passed: gate.state === 'PASS' ? 3 : 2, + failed: gate.state === 'BLOCK' ? 1 : 0, + warnings: gate.state === 'WARN' ? 1 : 0, + }, + rules: [ + { + ruleId: `${gateId}-1`, + ruleName: `${gate.name} baseline contract`, + description: 'Verifies the policy contract against the current promotion path.', + passed: gate.state !== 'BLOCK', + severity: gate.state === 'BLOCK' ? 'critical' : 'medium', + explanation: gate.reason ?? 'Evaluation completed successfully.', + evidenceRefs: [this.snapshotReference(), this.policyReference()], + remediation: gate.state === 'BLOCK' + ? 'Trigger simulation or collect additional witness evidence before promoting.' + : undefined, + evaluationTimeMs: 18, + }, + { + ruleId: `${gateId}-2`, + ruleName: `${gate.name} freshness`, + description: 'Checks that the supporting data snapshot is fresh enough for this path.', + passed: true, + severity: 'medium', + explanation: 'Supporting snapshot is present and attached to the decision.', + evidenceRefs: [this.snapshotReference()], + evaluationTimeMs: 12, + }, + ], + }; + }); + + readonly blockingReasons = computed(() => + this.gateResults() + .filter((gate) => gate.state === 'BLOCK' || gate.state === 'WARN') + .map((gate) => gate.reason ?? gate.name) + ); + + readonly recommendedActions = computed(() => { + const actions = [ + 'Open promotion simulation to inspect how the active baseline would change after fixes.', + 'Review VEX consensus and exceptions before deciding whether a warning should stay non-blocking.', + ]; + + if (this.scope() !== 'global') { + actions.unshift('Use the explain drawer to capture the exact rule and evidence chain that blocked this decision.'); + } + + return actions; + }); + + contextQueryParams(): Record { + return buildContextRouteParams({ + releaseId: this.releaseId(), + approvalId: this.approvalId(), + environment: this.environmentId(), + artifact: this.artifactDigest(), + returnTo: this.returnTo(), + }); + } + + openExplain(gateId: string): void { + this.selectedGateId.set(gateId); + this.drawerOpen.set(true); + } + + openEvidence(): void { + void this.router.navigate(['/evidence/capsules'], { + queryParams: this.contextQueryParams(), + }); + } + + returnToSource(): void { + const returnTo = this.returnTo(); + if (!returnTo) { + return; + } + + void this.router.navigateByUrl(returnTo); + } +} + +function coerceString(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function truncateDigest(value: string): string { + return value.length > 18 ? `${value.slice(0, 12)}...${value.slice(-4)}` : value; +} diff --git a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-overview-page.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-overview-page.component.ts new file mode 100644 index 000000000..ca746669a --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-overview-page.component.ts @@ -0,0 +1,254 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, +} from '@angular/core'; +import { ActivatedRoute, RouterLink } from '@angular/router'; + +import { + buildContextRouteParams, +} from '../../shared/ui/context-route-state/context-route-state'; + +interface DecisioningOverviewCard { + readonly id: string; + readonly title: string; + readonly description: string; + readonly route: readonly unknown[]; + readonly accent: 'ops' | 'pack' | 'vex' | 'gate'; +} + +@Component({ + selector: 'app-policy-decisioning-overview-page', + imports: [CommonModule, RouterLink], + template: ` +
+
+
+

Decisioning Map

+

One operator shell for policy, VEX, and release gates

+

+ Decisioning Studio now owns policy packs, governance, simulation, VEX + conflicts, exceptions, gate review, and audit. Use the cards below to + move into the workflow you need without leaving the canonical route family. +

+
+ +
+
+ Canonical root + /ops/policy +
+
+ Primary workflows + 7 tabs +
+
+ Context modes + Global · Pack · Release +
+
+
+ +
+ @for (card of cards(); track card.id) { + + {{ card.title }} +

{{ card.description }}

+
+ } +
+
+ `, + styles: [` + .policy-overview { + display: grid; + gap: 1rem; + } + + .hero { + display: grid; + gap: 1rem; + grid-template-columns: 2fr 1fr; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--color-brand-primary) 10%, transparent), + var(--color-surface-primary) + ); + padding: 1.25rem; + } + + .hero__eyebrow { + margin: 0 0 0.35rem; + color: var(--color-status-info); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .hero h2 { + margin: 0; + color: var(--color-text-heading); + font-size: 1.5rem; + } + + .hero__copy { + margin: 0.45rem 0 0; + color: var(--color-text-secondary); + line-height: 1.55; + } + + .hero__metrics { + display: grid; + gap: 0.75rem; + } + + .hero__metrics article { + display: grid; + gap: 0.2rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + padding: 0.85rem; + } + + .metric-label { + color: var(--color-text-secondary); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 0.9rem; + } + + .card { + display: grid; + gap: 0.45rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + color: inherit; + padding: 1rem; + text-decoration: none; + } + + .card strong { + color: var(--color-text-heading); + font-size: 1rem; + } + + .card p { + margin: 0; + color: var(--color-text-secondary); + line-height: 1.45; + } + + .card--ops { + border-top: 3px solid var(--color-status-info); + } + + .card--pack { + border-top: 3px solid var(--color-status-success); + } + + .card--vex { + border-top: 3px solid var(--color-status-warning); + } + + .card--gate { + border-top: 3px solid var(--color-status-error); + } + + @media (max-width: 960px) { + .hero { + grid-template-columns: 1fr; + } + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PolicyDecisioningOverviewPageComponent { + private readonly route = inject(ActivatedRoute); + + readonly cards = computed(() => [ + { + id: 'packs', + title: 'Packs Workspace', + description: 'Edit, approve, simulate, and explain policy packs from one routed workspace.', + route: ['/ops/policy/packs'], + accent: 'pack', + }, + { + id: 'governance', + title: 'Governance Controls', + description: 'Manage risk budgets, trust weighting, profiles, conflicts, and schema validation.', + route: ['/ops/policy/governance'], + accent: 'ops', + }, + { + id: 'simulation', + title: 'Simulation Lab', + description: 'Run shadow mode, lint, coverage, effective policy, promotion, and exception simulation.', + route: ['/ops/policy/simulation'], + accent: 'ops', + }, + { + id: 'vex', + title: 'VEX & Exceptions', + description: 'Resolve VEX conflicts, search statements, manage consensus, and triage exceptions.', + route: ['/ops/policy/vex'], + accent: 'vex', + }, + { + id: 'gates', + title: 'Release Gates', + description: 'Inspect gate posture, promotion decisions, and release-context blockers with explainability.', + route: ['/ops/policy/gates'], + accent: 'gate', + }, + { + id: 'audit', + title: 'Policy Audit', + description: 'Review policy and VEX audit trails after the mutable flows have been consolidated.', + route: ['/ops/policy/audit'], + accent: 'ops', + }, + ]); + + contextQueryParams(): Record { + const queryParams = this.route.snapshot.root.queryParams ?? {}; + + return buildContextRouteParams({ + releaseId: coerceString(queryParams['releaseId']), + approvalId: coerceString(queryParams['approvalId']), + environment: coerceString(queryParams['environment']), + artifact: coerceString(queryParams['artifact']), + returnTo: coerceString(queryParams['returnTo']), + workflowId: coerceString(queryParams['workflowId']), + evidenceId: coerceString(queryParams['evidenceId']), + }); + } +} + +function coerceString(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} diff --git a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-shell.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-shell.component.ts new file mode 100644 index 000000000..74a6c21d3 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-shell.component.ts @@ -0,0 +1,437 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + inject, + signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + ActivatedRoute, + ActivatedRouteSnapshot, + NavigationEnd, + Router, + RouterLink, + RouterOutlet, +} from '@angular/router'; +import { filter, startWith } from 'rxjs'; + +import { + ContextHeaderComponent, + TabItem, + TabbedNavComponent, +} from '../../shared/ui'; +import { + buildContextRouteParams, +} from '../../shared/ui/context-route-state/context-route-state'; + +type DecisioningPrimaryTab = + | 'overview' + | 'packs' + | 'governance' + | 'simulation' + | 'vex' + | 'gates' + | 'audit'; + +type DecisioningContextKind = + | 'global' + | 'pack' + | 'release' + | 'approval' + | 'workflow' + | 'evidence'; + +interface DecisioningShellState { + readonly activeTab: DecisioningPrimaryTab; + readonly kind: DecisioningContextKind; + readonly packId: string | null; + readonly releaseId: string | null; + readonly approvalId: string | null; + readonly environment: string | null; + readonly artifact: string | null; + readonly returnTo: string | null; + readonly workflowId: string | null; + readonly evidenceId: string | null; +} + +@Component({ + selector: 'app-policy-decisioning-shell', + imports: [ + CommonModule, + RouterLink, + RouterOutlet, + ContextHeaderComponent, + TabbedNavComponent, + ], + template: ` +
+ + + Reset view + + + + + +
+ +
+
+ `, + styles: [` + :host { + display: block; + min-height: 100%; + } + + .policy-decisioning-shell { + display: grid; + gap: 1rem; + padding: 1.25rem; + } + + .policy-decisioning-shell__body { + min-width: 0; + } + + .shell-action { + display: inline-flex; + align-items: center; + border: 1px solid var(--color-border-primary); + border-radius: 0.75rem; + background: var(--color-surface-secondary); + color: var(--color-text-primary); + font-weight: 600; + padding: 0.6rem 0.9rem; + text-decoration: none; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PolicyDecisioningShellComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + + readonly shellState = signal(this.readShellState()); + + readonly primaryTabs = computed(() => { + const state = this.shellState(); + const queryParams = this.contextQueryParams(); + + return [ + { + id: 'overview', + label: 'Overview', + route: this.overviewRoute(), + queryParams, + testId: 'policy-tab-overview', + }, + { + id: 'packs', + label: 'Packs', + route: state.packId + ? ['/ops/policy/packs', state.packId] + : ['/ops/policy/packs'], + queryParams, + testId: 'policy-tab-packs', + }, + { + id: 'governance', + label: 'Governance', + route: ['/ops/policy/governance'], + queryParams, + testId: 'policy-tab-governance', + }, + { + id: 'simulation', + label: 'Simulation', + route: ['/ops/policy/simulation'], + queryParams, + testId: 'policy-tab-simulation', + }, + { + id: 'vex', + label: 'VEX & Exceptions', + route: ['/ops/policy/vex'], + queryParams, + testId: 'policy-tab-vex', + }, + { + id: 'gates', + label: 'Release Gates', + route: this.gatesRoute(), + queryParams, + testId: 'policy-tab-gates', + }, + { + id: 'audit', + label: 'Audit', + route: ['/ops/policy/audit'], + queryParams, + testId: 'policy-tab-audit', + }, + ]; + }); + + readonly headerTitle = computed(() => { + const state = this.shellState(); + + switch (state.kind) { + case 'pack': + return `Policy Pack ${state.packId}`; + case 'release': + return `Release ${state.releaseId} Decisioning`; + case 'approval': + return `Approval ${state.approvalId} Decisioning`; + case 'workflow': + return `Workflow ${state.workflowId} Decisioning`; + case 'evidence': + return `Evidence ${state.evidenceId} Decisioning`; + default: + return 'Policy Decisioning Studio'; + } + }); + + readonly headerSubtitle = computed(() => { + const state = this.shellState(); + + switch (state.kind) { + case 'pack': + return 'Author, validate, approve, and explain a pack from the canonical policy shell.'; + case 'release': + case 'approval': + return 'Review gates, simulation, VEX, and exceptions for a live release decision without leaving the shared shell.'; + case 'workflow': + return 'Keep workflow gate logic and release policy inspection in one decisioning workspace.'; + case 'evidence': + return 'Trace evidence, gate posture, and policy or VEX actions from a single canonical route.'; + default: + return 'One canonical shell for policy packs, governance, simulation, VEX, exceptions, release gates, and audit.'; + } + }); + + readonly headerNote = computed(() => { + const state = this.shellState(); + + switch (state.kind) { + case 'release': + return 'Release-context mode keeps gate review and operator actions inside the shared policy shell.'; + case 'approval': + return 'Approval-context mode preserves policy and VEX actions while allowing a direct return to the approval flow.'; + case 'pack': + return 'Pack mode exposes authoring, YAML, rules, approvals, simulation, and explainability for the selected pack.'; + case 'workflow': + return 'Workflow context is carried as a non-owning deep link for release pipeline editing.'; + case 'evidence': + return 'Evidence context is non-owning: Decisioning Studio stays focused on gates, policy, and VEX actions.'; + default: + return 'Use the primary tabs to move between policy packs, governance, simulation, release gates, VEX, and audit.'; + } + }); + + readonly headerChips = computed(() => { + const state = this.shellState(); + const chips: string[] = []; + + if (state.packId) { + chips.push(`Pack ${state.packId}`); + } + if (state.releaseId) { + chips.push(`Release ${state.releaseId}`); + } + if (state.approvalId) { + chips.push(`Approval ${state.approvalId}`); + } + if (state.environment) { + chips.push(`Env ${state.environment}`); + } + if (state.artifact) { + chips.push(`Artifact ${truncateValue(state.artifact)}`); + } + if (state.workflowId) { + chips.push(`Workflow ${state.workflowId}`); + } + if (state.evidenceId) { + chips.push(`Evidence ${state.evidenceId}`); + } + + return chips; + }); + + constructor() { + this.router.events + .pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + startWith(null), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => { + this.shellState.set(this.readShellState()); + }); + } + + overviewRoute(): readonly unknown[] { + return ['/ops/policy/overview']; + } + + gatesRoute(): readonly unknown[] { + const state = this.shellState(); + + if (state.approvalId) { + return ['/ops/policy/gates/approvals', state.approvalId]; + } + if (state.releaseId) { + return ['/ops/policy/gates/releases', state.releaseId]; + } + if (state.environment) { + return ['/ops/policy/gates/environments', state.environment]; + } + + return ['/ops/policy/gates']; + } + + contextQueryParams(): Record { + const state = this.shellState(); + + return buildContextRouteParams({ + releaseId: state.releaseId, + approvalId: state.approvalId, + environment: state.environment, + artifact: state.artifact, + returnTo: state.returnTo, + workflowId: state.workflowId, + evidenceId: state.evidenceId, + }); + } + + returnToSource(): void { + const returnTo = this.shellState().returnTo; + if (!returnTo) { + return; + } + + void this.router.navigateByUrl(returnTo); + } + + private readShellState(): DecisioningShellState { + const params = collectRouteParams(this.route.snapshot.root); + const queryParams = this.route.snapshot.root.queryParams ?? {}; + const currentUrl = this.router.url.split('?')[0] ?? ''; + const releaseId = coerceString(params['releaseId']) ?? coerceString(queryParams['releaseId']); + const approvalId = coerceString(params['approvalId']) ?? coerceString(queryParams['approvalId']); + const packId = coerceString(params['packId']) ?? coerceString(queryParams['packId']); + const environment = coerceString(params['environment']) ?? coerceString(queryParams['environment']); + const artifact = + coerceString(queryParams['artifact']) + ?? coerceString(queryParams['artifactDigest']) + ?? coerceString(queryParams['bundleDigest']); + const workflowId = coerceString(queryParams['workflowId']); + const evidenceId = + coerceString(queryParams['evidenceId']) + ?? coerceString(queryParams['packetId']); + + let kind: DecisioningContextKind = 'global'; + + if (approvalId) { + kind = 'approval'; + } else if (releaseId) { + kind = 'release'; + } else if (packId) { + kind = 'pack'; + } else if (workflowId) { + kind = 'workflow'; + } else if (evidenceId) { + kind = 'evidence'; + } + + return { + activeTab: resolvePrimaryTab(currentUrl), + kind, + packId, + releaseId, + approvalId, + environment, + artifact, + returnTo: coerceString(queryParams['returnTo']), + workflowId, + evidenceId, + }; + } +} + +function collectRouteParams(snapshot: ActivatedRouteSnapshot | null): Record { + const params: Record = {}; + + if (!snapshot) { + return params; + } + + const stack: ActivatedRouteSnapshot[] = [snapshot]; + while (stack.length > 0) { + const current = stack.pop()!; + for (const [key, value] of Object.entries(current.params ?? {})) { + if (typeof value === 'string' && value.length > 0) { + params[key] = value; + } + } + stack.push(...current.children); + } + + return params; +} + +function resolvePrimaryTab(currentUrl: string): DecisioningPrimaryTab { + if (currentUrl.includes('/ops/policy/packs')) { + return 'packs'; + } + if (currentUrl.includes('/ops/policy/governance')) { + return 'governance'; + } + if (currentUrl.includes('/ops/policy/simulation')) { + return 'simulation'; + } + if (currentUrl.includes('/ops/policy/vex')) { + return 'vex'; + } + if (currentUrl.includes('/ops/policy/gates')) { + return 'gates'; + } + if (currentUrl.includes('/ops/policy/audit')) { + return 'audit'; + } + + return 'overview'; +} + +function coerceString(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function truncateValue(value: string): string { + return value.length > 20 ? `${value.slice(0, 12)}...${value.slice(-6)}` : value; +} diff --git a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-vex-shell.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-vex-shell.component.ts new file mode 100644 index 000000000..2150c5bc0 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-vex-shell.component.ts @@ -0,0 +1,221 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + inject, + signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + ActivatedRoute, + NavigationEnd, + Router, + RouterOutlet, +} from '@angular/router'; +import { filter, startWith } from 'rxjs'; + +import { + buildContextRouteParams, +} from '../../shared/ui/context-route-state/context-route-state'; +import { + TabItem, + TabbedNavComponent, +} from '../../shared/ui'; + +type VexSubview = + | 'dashboard' + | 'search' + | 'create' + | 'stats' + | 'consensus' + | 'explorer' + | 'conflicts' + | 'exceptions'; + +@Component({ + selector: 'app-policy-decisioning-vex-shell', + imports: [CommonModule, RouterOutlet, TabbedNavComponent], + template: ` +
+
+
+

VEX & Exceptions

+

Mutable VEX actions now live in Decisioning Studio

+

+ Search statements, resolve consensus, open exception queues, and keep release-context + deep links inside the same policy shell. +

+
+
+ + + +
+ +
+
+ `, + styles: [` + .policy-vex-shell { + display: grid; + gap: 0.85rem; + } + + .section-header { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + padding: 1rem; + } + + .section-header__eyebrow { + margin: 0 0 0.25rem; + color: var(--color-status-warning); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .section-header h2 { + margin: 0; + color: var(--color-text-heading); + } + + .section-header p { + margin: 0.35rem 0 0; + color: var(--color-text-secondary); + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PolicyDecisioningVexShellComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + + readonly activeSubview = signal(this.readSubview()); + + readonly tabItems = computed(() => { + const queryParams = buildContextRouteParams({ + releaseId: coerceString(this.route.snapshot.root.queryParams['releaseId']), + approvalId: coerceString(this.route.snapshot.root.queryParams['approvalId']), + environment: coerceString(this.route.snapshot.root.queryParams['environment']), + artifact: coerceString(this.route.snapshot.root.queryParams['artifact']), + returnTo: coerceString(this.route.snapshot.root.queryParams['returnTo']), + }); + + return [ + { + id: 'dashboard', + label: 'Dashboard', + route: ['/ops/policy/vex'], + queryParams, + testId: 'policy-vex-tab-dashboard', + }, + { + id: 'search', + label: 'Search', + route: ['/ops/policy/vex/search'], + queryParams, + testId: 'policy-vex-tab-search', + }, + { + id: 'create', + label: 'Create', + route: ['/ops/policy/vex/create'], + queryParams, + testId: 'policy-vex-tab-create', + }, + { + id: 'stats', + label: 'Stats', + route: ['/ops/policy/vex/stats'], + queryParams, + testId: 'policy-vex-tab-stats', + }, + { + id: 'consensus', + label: 'Consensus', + route: ['/ops/policy/vex/consensus'], + queryParams, + testId: 'policy-vex-tab-consensus', + }, + { + id: 'explorer', + label: 'Explorer', + route: ['/ops/policy/vex/explorer'], + queryParams, + testId: 'policy-vex-tab-explorer', + }, + { + id: 'conflicts', + label: 'Conflicts', + route: ['/ops/policy/vex/conflicts'], + queryParams, + testId: 'policy-vex-tab-conflicts', + }, + { + id: 'exceptions', + label: 'Exceptions', + route: ['/ops/policy/vex/exceptions'], + queryParams, + testId: 'policy-vex-tab-exceptions', + }, + ]; + }); + + constructor() { + this.router.events + .pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + startWith(null), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => { + this.activeSubview.set(this.readSubview()); + }); + } + + private readSubview(): VexSubview { + const url = this.router.url.split('?')[0] ?? ''; + + if (url.includes('/vex/search')) { + return 'search'; + } + if (url.includes('/vex/create')) { + return 'create'; + } + if (url.includes('/vex/stats')) { + return 'stats'; + } + if (url.includes('/vex/consensus')) { + return 'consensus'; + } + if (url.includes('/vex/explorer')) { + return 'explorer'; + } + if (url.includes('/vex/conflicts')) { + return 'conflicts'; + } + if (url.includes('/vex/exceptions')) { + return 'exceptions'; + } + + return 'dashboard'; + } +} + +function coerceString(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} diff --git a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning.routes.ts b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning.routes.ts new file mode 100644 index 000000000..24519a320 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning.routes.ts @@ -0,0 +1,317 @@ +import { Routes } from '@angular/router'; + +export const policyDecisioningRoutes: Routes = [ + { + path: '', + title: 'Policy Decisioning', + loadComponent: () => + import('./policy-decisioning-shell.component').then( + (m) => m.PolicyDecisioningShellComponent, + ), + children: [ + { + path: '', + pathMatch: 'full', + redirectTo: 'overview', + }, + { + path: 'overview', + title: 'Policy Decisioning Overview', + loadComponent: () => + import('./policy-decisioning-overview-page.component').then( + (m) => m.PolicyDecisioningOverviewPageComponent, + ), + }, + { + path: 'baselines', + pathMatch: 'full', + redirectTo: 'overview', + }, + { + path: 'waivers', + pathMatch: 'full', + redirectTo: 'vex/exceptions', + }, + { + path: 'exceptions', + pathMatch: 'full', + redirectTo: 'vex/exceptions', + }, + { + path: 'packs', + title: 'Policy Packs', + loadComponent: () => + import('./policy-pack-shell.component').then( + (m) => m.PolicyPackShellComponent, + ), + children: [ + { + path: '', + loadComponent: () => + import('../policy-studio/workspace/policy-workspace.component').then( + (m) => m.PolicyWorkspaceComponent, + ), + }, + { + path: ':packId', + pathMatch: 'full', + redirectTo: ':packId/dashboard', + }, + { + path: ':packId/dashboard', + loadComponent: () => + import('../policy-studio/dashboard/policy-dashboard.component').then( + (m) => m.PolicyDashboardComponent, + ), + }, + { + path: ':packId/edit', + loadComponent: () => + import('../policy-studio/editor/policy-editor.component').then( + (m) => m.PolicyEditorComponent, + ), + }, + { + path: ':packId/editor', + pathMatch: 'full', + redirectTo: ':packId/edit', + }, + { + path: ':packId/rules', + loadComponent: () => + import('../policy-studio/rule-builder/policy-rule-builder.component').then( + (m) => m.PolicyRuleBuilderComponent, + ), + }, + { + path: ':packId/yaml', + loadComponent: () => + import('../policy-studio/yaml/policy-yaml-editor.component').then( + (m) => m.PolicyYamlEditorComponent, + ), + }, + { + path: ':packId/approvals', + loadComponent: () => + import('../policy-studio/approvals/policy-approvals.component').then( + (m) => m.PolicyApprovalsComponent, + ), + }, + { + path: ':packId/simulate', + loadComponent: () => + import('../policy-studio/simulation/policy-simulation.component').then( + (m) => m.PolicySimulationComponent, + ), + }, + { + path: ':packId/explain/:runId', + loadComponent: () => + import('../policy-studio/explain/policy-explain.component').then( + (m) => m.PolicyExplainComponent, + ), + }, + ], + }, + { + path: 'governance', + title: 'Policy Governance', + loadChildren: () => + import('../policy-governance/policy-governance.routes').then( + (m) => m.policyGovernanceRoutes, + ), + }, + { + path: 'simulation', + title: 'Policy Simulation', + loadChildren: () => + import('../policy-simulation/policy-simulation.routes').then( + (m) => m.policySimulationRoutes, + ), + }, + { + path: 'vex', + title: 'VEX & Exceptions', + loadComponent: () => + import('./policy-decisioning-vex-shell.component').then( + (m) => m.PolicyDecisioningVexShellComponent, + ), + children: [ + { + path: '', + loadComponent: () => + import('../vex-hub/vex-hub-dashboard.component').then( + (m) => m.VexHubDashboardComponent, + ), + }, + { + path: 'dashboard', + pathMatch: 'full', + redirectTo: '', + }, + { + path: 'search', + loadComponent: () => + import('../vex-hub/vex-statement-search.component').then( + (m) => m.VexStatementSearchComponent, + ), + }, + { + path: 'search/detail/:id', + loadComponent: () => + import('../vex-hub/vex-statement-detail.component').then( + (m) => m.VexStatementDetailComponent, + ), + }, + { + path: 'create', + loadComponent: () => + import('../vex-hub/vex-create-workflow.component').then( + (m) => m.VexCreateWorkflowComponent, + ), + }, + { + path: 'stats', + loadComponent: () => + import('../vex-hub/vex-hub-stats.component').then( + (m) => m.VexHubStatsComponent, + ), + }, + { + path: 'consensus', + loadComponent: () => + import('../vex-hub/vex-consensus.component').then( + (m) => m.VexConsensusComponent, + ), + }, + { + path: 'explorer', + loadComponent: () => + import('../vex-hub/vex-hub.component').then( + (m) => m.VexHubComponent, + ), + }, + { + path: 'conflicts', + loadComponent: () => + import('../vex-hub/vex-conflict-resolution.component').then( + (m) => m.VexConflictResolutionComponent, + ), + }, + { + path: 'exceptions', + loadComponent: () => + import('../exceptions/exception-dashboard.component').then( + (m) => m.ExceptionDashboardComponent, + ), + }, + { + path: 'exceptions/approvals', + loadComponent: () => + import('../exceptions/exception-approval-queue.component').then( + (m) => m.ExceptionApprovalQueueComponent, + ), + }, + { + path: 'exceptions/:exceptionId', + loadComponent: () => + import('../exceptions/exception-dashboard.component').then( + (m) => m.ExceptionDashboardComponent, + ), + }, + ], + }, + { + path: 'gates', + title: 'Release Gates', + loadComponent: () => + import('./policy-decisioning-gates-page.component').then( + (m) => m.PolicyDecisioningGatesPageComponent, + ), + }, + { + path: 'gates/catalog', + title: 'Policy Gate Catalog', + loadComponent: () => + import('../policy-gates/components/policy-preview-panel/policy-preview-panel.component').then( + (m) => m.PolicyPreviewPanelComponent, + ), + }, + { + path: 'gates/simulate/:promotionId', + title: 'Gate Simulation', + loadComponent: () => + import('../policy-gates/components/bundle-simulator/bundle-simulator.component').then( + (m) => m.BundleSimulatorComponent, + ), + }, + { + path: 'gates/environments/:environment', + title: 'Environment Gate Review', + loadComponent: () => + import('./policy-decisioning-gates-page.component').then( + (m) => m.PolicyDecisioningGatesPageComponent, + ), + }, + { + path: 'gates/releases/:releaseId', + title: 'Release Gate Review', + loadComponent: () => + import('./policy-decisioning-gates-page.component').then( + (m) => m.PolicyDecisioningGatesPageComponent, + ), + }, + { + path: 'gates/approvals/:approvalId', + title: 'Approval Gate Review', + loadComponent: () => + import('./policy-decisioning-gates-page.component').then( + (m) => m.PolicyDecisioningGatesPageComponent, + ), + }, + { + path: 'audit', + title: 'Policy Audit', + loadComponent: () => + import('./policy-decisioning-audit-shell.component').then( + (m) => m.PolicyDecisioningAuditShellComponent, + ), + children: [ + { + path: '', + pathMatch: 'full', + redirectTo: 'policy', + }, + { + path: 'policy', + loadComponent: () => + import('../audit-log/audit-policy.component').then( + (m) => m.AuditPolicyComponent, + ), + }, + { + path: 'vex', + loadComponent: () => + import('../audit-log/audit-vex.component').then( + (m) => m.AuditVexComponent, + ), + }, + { + path: 'log', + loadComponent: () => + import('../audit-log/audit-log-dashboard.component').then( + (m) => m.AuditLogDashboardComponent, + ), + }, + { + path: 'log/events', + loadComponent: () => + import('../audit-log/audit-log-table.component').then( + (m) => m.AuditLogTableComponent, + ), + }, + ], + }, + ], + }, +]; diff --git a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-pack-shell.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-pack-shell.component.ts new file mode 100644 index 000000000..901f5f442 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-pack-shell.component.ts @@ -0,0 +1,229 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + inject, + signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + ActivatedRoute, + ActivatedRouteSnapshot, + NavigationEnd, + Router, + RouterOutlet, +} from '@angular/router'; +import { filter, startWith } from 'rxjs'; + +import { + TabItem, + TabbedNavComponent, +} from '../../shared/ui'; + +type PackSubview = + | 'workspace' + | 'dashboard' + | 'edit' + | 'rules' + | 'yaml' + | 'approvals' + | 'simulate' + | 'explain'; + +@Component({ + selector: 'app-policy-pack-shell', + imports: [CommonModule, RouterOutlet, TabbedNavComponent], + template: ` +
+
+
+

Packs

+

{{ packId() ? 'Pack ' + packId() : 'Policy Pack Workspace' }}

+

+ {{ + packId() + ? 'Edit rules, YAML, approvals, and simulations for the selected pack.' + : 'Browse deterministic pack inventory and open a pack into authoring mode.' + }} +

+
+
+ + + +
+ +
+
+ `, + styles: [` + .policy-pack-shell { + display: grid; + gap: 0.85rem; + } + + .section-header { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + padding: 1rem; + } + + .section-header__eyebrow { + margin: 0 0 0.25rem; + color: var(--color-status-success); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .section-header h2 { + margin: 0; + color: var(--color-text-heading); + } + + .section-header p { + margin: 0.35rem 0 0; + color: var(--color-text-secondary); + } + `], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PolicyPackShellComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + + readonly packId = signal(this.readPackId()); + readonly activeSubview = signal(this.readSubview()); + + readonly tabItems = computed(() => { + const packId = this.packId(); + + if (!packId) { + return [ + { + id: 'workspace', + label: 'Workspace', + route: ['/ops/policy/packs'], + testId: 'policy-pack-tab-workspace', + }, + ]; + } + + return [ + { + id: 'dashboard', + label: 'Dashboard', + route: ['/ops/policy/packs', packId], + testId: 'policy-pack-tab-dashboard', + }, + { + id: 'edit', + label: 'Edit', + route: ['/ops/policy/packs', packId, 'edit'], + testId: 'policy-pack-tab-edit', + }, + { + id: 'rules', + label: 'Rules', + route: ['/ops/policy/packs', packId, 'rules'], + testId: 'policy-pack-tab-rules', + }, + { + id: 'yaml', + label: 'YAML', + route: ['/ops/policy/packs', packId, 'yaml'], + testId: 'policy-pack-tab-yaml', + }, + { + id: 'approvals', + label: 'Approvals', + route: ['/ops/policy/packs', packId, 'approvals'], + testId: 'policy-pack-tab-approvals', + }, + { + id: 'simulate', + label: 'Simulate', + route: ['/ops/policy/packs', packId, 'simulate'], + testId: 'policy-pack-tab-simulate', + }, + ]; + }); + + constructor() { + this.router.events + .pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + startWith(null), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => { + this.packId.set(this.readPackId()); + this.activeSubview.set(this.readSubview()); + }); + } + + private readPackId(): string | null { + const params = collectParams(this.route.snapshot.root); + return typeof params['packId'] === 'string' && params['packId'].length > 0 + ? params['packId'] + : null; + } + + private readSubview(): PackSubview { + const url = this.router.url.split('?')[0] ?? ''; + + if (!this.readPackId()) { + return 'workspace'; + } + + if (url.endsWith('/edit') || url.endsWith('/editor')) { + return 'edit'; + } + if (url.endsWith('/rules')) { + return 'rules'; + } + if (url.endsWith('/yaml')) { + return 'yaml'; + } + if (url.endsWith('/approvals')) { + return 'approvals'; + } + if (url.endsWith('/simulate')) { + return 'simulate'; + } + if (url.includes('/explain/')) { + return 'explain'; + } + + return 'dashboard'; + } +} + +function collectParams(snapshot: ActivatedRouteSnapshot | null): Record { + const params: Record = {}; + + if (!snapshot) { + return params; + } + + const stack: ActivatedRouteSnapshot[] = [snapshot]; + while (stack.length > 0) { + const current = stack.pop()!; + for (const [key, value] of Object.entries(current.params ?? {})) { + if (typeof value === 'string' && value.length > 0) { + params[key] = value; + } + } + stack.push(...current.children); + } + + return params; +} diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/impact-preview.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/impact-preview.component.ts index 907b69fa9..af43e0681 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/impact-preview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/impact-preview.component.ts @@ -588,7 +588,7 @@ export class ImpactPreviewComponent implements OnInit { setTimeout(() => { this.applying.set(false); // Navigate back to trust weights - window.location.href = '/policy/governance/trust-weights'; + window.location.href = '/ops/policy/governance/trust-weights'; }, 1500); } } diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-audit-log.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-audit-log.component.ts index b3d69410b..9cac474dc 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-audit-log.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-audit-log.component.ts @@ -624,7 +624,7 @@ export class PolicyAuditLogComponent implements OnInit { viewDiff(entry: PolicyAuditEntry): void { if (entry.diffId && entry.policyVersion) { - this.router.navigate(['/policy/simulation/diff', entry.policyPackId], { + this.router.navigate(['/ops/policy/simulation/diff', entry.policyPackId], { queryParams: { from: entry.policyVersion - 1, to: entry.policyVersion, diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/promotion-gate.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/promotion-gate.component.ts index 2e5f00268..870c11aa5 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/promotion-gate.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/promotion-gate.component.ts @@ -686,7 +686,7 @@ export class PromotionGateComponent implements OnChanges { } onPromote(): void { - void this.router.navigate(['/policy/packs'], { + void this.router.navigate(['/ops/policy/packs'], { queryParams: { promotedPack: this.policyPackId, promotedVersion: this.policyVersion, diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts index dae0d5bfd..9ca93e6f9 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts @@ -615,11 +615,11 @@ export class SimulationDashboardComponent implements OnInit { } protected navigateToHistory(): void { - this.router.navigate(['/policy/simulation/history']); + this.router.navigate(['/ops/policy/simulation/history']); } protected navigateToPromotion(): void { - this.router.navigate(['/policy/simulation/promotion']); + this.router.navigate(['/ops/policy/simulation/promotion']); } } diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-history.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-history.component.ts index dc4855b9a..d351b2b2d 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-history.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-history.component.ts @@ -1118,7 +1118,7 @@ export class SimulationHistoryComponent implements OnInit { } viewSimulation(simulationId: string): void { - this.router.navigate(['/policy/simulation/console'], { + this.router.navigate(['/ops/policy/simulation/console'], { queryParams: { simulationId }, }); } diff --git a/src/Web/StellaOps.Web/src/app/features/policy-studio/workspace/policy-workspace.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-studio/workspace/policy-workspace.component.ts index 816b705ac..21d8ccd24 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-studio/workspace/policy-workspace.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-studio/workspace/policy-workspace.component.ts @@ -47,7 +47,7 @@ import { PolicyPackStore } from '../services/policy-pack.store';
{{ profile.profileId }} @@ -1088,7 +1088,7 @@ export class PolicyStudioComponent implements OnInit { } viewProfile(profile: RiskProfileSummary): void { - this.router.navigate(['/policy/governance/profiles', profile.profileId]); + this.router.navigate(['/ops/policy/governance/profiles', profile.profileId]); } simulateWithProfile(profile: RiskProfileSummary): void { @@ -1097,7 +1097,7 @@ export class PolicyStudioComponent implements OnInit { } viewPack(pack: PolicyPackSummary): void { - this.router.navigate(['/policy-studio/packs', pack.packId, 'dashboard']); + this.router.navigate(['/ops/policy/packs', pack.packId]); } createRevision(pack: PolicyPackSummary): void { @@ -1110,11 +1110,11 @@ export class PolicyStudioComponent implements OnInit { } openCreateProfile(): void { - this.router.navigate(['/policy/governance/profiles/new']); + this.router.navigate(['/ops/policy/governance/profiles/new']); } openCreatePack(): void { - this.router.navigate(['/policy-studio/packs']); + this.router.navigate(['/ops/policy/packs']); } runSimulation(): void { diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/promotion-request/promotion-request.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/promotion-request/promotion-request.component.ts index 250fb9d43..e5e6f3091 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/promotion-request/promotion-request.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/promotion-request/promotion-request.component.ts @@ -9,6 +9,7 @@ import { FormsModule } from '@angular/forms'; import { ApprovalStore } from '../approval.store'; import type { ApprovalUrgency, PromotionPreview, GateStatus } from '../../../../core/api/approval.models'; import { getGateStatusColor, getUrgencyLabel } from '../../../../core/api/approval.models'; +import { buildContextReturnTo } from '../../../../shared/ui/context-route-state/context-route-state'; @Component({ selector: 'app-promotion-request', @@ -143,6 +144,12 @@ import { getGateStatusColor, getUrgencyLabel } from '../../../../core/api/approv
} + +
+ +
} @@ -512,6 +519,21 @@ export class PromotionRequestComponent implements OnInit { } } + openDecisioningPreview(): void { + const returnTo = buildContextReturnTo( + this.router, + ['/releases', this.releaseId, 'request-promotion'], + ); + + void this.router.navigate(['/ops/policy/gates/releases', this.releaseId], { + queryParams: { + releaseId: this.releaseId, + environment: this.targetEnvironmentId || null, + returnTo, + }, + }); + } + isValid(): boolean { return !!this.targetEnvironmentId && !!this.justification && diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/environments/environment-detail/environment-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/environments/environment-detail/environment-detail.component.ts index 41ca3a1a6..31d5a138e 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/environments/environment-detail/environment-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/environments/environment-detail/environment-detail.component.ts @@ -268,7 +268,7 @@ interface AuditEventRow { } diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-detail/evidence-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-detail/evidence-detail.component.ts index 88aa1ff79..a65d3618c 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-detail/evidence-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-detail/evidence-detail.component.ts @@ -4,7 +4,7 @@ */ import { Component, OnInit, OnDestroy, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { ActivatedRoute, RouterModule } from '@angular/router'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { EvidenceStore } from '../evidence.store'; import { formatFileSize, @@ -12,6 +12,7 @@ import { getGateStatusClass, type ExportFormat, } from '../../../../core/api/release-evidence.models'; +import { buildContextReturnTo } from '../../../../shared/ui/context-route-state/context-route-state'; type TabType = 'overview' | 'content' | 'signature' | 'timeline'; @@ -62,6 +63,13 @@ type TabType = 'overview' | 'content' | 'signature' | 'timeline'; Verify } + @@ -1423,6 +1431,7 @@ type TabType = 'overview' | 'content' | 'signature' | 'timeline'; }) export class EvidenceDetailComponent implements OnInit, OnDestroy { private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); readonly store = inject(EvidenceStore); activeTab = signal('overview'); @@ -1492,6 +1501,26 @@ export class EvidenceDetailComponent implements OnInit, OnDestroy { } } + openDecisioningStudio(): void { + const packet = this.packet(); + if (!packet) { + return; + } + + const artifact = packet.content.artifacts[0]?.digest ?? packet.contentHash; + const returnTo = buildContextReturnTo(this.router, ['/release-orchestrator/evidence', packet.id]); + + void this.router.navigate(['/ops/policy/gates/releases', packet.releaseId], { + queryParams: { + releaseId: packet.releaseId, + environment: packet.environmentName, + artifact, + evidenceId: packet.id, + returnTo, + }, + }); + } + copyContent(): void { navigator.clipboard.writeText(this.rawJson()); } diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts index ecd331c1c..c76bc6d8f 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts @@ -10,6 +10,7 @@ import { ReleaseManagementStore } from '../release.store'; import { getEvidencePostureLabel, getGateStatusLabel, getRiskTierLabel } from '../../../../core/api/release-management.models'; import type { ManagedRelease } from '../../../../core/api/release-management.models'; import { DegradedStateBannerComponent } from '../../../../shared/components/degraded-state-banner/degraded-state-banner.component'; +import { buildContextReturnTo } from '../../../../shared/ui/context-route-state/context-route-state'; interface PlatformListResponse { items: T[]; total: number; limit: number; offset: number; } interface PlatformItemResponse { item: T; } @@ -152,6 +153,7 @@ interface ReloadOptions { {{ getEvidencePostureLabel(release()!.evidencePosture) }}
+ @@ -234,6 +236,7 @@ interface ReloadOptions { @for (check of preflightChecks(); track check.id) {
  • {{ check.label }}: {{ check.status }}
  • } +

    Open blockers

    } @@ -271,7 +274,7 @@ interface ReloadOptions { } @empty { No findings. } -

    +

    } @@ -611,6 +614,33 @@ export class ReleaseDetailComponent { canPromote(): boolean { return this.preflightChecks().every((c) => c.status !== 'fail'); } + openDecisioningStudio(): void { + const release = this.release(); + const contextId = this.releaseContextId(); + const environment = + this.runDetail()?.targetEnvironment + ?? release?.currentEnvironment + ?? release?.targetEnvironment + ?? null; + const artifact = + this.runDetail()?.releaseVersionDigest + ?? release?.digest + ?? null; + const returnTo = buildContextReturnTo( + this.router, + [this.detailBasePath(), this.releaseId(), this.activeTab()], + ); + + void this.router.navigate(['/ops/policy/gates/releases', this.releaseId()], { + queryParams: { + releaseId: contextId, + environment, + artifact, + returnTo, + }, + }); + } + toggleTarget(targetId: string, event: Event): void { const checked = (event.target as HTMLInputElement).checked; this.selectedTargets.update((cur) => { @@ -623,7 +653,30 @@ export class ReleaseDetailComponent { setBaseline(id: string): void { this.baselineId.set(id); this.loadDiff(); } openFinding(): void { void this.router.navigate(['/security/triage'], { queryParams: { releaseId: this.releaseContextId() } }); } - createException(): void { void this.router.navigate(['/security/disposition'], { queryParams: { releaseId: this.releaseContextId(), tab: 'exceptions' } }); } + createException(): void { + const release = this.release(); + const returnTo = buildContextReturnTo( + this.router, + [this.detailBasePath(), this.releaseId(), this.activeTab()], + ); + + void this.router.navigate(['/ops/policy/vex/exceptions'], { + queryParams: { + releaseId: this.releaseContextId(), + environment: + this.runDetail()?.targetEnvironment + ?? release?.currentEnvironment + ?? release?.targetEnvironment + ?? null, + artifact: + this.runDetail()?.releaseVersionDigest + ?? release?.digest + ?? null, + returnTo, + create: '1', + }, + }); + } openReachabilityWorkspace(): void { const release = this.release(); const search = this.findings()[0]?.cveId || release?.name || this.releaseContextId(); diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/workflows/workflow-editor/workflow-editor.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/workflows/workflow-editor/workflow-editor.component.ts index 4b70adc0d..977d23176 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/workflows/workflow-editor/workflow-editor.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/workflows/workflow-editor/workflow-editor.component.ts @@ -25,6 +25,7 @@ import { type WorkflowStepType, type StepTypeDefinition, } from '../../../../core/api/workflow.models'; +import { buildContextReturnTo } from '../../../../shared/ui/context-route-state/context-route-state'; import { WorkflowVisualizerComponent } from '../../../workflow-visualization/components/workflow-visualizer/workflow-visualizer.component'; import type { WorkflowGraph } from '../../../workflow-visualization/services/workflow-visualization.service'; @@ -73,6 +74,9 @@ interface ConnectionState { @if (store.isDirty()) { Unsaved changes } + @@ -1392,6 +1396,24 @@ export class WorkflowEditorComponent implements OnInit, OnDestroy, AfterViewInit } } + openDecisioningStudio(): void { + const workflowId = + this.store.selectedWorkflow()?.id + ?? this.route.snapshot.paramMap.get('workflowId') + ?? this.route.snapshot.paramMap.get('id'); + + const returnTo = workflowId + ? buildContextReturnTo(this.router, ['/release-orchestrator/workflows', workflowId]) + : buildContextReturnTo(this.router, ['/release-orchestrator/workflows']); + + void this.router.navigate(['/ops/policy/gates'], { + queryParams: { + workflowId, + returnTo, + }, + }); + } + // Connection handling selectConnection(connection: { from: string; to: string }): void { // Could implement connection selection for deletion diff --git a/src/Web/StellaOps.Web/src/app/features/releases/release-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/release-detail-page.component.ts index afaf6d398..55687e2d9 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/release-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/releases/release-detail-page.component.ts @@ -7,7 +7,9 @@ import { Component, ChangeDetectionStrategy, signal, inject, OnInit } from '@angular/core'; -import { ActivatedRoute, RouterLink } from '@angular/router'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; + +import { buildContextReturnTo } from '../../shared/ui/context-route-state/context-route-state'; type ReleaseDetailTabId = | 'overview' @@ -44,6 +46,9 @@ type ReleaseDetailTabId =

    + @@ -135,7 +140,7 @@ type ReleaseDetailTabId = VEX Consensus
    - @@ -207,7 +212,7 @@ type ReleaseDetailTabId =
    {{ gate.status }} {{ gate.name }} - +

    {{ gate.reason }}

    @@ -571,6 +576,7 @@ type ReleaseDetailTabId = }) export class ReleaseDetailPageComponent implements OnInit { private route = inject(ActivatedRoute); + private router = inject(Router); releaseId = signal(''); activeTab = signal('overview'); @@ -646,4 +652,18 @@ export class ReleaseDetailPageComponent implements OnInit { requestPromotion(): void { console.log('Request promotion'); } + + openDecisioning(): void { + const release = this.release(); + const returnTo = buildContextReturnTo(this.router, ['/releases', this.releaseId()]); + + void this.router.navigate(['/ops/policy/gates/releases', this.releaseId()], { + queryParams: { + releaseId: this.releaseId(), + environment: release.currentEnv, + artifact: release.bundleDigest, + returnTo, + }, + }); + } } diff --git a/src/Web/StellaOps.Web/src/app/features/security/exception-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/exception-detail-page.component.ts index 118080b9f..4fe7dd09d 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/exception-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/exception-detail-page.component.ts @@ -18,7 +18,7 @@ import { RouterLink } from '@angular/router';

    Exception Detail

    Policy exception details and evidence.

    - Back to Exceptions + Back to Exceptions

    Exception detail data will appear here once loaded.

    diff --git a/src/Web/StellaOps.Web/src/app/features/security/exceptions-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/exceptions-page.component.ts index c366cc810..47afd13a9 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/exceptions-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/exceptions-page.component.ts @@ -122,7 +122,7 @@ export class ExceptionsPageComponent implements OnInit { } requestException(): void { - void this.router.navigate(['/policy/exceptions'], { + void this.router.navigate(['/ops/policy/vex/exceptions'], { queryParams: { create: '1' }, }); } diff --git a/src/Web/StellaOps.Web/src/app/features/security/security-overview-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/security-overview-page.component.ts index fc9100e49..afb19e937 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/security-overview-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/security-overview-page.component.ts @@ -104,7 +104,7 @@ import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';

    VEX Coverage

    - Manage VEX → + Manage VEX →
    @@ -126,7 +126,7 @@ import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';

    Active Exceptions

    - Manage → + Manage →
    @for (exception of activeExceptions; track exception.id) { diff --git a/src/Web/StellaOps.Web/src/app/features/security/vulnerability-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/vulnerability-detail-page.component.ts index 6a0d0bb55..6d072b8ed 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/vulnerability-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/vulnerability-detail-page.component.ts @@ -363,8 +363,8 @@ export class VulnerabilityDetailPageComponent { openVex(): void { const id = this.detail()?.cveId ?? this.vulnerabilityId(); - void this.router.navigate(['/security/vex'], { - queryParams: { cve: id }, + void this.router.navigate(['/ops/policy/vex/search'], { + queryParams: { cveId: id }, }); } diff --git a/src/Web/StellaOps.Web/src/app/features/settings/policy/policy-governance-settings-page.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/policy/policy-governance-settings-page.component.ts index a21521f46..5495b4372 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/policy/policy-governance-settings-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/policy/policy-governance-settings-page.component.ts @@ -20,25 +20,25 @@ import { RouterLink } from '@angular/router';

    Policy Baselines

    Manage policy baselines for different environments.

    - + Create Baseline + + Create Baseline

    Governance Rules

    Define organizational governance rules for releases.

    - Edit Rules + Edit Rules

    Policy Simulation

    Test policy changes before applying them.

    - Run Simulation + Run Simulation

    Exception Workflow

    Configure how policy exceptions are requested and approved.

    - Configure Workflow + Configure Workflow
    diff --git a/src/Web/StellaOps.Web/src/app/features/timeline/components/evidence-links/evidence-links.component.ts b/src/Web/StellaOps.Web/src/app/features/timeline/components/evidence-links/evidence-links.component.ts index 0af98186f..5259ecea2 100644 --- a/src/Web/StellaOps.Web/src/app/features/timeline/components/evidence-links/evidence-links.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/timeline/components/evidence-links/evidence-links.component.ts @@ -76,7 +76,7 @@ export class EvidenceLinksComponent { type: 'VEX', id: String(record['vexId'] ?? record['vex_id'] ?? record['vexDigest']), icon: 'verified_user', - route: '/vex-hub', + route: '/ops/policy/vex/search/detail', color: 'var(--color-status-success)', }); } @@ -87,7 +87,7 @@ export class EvidenceLinksComponent { type: 'Policy', id: String(record['policyId'] ?? record['policy_id'] ?? record['policyDigest']), icon: 'policy', - route: '/policy', + route: '/ops/policy/packs', color: 'var(--color-status-warning)', }); } diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-conflict-resolution.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-conflict-resolution.component.ts index 223e3e7a1..7ed57076a 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-conflict-resolution.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-conflict-resolution.component.ts @@ -992,7 +992,8 @@ export class VexConflictResolutionComponent implements OnChanges { async resolve(): Promise { const selected = this.selectedStatement(); - if (!selected || !this.resolutionType) return; + const selectedStatementId = selected?.statementId?.trim(); + if (!selectedStatementId || !this.resolutionType) return; this.resolving.set(true); @@ -1000,14 +1001,14 @@ export class VexConflictResolutionComponent implements OnChanges { await firstValueFrom( this.vexHubApi.resolveConflict({ cveId: this.cveId(), - selectedStatementId: selected.statementId, + selectedStatementId, resolutionType: this.resolutionType, notes: this.resolutionNotes, }) ); this.resolved.emit({ - selectedStatementId: selected.statementId, + selectedStatementId, resolutionType: this.resolutionType, notes: this.resolutionNotes, }); diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-search.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-search.component.ts index 540404f79..a3b754547 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-search.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-search.component.ts @@ -693,7 +693,14 @@ export class VexStatementSearchComponent implements OnInit { async ngOnInit(): Promise { // Check for initial status from route or input + const cveIdParam = this.route.snapshot.queryParamMap.get('cveId'); + const qParam = this.route.snapshot.queryParamMap.get('q'); const statusParam = this.route.snapshot.queryParamMap.get('status'); + if (cveIdParam) { + this.cveFilter = cveIdParam; + } else if (qParam) { + this.cveFilter = qParam; + } if (statusParam) { this.statusFilter = statusParam; } else if (this.initialStatus()) { diff --git a/src/Web/StellaOps.Web/src/app/layout/context-chips/policy-baseline-chip.component.ts b/src/Web/StellaOps.Web/src/app/layout/context-chips/policy-baseline-chip.component.ts index 60d61f5b2..296f82e1d 100644 --- a/src/Web/StellaOps.Web/src/app/layout/context-chips/policy-baseline-chip.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/context-chips/policy-baseline-chip.component.ts @@ -19,7 +19,7 @@ import { PolicyPackStore } from '../../features/policy-studio/services/policy-pa template: `