feat(ui): ship policy decisioning studio

This commit is contained in:
master
2026-03-08 01:35:18 +02:00
parent 8ee40b56e9
commit 6e00a48e00
57 changed files with 3637 additions and 333 deletions

View File

@@ -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`.

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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'],
},
{

View File

@@ -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',
},

View File

@@ -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',

View File

@@ -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<string, string> }>;
}
interface SecurityFindingRow {
@@ -229,7 +231,7 @@ interface HistoryEvent {
@if (row.result === 'BLOCK') {
<div class="fix-links">
@for (link of row.fixLinks; track link.label) {
<a [routerLink]="link.route">{{ link.label }}</a>
<a [routerLink]="link.route" [queryParams]="link.queryParams || null">{{ link.label }}</a>
}
</div>
}
@@ -296,8 +298,8 @@ interface HistoryEvent {
<div class="footer-links">
<a routerLink="/security/findings">Open Findings (filtered)</a>
<a routerLink="/security/vex">Open VEX Hub</a>
<a routerLink="/administration/policy-governance/exceptions">Open Exceptions</a>
<a routerLink="/ops/policy/vex" [queryParams]="decisioningContextParams()">Open VEX Hub</a>
<a routerLink="/ops/policy/vex/exceptions" [queryParams]="decisioningContextParams()">Open Exceptions</a>
</div>
</section>
}
@@ -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<ApprovalTabId>('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<string, string> = {}): Record<string, string> {
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,
};
}
}

View File

@@ -97,7 +97,7 @@ import {
<a
class="policy-badge"
[class]="getPolicyClass(hit)"
[routerLink]="['/policy/gates', hit.gate]"
[routerLink]="['/ops/policy/gates/catalog']"
[title]="hit.message"
>
<span class="policy-result">{{ hit.result | uppercase }}</span>
@@ -367,4 +367,3 @@ export class PolicyHitAnnotationComponent {
}
}
}

View File

@@ -166,10 +166,10 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty';
</div>
</a>
<a routerLink="/administration/policy-governance" class="cross-link">
<a routerLink="/ops/policy/governance" class="cross-link">
<span class="cross-link-icon" aria-hidden="true">&#9670;</span>
<div class="cross-link-body">
<div class="cross-link-title">Administration &gt; Policy Governance</div>
<div class="cross-link-title">Ops &gt; Policy &gt; Governance</div>
<div class="cross-link-desc">Policy packs driving evidence requirements</div>
</div>
</a>

View File

@@ -275,12 +275,12 @@ import { SkeletonComponent } from '../../shared/components/skeleton/skeleton.com
</svg>
<span>Exception Queue</span>
</a>
<a routerLink="/policy-studio/packs" class="quick-action">
<a routerLink="/ops/policy" class="quick-action">
<svg viewBox="0 0 24 24" width="24" height="24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline points="14 2 14 8 20 8" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
<span>Policy Studio</span>
<span>Policy Decisioning</span>
</a>
<a routerLink="/graph" class="quick-action">
<svg viewBox="0 0 24 24" width="24" height="24">

View File

@@ -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: `
<section class="policy-audit-shell" data-testid="policy-audit-shell">
<header class="section-header">
<div>
<p class="section-header__eyebrow">Audit</p>
<h2>Policy and VEX audit now share the same owner shell</h2>
<p>
Review mutable policy and VEX actions after the cutover without routing operators back
into retired sibling products.
</p>
</div>
</header>
<app-tabbed-nav
[tabs]="tabItems()"
[activeTab]="activeSubview()"
/>
<div class="policy-audit-shell__content">
<router-outlet />
</div>
</section>
`,
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<AuditSubview>(this.readSubview());
readonly tabItems = computed<readonly TabItem[]>(() => {
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;
}

View File

@@ -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: `
<section class="policy-gates-page" data-testid="policy-gates-page">
<header class="page-header">
<div>
<p class="page-header__eyebrow">Release Gates</p>
<h2>{{ pageTitle() }}</h2>
<p>{{ pageSubtitle() }}</p>
</div>
<div class="page-actions">
<a
class="page-action"
[routerLink]="['/ops/policy/gates/catalog']"
[queryParams]="contextQueryParams()"
>
Open gate catalog
</a>
<a
class="page-action"
[routerLink]="['/ops/policy/vex/exceptions']"
[queryParams]="contextQueryParams()"
>
Open exceptions
</a>
<a
class="page-action page-action--primary"
[routerLink]="['/ops/policy/simulation/promotion']"
[queryParams]="contextQueryParams()"
>
Open promotion simulation
</a>
</div>
</header>
<div class="gates-grid">
<app-gate-summary-panel
[gates]="gateResults()"
[policyRef]="policyReference()"
[snapshotRef]="snapshotReference()"
(openExplain)="openExplain($event)"
(openEvidence)="openEvidence()"
/>
<aside class="context-panel">
<h3>Decision context</h3>
<dl>
<div>
<dt>Scope</dt>
<dd>{{ scopeLabel() }}</dd>
</div>
@if (releaseId()) {
<div>
<dt>Release</dt>
<dd>{{ releaseId() }}</dd>
</div>
}
@if (approvalId()) {
<div>
<dt>Approval</dt>
<dd>{{ approvalId() }}</dd>
</div>
}
@if (environmentId()) {
<div>
<dt>Environment</dt>
<dd>{{ environmentId() }}</dd>
</div>
}
@if (artifactDigest()) {
<div>
<dt>Artifact</dt>
<dd><code>{{ artifactDigest() }}</code></dd>
</div>
}
</dl>
<div class="context-links">
<a
[routerLink]="['/ops/policy/vex']"
[queryParams]="contextQueryParams()"
>
Review VEX posture
</a>
<a
[routerLink]="['/ops/policy/audit/policy']"
[queryParams]="contextQueryParams()"
>
Review audit trail
</a>
@if (returnTo()) {
<button type="button" class="return-button" (click)="returnToSource()">
Return to source
</button>
}
</div>
</aside>
</div>
<section class="details-panel">
<article class="details-card">
<h3>Blocking reasons</h3>
<ul>
@for (reason of blockingReasons(); track reason) {
<li>{{ reason }}</li>
}
</ul>
</article>
<article class="details-card">
<h3>Recommended next actions</h3>
<ul>
@for (action of recommendedActions(); track action) {
<li>{{ action }}</li>
}
</ul>
</article>
</section>
<app-gate-explain-drawer
[open]="drawerOpen()"
[gateEvaluation]="selectedEvaluation()"
(closed)="drawerOpen.set(false)"
/>
</section>
`,
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<string | null>(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<DecisioningGateScope>(() => {
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<readonly GateResult[]>(() => {
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<GateEvaluation | null>(() => {
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<readonly string[]>(() =>
this.gateResults()
.filter((gate) => gate.state === 'BLOCK' || gate.state === 'WARN')
.map((gate) => gate.reason ?? gate.name)
);
readonly recommendedActions = computed<readonly string[]>(() => {
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<string, string | null | undefined> {
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;
}

View File

@@ -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: `
<section class="policy-overview" data-testid="policy-decisioning-overview">
<div class="hero">
<div>
<p class="hero__eyebrow">Decisioning Map</p>
<h2>One operator shell for policy, VEX, and release gates</h2>
<p class="hero__copy">
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.
</p>
</div>
<div class="hero__metrics">
<article>
<span class="metric-label">Canonical root</span>
<strong>/ops/policy</strong>
</article>
<article>
<span class="metric-label">Primary workflows</span>
<strong>7 tabs</strong>
</article>
<article>
<span class="metric-label">Context modes</span>
<strong>Global · Pack · Release</strong>
</article>
</div>
</div>
<div class="cards">
@for (card of cards(); track card.id) {
<a
class="card"
[class]="'card card--' + card.accent"
[routerLink]="card.route"
[queryParams]="contextQueryParams()"
[attr.data-testid]="'policy-overview-card-' + card.id"
>
<strong>{{ card.title }}</strong>
<p>{{ card.description }}</p>
</a>
}
</div>
</section>
`,
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<readonly DecisioningOverviewCard[]>(() => [
{
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<string, string | null | undefined> {
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;
}

View File

@@ -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: `
<section class="policy-decisioning-shell" data-testid="policy-decisioning-shell">
<app-context-header
eyebrow="Ops / Policy"
[title]="headerTitle()"
[subtitle]="headerSubtitle()"
[contextNote]="headerNote()"
[chips]="headerChips()"
[backLabel]="shellState().returnTo ? 'Return to source' : null"
(backClick)="returnToSource()"
>
<a
header-actions
class="shell-action"
[routerLink]="overviewRoute()"
[queryParams]="contextQueryParams()"
>
Reset view
</a>
</app-context-header>
<app-tabbed-nav
[tabs]="primaryTabs()"
[activeTab]="shellState().activeTab"
/>
<div class="policy-decisioning-shell__body">
<router-outlet />
</div>
</section>
`,
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<DecisioningShellState>(this.readShellState());
readonly primaryTabs = computed<readonly TabItem[]>(() => {
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<string, string | null | undefined> {
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<string, string> {
const params: Record<string, string> = {};
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;
}

View File

@@ -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: `
<section class="policy-vex-shell" data-testid="policy-vex-shell">
<header class="section-header">
<div>
<p class="section-header__eyebrow">VEX & Exceptions</p>
<h2>Mutable VEX actions now live in Decisioning Studio</h2>
<p>
Search statements, resolve consensus, open exception queues, and keep release-context
deep links inside the same policy shell.
</p>
</div>
</header>
<app-tabbed-nav
[tabs]="tabItems()"
[activeTab]="activeSubview()"
/>
<div class="policy-vex-shell__content">
<router-outlet />
</div>
</section>
`,
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<VexSubview>(this.readSubview());
readonly tabItems = computed<readonly TabItem[]>(() => {
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;
}

View File

@@ -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,
),
},
],
},
],
},
];

View File

@@ -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: `
<section class="policy-pack-shell" data-testid="policy-pack-shell">
<header class="section-header">
<div>
<p class="section-header__eyebrow">Packs</p>
<h2>{{ packId() ? 'Pack ' + packId() : 'Policy Pack Workspace' }}</h2>
<p>
{{
packId()
? 'Edit rules, YAML, approvals, and simulations for the selected pack.'
: 'Browse deterministic pack inventory and open a pack into authoring mode.'
}}
</p>
</div>
</header>
<app-tabbed-nav
[tabs]="tabItems()"
[activeTab]="activeSubview()"
/>
<div class="policy-pack-shell__content">
<router-outlet />
</div>
</section>
`,
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<string | null>(this.readPackId());
readonly activeSubview = signal<PackSubview>(this.readSubview());
readonly tabItems = computed<readonly TabItem[]>(() => {
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<string, string> {
const params: Record<string, string> = {};
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;
}

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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']);
}
}

View File

@@ -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 },
});
}

View File

@@ -47,7 +47,7 @@ import { PolicyPackStore } from '../services/policy-pack.store';
</ul>
<div class="pack-card__actions">
<a
[routerLink]="['/policy-studio/packs', pack.id, 'editor']"
[routerLink]="['/ops/policy/packs', pack.id, 'edit']"
[class.action-disabled]="!canAuthor"
[attr.aria-disabled]="!canAuthor"
[title]="canAuthor ? '' : 'Requires policy:author scope'"
@@ -55,7 +55,7 @@ import { PolicyPackStore } from '../services/policy-pack.store';
Edit
</a>
<a
[routerLink]="['/policy-studio/packs', pack.id, 'simulate']"
[routerLink]="['/ops/policy/packs', pack.id, 'simulate']"
[class.action-disabled]="!canSimulate"
[attr.aria-disabled]="!canSimulate"
[title]="canSimulate ? '' : 'Requires policy:simulate scope'"
@@ -63,7 +63,7 @@ import { PolicyPackStore } from '../services/policy-pack.store';
Simulate
</a>
<a
[routerLink]="['/policy-studio/packs', pack.id, 'approvals']"
[routerLink]="['/ops/policy/packs', pack.id, 'approvals']"
[class.action-disabled]="!canReviewOrApprove"
[attr.aria-disabled]="!canReviewOrApprove"
[title]="canReviewOrApprove ? '' : 'Requires policy:review or policy:approve scope'"
@@ -71,7 +71,7 @@ import { PolicyPackStore } from '../services/policy-pack.store';
Approvals
</a>
<a
[routerLink]="['/policy-studio/packs', pack.id, 'dashboard']"
[routerLink]="['/ops/policy/packs', pack.id]"
[class.action-disabled]="!canView"
[attr.aria-disabled]="!canView"
[title]="canView ? '' : 'Requires policy:read scope'"

View File

@@ -197,7 +197,7 @@ type SortOrder = 'asc' | 'desc';
<td>
<a
class="policy-studio__link"
[routerLink]="['/policy/governance/profiles', profile.profileId]"
[routerLink]="['/ops/policy/governance/profiles', profile.profileId]"
>
{{ profile.profileId }}
</a>
@@ -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 {

View File

@@ -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
</div>
}
</div>
<div class="decisioning-actions">
<button type="button" class="btn btn-secondary" (click)="openDecisioningPreview()">
Open Decisioning Studio
</button>
</div>
</section>
}
@@ -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 &&

View File

@@ -268,7 +268,7 @@ interface AuditEventRow {
<div class="footer-links">
<a routerLink="/platform-ops/data-integrity/scan-pipeline">Trigger SBOM scan/rescan</a>
<a routerLink="/security/findings">Open Findings</a>
<a routerLink="/security/vex">Open VEX/Exceptions</a>
<a routerLink="/ops/policy/vex">Open VEX/Exceptions</a>
</div>
</section>
}

View File

@@ -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';
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> Verify
}
</button>
<button
class="btn btn-secondary"
type="button"
(click)="openDecisioningStudio()"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg> Policy Decisioning
</button>
<button class="btn btn-primary" (click)="showExportDialog.set(true)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Export
</button>
@@ -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<TabType>('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());
}

View File

@@ -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<T> { items: T[]; total: number; limit: number; offset: number; }
interface PlatformItemResponse<T> { item: T; }
@@ -152,6 +153,7 @@ interface ReloadOptions {
<span>{{ getEvidencePostureLabel(release()!.evidencePosture) }}</span>
</div>
<div class="actions">
<button type="button" (click)="openDecisioningStudio()">Decisioning</button>
<button type="button" (click)="openTab('gate-decision')">Promote</button>
<button type="button" (click)="openTab('deployments')">Deploy</button>
<button type="button" (click)="openTab('security-inputs')">Security</button>
@@ -234,6 +236,7 @@ interface ReloadOptions {
@for (check of preflightChecks(); track check.id) { <li>{{ check.label }}: <strong>{{ check.status }}</strong></li> }
</ul>
<button type="button" class="primary" [disabled]="!canPromote()">Promote Release</button>
<button type="button" (click)="openDecisioningStudio()">Open Decisioning Studio</button>
<p><a [routerLink]="[detailBasePath(), releaseId(), 'security-inputs']">Open blockers</a></p>
</article>
}
@@ -271,7 +274,7 @@ interface ReloadOptions {
} @empty { <tr><td colspan="7">No findings.</td></tr> }
</tbody>
</table>
<p><button type="button" (click)="openGlobalFindings()">Open Findings</button> <button type="button" (click)="openReachabilityWorkspace()">Open Reachability</button> <button type="button" (click)="createException()">Create Exception</button> <button type="button" (click)="openTab('rollback')">Compare Baseline</button> <button type="button" class="primary" (click)="exportSecurityEvidence()">Export Security Evidence</button></p>
<p><button type="button" (click)="openGlobalFindings()">Open Findings</button> <button type="button" (click)="openReachabilityWorkspace()">Open Reachability</button> <button type="button" (click)="createException()">Create Exception</button> <button type="button" (click)="openDecisioningStudio()">Open Decisioning Studio</button> <button type="button" (click)="openTab('rollback')">Compare Baseline</button> <button type="button" class="primary" (click)="exportSecurityEvidence()">Export Security Evidence</button></p>
</article>
}
@@ -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();

View File

@@ -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()) {
<span class="unsaved-indicator">Unsaved changes</span>
}
<button class="btn btn-secondary" type="button" (click)="openDecisioningStudio()">
Decisioning
</button>
<button class="btn btn-secondary" (click)="showYamlView.set(!showYamlView())">
{{ showYamlView() ? 'Visual' : 'YAML' }}
</button>
@@ -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

View File

@@ -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 =
</p>
</div>
<div class="header-actions">
<button type="button" class="btn btn--secondary" (click)="openDecisioning()">
Policy Decisioning
</button>
<button type="button" class="btn btn--secondary" (click)="openEvidence()">
Open Evidence
</button>
@@ -135,7 +140,7 @@ type ReleaseDetailTabId =
<span>VEX Consensus</span>
</div>
</div>
<button type="button" class="btn btn--sm btn--secondary" (click)="setTab('gates')">
<button type="button" class="btn btn--sm btn--secondary" (click)="openDecisioning()">
View Details
</button>
</div>
@@ -207,7 +212,7 @@ type ReleaseDetailTabId =
<div class="gate-detail-header">
<span class="gate-badge" [class]="'gate-badge--' + gate.status.toLowerCase()">{{ gate.status }}</span>
<span class="gate-name">{{ gate.name }}</span>
<button type="button" class="btn btn--sm">Explain</button>
<button type="button" class="btn btn--sm" (click)="openDecisioning()">Explain</button>
</div>
<p class="gate-reason">{{ gate.reason }}</p>
</div>
@@ -571,6 +576,7 @@ type ReleaseDetailTabId =
})
export class ReleaseDetailPageComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
releaseId = signal('');
activeTab = signal<ReleaseDetailTabId>('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,
},
});
}
}

View File

@@ -18,7 +18,7 @@ import { RouterLink } from '@angular/router';
<h1 class="page-title">Exception Detail</h1>
<p class="page-subtitle">Policy exception details and evidence.</p>
</div>
<a routerLink="/policy/exceptions" class="btn btn--secondary">Back to Exceptions</a>
<a routerLink="/ops/policy/vex/exceptions" class="btn btn--secondary">Back to Exceptions</a>
</header>
<div class="panel">
<p>Exception detail data will appear here once loaded.</p>

View File

@@ -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' },
});
}

View File

@@ -104,7 +104,7 @@ import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';
<section class="panel">
<div class="panel-header">
<h3>VEX Coverage</h3>
<a routerLink="/security/vex" class="panel-link">Manage VEX →</a>
<a routerLink="/ops/policy/vex" class="panel-link">Manage VEX →</a>
</div>
<div class="vex-stats">
<div class="vex-stat">
@@ -126,7 +126,7 @@ import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';
<section class="panel">
<div class="panel-header">
<h3>Active Exceptions</h3>
<a routerLink="/security/exceptions" class="panel-link">Manage →</a>
<a routerLink="/ops/policy/vex/exceptions" class="panel-link">Manage →</a>
</div>
<div class="exceptions-list">
@for (exception of activeExceptions; track exception.id) {

View File

@@ -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 },
});
}

View File

@@ -20,25 +20,25 @@ import { RouterLink } from '@angular/router';
<section class="settings-section">
<h2>Policy Baselines</h2>
<p>Manage policy baselines for different environments.</p>
<a routerLink="/policy/packs" class="btn btn--primary">+ Create Baseline</a>
<a routerLink="/ops/policy/packs" class="btn btn--primary">+ Create Baseline</a>
</section>
<section class="settings-section">
<h2>Governance Rules</h2>
<p>Define organizational governance rules for releases.</p>
<a routerLink="/policy/governance" class="btn btn--secondary">Edit Rules</a>
<a routerLink="/ops/policy/governance" class="btn btn--secondary">Edit Rules</a>
</section>
<section class="settings-section">
<h2>Policy Simulation</h2>
<p>Test policy changes before applying them.</p>
<a routerLink="/policy/simulation" class="btn btn--secondary">Run Simulation</a>
<a routerLink="/ops/policy/simulation" class="btn btn--secondary">Run Simulation</a>
</section>
<section class="settings-section">
<h2>Exception Workflow</h2>
<p>Configure how policy exceptions are requested and approved.</p>
<a routerLink="/policy/exceptions" class="btn btn--secondary">Configure Workflow</a>
<a routerLink="/ops/policy/vex/exceptions" class="btn btn--secondary">Configure Workflow</a>
</section>
</div>
</div>

View File

@@ -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)',
});
}

View File

@@ -992,7 +992,8 @@ export class VexConflictResolutionComponent implements OnChanges {
async resolve(): Promise<void> {
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,
});

View File

@@ -693,7 +693,14 @@ export class VexStatementSearchComponent implements OnInit {
async ngOnInit(): Promise<void> {
// 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()) {

View File

@@ -19,7 +19,7 @@ import { PolicyPackStore } from '../../features/policy-studio/services/policy-pa
template: `
<a
class="chip"
routerLink="/administration/policy-governance"
routerLink="/ops/policy/packs"
[attr.title]="tooltip()"
>
<svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
@@ -91,4 +91,3 @@ export class PolicyBaselineChipComponent {
return `Active policy baseline: ${activePack.name} ${activePack.version}. Click to manage policies.`;
});
}

