save progress

This commit is contained in:
StellaOps Bot
2025-12-28 01:40:35 +02:00
parent 3bfbbae115
commit cec4265a40
694 changed files with 88052 additions and 24718 deletions

View File

@@ -0,0 +1,268 @@
# Sprint: Diff-First Default View
| Field | Value |
|-------|-------|
| **Sprint ID** | SPRINT_1227_0005_0001 |
| **Batch** | 001 - Quick Win |
| **Module** | FE (Frontend) |
| **Topic** | Diff-first default view toggle |
| **Priority** | P0 - UX Improvement |
| **Estimated Effort** | Very Low |
| **Dependencies** | None (CompareView exists) |
| **Working Directory** | `src/Web/StellaOps.Web/src/app/features/` |
---
## Objective
Make the comparison (diff) view the default when navigating to findings, with easy toggle to detail view:
1. Default to diff view showing changes between scans
2. Remember user preference in local storage
3. Highlight material changes using existing SmartDiff rules
4. Preserve existing detail view as alternative
---
## Background
### Current State
- `CompareViewComponent` fully implemented with 3-pane layout
- `FindingsListComponent` is current default view
- SmartDiff with R1-R4 detection rules operational
- No user preference persistence for view mode
### Target State
- Diff view as default on findings navigation
- User toggle persisted in local storage
- URL parameter override (`?view=detail` or `?view=diff`)
- SmartDiff badges prominently displayed
---
## Deliverables
### D1: View Toggle Service
**File:** `src/Web/StellaOps.Web/src/app/core/services/view-preference.service.ts`
```typescript
@Injectable({ providedIn: 'root' })
export class ViewPreferenceService {
private readonly STORAGE_KEY = 'stellaops.findings.defaultView';
private readonly DEFAULT_VIEW: ViewMode = 'diff';
private viewMode$ = new BehaviorSubject<ViewMode>(this.loadPreference());
getViewMode(): Observable<ViewMode> {
return this.viewMode$.asObservable();
}
setViewMode(mode: ViewMode): void {
localStorage.setItem(this.STORAGE_KEY, mode);
this.viewMode$.next(mode);
}
private loadPreference(): ViewMode {
const stored = localStorage.getItem(this.STORAGE_KEY);
return (stored as ViewMode) || this.DEFAULT_VIEW;
}
}
export type ViewMode = 'diff' | 'detail';
```
### D2: View Toggle Component
**File:** `src/Web/StellaOps.Web/src/app/shared/components/view-toggle/view-toggle.component.ts`
```typescript
@Component({
selector: 'app-view-toggle',
template: `
<mat-button-toggle-group
[value]="currentView()"
(change)="onViewChange($event.value)"
aria-label="View mode">
<mat-button-toggle value="diff">
<mat-icon>compare_arrows</mat-icon>
Diff View
</mat-button-toggle>
<mat-button-toggle value="detail">
<mat-icon>list</mat-icon>
Detail View
</mat-button-toggle>
</mat-button-toggle-group>
`
})
export class ViewToggleComponent {
currentView = signal<ViewMode>('diff');
constructor(private viewPref: ViewPreferenceService) {
this.viewPref.getViewMode().subscribe(mode => this.currentView.set(mode));
}
onViewChange(mode: ViewMode): void {
this.viewPref.setViewMode(mode);
}
}
```
### D3: Findings Container Update
**File:** `src/Web/StellaOps.Web/src/app/features/findings/findings-container.component.ts`
```typescript
@Component({
selector: 'app-findings-container',
template: `
<div class="findings-header">
<h1>Findings</h1>
<app-view-toggle />
</div>
@switch (viewMode()) {
@case ('diff') {
<app-compare-view
[baselineScan]="baselineScan()"
[currentScan]="currentScan()"
[smartDiffResults]="smartDiffResults()" />
}
@case ('detail') {
<app-findings-list
[findings]="currentFindings()"
[filters]="activeFilters()" />
}
}
`
})
export class FindingsContainerComponent {
viewMode = signal<ViewMode>('diff');
constructor(
private viewPref: ViewPreferenceService,
private route: ActivatedRoute
) {
// Check URL override first
const urlView = this.route.snapshot.queryParamMap.get('view');
if (urlView === 'diff' || urlView === 'detail') {
this.viewMode.set(urlView);
} else {
// Fall back to user preference
this.viewPref.getViewMode().subscribe(mode => this.viewMode.set(mode));
}
}
}
```
### D4: SmartDiff Badge Enhancement
**File:** `src/Web/StellaOps.Web/src/app/shared/components/diff-badge/diff-badge.component.ts`
Enhance existing badge to show rule type:
```typescript
@Component({
selector: 'app-diff-badge',
template: `
<span class="diff-badge" [class]="badgeClass()">
<mat-icon>{{ icon() }}</mat-icon>
{{ label() }}
@if (tooltip()) {
<mat-tooltip [matTooltip]="tooltip()" />
}
</span>
`
})
export class DiffBadgeComponent {
@Input() rule!: SmartDiffRule;
icon = computed(() => {
switch (this.rule) {
case 'R1': return 'call_split'; // reachability_flip
case 'R2': return 'swap_horiz'; // vex_flip
case 'R3': return 'trending_up'; // range_boundary
case 'R4': return 'warning'; // intelligence_flip
}
});
label = computed(() => {
switch (this.rule) {
case 'R1': return 'Reachability Changed';
case 'R2': return 'VEX Status Changed';
case 'R3': return 'Version Boundary';
case 'R4': return 'Risk Intelligence';
}
});
}
```
### D5: Route Configuration Update
**File:** `src/Web/StellaOps.Web/src/app/features/findings/findings.routes.ts`
```typescript
export const FINDINGS_ROUTES: Routes = [
{
path: '',
component: FindingsContainerComponent,
children: [
{
path: '',
redirectTo: 'overview',
pathMatch: 'full'
},
{
path: 'overview',
component: FindingsContainerComponent,
data: { defaultView: 'diff' }
}
]
}
];
```
---
## Tasks
| ID | Task | Status | Notes |
|----|------|--------|-------|
| T1 | Create `ViewPreferenceService` | DONE | `core/services/view-preference.service.ts` |
| T2 | Create `ViewToggleComponent` | DONE | `shared/components/findings-view-toggle/` |
| T3 | Create `FindingsContainerComponent` | DONE | `features/findings/container/` |
| T4 | Create `SmartDiffBadgeComponent` | DONE | `shared/components/smart-diff-badge/` |
| T5 | Update route configuration | DONE | Added `/findings` and `/findings/:scanId` |
| T6 | Add URL parameter handling | DONE | `?view=diff\|detail` supported |
| T7 | Write unit tests | DONE | All components tested |
| T8 | Update E2E tests | DONE | `findings-navigation.e2e.spec.ts` |
---
## Acceptance Criteria
1. [x] Diff view loads by default on findings page
2. [x] User can toggle to detail view
3. [x] Preference persists across sessions
4. [x] URL parameter overrides preference
5. [x] SmartDiff badges show change type
6. [x] No performance regression on view switch
7. [x] Keyboard accessible (Enter/Space on toggle)
---
## Telemetry
### Events
- `findings.view.toggle{mode, source}` - View mode changed
- `findings.view.load{mode, url_override}` - Initial view load
---
## Execution Log
| Date | Action | By |
|------|--------|------|
| 2025-12-27 | Sprint created | PM |
| 2025-12-27 | T1: Created ViewPreferenceService with localStorage persistence | Claude |
| 2025-12-27 | T2: Created FindingsViewToggleComponent (Mat button toggle) | Claude |
| 2025-12-27 | T3: Created FindingsContainerComponent with view switching | Claude |
| 2025-12-27 | T4: Created SmartDiffBadgeComponent with R1-R4 rules | Claude |
| 2025-12-27 | T5: Added /findings routes to app.routes.ts | Claude |
| 2025-12-27 | T6: URL parameter ?view=diff\|detail implemented | Claude |
| 2025-12-27 | T7: Unit tests written for all components | Claude |
| 2025-12-28 | T8: Created `findings-navigation.e2e.spec.ts` Playwright tests | Claude |

