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

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