Stabilize release confidence approval decision journey

This commit is contained in:
master
2026-03-15 04:04:36 +02:00
parent 4a5185121d
commit 7bdfcd5055
6 changed files with 314 additions and 23 deletions

View File

@@ -0,0 +1,76 @@
# Sprint 20260315_002 - Platform Release Confidence Operator Journey Audit
## Topic & Scope
- Use Stella Ops as a release operator trying to gain confidence in a production promotion decision.
- Drive the release-control journey end to end: mission control to approvals, promotions, deployments, release health, hotfixes, release evidence, and the adjacent security/evidence pivots a release operator actually uses while deciding.
- Treat Playwright as retained evidence only after manual discovery. Every newly discovered release-confidence step or defect becomes retained coverage before the sprint closes.
- Group fixes by root cause so the iteration closes whole release-confidence behavior slices, not isolated page patches.
- Working directory: `.`.
- Expected evidence: operator journey notes, retained Playwright additions, grouped defect analysis, focused regression tests where code changes land, rebuilt-stack retest results, and live journey evidence.
## Dependencies & Concurrency
- Depends on local commit `4a5185121` as the closed baseline from the setup/admin operator journey.
- Safe parallelism: avoid environment resets while the live release-confidence journey is being exercised because releases, approvals, and evidence surfaces share the same stack state.
## Documentation Prerequisites
- `AGENTS.md`
- `docs/INSTALL_GUIDE.md`
- `docs/dev/DEV_ENVIRONMENT_SETUP.md`
- `docs/qa/feature-checks/FLOW.md`
- `docs/modules/platform/architecture-overview.md`
## Delivery Tracker
### PLATFORM-RELEASE-CONFIDENCE-001 - Define and execute the release-confidence operator journey
Status: DONE
Dependency: none
Owners: QA, Product Manager
Task description:
- Act as a release operator preparing to promote or hotfix with confidence. Walk the visible release-control flow the way a user would: entry from mission control, release health, approvals, promotions, deployment detail, version detail, hotfix flow, and evidence hand-offs needed to justify a decision.
Completion criteria:
- [x] The release-confidence operator journey is explicitly listed before fixes begin.
- [x] Playwright is used to execute the journey as an operator would, not only as a route sweep.
- [x] Every broken route, page-load, data-load, hand-off, validation rule, or action encountered on the release path is recorded before any fix starts.
### PLATFORM-RELEASE-CONFIDENCE-002 - Convert newly discovered release steps into retained coverage
Status: DONE
Dependency: PLATFORM-RELEASE-CONFIDENCE-001
Owners: QA, Test Automation
Task description:
- Add or deepen retained Playwright coverage for every newly discovered release-confidence step so future iterations automatically recheck the same operator behavior.
Completion criteria:
- [x] Every newly discovered operator/release step is mapped to retained Playwright coverage or an explicit backlog gap.
- [x] Retained coverage additions are organized by user journey, not only by route.
- [x] The next aggregate run would exercise the newly discovered release-confidence path automatically.
### PLATFORM-RELEASE-CONFIDENCE-003 - Repair grouped release-confidence defects and retest
Status: DONE
Dependency: PLATFORM-RELEASE-CONFIDENCE-002
Owners: 3rd line support, Architect, Developer
Task description:
- Diagnose the grouped failures exposed by the release-confidence journey, choose the clean product/architecture-conformant fix, implement it, add retained Playwright coverage for the new behavior when needed, and rerun the affected journeys plus the aggregate audit before committing.
Completion criteria:
- [x] Root causes are recorded for the grouped failures.
- [x] Fixes land with focused regression coverage and retained Playwright scenario updates where practical.
- [x] The live stack is retested through the same release-confidence journeys before the iteration commit.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-15 | Sprint created immediately after local commit `4a5185121` closed the setup/admin operator journey. | QA |
| 2026-03-15 | Executed the release-confidence operator path from releases overview through deployments, approval detail, decision capsules, triage, advisories/VEX, reachability, security reports, promotions, and hotfix creation before fixing anything. | QA |
| 2026-03-15 | Converted the approval-detail operator handoffs into retained Playwright coverage in `live-release-confidence-journey.mjs`, including the real decision cockpit route and scope-preservation assertions for Reachability, Ops/Data, and gate-fix pivots. | Test Automation |
| 2026-03-15 | Root-caused the release-confidence defect set to the approval detail route loading a legacy placeholder component and the real cockpit dropping active operator scope on handoff links. Fixed the canonical route, centralized handoff query-param preservation in `approval-detail-page.component.ts`, added focused Angular regression coverage, rebuilt the web bundle, redeployed, and reran the live release-confidence journey clean with `failedStepCount=0` and `runtimeIssueCount=0`. | QA / 3rd line support / Architect / Developer |
## Decisions & Risks
- Decision: this iteration prioritizes release-confidence behavior over broad route counts.
- Risk: several release-control surfaces already have route/action sweeps, but the full operator decision journey may still have hand-off gaps that only appear when used sequentially as a real user.
- Decision: operator scope (`tenant`, `regions`, `environments`, `timeWindow`) must survive approval-detail pivots the same way it survives deployment-detail pivots; preserving that scope is part of the release-confidence contract, not optional UI state.
- Root cause: `/releases/approvals/:id` still pointed at a legacy placeholder `approval-detail.component` while the actual decision cockpit lived in `approval-detail-page.component`; once the route was corrected, the retained journey exposed that cockpit handoffs generated canonical paths but discarded current operator scope because plain `routerLink` anchors and gate-fix links were not built from a shared scope-preserving query-param contract.
## Next Checkpoints
- Define the exact release-confidence path before fixing anything.
- Run the journey manually with Playwright, then convert newly discovered steps into retained coverage.