View File

@@ -0,0 +1,388 @@
# Sprint: Finding Card Proof Tree Integration
| Field | Value |
|-------|-------|
| **Sprint ID** | SPRINT_1227_0005_0002 |
| **Batch** | 002 - Core Value |
| **Module** | FE (Frontend) |
| **Topic** | Proof tree display in finding cards |
| **Priority** | P0 - Core Differentiator |
| **Estimated Effort** | Low |
| **Dependencies** | ProofSpine API available |
| **Working Directory** | `src/Web/StellaOps.Web/src/app/features/findings/` |
---
## Objective
Integrate ProofSpine visualization into finding cards:
1. Display collapsible proof tree showing evidence chain
2. Show ProofBadges (4-axis) at a glance
3. Link each segment to detailed evidence view
4. Highlight cryptographic chain integrity
---
## Background
### Current State
- `ProofSpine` with 6 segment types exists in backend
- `ProofBadges` model with 4 dimensions available
- Finding cards show basic metadata only
- No visual representation of evidence chain
### Target State
- Each finding card has expandable proof tree
- ProofBadges visible without expansion
- Segment drill-down to evidence details
- Chain integrity indicator (all digests valid)
---
## Deliverables
### D1: Proof Tree Component
**File:** `src/Web/StellaOps.Web/src/app/shared/components/proof-tree/proof-tree.component.ts`
```typescript
@Component({
selector: 'app-proof-tree',
template: `
<div class="proof-tree" [class.expanded]="expanded()">
<button class="proof-tree-toggle" (click)="toggle()">
<mat-icon>{{ expanded() ? 'expand_less' : 'expand_more' }}</mat-icon>
<span>Evidence Chain ({{ segments().length }} segments)</span>
<app-chain-integrity-badge [valid]="chainValid()" />
</button>
@if (expanded()) {
<div class="proof-tree-content">
@for (segment of segments(); track segment.segmentDigest) {
<app-proof-segment
[segment]="segment"
[isFirst]="$first"
[isLast]="$last"
(viewDetails)="onViewDetails(segment)" />
}
</div>
}
</div>
`
})
export class ProofTreeComponent {
@Input() proofSpine!: ProofSpine;
@Output() viewSegmentDetails = new EventEmitter<ProofSegment>();
expanded = signal(false);
segments = computed(() => this.proofSpine?.segments ?? []);
chainValid = computed(() => this.validateChain());
toggle(): void {
this.expanded.update(v => !v);
}
private validateChain(): boolean {
const segs = this.segments();
for (let i = 1; i < segs.length; i++) {
if (segs[i].previousSegmentDigest !== segs[i - 1].segmentDigest) {
return false;
}
}
return true;
}
}
```
### D2: Proof Segment Component
**File:** `src/Web/StellaOps.Web/src/app/shared/components/proof-tree/proof-segment.component.ts`
```typescript
@Component({
selector: 'app-proof-segment',
template: `
<div class="proof-segment" [class.first]="isFirst" [class.last]="isLast">
<div class="segment-connector">
@if (!isFirst) {
<div class="connector-line"></div>
}
<div class="segment-icon" [class]="segmentTypeClass()">
<mat-icon>{{ segmentIcon() }}</mat-icon>
</div>
@if (!isLast) {
<div class="connector-line"></div>
}
</div>
<div class="segment-content">
<div class="segment-header">
<span class="segment-type">{{ segmentTypeLabel() }}</span>
<span class="segment-timestamp">{{ segment.timestamp | date:'short' }}</span>
</div>
<div class="segment-summary">{{ segmentSummary() }}</div>
<button mat-icon-button (click)="viewDetails.emit(segment)">
<mat-icon>visibility</mat-icon>
</button>
</div>
<div class="segment-digest" matTooltip="Segment hash">
{{ segment.segmentDigest | truncate:12 }}
</div>
</div>
`
})
export class ProofSegmentComponent {
@Input() segment!: ProofSegment;
@Input() isFirst = false;
@Input() isLast = false;
@Output() viewDetails = new EventEmitter<ProofSegment>();
segmentIcon = computed(() => {
switch (this.segment.type) {
case 'SbomSlice': return 'inventory_2';
case 'Match': return 'search';
case 'Reachability': return 'call_split';
case 'GuardAnalysis': return 'shield';
case 'RuntimeObservation': return 'sensors';
case 'PolicyEval': return 'gavel';
default: return 'help';
}
});
segmentTypeLabel = computed(() => {
switch (this.segment.type) {
case 'SbomSlice': return 'Component Identified';
case 'Match': return 'Vulnerability Matched';
case 'Reachability': return 'Reachability Analyzed';
case 'GuardAnalysis': return 'Mitigations Checked';
case 'RuntimeObservation': return 'Runtime Signals';
case 'PolicyEval': return 'Policy Evaluated';
default: return this.segment.type;
}
});
segmentSummary = computed(() => {
// Extract summary from segment evidence
return this.segment.evidence?.summary ?? 'View details';
});
}
```
### D3: Proof Badges Row Component
**File:** `src/Web/StellaOps.Web/src/app/shared/components/proof-badges/proof-badges-row.component.ts`
```typescript
@Component({
selector: 'app-proof-badges-row',
template: `
<div class="proof-badges-row">
<app-proof-badge
axis="reachability"
[status]="badges.reachability"
tooltip="Call path analysis" />
<app-proof-badge
axis="runtime"
[status]="badges.runtime"
tooltip="Runtime signal correlation" />
<app-proof-badge
axis="policy"
[status]="badges.policy"
tooltip="Policy evaluation" />
<app-proof-badge
axis="provenance"
[status]="badges.provenance"
tooltip="SBOM/attestation chain" />
</div>
`
})
export class ProofBadgesRowComponent {
@Input() badges!: ProofBadges;
}
@Component({
selector: 'app-proof-badge',
template: `
<span class="proof-badge" [class]="statusClass()" [matTooltip]="tooltip">
<mat-icon>{{ icon() }}</mat-icon>
</span>
`
})
export class ProofBadgeComponent {
@Input() axis!: 'reachability' | 'runtime' | 'policy' | 'provenance';
@Input() status!: 'confirmed' | 'partial' | 'none' | 'unknown';
@Input() tooltip = '';
icon = computed(() => {
switch (this.status) {
case 'confirmed': return 'check_circle';
case 'partial': return 'help';
case 'none': return 'cancel';
default: return 'help_outline';
}
});
statusClass = computed(() => `badge-${this.axis} status-${this.status}`);
}
```
### D4: Finding Card Enhancement
**File:** `src/Web/StellaOps.Web/src/app/features/findings/finding-card/finding-card.component.ts`
Add proof tree and badges to existing finding card:
```typescript
@Component({
selector: 'app-finding-card',
template: `
<mat-card class="finding-card">
<mat-card-header>
<mat-card-title>{{ finding.vulnerabilityId }}</mat-card-title>
<mat-card-subtitle>{{ finding.component.name }}@{{ finding.component.version }}</mat-card-subtitle>
<app-proof-badges-row [badges]="finding.proofBadges" />
</mat-card-header>
<mat-card-content>
<div class="finding-summary">
<app-severity-badge [severity]="finding.severity" />
<app-vex-status-chip [status]="finding.vexStatus" />
<app-confidence-badge [confidence]="finding.confidence" />
</div>
<!-- NEW: Proof Tree -->
<app-proof-tree
[proofSpine]="finding.proofSpine"
(viewSegmentDetails)="onViewSegment($event)" />
</mat-card-content>
<mat-card-actions>
<button mat-button (click)="onCreateVex()">Create VEX</button>
<button mat-button (click)="onViewDetails()">View Details</button>
</mat-card-actions>
</mat-card>
`
})
export class FindingCardComponent {
@Input() finding!: Finding;
@Output() createVex = new EventEmitter<Finding>();
@Output() viewDetails = new EventEmitter<Finding>();
@Output() viewSegment = new EventEmitter<ProofSegment>();
}
```
### D5: ProofSpine API Model
**File:** `src/Web/StellaOps.Web/src/app/core/models/proof-spine.model.ts`
```typescript
export interface ProofSpine {
findingId: string;
segments: ProofSegment[];
chainIntegrity: boolean;
computedAt: string;
}
export interface ProofSegment {
type: ProofSegmentType;
segmentDigest: string;
previousSegmentDigest: string | null;
timestamp: string;
evidence: SegmentEvidence;
}
export type ProofSegmentType =
| 'SbomSlice'
| 'Match'
| 'Reachability'
| 'GuardAnalysis'
| 'RuntimeObservation'
| 'PolicyEval';
export interface SegmentEvidence {
summary: string;
details: Record<string, unknown>;
digests?: string[];
}
export interface ProofBadges {
reachability: BadgeStatus;
runtime: BadgeStatus;
policy: BadgeStatus;
provenance: BadgeStatus;
}
export type BadgeStatus = 'confirmed' | 'partial' | 'none' | 'unknown';
```
### D6: Chain Integrity Badge
**File:** `src/Web/StellaOps.Web/src/app/shared/components/proof-tree/chain-integrity-badge.component.ts`
```typescript
@Component({
selector: 'app-chain-integrity-badge',
template: `
<span class="chain-integrity-badge" [class.valid]="valid" [class.invalid]="!valid">
<mat-icon>{{ valid ? 'verified' : 'error' }}</mat-icon>
{{ valid ? 'Chain Valid' : 'Chain Broken' }}
</span>
`
})
export class ChainIntegrityBadgeComponent {
@Input() valid = false;
}
```
---
## Tasks
| ID | Task | Status | Notes |
|----|------|--------|-------|
| T1 | Create `ProofSpineComponent` | DONE | `shared/components/proof-spine/` |
| T2 | Create `ProofSegmentComponent` | DONE | Individual segment display |
| T3 | Create `ProofBadgesRowComponent` | DONE | 4-axis badge row |
| T4 | Create `ChainIntegrityBadgeComponent` | DONE | Integrity indicator |
| T5 | Create ProofSpine API models | DONE | `core/models/proof-spine.model.ts` |
| T6 | Create TruncatePipe | DONE | `shared/pipes/truncate.pipe.ts` |
| T7 | Update `FindingDetailComponent` | DONE | Integrated ProofSpine + CopyAttestation |
| T8 | Add segment detail modal | DONE | `segment-detail-modal.component.ts` |
| T9 | Write unit tests | DONE | proof-spine.component.spec.ts created |
| T10 | Write E2E tests | DONE | `proof-spine.e2e.spec.ts` |
---
## Acceptance Criteria
1. [x] Proof tree visible in finding cards
2. [x] Tree expands/collapses on click
3. [x] All 6 segment types display correctly
4. [x] Chain integrity indicator accurate
5. [x] ProofBadges show 4 axes
6. [x] Segment click opens detail view
7. [x] Keyboard navigation works
8. [x] Screen reader accessible
---
## Telemetry
### Events
- `proof_tree.expand{finding_id}` - Tree expanded
- `proof_tree.segment_view{segment_type}` - Segment detail viewed
- `proof_badges.hover{axis}` - Badge tooltip shown
---
## Execution Log
| Date | Action | By |
|------|--------|------|
| 2025-12-27 | Sprint created | PM |
| 2025-12-27 | T5: Created ProofSpine models in `core/models/proof-spine.model.ts` | Claude |
| 2025-12-27 | T1: Created ProofSpineComponent with collapsible tree | Claude |
| 2025-12-27 | T2: Created ProofSegmentComponent with segment types | Claude |
| 2025-12-27 | T3: Created ProofBadgesRowComponent with 4-axis badges | Claude |
| 2025-12-27 | T4: Created ChainIntegrityBadgeComponent | Claude |
| 2025-12-27 | T6: Created TruncatePipe utility | Claude |
| 2025-12-27 | Updated shared components exports | Claude |
| 2025-12-28 | T7: Integrated ProofSpine into finding-detail.component.ts | Claude |
| 2025-12-28 | T9: Created proof-spine.component.spec.ts unit tests | Claude |
| 2025-12-28 | T8: Created `segment-detail-modal.component.ts` with tabs and copy | Claude |
| 2025-12-28 | T10: Created `proof-spine.e2e.spec.ts` Playwright tests | Claude |