View File

@@ -24,7 +24,7 @@ export const SEARCH_ACTION_ROUTE_MATRIX: ReadonlyArray<SearchRouteMatrixEntry> =
{
domain: 'vex',
sourceRoute: '/vex-hub/CVE-2024-21626',
expectedRoute: '/security/advisories-vex?q=CVE-2024-21626',
expectedRoute: '/ops/policy/vex/search?cveId=CVE-2024-21626',
},
{
domain: 'platform',
@@ -65,8 +65,8 @@ export function normalizeSearchActionRoute(route: string): string {
parsedUrl.pathname = `/security/findings/${pathname.substring('/triage/findings/'.length)}`;
} else if (pathname.startsWith('/vex-hub/')) {
const lookup = decodeURIComponent(pathname.substring('/vex-hub/'.length));
parsedUrl.pathname = '/security/advisories-vex';
parsedUrl.search = lookup ? `?q=${encodeURIComponent(lookup)}` : '';
parsedUrl.pathname = '/ops/policy/vex/search';
parsedUrl.search = lookup ? `?cveId=${encodeURIComponent(lookup)}` : '';
} else if (pathname.startsWith('/proof-chain/')) {
const digest = decodeURIComponent(pathname.substring('/proof-chain/'.length));
parsedUrl.pathname = '/evidence/proofs';

View File

@@ -17,7 +17,32 @@
* until SPRINT_20260218_016 cutover; this file owns the /administration/* canonical paths.
*/
import { Routes } from '@angular/router';
import { inject } from '@angular/core';
import { Router, Routes } from '@angular/router';
function redirectToDecisioning(path: string) {
return ({
params,
queryParams,
fragment,
}: {
params: Record<string, string>;
queryParams: Record<string, string>;
fragment?: string | null;
}) => {
const router = inject(Router);
let targetPath = path;
for (const [name, value] of Object.entries(params ?? {})) {
targetPath = targetPath.replaceAll(`:${name}`, encodeURIComponent(value));
}
const target = router.parseUrl(targetPath);
target.queryParams = { ...queryParams };
target.fragment = fragment ?? null;
return target;
};
}
export const ADMINISTRATION_ROUTES: Routes = [
// A0 — Administration overview
@@ -117,73 +142,106 @@ export const ADMINISTRATION_ROUTES: Routes = [
path: 'policy-governance',
title: 'Policy Governance',
data: { breadcrumb: 'Policy Governance' },
loadChildren: () =>
import('../features/policy-governance/policy-governance.routes').then(
(m) => m.policyGovernanceRoutes
),
redirectTo: redirectToDecisioning('/ops/policy/governance'),
pathMatch: 'full',
},
{
path: 'policy-governance/exceptions',
title: 'Exceptions',
data: { breadcrumb: 'Exceptions' },
redirectTo: redirectToDecisioning('/ops/policy/vex/exceptions'),
pathMatch: 'full',
},
{
path: 'policy-governance/exceptions/:id',
title: 'Exception Detail',
data: { breadcrumb: 'Exception Detail' },
redirectTo: redirectToDecisioning('/ops/policy/vex/exceptions/:id'),
pathMatch: 'full',
},
{
path: 'policy-governance/:page',
title: 'Policy Governance',
data: { breadcrumb: 'Policy Governance' },
redirectTo: redirectToDecisioning('/ops/policy/governance/:page'),
pathMatch: 'full',
},
{
path: 'policy-governance/:page/:child',
title: 'Policy Governance',
data: { breadcrumb: 'Policy Governance' },
redirectTo: redirectToDecisioning('/ops/policy/governance/:page/:child'),
pathMatch: 'full',
},
{
path: 'policy',
title: 'Policy Governance',
data: { breadcrumb: 'Policy Governance' },
loadComponent: () =>
import('../features/settings/policy/policy-governance-settings-page.component').then(
(m) => m.PolicyGovernanceSettingsPageComponent
),
redirectTo: redirectToDecisioning('/ops/policy/governance'),
pathMatch: 'full',
},
{
path: 'policy/packs',
title: 'Policy Packs',
data: { breadcrumb: 'Policy Packs' },
loadComponent: () =>
import('../features/policy-studio/workspace/policy-workspace.component').then(
(m) => m.PolicyWorkspaceComponent
),
redirectTo: redirectToDecisioning('/ops/policy/packs'),
pathMatch: 'full',
},
{
path: 'policy/exceptions',
title: 'Exceptions',
data: { breadcrumb: 'Exceptions' },
loadComponent: () =>
import('../features/triage/triage-artifacts.component').then(
(m) => m.TriageArtifactsComponent
),
redirectTo: redirectToDecisioning('/ops/policy/vex/exceptions'),
pathMatch: 'full',
},
{
path: 'policy/exceptions/:id',
title: 'Exception Detail',
data: { breadcrumb: 'Exception Detail' },
loadComponent: () =>
import('../features/triage/triage-workspace.component').then(
(m) => m.TriageWorkspaceComponent
),
redirectTo: redirectToDecisioning('/ops/policy/vex/exceptions/:id'),
pathMatch: 'full',
},
{
path: 'policy/packs/:packId',
title: 'Policy Pack',
data: { breadcrumb: 'Policy Pack' },
loadComponent: () =>
import('../features/policy-studio/workspace/policy-workspace.component').then(
(m) => m.PolicyWorkspaceComponent
),
redirectTo: redirectToDecisioning('/ops/policy/packs/:packId'),
pathMatch: 'full',
},
{
path: 'policy/packs/:packId/:page',
title: 'Policy Pack',
data: { breadcrumb: 'Policy Pack' },
loadComponent: () =>
import('../features/policy-studio/workspace/policy-workspace.component').then(
(m) => m.PolicyWorkspaceComponent
),
redirectTo: redirectToDecisioning('/ops/policy/packs/:packId/:page'),
pathMatch: 'full',
},
{
path: 'policy/packs/:packId/explain/:runId',
title: 'Policy Explain',
data: { breadcrumb: 'Policy Explain' },
redirectTo: redirectToDecisioning('/ops/policy/packs/:packId/explain/:runId'),
pathMatch: 'full',
},
{
path: 'policy/governance',
title: 'Policy Governance',
data: { breadcrumb: 'Policy Governance' },
loadChildren: () =>
import('../features/policy-governance/policy-governance.routes').then(
(m) => m.policyGovernanceRoutes
),
redirectTo: redirectToDecisioning('/ops/policy/governance'),
pathMatch: 'full',
},
{
path: 'policy/governance/:page',
title: 'Policy Governance',
data: { breadcrumb: 'Policy Governance' },
redirectTo: redirectToDecisioning('/ops/policy/governance/:page'),
pathMatch: 'full',
},
{
path: 'policy/governance/:page/:child',
title: 'Policy Governance',
data: { breadcrumb: 'Policy Governance' },
redirectTo: redirectToDecisioning('/ops/policy/governance/:page/:child'),
pathMatch: 'full',
},
// A6 — Trust & Signing

View File

@@ -8,6 +8,201 @@ export interface LegacyRedirectRouteTemplate {
}
export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectRouteTemplate[] = [
{
path: 'policy-studio',
redirectTo: '/ops/policy/packs',
pathMatch: 'full',
},
{
path: 'policy-studio/packs',
redirectTo: '/ops/policy/packs',
pathMatch: 'full',
},
{
path: 'policy-studio/packs/:packId',
redirectTo: '/ops/policy/packs/:packId',
pathMatch: 'full',
},
{
path: 'policy-studio/packs/:packId/dashboard',
redirectTo: '/ops/policy/packs/:packId',
pathMatch: 'full',
},
{
path: 'policy-studio/packs/:packId/editor',
redirectTo: '/ops/policy/packs/:packId/edit',
pathMatch: 'full',
},
{
path: 'policy-studio/packs/:packId/edit',
redirectTo: '/ops/policy/packs/:packId/edit',
pathMatch: 'full',
},
{
path: 'policy-studio/packs/:packId/rules',
redirectTo: '/ops/policy/packs/:packId/rules',
pathMatch: 'full',
},
{
path: 'policy-studio/packs/:packId/yaml',
redirectTo: '/ops/policy/packs/:packId/yaml',
pathMatch: 'full',
},
{
path: 'policy-studio/packs/:packId/approvals',
redirectTo: '/ops/policy/packs/:packId/approvals',
pathMatch: 'full',
},
{
path: 'policy-studio/packs/:packId/simulate',
redirectTo: '/ops/policy/packs/:packId/simulate',
pathMatch: 'full',
},
{
path: 'policy-studio/packs/:packId/explain/:runId',
redirectTo: '/ops/policy/packs/:packId/explain/:runId',
pathMatch: 'full',
},
{
path: 'policy-studio/dashboard',
redirectTo: '/ops/policy/overview',
pathMatch: 'full',
},
{
path: 'policy-studio/simulate',
redirectTo: '/ops/policy/simulation',
pathMatch: 'full',
},
{
path: 'policy-studio/approvals',
redirectTo: '/ops/policy/packs',
pathMatch: 'full',
},
{
path: 'policy',
redirectTo: '/ops/policy/governance',
pathMatch: 'full',
},
{
path: 'policy/packs',
redirectTo: '/ops/policy/packs',
pathMatch: 'full',
},
{
path: 'policy/packs/:packId',
redirectTo: '/ops/policy/packs/:packId',
pathMatch: 'full',
},
{
path: 'policy/governance',
redirectTo: '/ops/policy/governance',
pathMatch: 'full',
},
{
path: 'policy/governance/:page',
redirectTo: '/ops/policy/governance/:page',
pathMatch: 'full',
},
{
path: 'policy/governance/:page/:child',
redirectTo: '/ops/policy/governance/:page/:child',
pathMatch: 'full',
},
{
path: 'policy/baselines',
redirectTo: '/ops/policy/overview',
pathMatch: 'full',
},
{
path: 'policy/gates',
redirectTo: '/ops/policy/gates/catalog',
pathMatch: 'full',
},
{
path: 'policy/gates/simulate/:promotionId',
redirectTo: '/ops/policy/gates/simulate/:promotionId',
pathMatch: 'full',
},
{
path: 'policy/simulation',
redirectTo: '/ops/policy/simulation',
pathMatch: 'full',
},
{
path: 'policy/simulation/:page',
redirectTo: '/ops/policy/simulation/:page',
pathMatch: 'full',
},
{
path: 'policy/simulation/diff/:policyPackId',
redirectTo: '/ops/policy/simulation/diff/:policyPackId',
pathMatch: 'full',
},
{
path: 'policy/waivers',
redirectTo: '/ops/policy/vex/exceptions',
pathMatch: 'full',
},
{
path: 'policy/exceptions',
redirectTo: '/ops/policy/vex/exceptions',
pathMatch: 'full',
},
{
path: 'policy/exceptions/:id',
redirectTo: '/ops/policy/vex/exceptions/:id',
pathMatch: 'full',
},
{
path: 'vex-hub',
redirectTo: '/ops/policy/vex',
pathMatch: 'full',
},
{
path: 'vex-hub/:page',
redirectTo: '/ops/policy/vex/:page',
pathMatch: 'full',
},
{
path: 'vex-hub/search/detail/:id',
redirectTo: '/ops/policy/vex/search/detail/:id',
pathMatch: 'full',
},
{
path: 'admin/vex-hub',
redirectTo: '/ops/policy/vex',
pathMatch: 'full',
},
{
path: 'admin/vex-hub/:page',
redirectTo: '/ops/policy/vex/:page',
pathMatch: 'full',
},
{
path: 'admin/policy/governance',
redirectTo: '/ops/policy/governance',
pathMatch: 'full',
},
{
path: 'admin/policy/governance/:page',
redirectTo: '/ops/policy/governance/:page',
pathMatch: 'full',
},
{
path: 'admin/policy/governance/:page/:child',
redirectTo: '/ops/policy/governance/:page/:child',
pathMatch: 'full',
},
{
path: 'admin/policy/simulation',
redirectTo: '/ops/policy/simulation',
pathMatch: 'full',
},
{
path: 'admin/policy/simulation/:page',
redirectTo: '/ops/policy/simulation/:page',
pathMatch: 'full',
},
{
path: 'ops/health',
redirectTo: '/ops/operations/health-slo',

View File

@@ -23,46 +23,13 @@ export const OPS_ROUTES: Routes = [
import('../features/integration-hub/integration-hub.routes').then((m) => m.integrationHubRoutes),
},
// Standalone policy views (outside governance tabs, must be listed before 'policy' catch-all)
{
path: 'policy/baselines',
title: 'Baselines',
data: { breadcrumb: 'Baselines' },
loadComponent: () =>
import('../features/policy/policy-studio.component').then((m) => m.PolicyStudioComponent),
},
{
path: 'policy/gates',
title: 'Gate Catalog',
data: { breadcrumb: 'Gate Catalog' },
loadChildren: () =>
import('../features/policy-gates/policy-gates.routes').then((m) => m.POLICY_GATES_ROUTES),
},
{
path: 'policy/simulation',
title: 'Simulation',
data: { breadcrumb: 'Simulation' },
loadChildren: () =>
import('../features/policy-simulation/policy-simulation.routes').then(
(m) => m.policySimulationRoutes,
),
},
{
path: 'policy/waivers',
title: 'Waivers / Exceptions',
data: { breadcrumb: 'Waivers' },
loadComponent: () =>
import('../features/security/exceptions-page.component').then(
(m) => m.ExceptionsPageComponent,
),
},
// Policy Governance tabbed layout (catches /ops/policy and /ops/policy/<tab>)
{
path: 'policy',
title: 'Policy',
title: 'Policy Decisioning',
data: { breadcrumb: 'Policy' },
loadChildren: () =>
import('../features/policy-governance/policy-governance.routes').then(
(m) => m.policyGovernanceRoutes,
import('../features/policy-decisioning/policy-decisioning.routes').then(
(m) => m.policyDecisioningRoutes,
),
},
{

View File

@@ -15,6 +15,30 @@ function redirectToTriageWorkspace(path: string) {
};
}
function redirectToDecisioning(path: string) {
return ({
params,
queryParams,
fragment,
}: {
params: Record<string, string>;
queryParams: Record<string, string>;
fragment?: string | null;
}) => {
const router = inject(Router);
let targetPath = path;
for (const [name, value] of Object.entries(params ?? {})) {
targetPath = targetPath.replaceAll(`:${name}`, encodeURIComponent(value));
}
const target = router.parseUrl(targetPath);
target.queryParams = { ...queryParams };
target.fragment = fragment ?? null;
return target;
};
}
export const SECURITY_RISK_ROUTES: Routes = [
{
path: '',
@@ -170,40 +194,47 @@ export const SECURITY_RISK_ROUTES: Routes = [
loadComponent: () =>
import('../features/analytics/sbom-lake-page.component').then((m) => m.SbomLakePageComponent),
},
{
path: 'vex/search/detail/:id',
title: 'VEX Hub',
data: { breadcrumb: 'VEX Hub' },
redirectTo: redirectToDecisioning('/ops/policy/vex/search/detail/:id'),
pathMatch: 'full',
},
{
path: 'vex',
title: 'VEX Hub',
data: { breadcrumb: 'VEX Hub' },
loadChildren: () => import('../features/vex-hub/vex-hub.routes').then((m) => m.vexHubRoutes),
redirectTo: redirectToDecisioning('/ops/policy/vex'),
pathMatch: 'full',
},
{
path: 'vex/:page',
title: 'VEX Hub',
data: { breadcrumb: 'VEX Hub' },
loadChildren: () => import('../features/vex-hub/vex-hub.routes').then((m) => m.vexHubRoutes),
redirectTo: redirectToDecisioning('/ops/policy/vex/:page'),
pathMatch: 'full',
},
{
path: 'exceptions',
title: 'Exceptions',
data: { breadcrumb: 'Exceptions' },
loadComponent: () =>
import('../features/exceptions/exception-dashboard.component').then((m) => m.ExceptionDashboardComponent),
redirectTo: redirectToDecisioning('/ops/policy/vex/exceptions'),
pathMatch: 'full',
},
{
path: 'exceptions/approvals',
title: 'Exception Approvals',
data: { breadcrumb: 'Exception Approvals' },
loadComponent: () =>
import('../features/exceptions/exception-approval-queue.component').then(
(m) => m.ExceptionApprovalQueueComponent
),
redirectTo: redirectToDecisioning('/ops/policy/vex/exceptions/approvals'),
pathMatch: 'full',
},
{
path: 'exceptions/:exceptionId',
title: 'Exception Detail',
data: { breadcrumb: 'Exception Detail' },
loadComponent: () =>
import('../features/exceptions/exception-dashboard.component').then((m) => m.ExceptionDashboardComponent),
redirectTo: redirectToDecisioning('/ops/policy/vex/exceptions/:exceptionId'),
pathMatch: 'full',
},
{
path: 'lineage',

View File

@@ -54,10 +54,10 @@ describe('ADMINISTRATION_ROUTES (administration)', () => {
expect(route?.data?.['breadcrumb']).toBe('Tenant & Branding');
});
it('policy-governance route is under Administration (has loadChildren)', () => {
it('policy-governance route is preserved as an Administration alias into decisioning', () => {
const route = ADMINISTRATION_ROUTES.find((r) => r.path === 'policy-governance');
expect(route).toBeDefined();
expect(route?.loadChildren).toBeTruthy();
expect(typeof route?.redirectTo).toBe('function');
});
it('policy-governance breadcrumb is canonical (no Release Control ownership)', () => {

View File

@@ -23,8 +23,8 @@ describe('normalizeSearchActionRoute', () => {
expect(normalizeSearchActionRoute('/triage/findings/abc-123')).toBe('/security/findings/abc-123');
});
it('maps vex hub routes into advisories page query', () => {
expect(normalizeSearchActionRoute('/vex-hub/CVE-2024-21626')).toBe('/security/advisories-vex?q=CVE-2024-21626');
it('maps vex hub routes into decisioning search context', () => {
expect(normalizeSearchActionRoute('/vex-hub/CVE-2024-21626')).toBe('/ops/policy/vex/search?cveId=CVE-2024-21626');
});
it('maps proof-chain routes into evidence proofs query', () => {

View File

@@ -19,6 +19,18 @@ describe('Legacy redirect policy', () => {
path: 'triage/findings/:findingId',
redirectTo: '/security/findings/:findingId',
}),
jasmine.objectContaining({
path: 'policy-studio/dashboard',
redirectTo: '/ops/policy/overview',
}),
jasmine.objectContaining({
path: 'policy/packs',
redirectTo: '/ops/policy/packs',
}),
jasmine.objectContaining({
path: 'admin/policy/governance',
redirectTo: '/ops/policy/governance',
}),
]),
);
});

View File

@@ -0,0 +1,74 @@
import { policyDecisioningRoutes } from '../../app/features/policy-decisioning/policy-decisioning.routes';
describe('policyDecisioningRoutes', () => {
const root = policyDecisioningRoutes[0];
const children = root.children ?? [];
it('publishes the canonical primary tabs under /ops/policy', () => {
expect(root.path).toBe('');
expect(children.map((route) => route.path)).toEqual(
jasmine.arrayContaining([
'overview',
'packs',
'governance',
'simulation',
'vex',
'gates',
'audit',
]),
);
});
it('keeps pack authoring subviews inside the packs shell', () => {
const packsRoute = children.find((route) => route.path === 'packs');
const packPaths = packsRoute?.children?.map((route) => route.path) ?? [];
expect(packPaths).toEqual(
jasmine.arrayContaining([
'',
':packId',
':packId/dashboard',
':packId/edit',
':packId/editor',
':packId/rules',
':packId/yaml',
':packId/approvals',
':packId/simulate',
':packId/explain/:runId',
]),
);
});
it('keeps mutable VEX, exceptions, gates, and audit under the same tree', () => {
const vexRoute = children.find((route) => route.path === 'vex');
const auditRoute = children.find((route) => route.path === 'audit');
expect(vexRoute?.children?.map((route) => route.path)).toEqual(
jasmine.arrayContaining([
'',
'search',
'search/detail/:id',
'create',
'stats',
'consensus',
'explorer',
'conflicts',
'exceptions',
'exceptions/approvals',
'exceptions/:exceptionId',
]),
);
expect(children.map((route) => route.path)).toEqual(
jasmine.arrayContaining([
'gates/catalog',
'gates/simulate/:promotionId',
'gates/environments/:environment',
'gates/releases/:releaseId',
'gates/approvals/:approvalId',
]),
);
expect(auditRoute?.children?.map((route) => route.path)).toEqual(
jasmine.arrayContaining(['', 'policy', 'vex', 'log', 'log/events']),
);
});
});

View File

@@ -0,0 +1,117 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter, Router } from '@angular/router';
import { PolicyDecisioningShellComponent } from '../../app/features/policy-decisioning/policy-decisioning-shell.component';
describe('PolicyDecisioningShellComponent', () => {
let router: Router;
let currentUrl = '/ops/policy/overview';
let queryParams: Record<string, string> = {};
let childParams: Array<Record<string, string>> = [];
const routeStub = {
get snapshot() {
return {
root: buildSnapshot(queryParams, childParams),
};
},
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PolicyDecisioningShellComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: routeStub,
},
],
}).compileComponents();
router = TestBed.inject(Router);
Object.defineProperty(router, 'url', {
configurable: true,
get: () => currentUrl,
});
});
function createShell(
url: string,
nextQueryParams: Record<string, string> = {},
nextChildParams: Array<Record<string, string>> = [],
): ComponentFixture<PolicyDecisioningShellComponent> {
currentUrl = url;
queryParams = nextQueryParams;
childParams = nextChildParams;
const fixture = TestBed.createComponent(PolicyDecisioningShellComponent);
fixture.detectChanges();
return fixture;
}
it('renders the canonical shell tabs in global mode', () => {
const fixture = createShell('/ops/policy/overview');
const component = fixture.componentInstance;
const text = fixture.nativeElement.textContent as string;
expect(component.headerTitle()).toBe('Policy Decisioning Studio');
expect(component.shellState().kind).toBe('global');
expect(component.shellState().activeTab).toBe('overview');
expect(component.primaryTabs().map((tab) => tab.id)).toEqual([
'overview',
'packs',
'governance',
'simulation',
'vex',
'gates',
'audit',
]);
expect(text).toContain('Policy Decisioning Studio');
expect(text).toContain('Overview');
expect(text).toContain('VEX & Exceptions');
});
it('keeps release context and return navigation inside the shared shell', () => {
const fixture = createShell(
'/ops/policy/gates/releases/rel-42',
{
releaseId: 'rel-42',
environment: 'prod-eu',
artifact: 'sha256:abc123',
returnTo: '/releases/rel-42',
},
[{ releaseId: 'rel-42' }],
);
const component = fixture.componentInstance;
const navigateByUrlSpy = spyOn(router, 'navigateByUrl').and.returnValue(Promise.resolve(true));
expect(component.headerTitle()).toBe('Release rel-42 Decisioning');
expect(component.shellState().kind).toBe('release');
expect(component.shellState().activeTab).toBe('gates');
expect(component.headerChips()).toEqual([
'Release rel-42',
'Env prod-eu',
'Artifact sha256:abc123',
]);
component.returnToSource();
expect(navigateByUrlSpy).toHaveBeenCalledWith('/releases/rel-42');
});
});
function buildSnapshot(
rootQueryParams: Record<string, string>,
nestedParams: Array<Record<string, string>>,
): { params: Record<string, string>; queryParams: Record<string, string>; children: any[] } {
return {
params: {},
queryParams: rootQueryParams,
children: nestedParams.map((params) => ({
params,
children: [],
})),
};
}

View File

@@ -0,0 +1,135 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router';
import { EvidenceStore } from '../../app/features/release-orchestrator/evidence/evidence.store';
import { EvidenceDetailComponent } from '../../app/features/release-orchestrator/evidence/evidence-detail/evidence-detail.component';
describe('EvidenceDetailComponent (release orchestrator)', () => {
let fixture: ComponentFixture<EvidenceDetailComponent>;
let component: EvidenceDetailComponent;
let router: Router;
let loadPacketSpy: jasmine.Spy;
beforeEach(async () => {
const packet = {
id: 'packet-42',
deploymentId: 'dep-42',
releaseId: 'rel-42',
releaseName: 'Checkout Hotfix',
releaseVersion: '2.1.0',
environmentId: 'prod-eu',
environmentName: 'prod-eu',
status: 'complete',
signatureStatus: 'valid',
contentHash: 'sha256:deadbeef',
signedAt: null,
signedBy: null,
createdAt: '2026-03-07T10:00:00Z',
size: 1024,
contentTypes: ['json'],
content: {
metadata: {
deploymentId: 'dep-42',
releaseId: 'rel-42',
environmentId: 'prod-eu',
startedAt: '2026-03-07T09:55:00Z',
completedAt: '2026-03-07T10:00:00Z',
initiatedBy: 'casey',
outcome: 'success',
},
release: {
name: 'Checkout Hotfix',
version: '2.1.0',
components: [],
},
workflow: {
id: 'wf-42',
name: 'Release Pipeline',
version: 4,
stepsExecuted: 7,
stepsFailed: 0,
},
targets: [],
approvals: [],
gateResults: [],
artifacts: [
{
name: 'bundle',
type: 'image',
digest: 'sha256:feedface',
size: 128,
},
],
},
signature: null,
verificationResult: null,
};
loadPacketSpy = jasmine.createSpy('loadPacket');
await TestBed.configureTestingModule({
imports: [EvidenceDetailComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: convertToParamMap({ id: 'packet-42' }),
},
},
},
{
provide: EvidenceStore,
useValue: {
loading: signal(false),
verifying: signal(false),
selectedPacket: signal(packet),
packetContent: signal(packet.content),
packetSignature: signal(packet.signature),
verificationResult: signal(packet.verificationResult),
loadPacket: loadPacketSpy,
clearSelection: jasmine.createSpy('clearSelection'),
verifyEvidence: jasmine.createSpy('verifyEvidence'),
exportEvidence: jasmine.createSpy('exportEvidence'),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(EvidenceDetailComponent);
component = fixture.componentInstance;
router = TestBed.inject(Router);
fixture.detectChanges();
});
it('loads the requested packet and renders the decisioning action', () => {
const text = fixture.nativeElement.textContent as string;
expect(loadPacketSpy).toHaveBeenCalledWith('packet-42');
expect(text).toContain('Policy Decisioning');
expect(text).toContain('Checkout Hotfix 2.1.0');
});
it('deep-links release-context decisioning with evidence context preserved', () => {
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
component.openDecisioningStudio();
expect(navigateSpy).toHaveBeenCalledWith(
['/ops/policy/gates/releases', 'rel-42'],
jasmine.objectContaining({
queryParams: jasmine.objectContaining({
releaseId: 'rel-42',
environment: 'prod-eu',
artifact: 'sha256:feedface',
evidenceId: 'packet-42',
}),
}),
);
const queryParams = navigateSpy.calls.mostRecent().args[1]?.queryParams;
expect(queryParams.returnTo).toContain('/release-orchestrator/evidence/packet-42');
});
});

View File

@@ -1,11 +1,12 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router';
import { MockWorkflowClient, WORKFLOW_API } from '../../app/core/api/workflow.client';
import { STEP_TYPES } from '../../app/core/api/workflow.models';
import { WorkflowEditorComponent } from '../../app/features/release-orchestrator/workflows/workflow-editor/workflow-editor.component';
describe('visual-workflow-editor behavior', () => {
let router: Router;
const routeState = {
workflowId: 'wf-001',
query: {} as Record<string, string>,
@@ -38,6 +39,8 @@ describe('visual-workflow-editor behavior', () => {
},
],
}).compileComponents();
router = TestBed.inject(Router);
});
async function createEditor(query: Record<string, string> = {}): Promise<ComponentFixture<WorkflowEditorComponent>> {
@@ -143,4 +146,24 @@ describe('visual-workflow-editor behavior', () => {
expect(updated?.dependencies).toContain('step-7');
expect(component.store.validationErrors().join(' ')).not.toContain('Dependency validation');
});
it('deep-links the shared decisioning shell from workflow context', async () => {
const fixture = await createEditor();
const component = fixture.componentInstance;
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
component.openDecisioningStudio();
expect(navigateSpy).toHaveBeenCalledWith(
['/ops/policy/gates'],
jasmine.objectContaining({
queryParams: jasmine.objectContaining({
workflowId: 'wf-001',
}),
}),
);
const queryParams = navigateSpy.calls.mostRecent().args[1]?.queryParams;
expect(queryParams.returnTo).toContain('/release-orchestrator/workflows/wf-001');
});
});

View File

@@ -61,6 +61,10 @@ describe('Legacy Route Migration Framework (routes)', () => {
const testRoutes: Routes = [
...LEGACY_REDIRECT_ROUTES,
{ path: 'platform/ops/health-slo', component: DummyRouteTargetComponent },
{ path: 'ops/policy/overview', component: DummyRouteTargetComponent },
{ path: 'ops/policy/packs', component: DummyRouteTargetComponent },
{ path: 'ops/policy/packs/:packId', component: DummyRouteTargetComponent },
{ path: 'ops/policy/governance/:page', component: DummyRouteTargetComponent },
{ path: 'topology/regions', component: DummyRouteTargetComponent },
{ path: '**', component: DummyRouteTargetComponent },
];
@@ -83,5 +87,20 @@ describe('Legacy Route Migration Framework (routes)', () => {
await router.navigateByUrl('/release-orchestrator/environments');
expect(router.url).toBe('/topology/regions');
});
it('redirects legacy policy studio bookmarks into the decisioning shell', async () => {
await router.navigateByUrl('/policy-studio/dashboard');
expect(router.url).toBe('/ops/policy/overview');
});
it('redirects legacy policy pack bookmarks into the canonical packs shell', async () => {
await router.navigateByUrl('/policy/packs/pack-001');
expect(router.url).toBe('/ops/policy/packs/pack-001');
});
it('redirects admin policy governance aliases into the canonical governance view', async () => {
await router.navigateByUrl('/admin/policy/governance/profiles');
expect(router.url).toBe('/ops/policy/governance/profiles');
});
});
});

View File

@@ -196,19 +196,20 @@ describe('SECURITY_RISK_ROUTES', () => {
expect(getRouteByPath('lineage')?.data?.['breadcrumb']).toBe('Lineage');
});
it('exceptions route loads ExceptionDashboardComponent', async () => {
const component = await loadComponentByPath('exceptions');
expect((component as { name?: string }).name).toContain('ExceptionDashboardComponent');
it('vex route is preserved as a redirect into decisioning', () => {
expect(typeof getRouteByPath('vex')?.redirectTo).toBe('function');
});
it('exceptions detail route loads ExceptionDashboardComponent', async () => {
const component = await loadComponentByPath('exceptions/:exceptionId');
expect((component as { name?: string }).name).toContain('ExceptionDashboardComponent');
it('exceptions route is preserved as a redirect into decisioning', () => {
expect(typeof getRouteByPath('exceptions')?.redirectTo).toBe('function');
});
it('exception approvals route loads ExceptionApprovalQueueComponent', async () => {
const component = await loadComponentByPath('exceptions/approvals');
expect((component as { name?: string }).name).toContain('ExceptionApprovalQueueComponent');
it('exceptions detail route is preserved as a redirect into decisioning', () => {
expect(typeof getRouteByPath('exceptions/:exceptionId')?.redirectTo).toBe('function');
});
it('exception approvals route is preserved as a redirect into decisioning', () => {
expect(typeof getRouteByPath('exceptions/approvals')?.redirectTo).toBe('function');
});
it('reachability witness detail route loads WitnessPageComponent', async () => {

View File

@@ -72,4 +72,13 @@ describe('security-overview-dashboard behavior', () => {
expect(navigateSpy).toHaveBeenCalledWith('/ops/scanner');
});
it('routes VEX and exceptions shortcuts into policy decisioning', () => {
const host = fixture.nativeElement as HTMLElement;
const vexLink = host.querySelector('a[href="/ops/policy/vex"]');
const exceptionsLink = host.querySelector('a[href="/ops/policy/vex/exceptions"]');
expect(vexLink?.textContent).toContain('Manage VEX');
expect(exceptionsLink?.textContent).toContain('Manage');
});
});

View File

@@ -0,0 +1,232 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import type { StubAuthSession } from '../../src/app/testing/auth-fixtures';
const adminSession: StubAuthSession = {
subjectId: 'policy-e2e-user',
tenant: 'tenant-default',
scopes: [
'admin',
'ui.read',
'ui.admin',
'release:read',
'policy:read',
'policy:author',
'policy:review',
'policy:approve',
'policy:simulate',
'policy:audit',
'vex:read',
'vex:write',
'vex:export',
'exception:read',
'exception:approve',
'findings:read',
'vuln:view',
'orch:read',
'orch:operate',
],
};
const mockConfig = {
authority: {
issuer: '/authority',
clientId: 'stella-ops-ui',
authorizeEndpoint: '/authority/connect/authorize',
tokenEndpoint: '/authority/connect/token',
logoutEndpoint: '/authority/connect/logout',
redirectUri: 'https://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'https://127.0.0.1:4400/',
scope: 'openid profile email ui.read',
audience: '/gateway',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: '/authority',
scanner: '/scanner',
policy: '/policy',
concelier: '/concelier',
attestor: '/attestor',
gateway: '/gateway',
},
quickstartMode: true,
setup: 'complete',
};
const policyPacks = [
{
id: 'pack-001',
name: 'Core Policy Pack',
description: 'Default pack for release gating',
version: '2026.03.07',
status: 'active',
createdAt: '2026-03-01T08:00:00Z',
modifiedAt: '2026-03-07T08:00:00Z',
createdBy: 'ops@example.com',
modifiedBy: 'ops@example.com',
tags: ['release', 'core'],
},
];
const packDashboard = {
runs: [
{
runId: 'run-001',
policyVersion: '2026.03.07',
status: 'completed',
completedAt: '2026-03-07T09:00:00Z',
findingsCount: 5,
changedCount: 2,
},
],
ruleHeatmap: [
{
ruleName: 'reachable-critical',
hitCount: 5,
averageLatencyMs: 14,
},
],
vexWinsByDay: [{ date: '2026-03-07', value: 2 }],
suppressionsByDay: [{ date: '2026-03-07', value: 1 }],
};
async function fulfillJson(route: Route, body: unknown): Promise<void> {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
}
async function navigateClientSide(page: Page, target: string): Promise<void> {
await page.evaluate((url) => {
window.history.pushState({}, '', url);
window.dispatchEvent(new PopStateEvent('popstate', { state: window.history.state }));
}, target);
}
async function setupHarness(page: Page): Promise<void> {
await page.addInitScript((session) => {
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
}, adminSession);
await page.route('**/platform/envsettings.json', (route) => fulfillJson(route, mockConfig));
await page.route('**/config.json', (route) => fulfillJson(route, mockConfig));
await page.route('**/.well-known/openid-configuration', (route) =>
fulfillJson(route, {
issuer: 'https://127.0.0.1:4400/authority',
authorization_endpoint: 'https://127.0.0.1:4400/authority/connect/authorize',
token_endpoint: 'https://127.0.0.1:4400/authority/connect/token',
jwks_uri: 'https://127.0.0.1:4400/authority/.well-known/jwks.json',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
}),
);
await page.route('**/authority/.well-known/jwks.json', (route) => fulfillJson(route, { keys: [] }));
await page.route('**/console/profile**', (route) =>
fulfillJson(route, {
subjectId: adminSession.subjectId,
username: 'policy-e2e',
displayName: 'Policy E2E',
tenant: adminSession.tenant,
roles: ['admin'],
scopes: adminSession.scopes,
}),
);
await page.route('**/console/token/introspect**', (route) =>
fulfillJson(route, {
active: true,
tenant: adminSession.tenant,
subject: adminSession.subjectId,
scopes: adminSession.scopes,
}),
);
await page.route('**/api/v2/context/regions', (route) =>
fulfillJson(route, [{ regionId: 'eu-west', displayName: 'EU West', sortOrder: 1, enabled: true }]),
);
await page.route('**/api/v2/context/environments**', (route) =>
fulfillJson(route, [
{
environmentId: 'prod-eu',
regionId: 'eu-west',
environmentType: 'prod',
displayName: 'Prod EU',
sortOrder: 1,
enabled: true,
},
]),
);
await page.route('**/api/v2/context/preferences', (route) =>
fulfillJson(route, {
tenantId: adminSession.tenant,
actorId: adminSession.subjectId,
regions: ['eu-west'],
environments: ['prod-eu'],
timeWindow: '24h',
stage: 'all',
updatedAt: '2026-03-07T12:00:00Z',
updatedBy: adminSession.subjectId,
}),
);
await page.route('**/doctor/api/v1/doctor/trends**', (route) => fulfillJson(route, []));
await page.route('**/api/v1/approvals**', (route) => fulfillJson(route, []));
await page.route('**/api/policy/packs?**', (route) => fulfillJson(route, policyPacks));
await page.route('**/api/policy/packs', (route) => fulfillJson(route, policyPacks));
await page.route('**/api/policy/packs/pack-001/dashboard**', (route) =>
fulfillJson(route, packDashboard),
);
}
test.beforeEach(async ({ page }) => {
await setupHarness(page);
});
test('renders the canonical global shell under /ops/policy', async ({ page }) => {
await page.goto('/ops/policy/overview', { waitUntil: 'networkidle' });
await expect(page.getByTestId('policy-decisioning-shell')).toBeVisible();
await expect(page.getByTestId('policy-decisioning-overview')).toBeVisible();
await expect(page.getByTestId('policy-tab-overview')).toBeVisible();
await expect(page.getByTestId('policy-tab-vex')).toBeVisible();
await expect(page.getByText('Policy Decisioning Studio')).toBeVisible();
});
test('redirects legacy pack bookmarks into pack-mode decisioning', async ({ page }) => {
await page.goto('/ops/policy/overview', { waitUntil: 'networkidle' });
await navigateClientSide(page, '/policy-studio/packs/pack-001/dashboard');
await expect(page).toHaveURL(/\/ops\/policy\/packs\/pack-001(?:\/dashboard)?$/);
await expect(page.getByTestId('policy-pack-shell')).toBeVisible();
await expect(
page.getByTestId('policy-pack-shell').getByRole('heading', { name: 'Pack pack-001' }),
).toBeVisible();
await expect(page.getByText('Run dashboards')).toBeVisible();
});
test('keeps release-context gate review inside the shared shell', async ({ page }) => {
await page.goto(
'/ops/policy/gates/releases/rel-42?environment=prod-eu&artifact=sha256%3Afeedface&returnTo=%2Freleases%2Frel-42',
{ waitUntil: 'networkidle' },
);
await expect(page.getByTestId('policy-decisioning-shell')).toBeVisible();
await expect(page.getByTestId('policy-gates-page')).toBeVisible();
await expect(page.getByText('Release rel-42 Decisioning')).toBeVisible();
await expect(page.getByText('Env prod-eu')).toBeVisible();
await expect(
page.locator('app-context-header').getByRole('button', { name: 'Return to source' }),
).toBeVisible();
});
test('redirects security VEX aliases into the canonical decisioning shell', async ({ page }) => {
await page.goto('/ops/policy/overview', { waitUntil: 'networkidle' });
await navigateClientSide(page, '/security/vex?cveId=CVE-2024-21626');
await expect(page).toHaveURL(/\/ops\/policy\/vex\?cveId=CVE-2024-21626$/);
await expect(page.getByTestId('policy-vex-shell')).toBeVisible();
await expect(page.getByText('Mutable VEX actions now live in Decisioning Studio')).toBeVisible();
});