View File

@@ -443,6 +443,76 @@ async function main() {
};
});
await recordStep(page, report, '02b-approval-detail-decision-cockpit', async (issues) => {
const detailUrl = buildScopedUrl('/releases/approvals/apr-001');
const verifyApprovalLink = async (tabName, linkName, expectedPath, options = {}) => {
await page.goto(detailUrl, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page, 2_000);
if (tabName !== 'Overview') {
const tabButton = page.getByRole('button', { name: tabName, exact: true }).first();
if (!(await tabButton.isVisible().catch(() => false))) {
issues.push(`approval detail is missing the "${tabName}" tab`);
return;
}
await clickStable(page, tabButton);
await settle(page, 1_000);
}
if (typeof options.beforeLinkClick === 'function') {
await options.beforeLinkClick();
}
const target = page.getByRole('link', { name: linkName, exact: true }).first();
if (!(await target.isVisible().catch(() => false))) {
issues.push(`approval detail is missing the "${linkName}" handoff on ${tabName}`);
return;
}
await clickStable(page, target);
await page.waitForURL((url) => url.pathname === expectedPath, { timeout: 20_000 }).catch(() => {});
await settle(page, 1_500);
const finalPath = new URL(page.url()).pathname;
if (finalPath !== expectedPath) {
issues.push(`approval detail "${linkName}" landed on ${page.url()} instead of ${expectedPath}`);
}
issues.push(...scopeIssues(page.url(), `approval detail ${tabName} -> ${linkName}`));
};
await page.goto(detailUrl, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page, 2_000);
await ensureHeading(page, /approval detail/i, issues, 'approval detail');
const cockpitText = await bodyText(page);
if (/coming soon/i.test(cockpitText) || !/decision case-file|reachability|ops\/data|replay\/verify/i.test(cockpitText)) {
issues.push('approval detail is still rendering placeholder content instead of the decision cockpit');
}
await verifyApprovalLink('Reachability', 'Open Reachability Ingest Health', '/ops/operations/data-integrity/reachability-ingest');
await verifyApprovalLink('Reachability', 'Open Env Detail', '/setup/topology/environments');
await verifyApprovalLink('Ops/Data', 'Open Data Integrity', '/ops/operations/data-integrity');
await verifyApprovalLink('Ops/Data', 'Open Integrations', '/setup/integrations');
await verifyApprovalLink('Gates', 'Trigger SBOM Scan', '/ops/operations/data-integrity/scan-pipeline', {
beforeLinkClick: async () => {
await clickStable(page, page.getByRole('button', { name: 'Gate detail trace', exact: true }).first());
await settle(page, 750);
},
});
return {
detailUrl,
};
});
await recordStep(page, report, '03-decision-capsules-search-and-detail', async (issues) => {
await page.goto(buildScopedUrl('/evidence/capsules'), {
waitUntil: 'domcontentloaded',

View File

@@ -0,0 +1,121 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { ApprovalDetailPageComponent } from './approval-detail-page.component';
import { APPROVALS_ROUTES } from './approvals.routes';
import { OPERATIONS_PATHS, dataIntegrityPath } from '../platform/ops/operations-paths';
describe('ApprovalDetailPageComponent', () => {
let component: ApprovalDetailPageComponent;
let fixture: ComponentFixture<ApprovalDetailPageComponent>;
const scopeQueryParams = {
tenant: 'demo-prod',
regions: 'eu-west',
environments: 'prod',
timeWindow: '24h',
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ApprovalDetailPageComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
params: of({ id: 'apr-001' }),
queryParamMap: of(convertToParamMap(scopeQueryParams)),
snapshot: {
paramMap: convertToParamMap({ id: 'apr-001' }),
queryParamMap: convertToParamMap(scopeQueryParams),
queryParams: scopeQueryParams,
},
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(ApprovalDetailPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('loads the decision cockpit for approval detail routes', async () => {
const detailRoute = APPROVALS_ROUTES.find((route) => route.path === ':id');
expect(await detailRoute?.loadComponent?.()).toBe(ApprovalDetailPageComponent);
});
it('uses canonical reachability handoff routes', () => {
component.setActiveTab('reachability');
fixture.detectChanges();
const hrefs = Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll('.footer-links a'),
).map((anchor) => (anchor as HTMLAnchorElement).getAttribute('href'));
expect(hrefs.some((href) => href?.startsWith(dataIntegrityPath('reachability-ingest')))).toBeTrue();
expect(hrefs.some((href) => href?.startsWith('/setup/topology/environments'))).toBeTrue();
});
it('uses canonical ops/data handoff routes', () => {
component.setActiveTab('ops-data');
fixture.detectChanges();
const hrefs = Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll('.footer-links a'),
).map((anchor) => (anchor as HTMLAnchorElement).getAttribute('href'));
expect(hrefs.some((href) => href?.startsWith(OPERATIONS_PATHS.dataIntegrity))).toBeTrue();
expect(hrefs.some((href) => href?.startsWith('/setup/integrations'))).toBeTrue();
expect(hrefs.some((href) => href?.startsWith(OPERATIONS_PATHS.schedulerRuns))).toBeTrue();
expect(hrefs.some((href) => href?.startsWith(OPERATIONS_PATHS.deadLetter))).toBeTrue();
});
it('uses canonical gate fix links for reachability blockers', () => {
const reachabilityGate = component.gateTraceRows.find((row) => row.id === 'reachability');
expect(reachabilityGate?.fixLinks.map((link) => link.route)).toEqual([
dataIntegrityPath('scan-pipeline'),
'/security/findings',
'/ops/policy/vex/exceptions',
OPERATIONS_PATHS.dataIntegrity,
]);
});
it('preserves operator scope in decision context query params', () => {
expect(component.decisioningContextParams({ create: '1' })).toEqual(
jasmine.objectContaining({
tenant: 'demo-prod',
regions: 'eu-west',
environments: 'prod',
timeWindow: '24h',
approvalId: 'apr-001',
create: '1',
}),
);
});
it('preserves operator scope in approval handoff links', () => {
component.setActiveTab('ops-data');
fixture.detectChanges();
const hrefs = Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll('.footer-links a'),
).map((anchor) => (anchor as HTMLAnchorElement).getAttribute('href') ?? '');
const dataIntegrityHref = hrefs.find((href) => href.startsWith(OPERATIONS_PATHS.dataIntegrity));
const integrationsHref = hrefs.find((href) => href.startsWith('/setup/integrations'));
expect(dataIntegrityHref).toContain('tenant=demo-prod');
expect(dataIntegrityHref).toContain('regions=eu-west');
expect(dataIntegrityHref).toContain('environments=prod');
expect(dataIntegrityHref).toContain('timeWindow=24h');
expect(integrationsHref).toContain('tenant=demo-prod');
expect(integrationsHref).toContain('regions=eu-west');
expect(integrationsHref).toContain('environments=prod');
expect(integrationsHref).toContain('timeWindow=24h');
});
});

View File

@@ -1,10 +1,10 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ActivatedRoute, ParamMap, Router, RouterLink } from '@angular/router';
import { buildContextReturnTo } from '../../shared/ui/context-route-state/context-route-state';
import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
import { OPERATIONS_PATHS, dataIntegrityPath } from '../platform/ops/operations-paths';
type GateResult = 'PASS' | 'WARN' | 'BLOCK';
type HealthStatus = 'OK' | 'WARN' | 'FAIL';
@@ -97,7 +97,7 @@ interface HistoryEvent {
template: `
<div class="approval-detail-v2">
<header class="decision-header">
<a routerLink="/releases/approvals" class="back-link">Back to Approvals</a>
<a routerLink="/releases/approvals" [queryParams]="scopeQueryParams()" class="back-link">Back to Approvals</a>
<div class="decision-header__title-row">
<h1>Approval Detail</h1>
@@ -232,7 +232,7 @@ interface HistoryEvent {
@if (row.result === 'BLOCK') {
<div class="fix-links">
@for (link of row.fixLinks; track link.label) {
<a [routerLink]="link.route" [queryParams]="link.queryParams || null">{{ link.label }}</a>
<a [routerLink]="link.route" [queryParams]="handoffQueryParams(link.queryParams)">{{ link.label }}</a>
}
</div>
}
@@ -298,7 +298,7 @@ interface HistoryEvent {
</table>
<div class="footer-links">
<a routerLink="/security/findings">Open Findings (filtered)</a>
<a routerLink="/security/findings" [queryParams]="scopeQueryParams()">Open Findings (filtered)</a>
<a routerLink="/ops/policy/vex" [queryParams]="decisioningContextParams()">Open VEX Hub</a>
<a routerLink="/ops/policy/vex/exceptions" [queryParams]="decisioningContextParams()">Open Exceptions</a>
</div>
@@ -339,8 +339,8 @@ interface HistoryEvent {
</table>
<div class="footer-links">
<a routerLink="/platform-ops/data-integrity/reachability-ingest">Open Reachability Ingest Health</a>
<a routerLink="/topology/environments">Open Env Detail</a>
<a [routerLink]="dataIntegrityPath('reachability-ingest')" [queryParams]="scopeQueryParams()">Open Reachability Ingest Health</a>
<a [routerLink]="topologyEnvironmentsRoute" [queryParams]="scopeQueryParams()">Open Env Detail</a>
</div>
</section>
}
@@ -398,10 +398,10 @@ interface HistoryEvent {
</div>
<div class="footer-links">
<a routerLink="/platform-ops/data-integrity">Open Data Integrity</a>
<a routerLink="/integrations">Open Integrations</a>
<a [routerLink]="OPERATIONS_PATHS.schedulerRuns">Open Scheduler Runs</a>
<a [routerLink]="OPERATIONS_PATHS.deadLetter">Open DLQ</a>
<a [routerLink]="OPERATIONS_PATHS.dataIntegrity" [queryParams]="scopeQueryParams()">Open Data Integrity</a>
<a [routerLink]="integrationsRoute" [queryParams]="scopeQueryParams()">Open Integrations</a>
<a [routerLink]="OPERATIONS_PATHS.schedulerRuns" [queryParams]="scopeQueryParams()">Open Scheduler Runs</a>
<a [routerLink]="OPERATIONS_PATHS.deadLetter" [queryParams]="scopeQueryParams()">Open DLQ</a>
</div>
</section>
}
@@ -420,8 +420,8 @@ interface HistoryEvent {
<p>Signature status: DSSE signed, transparency log anchored, replay metadata present.</p>
<div class="footer-links">
<button type="button" class="link-btn" (click)="exportPacket()">Export Packet</button>
<a routerLink="/evidence/exports">Open Export Center</a>
<a routerLink="/evidence/capsules">Open Proof Chain</a>
<a routerLink="/evidence/exports" [queryParams]="scopeQueryParams()">Open Export Center</a>
<a routerLink="/evidence/capsules" [queryParams]="scopeQueryParams()">Open Proof Chain</a>
</div>
</section>
}
@@ -447,7 +447,7 @@ interface HistoryEvent {
</ul>
<div class="footer-links">
<a routerLink="/evidence/verify-replay">Open canonical Replay/Verify</a>
<a routerLink="/evidence/verify-replay" [queryParams]="scopeQueryParams()">Open canonical Replay/Verify</a>
</div>
</section>
}
@@ -788,12 +788,16 @@ interface HistoryEvent {
})
export class ApprovalDetailPageComponent implements OnInit {
protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS;
protected readonly dataIntegrityPath = dataIntegrityPath;
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
protected readonly integrationsRoute = '/setup/integrations';
protected readonly topologyEnvironmentsRoute = '/setup/topology/environments';
readonly minDecisionReasonLength = 10;
readonly activeTab = signal<ApprovalTabId>('overview');
readonly expandedGateId = signal<string | null>(null);
readonly scopeQueryParams = signal<Record<string, string>>({});
readonly approval = signal<ApprovalDetailState>({
id: 'apr-001',
@@ -846,14 +850,14 @@ export class ApprovalDetailPageComponent implements OnInit {
timestamp: '2026-02-19T18:11:00Z',
evidenceAge: '2h 11m',
fixLinks: [
{ label: 'Trigger SBOM Scan', route: '/platform-ops/data-integrity/scan-pipeline' },
{ label: 'Trigger SBOM Scan', route: dataIntegrityPath('scan-pipeline') },
{ label: 'Open Finding', route: '/security/findings' },
{
label: 'Request Exception',
route: '/ops/policy/vex/exceptions',
queryParams: this.decisioningContextParams({ create: '1' }),
},
{ label: 'Open Data Integrity', route: '/platform-ops/data-integrity' },
{ label: 'Open Data Integrity', route: OPERATIONS_PATHS.dataIntegrity },
],
},
{
@@ -983,6 +987,10 @@ export class ApprovalDetailPageComponent implements OnInit {
this.approval.update((state) => ({ ...state, id }));
}
});
this.route.queryParamMap.subscribe((queryParamMap) => {
this.scopeQueryParams.set(this.mapQueryParams(queryParamMap));
});
}
setActiveTab(tab: ApprovalTabId): void {
@@ -1050,17 +1058,34 @@ export class ApprovalDetailPageComponent implements OnInit {
}));
}
handoffQueryParams(extra: Record<string, string> = {}): Record<string, string> {
return {
...this.scopeQueryParams(),
...extra,
};
}
decisioningContextParams(extra: Record<string, string> = {}): Record<string, string> {
const approval = this.approval();
const returnTo = buildContextReturnTo(this.router, ['/releases/approvals', approval.id]);
return {
return this.handoffQueryParams({
approvalId: approval.id,
releaseId: approval.bundleVersion,
environment: approval.targetEnvironment,
artifact: approval.bundleDigest,
returnTo,
...extra,
};
});
}
private mapQueryParams(queryParamMap: ParamMap): Record<string, string> {
return queryParamMap.keys.reduce<Record<string, string>>((params, key) => {
const value = queryParamMap.get(key);
if (value !== null && value.length > 0) {
params[key] = value;
}
return params;
}, {});
}
}

View File

@@ -27,16 +27,14 @@ export const APPROVALS_ROUTES: Routes = [
// A6-02 through A6-05 — Decision cockpit
{
path: ':id',
title: 'Approval Decision',
title: 'Approval Detail',
data: {
breadcrumb: 'Approval Decision',
breadcrumb: 'Approval Detail',
// Available tabs in the decision cockpit:
// overview | gates | security | reachability | ops-data | evidence | replay | history
decisionTabs: ['overview', 'gates', 'security', 'reachability', 'ops-data', 'evidence', 'replay', 'history'],
},
loadComponent: () =>
import('../release-orchestrator/approvals/approval-detail/approval-detail.component').then(
(m) => m.ApprovalDetailComponent
),
import('./approval-detail-page.component').then((m) => m.ApprovalDetailPageComponent),
},
];

View File

@@ -13,6 +13,7 @@
"src/app/core/auth/tenant-activation.service.spec.ts",
"src/app/core/console/console-status.service.spec.ts",
"src/app/features/change-trace/change-trace-viewer.component.spec.ts",
"src/app/features/approvals/approval-detail-page.component.spec.ts",
"src/app/features/admin-notifications/components/notification-rule-list.component.spec.ts",
"src/app/features/admin-notifications/components/notification-dashboard.component.spec.ts",
"src/app/features/admin-notifications/components/channel-management.component.spec.ts",