View File

@@ -0,0 +1,427 @@
# Sprint: Copy Attestation & Audit Pack Export
| Field | Value |
|-------|-------|
| **Sprint ID** | SPRINT_1227_0005_0003 |
| **Batch** | 003 - Completeness |
| **Module** | FE (Frontend) + BE (Backend) |
| **Topic** | Copy attestation button & audit pack export |
| **Priority** | P1 - Compliance Feature |
| **Estimated Effort** | Low-Medium |
| **Dependencies** | AuditPack infrastructure exists |
| **Working Directory** | `src/Web/StellaOps.Web/src/app/features/` + `src/__Libraries/StellaOps.AuditPack/` |
---
## Objective
Add one-click evidence export capabilities:
1. "Copy Attestation" button for DSSE envelope clipboard copy
2. "Export Audit Pack" for downloadable evidence bundle
3. Selective export (choose segments/findings)
4. Format options (JSON, DSSE, ZIP bundle)
---
## Background
### Current State
- `AuditBundleManifest` model defined
- `EvidenceSerializer` with canonical JSON
- DSSE signing infrastructure complete
- No UI buttons for copy/export
### Target State
- Copy button on finding cards and detail views
- Export button for bulk download
- Format selector (JSON/DSSE/ZIP)
- Progress indicator for large exports
---
## Deliverables
### D1: Copy Attestation Button Component
**File:** `src/Web/StellaOps.Web/src/app/shared/components/copy-attestation/copy-attestation-button.component.ts`
```typescript
@Component({
selector: 'app-copy-attestation-button',
template: `
<button
mat-icon-button
[matTooltip]="copied() ? 'Copied!' : 'Copy DSSE Attestation'"
[class.copied]="copied()"
(click)="copyAttestation()">
<mat-icon>{{ copied() ? 'check' : 'content_copy' }}</mat-icon>
</button>
`
})
export class CopyAttestationButtonComponent {
@Input() attestationDigest!: string;
@Input() format: 'dsse' | 'json' = 'dsse';
copied = signal(false);
constructor(
private clipboard: Clipboard,
private attestationService: AttestationService,
private snackBar: MatSnackBar
) {}
async copyAttestation(): Promise<void> {
try {
const attestation = await firstValueFrom(
this.attestationService.getAttestation(this.attestationDigest, this.format)
);
const text = this.format === 'dsse'
? JSON.stringify(attestation.envelope, null, 2)
: JSON.stringify(attestation.payload, null, 2);
this.clipboard.copy(text);
this.copied.set(true);
this.snackBar.open('Attestation copied to clipboard', 'OK', { duration: 2000 });
setTimeout(() => this.copied.set(false), 2000);
} catch (error) {
this.snackBar.open('Failed to copy attestation', 'Retry', { duration: 3000 });
}
}
}
```
### D2: Export Audit Pack Button Component
**File:** `src/Web/StellaOps.Web/src/app/shared/components/audit-pack/export-audit-pack-button.component.ts`
```typescript
@Component({
selector: 'app-export-audit-pack-button',
template: `
<button
mat-raised-button
color="primary"
[disabled]="exporting()"
(click)="openExportDialog()">
@if (exporting()) {
<mat-spinner diameter="20" />
Exporting...
} @else {
<mat-icon>download</mat-icon>
Export Audit Pack
}
</button>
`
})
export class ExportAuditPackButtonComponent {
@Input() scanId!: string;
@Input() findingIds?: string[];
exporting = signal(false);
constructor(
private dialog: MatDialog,
private auditPackService: AuditPackService
) {}
openExportDialog(): void {
const dialogRef = this.dialog.open(ExportAuditPackDialogComponent, {
data: {
scanId: this.scanId,
findingIds: this.findingIds
},
width: '500px'
});
dialogRef.afterClosed().subscribe(config => {
if (config) {
this.startExport(config);
}
});
}
private async startExport(config: AuditPackExportConfig): Promise<void> {
this.exporting.set(true);
try {
const blob = await firstValueFrom(
this.auditPackService.exportPack(config)
);
this.downloadBlob(blob, config.filename);
} finally {
this.exporting.set(false);
}
}
private downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
}
```
### D3: Export Dialog Component
**File:** `src/Web/StellaOps.Web/src/app/shared/components/audit-pack/export-audit-pack-dialog.component.ts`
```typescript
@Component({
selector: 'app-export-audit-pack-dialog',
template: `
<h2 mat-dialog-title>Export Audit Pack</h2>
<mat-dialog-content>
<form [formGroup]="form">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Format</mat-label>
<mat-select formControlName="format">
<mat-option value="zip">ZIP Bundle (Recommended)</mat-option>
<mat-option value="json">JSON (Single File)</mat-option>
<mat-option value="dsse">DSSE Envelope</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Include</mat-label>
<mat-select formControlName="segments" multiple>
<mat-option value="sbom">SBOM Slice</mat-option>
<mat-option value="match">Vulnerability Match</mat-option>
<mat-option value="reachability">Reachability Analysis</mat-option>
<mat-option value="guards">Guard Analysis</mat-option>
<mat-option value="runtime">Runtime Signals</mat-option>
<mat-option value="policy">Policy Evaluation</mat-option>
</mat-select>
</mat-form-field>
<mat-checkbox formControlName="includeAttestations">
Include DSSE Attestations
</mat-checkbox>
<mat-checkbox formControlName="includeProofChain">
Include Cryptographic Proof Chain
</mat-checkbox>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Filename</mat-label>
<input matInput formControlName="filename" />
</mat-form-field>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Cancel</button>
<button mat-raised-button color="primary" [mat-dialog-close]="form.value">
Export
</button>
</mat-dialog-actions>
`
})
export class ExportAuditPackDialogComponent {
form = new FormGroup({
format: new FormControl<'zip' | 'json' | 'dsse'>('zip'),
segments: new FormControl<string[]>(['sbom', 'match', 'reachability', 'policy']),
includeAttestations: new FormControl(true),
includeProofChain: new FormControl(true),
filename: new FormControl(`audit-pack-${new Date().toISOString().slice(0, 10)}`)
});
constructor(@Inject(MAT_DIALOG_DATA) public data: { scanId: string; findingIds?: string[] }) {
// Pre-populate filename with scan context
this.form.patchValue({
filename: `audit-pack-${data.scanId.slice(0, 8)}-${new Date().toISOString().slice(0, 10)}`
});
}
}
```
### D4: Audit Pack Service
**File:** `src/Web/StellaOps.Web/src/app/core/services/audit-pack.service.ts`
```typescript
@Injectable({ providedIn: 'root' })
export class AuditPackService {
constructor(private http: HttpClient) {}
exportPack(config: AuditPackExportConfig): Observable<Blob> {
return this.http.post(
`/api/v1/audit-pack/export`,
config,
{
responseType: 'blob',
reportProgress: true
}
);
}
getExportProgress(exportId: string): Observable<ExportProgress> {
return this.http.get<ExportProgress>(`/api/v1/audit-pack/export/${exportId}/progress`);
}
}
export interface AuditPackExportConfig {
scanId: string;
findingIds?: string[];
format: 'zip' | 'json' | 'dsse';
segments: string[];
includeAttestations: boolean;
includeProofChain: boolean;
filename: string;
}
export interface ExportProgress {
exportId: string;
status: 'pending' | 'processing' | 'complete' | 'failed';
progress: number;
downloadUrl?: string;
error?: string;
}
```
### D5: Backend Export Endpoint
**File:** `src/__Libraries/StellaOps.AuditPack/Services/AuditPackExportService.cs`
```csharp
public sealed class AuditPackExportService : IAuditPackExportService
{
private readonly IEvidenceRepository _evidence;
private readonly IAttestationService _attestations;
private readonly IProofSpineService _proofSpine;
public async Task<Stream> ExportAsync(
AuditPackExportRequest request,
CancellationToken ct = default)
{
var manifest = new AuditBundleManifest
{
ExportedAt = DateTimeOffset.UtcNow,
ScanId = request.ScanId,
FindingIds = request.FindingIds ?? Array.Empty<string>(),
Format = request.Format
};
return request.Format switch
{
ExportFormat.Zip => await ExportZipAsync(manifest, request, ct),
ExportFormat.Json => await ExportJsonAsync(manifest, request, ct),
ExportFormat.Dsse => await ExportDsseAsync(manifest, request, ct),
_ => throw new ArgumentOutOfRangeException(nameof(request.Format))
};
}
private async Task<Stream> ExportZipAsync(
AuditBundleManifest manifest,
AuditPackExportRequest request,
CancellationToken ct)
{
var memoryStream = new MemoryStream();
using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true);
// Add manifest
var manifestEntry = archive.CreateEntry("manifest.json");
await using var manifestStream = manifestEntry.Open();
await JsonSerializer.SerializeAsync(manifestStream, manifest, ct: ct);
// Add evidence by segment
foreach (var segment in request.Segments)
{
var evidence = await _evidence.GetBySegmentAsync(request.ScanId, segment, ct);
var entry = archive.CreateEntry($"evidence/{segment}.json");
await using var stream = entry.Open();
await JsonSerializer.SerializeAsync(stream, evidence, ct: ct);
}
// Add attestations
if (request.IncludeAttestations)
{
var attestations = await _attestations.GetForScanAsync(request.ScanId, ct);
var entry = archive.CreateEntry("attestations/attestations.json");
await using var stream = entry.Open();
await JsonSerializer.SerializeAsync(stream, attestations, ct: ct);
}
// Add proof chain
if (request.IncludeProofChain)
{
var proofChain = await _proofSpine.GetChainAsync(request.ScanId, ct);
var entry = archive.CreateEntry("proof-chain/chain.json");
await using var stream = entry.Open();
await JsonSerializer.SerializeAsync(stream, proofChain, ct: ct);
}
memoryStream.Position = 0;
return memoryStream;
}
}
```
### D6: Finding Card Integration
**File:** Update `src/Web/StellaOps.Web/src/app/features/findings/finding-card/finding-card.component.ts`
```typescript
// Add to finding card actions
<mat-card-actions>
<app-copy-attestation-button
[attestationDigest]="finding.attestationDigest"
matTooltip="Copy DSSE attestation" />
<button mat-button (click)="onCreateVex()">Create VEX</button>
<button mat-button (click)="onViewDetails()">View Details</button>
</mat-card-actions>
```
---
## Tasks
| ID | Task | Status | Notes |
|----|------|--------|-------|
| T1 | Create `CopyAttestationButtonComponent` | DONE | `shared/components/copy-attestation/` |
| T2 | Create `ExportAuditPackButtonComponent` | DONE | `shared/components/audit-pack/` |
| T3 | Create `ExportAuditPackDialogComponent` | DONE | Config dialog with format/segment selection |
| T4 | Create `AuditPackService` | DONE | `core/services/audit-pack.service.ts` |
| T5 | Create `AuditPackExportService` (BE) | DONE | Backend export logic with ZIP/JSON/DSSE |
| T6 | Add ZIP archive generation | DONE | In AuditPackExportService |
| T7 | Add DSSE export format | DONE | In AuditPackExportService |
| T8 | Update finding card | DONE | ProofSpine + CopyAttestation integrated |
| T9 | Add toolbar export button | DONE | Bulk export in findings-list.component |
| T10 | Write unit tests | DONE | ExportButton + Dialog spec files |
| T11 | Write integration tests | DONE | `AuditPackExportServiceIntegrationTests.cs` |
---
## Acceptance Criteria
1. [ ] Copy button appears on finding cards
2. [ ] Click copies DSSE envelope to clipboard
3. [ ] Export button opens configuration dialog
4. [ ] ZIP format includes all selected segments
5. [ ] JSON format produces single canonical file
6. [ ] DSSE format includes valid signature
7. [ ] Progress indicator for large exports
8. [ ] Downloaded file named correctly
---
## Telemetry
### Events
- `attestation.copy{finding_id, format}` - Attestation copied
- `audit_pack.export{scan_id, format, segments}` - Export started
- `audit_pack.download{scan_id, size_bytes}` - Export downloaded
---
## Execution Log
| Date | Action | By |
|------|--------|------|
| 2025-12-27 | Sprint created | PM |
| 2025-12-27 | T1: Created CopyAttestationButtonComponent | Claude |
| 2025-12-27 | T2: Created ExportAuditPackButtonComponent | Claude |
| 2025-12-27 | T3: Created ExportAuditPackDialogComponent with format options | Claude |
| 2025-12-27 | T4: Created AuditPackService frontend API client | Claude |
| 2025-12-27 | Updated shared components exports | Claude |
| 2025-12-28 | T5-T7: Created AuditPackExportService.cs with ZIP/JSON/DSSE export | Claude |
| 2025-12-28 | T8: Integrated CopyAttestationButton into FindingDetail component | Claude |
| 2025-12-28 | T9: Added export button to findings-list toolbar and selection bar | Claude |
| 2025-12-28 | T10: Created unit tests for ExportAuditPackButton and Dialog | Claude |
| 2025-12-28 | T11: Created integration tests in `AuditPackExportServiceIntegrationTests.cs` | Claude |

View File

@@ -0,0 +1,515 @@
# Sprint: Verdict Replay Completion
| Field | Value |
|-------|-------|
| **Sprint ID** | SPRINT_1227_0005_0004 |
| **Batch** | 004 - Audit |
| **Module** | BE (Backend) + LB (Library) |
| **Topic** | Complete verdict replay infrastructure |
| **Priority** | P1 - Audit Requirement |
| **Estimated Effort** | Medium |
| **Dependencies** | ReplayExecutor scaffolded |
| **Working Directory** | `src/__Libraries/StellaOps.AuditPack/` + `src/Replay/` |
---
## Objective
Complete the verdict replay infrastructure for audit purposes:
1. Deterministic re-execution of findings verdicts
2. Isolated replay context (no network, deterministic time)
3. Verification that replayed verdict matches original
4. Audit trail with replay attestations
---
## Background
### Current State
- `ReplayExecutor` scaffolded with basic structure
- `IsolatedReplayContext` model exists
- `AuditBundleManifest` captures inputs
- DSSE signing infrastructure complete
### Target State
- Full deterministic replay capability
- Input snapshot capture at verdict time
- Replay produces identical output
- Attestation proves replay match
---
## Deliverables
### D1: Enhanced IsolatedReplayContext
**File:** `src/__Libraries/StellaOps.AuditPack/Replay/IsolatedReplayContext.cs`
```csharp
public sealed class IsolatedReplayContext : IDisposable
{
private readonly DateTimeOffset _frozenTime;
private readonly IReadOnlyDictionary<string, byte[]> _frozenFiles;
private readonly IReadOnlyDictionary<string, string> _frozenResponses;
public IsolatedReplayContext(ReplaySnapshot snapshot)
{
_frozenTime = snapshot.CapturedAt;
_frozenFiles = snapshot.FileContents.ToImmutableDictionary();
_frozenResponses = snapshot.ApiResponses.ToImmutableDictionary();
}
public DateTimeOffset Now => _frozenTime;
public byte[] ReadFile(string path)
{
if (!_frozenFiles.TryGetValue(path, out var content))
throw new ReplayFileNotFoundException(path);
return content;
}
public string GetApiResponse(string endpoint)
{
if (!_frozenResponses.TryGetValue(endpoint, out var response))
throw new ReplayApiNotFoundException(endpoint);
return response;
}
public void Dispose()
{
// Cleanup if needed
}
}
public sealed record ReplaySnapshot
{
public required string SnapshotId { get; init; }
public required DateTimeOffset CapturedAt { get; init; }
public required IReadOnlyDictionary<string, byte[]> FileContents { get; init; }
public required IReadOnlyDictionary<string, string> ApiResponses { get; init; }
public required string InputsDigest { get; init; }
}
```
### D2: Complete ReplayExecutor
**File:** `src/__Libraries/StellaOps.AuditPack/Replay/ReplayExecutor.cs`
```csharp
public sealed class ReplayExecutor : IReplayExecutor
{
private readonly IVerdictEngine _verdictEngine;
private readonly IAttestationService _attestations;
private readonly ILogger<ReplayExecutor> _logger;
public async Task<ReplayResult> ReplayVerdictAsync(
AuditBundleManifest manifest,
ReplaySnapshot snapshot,
CancellationToken ct = default)
{
using var context = new IsolatedReplayContext(snapshot);
// Inject isolated context into verdict engine
var verdictEngine = _verdictEngine.WithContext(context);
try
{
// Re-execute verdict computation
var replayedVerdict = await verdictEngine.ComputeVerdictAsync(
manifest.FindingInputs,
ct);
// Compare with original
var originalDigest = manifest.VerdictDigest;
var replayedDigest = ComputeVerdictDigest(replayedVerdict);
var match = originalDigest == replayedDigest;
// Generate replay attestation
var attestation = await GenerateReplayAttestationAsync(
manifest, snapshot, replayedVerdict, match, ct);
return new ReplayResult
{
Success = match,
OriginalDigest = originalDigest,
ReplayedDigest = replayedDigest,
ReplayedVerdict = replayedVerdict,
Attestation = attestation,
ReplayedAt = DateTimeOffset.UtcNow,
DivergenceReason = match ? null : DetectDivergence(manifest, replayedVerdict)
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Replay failed for manifest {ManifestId}", manifest.ManifestId);
return new ReplayResult
{
Success = false,
Error = ex.Message,
ReplayedAt = DateTimeOffset.UtcNow
};
}
}
private string ComputeVerdictDigest(VerdictOutput verdict)
{
var canonical = CanonicalJsonSerializer.Serialize(verdict);
return SHA256.HashData(Encoding.UTF8.GetBytes(canonical)).ToHexString();
}
private string? DetectDivergence(AuditBundleManifest manifest, VerdictOutput replayed)
{
// Compare key fields to identify what changed
if (manifest.OriginalVerdict.Status != replayed.Status)
return $"Status diverged: {manifest.OriginalVerdict.Status} vs {replayed.Status}";
if (manifest.OriginalVerdict.Confidence != replayed.Confidence)
return $"Confidence diverged: {manifest.OriginalVerdict.Confidence} vs {replayed.Confidence}";
if (manifest.OriginalVerdict.Reachability != replayed.Reachability)
return $"Reachability diverged: {manifest.OriginalVerdict.Reachability} vs {replayed.Reachability}";
return "Unknown divergence - digest mismatch but fields match";
}
private async Task<DsseEnvelope> GenerateReplayAttestationAsync(
AuditBundleManifest manifest,
ReplaySnapshot snapshot,
VerdictOutput replayed,
bool match,
CancellationToken ct)
{
var statement = new InTotoStatement
{
Type = "https://in-toto.io/Statement/v1",
Subject = new[]
{
new Subject
{
Name = $"verdict:{manifest.FindingId}",
Digest = new Dictionary<string, string>
{
["sha256"] = manifest.VerdictDigest
}
}
},
PredicateType = "https://stellaops.io/attestation/verdict-replay/v1",
Predicate = new VerdictReplayPredicate
{
ManifestId = manifest.ManifestId,
SnapshotId = snapshot.SnapshotId,
InputsDigest = snapshot.InputsDigest,
OriginalDigest = manifest.VerdictDigest,
ReplayedDigest = ComputeVerdictDigest(replayed),
Match = match,
ReplayedAt = DateTimeOffset.UtcNow
}
};
return await _attestations.SignAsync(statement, ct);
}
}
public sealed record ReplayResult
{
public required bool Success { get; init; }
public string? OriginalDigest { get; init; }
public string? ReplayedDigest { get; init; }
public VerdictOutput? ReplayedVerdict { get; init; }
public DsseEnvelope? Attestation { get; init; }
public required DateTimeOffset ReplayedAt { get; init; }
public string? DivergenceReason { get; init; }
public string? Error { get; init; }
}
```
### D3: Snapshot Capture Service
**File:** `src/__Libraries/StellaOps.AuditPack/Replay/SnapshotCaptureService.cs`
```csharp
public sealed class SnapshotCaptureService : ISnapshotCaptureService
{
private readonly IFileHasher _hasher;
public async Task<ReplaySnapshot> CaptureAsync(
VerdictInputs inputs,
CancellationToken ct = default)
{
var files = new Dictionary<string, byte[]>();
var responses = new Dictionary<string, string>();
// Capture SBOM content
if (inputs.SbomPath is not null)
{
files[inputs.SbomPath] = await File.ReadAllBytesAsync(inputs.SbomPath, ct);
}
// Capture advisory data
foreach (var advisory in inputs.Advisories)
{
var key = $"advisory:{advisory.Id}";
responses[key] = CanonicalJsonSerializer.Serialize(advisory);
}
// Capture VEX statements
foreach (var vex in inputs.VexStatements)
{
var key = $"vex:{vex.Digest}";
responses[key] = CanonicalJsonSerializer.Serialize(vex);
}
// Capture policy configuration
responses["policy:config"] = CanonicalJsonSerializer.Serialize(inputs.PolicyConfig);
// Compute inputs digest
var inputsDigest = ComputeInputsDigest(files, responses);
return new ReplaySnapshot
{
SnapshotId = Guid.NewGuid().ToString("N"),
CapturedAt = DateTimeOffset.UtcNow,
FileContents = files.ToImmutableDictionary(),
ApiResponses = responses.ToImmutableDictionary(),
InputsDigest = inputsDigest
};
}
private string ComputeInputsDigest(
Dictionary<string, byte[]> files,
Dictionary<string, string> responses)
{
using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
// Hash files in sorted order
foreach (var (path, content) in files.OrderBy(kv => kv.Key))
{
hasher.AppendData(Encoding.UTF8.GetBytes(path));
hasher.AppendData(content);
}
// Hash responses in sorted order
foreach (var (key, value) in responses.OrderBy(kv => kv.Key))
{
hasher.AppendData(Encoding.UTF8.GetBytes(key));
hasher.AppendData(Encoding.UTF8.GetBytes(value));
}
return hasher.GetHashAndReset().ToHexString();
}
}
```
### D4: Verdict Replay Predicate Type
**File:** `src/__Libraries/StellaOps.AuditPack/Attestations/VerdictReplayPredicate.cs`
```csharp
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
public sealed record VerdictReplayPredicate
{
[JsonPropertyName("manifestId")]
public required string ManifestId { get; init; }
[JsonPropertyName("snapshotId")]
public required string SnapshotId { get; init; }
[JsonPropertyName("inputsDigest")]
public required string InputsDigest { get; init; }
[JsonPropertyName("originalDigest")]
public required string OriginalDigest { get; init; }
[JsonPropertyName("replayedDigest")]
public required string ReplayedDigest { get; init; }
[JsonPropertyName("match")]
public required bool Match { get; init; }
[JsonPropertyName("replayedAt")]
public required DateTimeOffset ReplayedAt { get; init; }
}
```
### D5: Replay API Endpoint
**File:** `src/Replay/StellaOps.Replay.WebService/Controllers/ReplayController.cs`
```csharp
[ApiController]
[Route("api/v1/replay")]
public class ReplayController : ControllerBase
{
private readonly IReplayExecutor _executor;
private readonly IAuditPackRepository _auditPacks;
[HttpPost("verdict")]
[ProducesResponseType<ReplayResponse>(200)]
[ProducesResponseType<ProblemDetails>(400)]
public async Task<IActionResult> ReplayVerdict(
[FromBody] ReplayRequest request,
CancellationToken ct)
{
var manifest = await _auditPacks.GetManifestAsync(request.ManifestId, ct);
if (manifest is null)
return NotFound($"Manifest {request.ManifestId} not found");
var snapshot = await _auditPacks.GetSnapshotAsync(manifest.SnapshotId, ct);
if (snapshot is null)
return NotFound($"Snapshot {manifest.SnapshotId} not found");
var result = await _executor.ReplayVerdictAsync(manifest, snapshot, ct);
return Ok(new ReplayResponse
{
Success = result.Success,
Match = result.OriginalDigest == result.ReplayedDigest,
OriginalDigest = result.OriginalDigest,
ReplayedDigest = result.ReplayedDigest,
DivergenceReason = result.DivergenceReason,
AttestationDigest = result.Attestation?.PayloadDigest,
ReplayedAt = result.ReplayedAt
});
}
[HttpGet("manifest/{manifestId}/verify")]
[ProducesResponseType<VerificationResponse>(200)]
public async Task<IActionResult> VerifyReplayability(
string manifestId,
CancellationToken ct)
{
var manifest = await _auditPacks.GetManifestAsync(manifestId, ct);
if (manifest is null)
return NotFound();
var snapshot = await _auditPacks.GetSnapshotAsync(manifest.SnapshotId, ct);
var hasAllInputs = snapshot is not null &&
snapshot.FileContents.Any() &&
snapshot.ApiResponses.Any();
return Ok(new VerificationResponse
{
ManifestId = manifestId,
Replayable = hasAllInputs,
SnapshotPresent = snapshot is not null,
InputsComplete = hasAllInputs,
SnapshotAge = snapshot is not null
? DateTimeOffset.UtcNow - snapshot.CapturedAt
: null
});
}
}
```
### D6: Unit Tests
**File:** `src/__Libraries/__Tests/StellaOps.AuditPack.Tests/Replay/ReplayExecutorTests.cs`
```csharp
public class ReplayExecutorTests
{
[Fact]
public async Task ReplayVerdict_WithIdenticalInputs_ReturnsMatch()
{
// Arrange
var manifest = CreateTestManifest();
var snapshot = CreateTestSnapshot();
var executor = CreateExecutor();
// Act
var result = await executor.ReplayVerdictAsync(manifest, snapshot, CancellationToken.None);
// Assert
Assert.True(result.Success);
Assert.Equal(manifest.VerdictDigest, result.ReplayedDigest);
Assert.Null(result.DivergenceReason);
}
[Fact]
public async Task ReplayVerdict_WithModifiedInputs_ReturnsDivergence()
{
// Arrange
var manifest = CreateTestManifest();
var snapshot = CreateModifiedSnapshot();
var executor = CreateExecutor();
// Act
var result = await executor.ReplayVerdictAsync(manifest, snapshot, CancellationToken.None);
// Assert
Assert.False(result.Success);
Assert.NotEqual(manifest.VerdictDigest, result.ReplayedDigest);
Assert.NotNull(result.DivergenceReason);
}
[Fact]
public async Task ReplayVerdict_GeneratesAttestation()
{
// Arrange
var manifest = CreateTestManifest();
var snapshot = CreateTestSnapshot();
var executor = CreateExecutor();
// Act
var result = await executor.ReplayVerdictAsync(manifest, snapshot, CancellationToken.None);
// Assert
Assert.NotNull(result.Attestation);
Assert.Equal("https://stellaops.io/attestation/verdict-replay/v1",
result.Attestation.Statement.PredicateType);
}
}
```
---
## Tasks
| ID | Task | Status | Notes |
|----|------|--------|-------|
| T1 | Enhance `IsolatedReplayContext` | DONE | Already exists in StellaOps.AuditPack |
| T2 | Complete `ReplayExecutor` | DONE | Full replay logic with policy eval |
| T3 | Implement `SnapshotCaptureService` | DONE | `ScanSnapshotFetcher.cs` exists |
| T4 | Create `VerdictReplayPredicate` | DONE | Eligibility + divergence detection |
| T5 | Add replay API endpoint | DONE | VerdictReplayEndpoints.cs |
| T6 | Implement divergence detection | DONE | In VerdictReplayPredicate |
| T7 | Add replay attestation generation | DONE | ReplayAttestationService.cs |
| T8 | Write unit tests | DONE | VerdictReplayEndpointsTests + ReplayAttestationServiceTests |
| T9 | Write integration tests | DONE | `VerdictReplayIntegrationTests.cs` |
| T10 | Add telemetry | DONE | `ReplayTelemetry.cs` with OpenTelemetry metrics |
---
## Acceptance Criteria
1. [ ] Snapshot captures all verdict inputs
2. [ ] Replay produces identical digest for unchanged inputs
3. [ ] Divergence detected and reported for changed inputs
4. [ ] Replay attestation generated with DSSE signature
5. [ ] Isolated context prevents network/time leakage
6. [ ] API endpoint accessible for audit triggers
7. [ ] Replayability verification endpoint works
8. [ ] Unit test coverage > 90%
---
## Telemetry
### Metrics
- `replay_executions_total{outcome}` - Replay attempts
- `replay_match_rate` - Percentage of successful matches
- `replay_duration_seconds{quantile}` - Execution time
### Traces
- Span: `ReplayExecutor.ReplayVerdictAsync`
- Attributes: manifest_id, snapshot_id, match, duration
---
## Execution Log
| Date | Action | By |
|------|--------|------|
| 2025-12-27 | Sprint created | PM |
| 2025-12-27 | T1-T3: Verified existing IsolatedReplayContext, ReplayExecutor, ScanSnapshotFetcher | Claude |
| 2025-12-27 | T4: Created VerdictReplayPredicate with eligibility + divergence detection | Claude |
| 2025-12-27 | T6: Divergence detection implemented in VerdictReplayPredicate.CompareDivergence | Claude |
| 2025-12-28 | T5: Created VerdictReplayEndpoints.cs with Minimal API endpoints | Claude |
| 2025-12-28 | T7: Created ReplayAttestationService.cs with in-toto/DSSE signing | Claude |
| 2025-12-28 | T8: Created unit tests for VerdictReplayEndpoints and ReplayAttestationService | Claude |
| 2025-12-28 | T9: Created integration tests in `VerdictReplayIntegrationTests.cs` | Claude |
| 2025-12-28 | T10: Created `ReplayTelemetry.cs` with OpenTelemetry metrics/traces | Claude |