feat: align baseline compare product process flow (#406)

## Summary
- align the Baseline Compare landing page with the shared Product Process Flow contract introduced by Spec 332
- add the horizontal flow rendering primitive and update the landing view/state presentation for readiness, proof, evidence, and next action
- add Spec 336 artifacts, screenshots, focused feature coverage, and browser smoke coverage for the aligned states

## Testing
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareEnvironmentRouteContractTest.php tests/Feature/Filament/Spec330EnvironmentDashboardBaselineCompareProductizationTest.php tests/Feature/Filament/Spec336BaselineCompareProductProcessFlowAlignmentTest.php tests/Browser/Spec330EnvironmentDashboardBaselineCompareSmokeTest.php tests/Browser/Spec336BaselineCompareProductProcessFlowAlignmentSmokeTest.php`

## Notes
- Filament v5 / Livewire v4 stack remains unchanged
- no panel provider registration changes; `bootstrap/providers.php` is unaffected
- no global-search resource behavior changes
- no new destructive actions and no asset registration/deployment changes

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #406
This commit is contained in:
ahmido 2026-05-29 22:22:53 +00:00
parent 4edb047901
commit 4c661f18f0
22 changed files with 2180 additions and 368 deletions

View File

@ -391,8 +391,8 @@ protected function getViewData(): array
'decisionCard' => $this->decisionCard($hasWarnings, $hasCoverageWarnings, $hasEvidenceGaps),
'decisionSummaryItems' => $this->decisionSummaryItems($hasWarnings, $hasCoverageWarnings, $hasEvidenceGaps),
'proofPanelItems' => $this->proofPanelItems($hasWarnings, $hasCoverageWarnings, $hasEvidenceGaps),
'compareReadinessFlow' => $this->compareReadinessFlow(),
'availableCompareInputs' => $this->availableCompareInputs(),
'compareReadinessFlow' => $this->compareReadinessFlow($hasWarnings, $hasCoverageWarnings),
'availableCompareInputs' => $this->availableCompareInputs($hasWarnings, $hasCoverageWarnings, $hasEvidenceGaps),
'assignmentUnlocks' => $this->assignmentUnlocks(),
'diagnosticsDisclosure' => $this->diagnosticsDisclosure($hasEvidenceGapDiagnostics),
'hasCoverageWarnings' => $hasCoverageWarnings,
@ -436,7 +436,7 @@ private function decisionCard(bool $hasWarnings, bool $hasCoverageWarnings, bool
default => 'gray',
},
'reasonLabel' => 'Reason',
'reason' => $this->decisionReason($state),
'reason' => $this->decisionReason($state, $findingsCount),
'impactLabel' => 'Impact',
'impact' => $this->decisionImpact($state, $findingsCount, $hasCoverageWarnings, $hasEvidenceGaps),
'evidenceLabel' => 'Evidence path',
@ -450,39 +450,35 @@ private function decisionStatus(string $state, int $findingsCount, bool $hasWarn
{
return match ($state) {
'no_assignment' => 'Baseline not assigned',
'no_snapshot' => 'Compare unavailable',
'invalid_scope' => 'Compare unavailable',
'comparing' => 'Compare running',
'no_snapshot' => 'Baseline snapshot required',
'invalid_scope' => 'Baseline scope requires review',
'comparing' => 'Compare in progress',
'failed' => 'Compare failed',
'idle' => 'Compare available',
'idle' => 'Compare run required',
'ready' => $findingsCount > 0
? 'Drift requires review'
: ($hasWarnings ? 'Evidence requires review' : 'No confirmed drift'),
? 'Drift findings available'
: ($hasWarnings ? 'Decision output needs review' : 'No drift detected'),
default => 'Compare unavailable',
};
}
private function decisionReason(string $state): string
private function decisionReason(string $state, int $findingsCount): string
{
$reason = trim((string) ($this->reasonMessage ?? ''));
if ($reason !== '') {
return $reason;
}
$message = trim((string) ($this->message ?? ''));
if ($message !== '') {
return $message;
}
return match ($state) {
'no_assignment' => 'No baseline profile is assigned to this environment.',
'no_snapshot' => 'No complete baseline snapshot is available for comparison.',
'invalid_scope' => 'The assigned baseline scope cannot be compared safely.',
'failed' => $this->failureReason ?: 'The latest compare run failed.',
'comparing' => 'The comparison is still running.',
'idle' => 'The assigned baseline can be compared against current observed inventory.',
'no_assignment' => 'This environment does not have an assigned baseline.',
'no_snapshot' => 'A baseline is assigned, but no usable baseline snapshot is available.'
.($reason !== '' ? ' '.$reason : ($message !== '' ? ' '.$message : '')),
'invalid_scope' => 'A baseline is assigned, but its scope cannot be used safely for compare.'
.($reason !== '' ? ' '.$reason : ($message !== '' ? ' '.$message : '')),
'failed' => $this->failureReason ?: ($reason !== '' ? $reason : 'The compare operation ended with errors.'),
'comparing' => 'Baseline comparison is currently running.',
'idle' => 'Required inputs exist, but no compare run has been created for the current state.',
'ready' => $findingsCount > 0
? 'Baseline comparison found governance-relevant differences. Drift requires review before a decision is recorded.'
: 'Current environment state matches the assigned baseline within available compare coverage.',
default => 'Compare state is derived from the latest baseline assignment, snapshot, and operation proof.',
};
}
@ -490,34 +486,42 @@ private function decisionReason(string $state): string
private function decisionImpact(string $state, int $findingsCount, bool $hasCoverageWarnings, bool $hasEvidenceGaps): string
{
if ($state === 'no_assignment') {
return 'Baseline compare cannot be used for governance decisions until an assignment exists. Compare trust is unavailable until a baseline assignment exists. No usable drift result is available yet.';
return 'Baseline drift cannot be used for governance decisions until a baseline assignment exists.';
}
if ($state === 'no_snapshot' || $state === 'invalid_scope') {
return 'Drift decisions stay unavailable until the baseline source is usable.';
if ($state === 'no_snapshot') {
return 'Compare cannot run until baseline snapshot input exists.';
}
if ($state === 'invalid_scope') {
return 'Compare cannot run safely until the assigned baseline scope is reviewed.';
}
if ($state === 'failed') {
return 'Review operation proof before retrying or treating the latest compare as evidence.';
return 'Drift findings cannot be trusted until the failure is resolved. Review operation proof before retrying.';
}
if ($state === 'comparing') {
return 'Wait for operation proof before acting on drift or evidence state.';
return 'Drift findings are not final yet. Wait for operation proof before acting on drift or evidence state.';
}
if ($state === 'idle') {
return 'Drift findings are not available yet. Run compare after the required inputs are confirmed.';
}
if ($findingsCount > 0) {
return 'Review drift findings before presenting this environment as aligned to baseline.';
return 'Review findings and decide the next governance action before presenting this environment as aligned to baseline.';
}
if ($hasCoverageWarnings || $hasEvidenceGaps) {
return 'Zero findings must not be treated as an all-clear while coverage or evidence gaps remain.';
return 'Zero findings must not be treated as final while coverage or evidence gaps remain.';
}
return 'The latest compare shows no confirmed drift for the assigned baseline profile.';
return 'No governance action is required from this compare result within available compare coverage.';
}
/**
* @return array{actionLabel:string,actionUrl:?string,actionDisabled:bool,helperText:?string}
* @return array{actionLabel:string,actionUrl:?string,actionDisabled:bool,helperText:?string,actionName?:string}
*/
private function primaryDecisionAction(string $state, int $findingsCount): array
{
@ -534,6 +538,51 @@ private function primaryDecisionAction(string $state, int $findingsCount): array
];
}
if (in_array($state, ['no_snapshot', 'invalid_scope'], true)) {
$profileUrl = $this->baselineProfileUrl();
return [
'actionLabel' => $profileUrl !== null ? 'Open baseline profile' : 'Baseline profile unavailable',
'actionUrl' => $profileUrl,
'actionDisabled' => $profileUrl === null,
'helperText' => $profileUrl !== null
? 'Open the assigned baseline profile to review capture and snapshot state.'
: 'No authorized baseline profile path is available from this page.',
];
}
if ($state === 'idle') {
$canRunCompare = $this->canRunCompareAction();
return [
'actionLabel' => $canRunCompare ? 'Compare now' : 'Compare unavailable',
'actionUrl' => null,
'actionDisabled' => ! $canRunCompare,
'actionName' => 'compareNow',
'helperText' => $canRunCompare
? 'Use the confirmed Compare now action to generate drift findings.'
: 'You are not authorized to start baseline compare from this environment.',
];
}
if ($state === 'comparing' && $this->getRunUrl() !== null) {
return [
'actionLabel' => 'View operation progress',
'actionUrl' => $this->getRunUrl(),
'actionDisabled' => false,
'helperText' => null,
];
}
if ($state === 'failed' && $this->getRunUrl() !== null) {
return [
'actionLabel' => 'Review compare failure',
'actionUrl' => $this->getRunUrl(),
'actionDisabled' => false,
'helperText' => null,
];
}
if ($state === 'ready' && $findingsCount > 0 && $this->getFindingsUrl() !== null) {
return [
'actionLabel' => 'Review drift findings',
@ -543,24 +592,15 @@ private function primaryDecisionAction(string $state, int $findingsCount): array
];
}
if (in_array($state, ['ready', 'failed'], true) && $this->getRunUrl() !== null) {
if ($state === 'ready' && $this->getRunUrl() !== null) {
return [
'actionLabel' => 'Open operation proof',
'actionLabel' => 'Review evidence',
'actionUrl' => $this->getRunUrl(),
'actionDisabled' => false,
'helperText' => null,
];
}
if ($state === 'idle') {
return [
'actionLabel' => 'Run compare from the page header',
'actionUrl' => null,
'actionDisabled' => true,
'helperText' => 'The existing confirmed Compare now action remains in the page header.',
];
}
return [
'actionLabel' => 'Review compare state',
'actionUrl' => $this->getRunUrl(),
@ -606,31 +646,56 @@ private function proofPanelItems(bool $hasWarnings, bool $hasCoverageWarnings, b
return [];
}
$environmentSnapshotState = $this->environmentSnapshotState($hasCoverageWarnings);
$operationProofAvailable = $this->operationRunId !== null && $this->getRunUrl() !== null;
$baselineSnapshotState = $this->snapshotId !== null ? 'Available' : ($this->state === 'no_snapshot' ? 'Missing' : 'Unavailable');
$driftFindingsAvailable = $this->state === 'ready';
return [
[
'key' => 'assigned_baseline',
'label' => 'Assigned baseline',
'value' => $this->profileName ?? 'Baseline not assigned',
'tone' => $this->profileName !== null ? 'success' : 'warning',
'description' => $this->snapshotId !== null ? 'Snapshot #'.$this->snapshotId : 'No complete baseline snapshot is linked.',
'description' => $this->state === 'invalid_scope'
? 'Assignment exists, but the baseline scope requires review.'
: 'Environment-owned baseline assignment state.',
'actionLabel' => $this->openCompareMatrixUrl() !== null ? 'Open compare matrix' : null,
'actionUrl' => $this->openCompareMatrixUrl(),
],
[
'key' => 'compare_trust',
'label' => 'Compare trust',
'value' => $this->compareTrustLabel($hasWarnings),
'tone' => $hasWarnings ? 'warning' : ($this->state === 'ready' ? 'success' : 'gray'),
'description' => $this->coverageDescription($hasCoverageWarnings, $hasEvidenceGaps),
'actionLabel' => $this->getRunUrl() !== null ? 'Open operation proof' : null,
'actionUrl' => $this->getRunUrl(),
'key' => 'baseline_snapshot',
'label' => 'Baseline snapshot',
'value' => $baselineSnapshotState,
'tone' => $this->flowTone($baselineSnapshotState),
'description' => $this->snapshotId !== null ? 'Snapshot #'.$this->snapshotId.' is the baseline compare input.' : 'No usable baseline snapshot input is linked.',
'actionLabel' => $this->baselineProfileUrl() !== null ? 'Open baseline profile' : null,
'actionUrl' => $this->baselineProfileUrl(),
],
[
'key' => 'drift_impact',
'label' => 'Drift impact',
'value' => $this->driftImpactLabel(),
'tone' => ((int) ($this->findingsCount ?? 0)) > 0 ? 'warning' : 'gray',
'description' => 'Findings and evidence gaps are reviewed before raw details.',
'key' => 'environment_snapshot',
'label' => 'Environment snapshot',
'value' => $environmentSnapshotState,
'tone' => $this->flowTone($environmentSnapshotState),
'description' => $this->environmentSnapshotDescription($environmentSnapshotState),
'actionLabel' => null,
'actionUrl' => null,
],
[
'key' => 'operation_run_proof',
'label' => 'OperationRun proof',
'value' => $operationProofAvailable ? 'Available' : 'Unavailable',
'tone' => $operationProofAvailable ? 'success' : 'gray',
'description' => $operationProofAvailable ? 'Compare proof is linked to an OperationRun.' : 'No compare OperationRun proof is available yet.',
'actionLabel' => $operationProofAvailable ? 'Open operation proof' : null,
'actionUrl' => $operationProofAvailable ? $this->getRunUrl() : null,
],
[
'key' => 'drift_findings',
'label' => 'Drift findings',
'value' => $driftFindingsAvailable ? $this->driftImpactLabel() : 'Unavailable',
'tone' => $driftFindingsAvailable && ((int) ($this->findingsCount ?? 0)) > 0 ? 'warning' : ($driftFindingsAvailable ? 'success' : 'gray'),
'description' => $this->driftFindingsDescription(),
'actionLabel' => $this->getFindingsUrl() !== null && ((int) ($this->findingsCount ?? 0)) > 0 ? 'Review findings' : null,
'actionUrl' => ((int) ($this->findingsCount ?? 0)) > 0 ? $this->getFindingsUrl() : null,
],
@ -639,7 +704,7 @@ private function proofPanelItems(bool $hasWarnings, bool $hasCoverageWarnings, b
'label' => 'Evidence path',
'value' => $this->evidencePathSummary($hasCoverageWarnings, $hasEvidenceGaps),
'tone' => ($hasCoverageWarnings || $hasEvidenceGaps) ? 'warning' : 'gray',
'description' => 'Evidence gaps and coverage limits stay visible before diagnostics.',
'description' => $this->evidenceInputDescription($hasCoverageWarnings, $hasEvidenceGaps),
'actionLabel' => null,
'actionUrl' => null,
],
@ -647,46 +712,93 @@ private function proofPanelItems(bool $hasWarnings, bool $hasCoverageWarnings, b
}
/**
* @return list<array<string, string>>
* @return list<array<string, mixed>>
*/
private function compareReadinessFlow(): array
private function compareReadinessFlow(bool $hasWarnings, bool $hasCoverageWarnings): array
{
if ($this->state !== 'no_assignment') {
return [];
}
$environmentSnapshotState = $this->hasEnvironmentSnapshot() ? 'Available' : 'Unavailable';
$state = (string) ($this->state ?? 'unknown');
$environmentSnapshotState = $this->environmentSnapshotState($hasCoverageWarnings);
$baselineAssignedState = match ($state) {
'no_assignment' => 'Missing',
'invalid_scope' => 'Needs review',
'no_tenant' => 'Unavailable',
default => 'Complete',
};
$baselineSnapshotState = match ($state) {
'no_assignment' => 'Unavailable',
'no_snapshot' => 'Missing',
'invalid_scope' => 'Unavailable',
default => $this->snapshotId !== null ? 'Available' : 'Unavailable',
};
$compareRunState = match ($state) {
'idle' => 'Required',
'comparing' => 'In progress',
'failed' => 'Failed',
'ready' => 'Available',
default => 'Unavailable',
};
$decisionOutputState = match ($state) {
'ready' => $hasWarnings ? 'Needs review' : 'Available',
'idle' => 'Required',
default => 'Unavailable',
};
return [
[
'label' => 'Baseline assigned',
'state' => 'Missing',
'tone' => 'warning',
'description' => 'No baseline is assigned to this environment.',
'state' => $baselineAssignedState,
'tone' => $this->flowTone($baselineAssignedState),
'description' => match ($baselineAssignedState) {
'Complete' => 'Baseline assignment exists.',
'Needs review' => 'Assignment scope needs review.',
'Missing' => 'No baseline is assigned.',
default => 'Assignment unavailable.',
},
'currentBlocker' => in_array($state, ['no_assignment', 'invalid_scope'], true),
],
[
'label' => 'Baseline snapshot',
'state' => 'Unavailable',
'tone' => 'gray',
'description' => 'No baseline snapshot is linked.',
'state' => $baselineSnapshotState,
'tone' => $this->flowTone($baselineSnapshotState),
'description' => match ($baselineSnapshotState) {
'Available' => 'Snapshot #'.$this->snapshotId.' is available.',
'Missing' => 'No usable snapshot.',
'Needs review' => 'Snapshot needs review.',
default => 'No snapshot linked.',
},
'currentBlocker' => $state === 'no_snapshot',
],
[
'label' => 'Environment snapshot',
'state' => $environmentSnapshotState,
'tone' => $environmentSnapshotState === 'Available' ? 'success' : 'gray',
'description' => 'Environment snapshot state is required for compare.',
'tone' => $this->flowTone($environmentSnapshotState),
'description' => $this->environmentSnapshotDescription($environmentSnapshotState),
'currentBlocker' => false,
],
[
'label' => 'Compare run',
'state' => 'Unavailable',
'tone' => 'gray',
'description' => 'Compare cannot run until required inputs exist.',
'state' => $compareRunState,
'tone' => $this->flowTone($compareRunState),
'description' => match ($compareRunState) {
'Available' => 'Completed run available.',
'Required' => 'Run compare.',
'In progress' => 'Queued or running.',
'Failed' => 'Latest run failed.',
default => 'Blocked by missing inputs.',
},
'currentBlocker' => in_array($state, ['idle', 'comparing', 'failed'], true),
],
[
'label' => 'Decision output',
'state' => 'Unavailable',
'tone' => 'gray',
'description' => 'No drift decision output is available yet.',
'state' => $decisionOutputState,
'tone' => $this->flowTone($decisionOutputState),
'description' => match ($decisionOutputState) {
'Available' => 'Decision output available.',
'Needs review' => 'Evidence or coverage needs review.',
'Required' => 'Run compare first.',
default => 'No decision output.',
},
'currentBlocker' => $state === 'ready' && $hasWarnings,
],
];
}
@ -694,10 +806,61 @@ private function compareReadinessFlow(): array
/**
* @return list<array<string, string>>
*/
private function availableCompareInputs(): array
private function availableCompareInputs(bool $hasWarnings, bool $hasCoverageWarnings, bool $hasEvidenceGaps): array
{
if ($this->state !== 'no_assignment') {
return [];
$environmentSnapshotState = $this->environmentSnapshotState($hasCoverageWarnings);
$baselineSnapshotState = $this->state === 'no_snapshot'
? 'Missing'
: ($this->snapshotId !== null ? 'Available' : 'Unavailable');
$operationProofState = $this->operationRunId !== null && $this->getRunUrl() !== null ? 'Available' : 'Unavailable';
$driftFindingsState = $this->state === 'ready' ? 'Available' : 'Unavailable';
$evidencePathState = $this->evidenceInputState($hasWarnings);
return [
[
'label' => 'Assigned baseline',
'state' => $this->state === 'invalid_scope' ? 'Needs review' : 'Available',
'tone' => $this->state === 'invalid_scope' ? 'warning' : 'success',
'description' => $this->profileName !== null
? 'Assigned baseline: '.$this->profileName.'.'
: 'Baseline assignment exists but requires review.',
],
[
'label' => 'Baseline snapshot',
'state' => $baselineSnapshotState,
'tone' => $this->flowTone($baselineSnapshotState),
'description' => $this->snapshotId !== null
? 'Snapshot #'.$this->snapshotId.' is the compare input.'
: 'No usable baseline snapshot input is linked.',
],
[
'label' => 'Environment snapshot',
'state' => $environmentSnapshotState,
'tone' => $this->flowTone($environmentSnapshotState),
'description' => $this->environmentSnapshotDescription($environmentSnapshotState),
],
[
'label' => 'OperationRun proof',
'state' => $operationProofState,
'tone' => $this->flowTone($operationProofState),
'description' => $operationProofState === 'Available'
? 'A compare OperationRun proof link is available.'
: 'No compare OperationRun proof is available yet.',
],
[
'label' => 'Drift findings',
'state' => $driftFindingsState,
'tone' => $driftFindingsState === 'Available' && ((int) ($this->findingsCount ?? 0)) > 0 ? 'warning' : $this->flowTone($driftFindingsState),
'description' => $this->driftFindingsDescription(),
],
[
'label' => 'Evidence path',
'state' => $evidencePathState,
'tone' => $this->flowTone($evidencePathState),
'description' => $this->evidenceInputDescription($hasCoverageWarnings, $hasEvidenceGaps),
],
];
}
$environmentSnapshotState = $this->hasEnvironmentSnapshot() ? 'Available' : 'Unavailable';
@ -782,6 +945,7 @@ private function compareTrustLabel(bool $hasWarnings): string
'failed' => 'Failed',
'no_assignment' => 'Unavailable',
'no_snapshot' => 'Unavailable',
'invalid_scope' => 'Needs review',
default => 'Unavailable',
};
}
@ -825,18 +989,117 @@ private function driftImpactLabel(): string
private function evidencePathSummary(bool $hasCoverageWarnings, bool $hasEvidenceGaps): string
{
if ($hasEvidenceGaps) {
return 'Evidence gaps need review';
return 'Evidence unavailable - Evidence gaps need review';
}
if ($hasCoverageWarnings) {
return 'Coverage warning recorded';
return 'Evidence unavailable - Coverage warning recorded';
}
if ($this->operationRunId !== null) {
return 'Operation proof available';
return 'Evidence unavailable - Operation proof available';
}
return 'Operation proof unavailable';
return 'Evidence unavailable';
}
private function baselineProfileUrl(): ?string
{
if (! BaselineProfileResource::canViewAny()) {
return null;
}
if ($this->profileId !== null) {
return BaselineProfileResource::getUrl('view', ['record' => $this->profileId], panel: 'admin');
}
return BaselineProfileResource::getUrl('index', panel: 'admin');
}
private function canRunCompareAction(): bool
{
if (! in_array($this->state, ['idle', 'ready', 'failed'], true)) {
return false;
}
$user = auth()->user();
$tenant = $this->currentEnvironment();
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment) {
return false;
}
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_SYNC);
}
private function environmentSnapshotState(bool $hasCoverageWarnings): string
{
if ($hasCoverageWarnings) {
return 'Needs review';
}
return $this->hasEnvironmentSnapshot() ? 'Available' : 'Unavailable';
}
private function environmentSnapshotDescription(string $environmentSnapshotState): string
{
return match ($environmentSnapshotState) {
'Available' => 'Environment snapshot evidence is present.',
'Needs review' => 'Coverage proof has warnings.',
default => 'No environment snapshot yet.',
};
}
private function flowTone(string $state): string
{
return match ($state) {
'Available', 'Complete' => 'success',
'Missing', 'Required', 'Needs review' => 'warning',
'In progress' => 'info',
'Failed' => 'danger',
default => 'gray',
};
}
private function driftFindingsDescription(): string
{
if ($this->state !== 'ready') {
return 'Run compare after required inputs exist.';
}
$findingsCount = (int) ($this->findingsCount ?? 0);
if ($findingsCount > 0) {
return $findingsCount.' '.\Illuminate\Support\Str::plural('open drift finding', $findingsCount).' available for review.';
}
return 'Zero open drift findings are recorded for the latest compare result.';
}
private function evidenceInputState(bool $hasWarnings): string
{
if ($this->operationRunId === null) {
return 'Unavailable';
}
return $hasWarnings ? 'Needs review' : 'Unavailable';
}
private function evidenceInputDescription(bool $hasCoverageWarnings, bool $hasEvidenceGaps): string
{
if ($hasEvidenceGaps) {
return 'Compare result exists, but evidence output is not available. Evidence gaps need review.';
}
if ($hasCoverageWarnings) {
return 'Compare result exists, but evidence output is not available. Coverage warnings need review.';
}
if ($this->operationRunId !== null) {
return 'Compare result exists, but no evidence output is linked yet.';
}
return 'No evidence output is linked yet.';
}
/**

View File

@ -9,8 +9,8 @@
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Support\OperationCatalog;
use App\Support\OperationRunStatus;
@ -19,6 +19,7 @@
use App\Support\Ui\OperatorExplanation\CountDescriptor;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
final class BaselineCompareStats
{

View File

@ -0,0 +1,87 @@
@php
$steps = is_array($steps ?? null) ? $steps : [];
$flowTestId = $flowTestId ?? 'product-process-flow';
$stepTestId = $stepTestId ?? 'product-process-flow-step';
$connectorTestId = $connectorTestId ?? 'product-process-flow-connector';
$badgeTestId = $badgeTestId ?? 'product-process-flow-badge';
$statusBadgeClasses = $statusBadgeClasses ?? static fn (?string $tone): string => 'inline-flex items-center rounded-md border px-2 py-0.5 text-left text-xs font-medium leading-5 whitespace-normal break-words '.match ($tone) {
'danger' => 'border-danger-200 bg-danger-50 text-danger-700 dark:border-danger-800 dark:bg-danger-500/10 dark:text-danger-300',
'success' => 'border-success-200 bg-success-50 text-success-700 dark:border-success-800 dark:bg-success-500/10 dark:text-success-300',
'warning' => 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-800 dark:bg-warning-500/10 dark:text-warning-300',
'info' => 'border-info-200 bg-info-50 text-info-700 dark:border-info-800 dark:bg-info-500/10 dark:text-info-300',
'primary' => 'border-primary-200 bg-primary-50 text-primary-700 dark:border-primary-800 dark:bg-primary-500/10 dark:text-primary-300',
default => 'border-gray-200 bg-gray-50 text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-200',
};
@endphp
<div data-testid="{{ $flowTestId }}" class="space-y-5">
<div class="space-y-1">
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
{{ $title ?? 'Product process flow' }}
</h2>
@if (filled($subtitle ?? null))
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
{{ $subtitle }}
</p>
@endif
</div>
<ol class="flex flex-col gap-3 lg:flex-row lg:items-stretch lg:gap-1.5" aria-label="{{ $ariaLabel ?? 'Product process flow' }}">
@foreach ($steps as $step)
@php
$isCurrentBlocker = (bool) ($step['currentBlocker'] ?? false);
$stepCardClasses = $isCurrentBlocker
? 'border-warning-300 bg-warning-50 shadow-sm dark:border-warning-700 dark:bg-warning-950/40'
: 'border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-950/50';
$stepNumberClasses = $isCurrentBlocker
? 'border-warning-300 bg-warning-50 text-warning-700 dark:border-warning-700 dark:bg-warning-950/40 dark:text-warning-300'
: 'border-gray-200 bg-white text-gray-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300';
@endphp
<li
data-testid="{{ $stepTestId }}"
data-step-label="{{ $step['label'] }}"
data-step-state="{{ $step['state'] }}"
data-step-current-blocker="{{ $isCurrentBlocker ? 'true' : 'false' }}"
class="flex min-w-0 flex-1 flex-col gap-2 lg:flex-row lg:items-stretch"
>
<div class="flex min-w-0 flex-1 flex-col rounded-lg border px-3 py-2.5 {{ $stepCardClasses }}">
<div class="flex min-w-0 items-start gap-3 lg:flex-col lg:gap-2">
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold leading-none {{ $stepNumberClasses }}">
{{ $loop->iteration }}
</span>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold leading-5 text-gray-950 break-normal dark:text-white">
{{ $step['label'] }}
</div>
<div class="mt-2">
<span data-testid="{{ $badgeTestId }}" class="{{ $statusBadgeClasses($step['tone'] ?? 'gray') }}">
{{ $step['state'] }}
</span>
</div>
</div>
</div>
<p class="mt-2 text-xs leading-5 text-gray-600 dark:text-gray-300">
{{ $step['description'] }}
</p>
</div>
@if (! $loop->last)
<div
data-testid="{{ $connectorTestId }}"
data-connector-label="{{ $step['label'] }} to {{ $steps[$loop->index + 1]['label'] ?? '' }}"
class="flex shrink-0 items-center justify-center text-gray-400 dark:text-gray-500 lg:w-6"
aria-hidden="true"
>
<span class="inline-flex h-7 min-w-7 items-center justify-center rounded-full border border-gray-200 bg-white px-2 text-sm font-semibold leading-none shadow-sm dark:border-gray-700 dark:bg-gray-900">
&rarr;
</span>
</div>
@endif
</li>
@endforeach
</ol>
</div>

View File

@ -39,7 +39,7 @@
@endphp
<x-filament::section>
<div data-testid="baseline-compare-decision-workbench" class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_20rem]">
<div data-testid="baseline-compare-decision-workbench" class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_19rem]">
<div class="min-w-0 space-y-4">
<div class="flex flex-wrap items-center gap-3">
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
@ -50,20 +50,32 @@
</span>
</div>
<dl class="grid gap-3 md:grid-cols-3">
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $decisionCard['statusLabel'] ?? 'Status' }}</dt>
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">{{ $decisionCard['status'] ?? 'Compare unavailable' }}</dd>
<dl class="overflow-hidden rounded-lg border border-gray-200 bg-gray-50/70 dark:border-gray-800 dark:bg-gray-950/40">
<div class="grid gap-1 px-4 py-3 sm:grid-cols-[8rem_minmax(0,1fr)] sm:gap-4">
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">
{{ $decisionCard['statusLabel'] ?? 'Status' }}
</dt>
<dd class="text-sm font-medium text-gray-950 dark:text-white">
{{ $decisionCard['status'] ?? 'Compare unavailable' }}
</dd>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $decisionCard['reasonLabel'] ?? 'Reason' }}</dt>
<dd class="mt-1 text-sm text-gray-700 dark:text-gray-200">{{ $decisionCard['reason'] ?? 'Compare state is unavailable.' }}</dd>
<div class="grid gap-1 border-t border-gray-200 px-4 py-3 dark:border-gray-800 sm:grid-cols-[8rem_minmax(0,1fr)] sm:gap-4">
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">
{{ $decisionCard['reasonLabel'] ?? 'Reason' }}
</dt>
<dd class="text-sm leading-6 text-gray-700 dark:text-gray-200">
{{ $decisionCard['reason'] ?? 'Compare state is unavailable.' }}
</dd>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $decisionCard['impactLabel'] ?? 'Impact' }}</dt>
<dd class="mt-1 text-sm text-gray-700 dark:text-gray-200">{{ $decisionCard['impact'] ?? 'No governance decision should rely on this compare state yet.' }}</dd>
<div class="grid gap-1 border-t border-gray-200 px-4 py-3 dark:border-gray-800 sm:grid-cols-[8rem_minmax(0,1fr)] sm:gap-4">
<dt class="text-xs font-semibold text-gray-500 dark:text-gray-400">
{{ $decisionCard['impactLabel'] ?? 'Impact' }}
</dt>
<dd class="text-sm leading-6 text-gray-700 dark:text-gray-200">
{{ $decisionCard['impact'] ?? 'No governance decision should rely on this compare state yet.' }}
</dd>
</div>
</dl>
@ -83,18 +95,21 @@
<aside data-testid="baseline-compare-proof-panel" class="min-w-0 space-y-3">
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $decisionCard['evidenceLabel'] ?? 'Evidence path' }}</div>
<p class="mt-1 text-sm text-gray-700 dark:text-gray-200">{{ $decisionCard['evidence'] ?? 'Operation proof unavailable' }}</p>
<p class="mt-1 text-sm leading-6 text-gray-700 dark:text-gray-200">{{ $decisionCard['evidence'] ?? 'Operation proof unavailable' }}</p>
<div class="mt-4 border-t border-gray-200 pt-4 dark:border-gray-800">
<div class="text-xs font-semibold text-gray-500 dark:text-gray-400">{{ $decisionCard['nextActionLabel'] ?? 'Next action' }}</div>
<div class="mt-1 text-sm font-medium text-gray-950 dark:text-white">{{ $decisionCard['actionLabel'] ?? 'Review compare state' }}</div>
@if (filled($decisionCard['actionUrl'] ?? null))
<x-filament::button class="mt-3" tag="a" :href="$decisionCard['actionUrl']" size="sm">
<x-filament::button class="mt-2 w-full justify-center" tag="a" :href="$decisionCard['actionUrl']" size="sm">
{{ $decisionCard['actionLabel'] ?? 'Review compare state' }}
</x-filament::button>
@elseif (($decisionCard['actionName'] ?? null) === 'compareNow' && ! (bool) ($decisionCard['actionDisabled'] ?? true))
<x-filament::button class="mt-2 w-full justify-center" type="button" wire:click="mountAction('compareNow')" size="sm">
{{ $decisionCard['actionLabel'] ?? 'Compare now' }}
</x-filament::button>
@else
<x-filament::button class="mt-3" color="gray" size="sm" disabled>
<x-filament::button class="mt-2 w-full justify-center" color="gray" size="sm" disabled>
{{ $decisionCard['actionLabel'] ?? 'Review compare state' }}
</x-filament::button>
@endif
@ -141,74 +156,18 @@
@if (! empty($compareReadinessFlow))
<x-filament::section>
<div data-testid="baseline-compare-readiness-flow" class="space-y-5">
<div class="space-y-1">
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
Compare readiness flow
</h2>
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
Baseline comparison needs an assigned baseline, linked snapshots, a compare run, and a decision output.
</p>
</div>
<ol class="flex flex-col gap-3 lg:flex-row lg:items-stretch lg:gap-2" aria-label="Compare readiness pipeline">
@foreach ($compareReadinessFlow as $step)
@php
$isCurrentBlocker = $loop->first && ($step['state'] ?? null) === 'Missing';
$stepCardClasses = $isCurrentBlocker
? 'border-warning-300 bg-warning-50 shadow-sm dark:border-warning-700 dark:bg-warning-950/40'
: 'border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-950/50';
$stepNumberClasses = $isCurrentBlocker
? 'border-warning-300 bg-warning-50 text-warning-700 dark:border-warning-700 dark:bg-warning-950/40 dark:text-warning-300'
: 'border-gray-200 bg-white text-gray-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300';
@endphp
<li
data-testid="baseline-compare-readiness-step"
data-step-label="{{ $step['label'] }}"
data-step-state="{{ $step['state'] }}"
data-step-current-blocker="{{ $isCurrentBlocker ? 'true' : 'false' }}"
class="flex min-w-0 flex-1 flex-col gap-2 lg:flex-row lg:items-stretch"
>
<div class="flex min-w-0 flex-1 flex-col rounded-lg border px-3 py-3 {{ $stepCardClasses }}">
<div class="flex min-w-0 items-start gap-3">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border text-xs font-semibold leading-none {{ $stepNumberClasses }}">
{{ $loop->iteration }}
</span>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold leading-5 text-gray-950 dark:text-white">
{{ $step['label'] }}
</div>
<div class="mt-2">
<span data-testid="baseline-compare-status-badge" class="{{ $baselineCompareStatusBadgeClasses($step['tone'] ?? 'gray') }}">
{{ $step['state'] }}
</span>
</div>
</div>
</div>
<p class="mt-3 text-xs leading-5 text-gray-600 dark:text-gray-300">
{{ $step['description'] }}
</p>
</div>
@if (! $loop->last)
<div
data-testid="baseline-compare-readiness-connector"
data-connector-label="{{ $step['label'] }} to {{ $compareReadinessFlow[$loop->index + 1]['label'] ?? '' }}"
class="flex shrink-0 items-center justify-center text-gray-400 dark:text-gray-500 lg:w-8"
aria-hidden="true"
>
<span class="inline-flex h-7 min-w-7 items-center justify-center rounded-full border border-gray-200 bg-white px-2 text-sm font-semibold leading-none shadow-sm dark:border-gray-700 dark:bg-gray-900">
&rarr;
</span>
</div>
@endif
</li>
@endforeach
</ol>
<div class="space-y-5">
@include('filament.components.product-process-flow-horizontal', [
'title' => 'Compare readiness flow',
'subtitle' => 'Baseline comparison needs an assigned baseline, linked snapshots, a compare run, and a decision output.',
'ariaLabel' => 'Compare readiness pipeline',
'steps' => $compareReadinessFlow,
'flowTestId' => 'baseline-compare-readiness-flow',
'stepTestId' => 'baseline-compare-readiness-step',
'connectorTestId' => 'baseline-compare-readiness-connector',
'badgeTestId' => 'baseline-compare-status-badge',
'statusBadgeClasses' => $baselineCompareStatusBadgeClasses,
])
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_18rem]">
<div data-testid="baseline-compare-available-inputs" class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
@ -221,21 +180,24 @@ class="flex shrink-0 items-center justify-center text-gray-400 dark:text-gray-50
</p>
</div>
<div class="mt-3 grid gap-2 md:grid-cols-3">
<div class="mt-3 overflow-hidden rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900/80">
@foreach ($availableCompareInputs as $input)
<div data-testid="baseline-compare-available-input" data-input-label="{{ $input['label'] }}" class="flex min-w-0 items-start justify-between gap-3 rounded-md border border-gray-200 bg-white px-3 py-2 dark:border-gray-800 dark:bg-gray-900/80">
<div class="min-w-0">
<div class="text-sm font-medium leading-5 text-gray-950 dark:text-white">
<div data-testid="baseline-compare-available-input" data-input-label="{{ $input['label'] }}" class="grid min-w-0 gap-2 border-b border-gray-200 px-3 py-2.5 last:border-b-0 dark:border-gray-800 sm:grid-cols-[minmax(8rem,1fr)_8rem_minmax(0,1.7fr)] sm:items-start">
<div class="min-w-0 text-sm font-medium leading-5 text-gray-950 dark:text-white">
<span class="break-words">
{{ $input['label'] }}
</div>
<p class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">
{{ $input['description'] }}
</p>
</span>
</div>
<span data-testid="baseline-compare-status-badge" class="{{ $baselineCompareStatusBadgeClasses($input['tone'] ?? 'gray') }}">
{{ $input['state'] }}
</span>
<div>
<span data-testid="baseline-compare-status-badge" class="{{ $baselineCompareStatusBadgeClasses($input['tone'] ?? 'gray') }}">
{{ $input['state'] }}
</span>
</div>
<p class="min-w-0 text-xs leading-5 text-gray-500 dark:text-gray-400">
{{ $input['description'] }}
</p>
</div>
@endforeach
</div>
@ -249,7 +211,7 @@ class="flex shrink-0 items-center justify-center text-gray-400 dark:text-gray-50
<ul class="mt-3 space-y-2">
@foreach ($assignmentUnlocks as $unlock)
<li class="flex gap-2 text-xs leading-5 text-gray-600 dark:text-gray-300">
<x-heroicon-m-check-circle class="mt-0.5 h-4 w-4 shrink-0 text-primary-500 dark:text-primary-400" />
<x-heroicon-m-arrow-right-circle class="mt-0.5 h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" />
<span>{{ $unlock }}</span>
</li>
@endforeach
@ -437,99 +399,6 @@ class="flex shrink-0 items-center justify-center text-gray-400 dark:text-gray-50
</x-filament::section>
@endif
{{-- Row 1: Stats Overview --}}
@if (in_array($state, ['ready', 'idle', 'comparing', 'failed']))
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
{{-- Stat: Assigned Baseline --}}
<x-filament::section>
<div class="flex flex-col gap-1">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('baseline-compare.stat_assigned_baseline') }}</div>
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ $profileName ?? '—' }}</div>
<div class="flex flex-wrap items-center gap-2">
@if ($snapshotId)
<x-filament::badge color="success" size="sm" class="w-fit">
{{ __('baseline-compare.badge_snapshot', ['id' => $snapshotId]) }}
</x-filament::badge>
@endif
@if (filled($coverageStatus))
<x-filament::badge
:color="$coverageStatus === 'ok' ? 'success' : 'warning'"
size="sm"
class="w-fit"
>
{{ $coverageStatus === 'ok' ? __('baseline-compare.badge_coverage_ok') : __('baseline-compare.badge_coverage_warnings') }}
</x-filament::badge>
@endif
@if (filled($fidelity))
<x-filament::badge color="gray" size="sm" class="w-fit">
{{ __('baseline-compare.badge_fidelity', ['level' => Str::title($fidelity)]) }}
</x-filament::badge>
@endif
@if ($hasEvidenceGaps)
<x-filament::badge color="warning" size="sm" class="w-fit" :title="$evidenceGapsTooltip">
{{ __('baseline-compare.badge_evidence_gaps', ['count' => $evidenceGapsCountValue]) }}
</x-filament::badge>
@endif
</div>
@if ($hasEvidenceGaps && filled($evidenceGapsSummary))
<div class="mt-1 text-xs text-warning-700 dark:text-warning-300">
{{ __('baseline-compare.evidence_gaps_tooltip', ['summary' => $evidenceGapsSummary]) }}
</div>
@endif
</div>
</x-filament::section>
{{-- Stat: Total Findings --}}
<x-filament::section>
<div class="flex flex-col gap-1">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('baseline-compare.stat_total_findings') }}</div>
@if ($state === 'failed')
<div class="text-lg font-semibold text-danger-600 dark:text-danger-400">{{ __('baseline-compare.stat_error') }}</div>
@else
<div class="text-3xl font-bold {{ $findingsColorClass }}">
{{ $findingsCount ?? 0 }}
</div>
@endif
@if ($state === 'comparing')
<div class="flex items-center gap-1 text-sm text-info-600 dark:text-info-400">
<x-filament::loading-indicator class="h-3 w-3" />
{{ __('baseline-compare.comparing_indicator') }}
</div>
@elseif (($findingsCount ?? 0) === 0 && $state === 'ready')
<div class="space-y-1">
<span class="text-sm {{ $whyNoFindingsColor }}">{{ $summary['headline'] ?? ($whyNoFindingsMessage ?? $whyNoFindingsFallback) }}</span>
@if (filled($summary['supportingMessage'] ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $summary['supportingMessage'] }}
</div>
@endif
</div>
@endif
</div>
</x-filament::section>
{{-- Stat: Last Compared --}}
<x-filament::section>
<div class="flex flex-col gap-1">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('baseline-compare.stat_last_compared') }}</div>
<div class="text-lg font-semibold text-gray-950 dark:text-white" @if ($lastComparedIso) title="{{ $lastComparedIso }}" @endif>
{{ $lastComparedAt ?? __('baseline-compare.stat_last_compared_never') }}
</div>
@if ($this->getRunUrl())
<x-filament::link :href="$this->getRunUrl()" size="sm">
{{ __('baseline-compare.button_view_run') }}
</x-filament::link>
@endif
</div>
</x-filament::section>
</div>
@endif
@if ($hasRbacRoleDefinitionSummary)
<x-filament::section :heading="__('baseline-compare.rbac_summary_title')">
<x-slot name="description">
@ -654,22 +523,6 @@ class="w-fit"
</div>
@endif
{{-- State: No tenant / no assignment / no snapshot --}}
@if (in_array($state, ['no_tenant', 'no_snapshot']))
<x-filament::section>
<div class="flex flex-col items-center justify-center gap-3 py-8 text-center">
@if ($state === 'no_tenant')
<x-heroicon-o-building-office class="h-12 w-12 text-gray-400 dark:text-gray-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.empty_no_tenant') }}</div>
@elseif ($state === 'no_snapshot')
<x-heroicon-o-camera class="h-12 w-12 text-warning-400 dark:text-warning-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.empty_no_snapshot') }}</div>
@endif
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">{{ $message }}</div>
</div>
</x-filament::section>
@endif
@if ($hasEvidenceGapDetailSection)
<x-filament::section :heading="__('baseline-compare.evidence_gap_details_heading')">
<x-slot name="description">
@ -745,69 +598,6 @@ class="w-fit"
</x-filament::section>
@endif
{{-- Ready: no drift --}}
@if ($state === 'ready' && ($findingsCount ?? 0) === 0 && ! $hasCoverageWarnings)
<x-filament::section>
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
<x-heroicon-o-check-circle class="h-12 w-12 text-success-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.no_drift_title') }}</div>
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
{{ __('baseline-compare.no_drift_body') }}
</div>
@if ($this->getRunUrl())
<x-filament::button
:href="$this->getRunUrl()"
tag="a"
color="gray"
outlined
icon="heroicon-o-queue-list"
size="sm"
>
{{ __('baseline-compare.button_review_last_run') }}
</x-filament::button>
@endif
</div>
</x-filament::section>
@endif
{{-- Ready: warnings, no findings --}}
@if ($state === 'ready' && ($findingsCount ?? 0) === 0 && $hasCoverageWarnings)
<x-filament::section>
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
<x-heroicon-o-exclamation-triangle class="h-12 w-12 text-warning-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.coverage_warnings_title') }}</div>
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
{{ __('baseline-compare.coverage_warnings_body') }}
</div>
@if ($this->getRunUrl())
<x-filament::button
:href="$this->getRunUrl()"
tag="a"
color="gray"
outlined
icon="heroicon-o-queue-list"
size="sm"
>
{{ __('baseline-compare.button_review_last_run') }}
</x-filament::button>
@endif
</div>
</x-filament::section>
@endif
{{-- Idle state --}}
@if ($state === 'idle')
<x-filament::section>
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
<x-heroicon-o-play class="h-12 w-12 text-gray-400 dark:text-gray-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ __('baseline-compare.idle_title') }}</div>
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
{{ $message }}
</div>
</div>
</x-filament::section>
@endif
@if ($hasEvidenceGapDiagnostics)
<x-filament::section :heading="__('baseline-compare.evidence_gap_diagnostics_heading')">
<x-slot name="description">

View File

@ -91,9 +91,8 @@
visit(ManagedEnvironmentLinks::baselineCompareUrl($environment))
->waitForText('Which baseline drift requires action?')
->assertSee('Baseline not assigned')
->assertSee('Baseline compare cannot be used for governance decisions until an assignment exists.')
->assertSee('Compare trust is unavailable until a baseline assignment exists.')
->assertSee('No usable drift result is available yet.')
->assertSee('Baseline drift cannot be used for governance decisions until a baseline assignment exists.')
->assertSee('Evidence unavailable')
->assertSee('Open baseline profiles to assign a baseline to this environment.')
->assertSee('Compare readiness flow')
->assertSee('Baseline comparison needs an assigned baseline, linked snapshots, a compare run, and a decision output.')
@ -101,7 +100,7 @@
->assertSee('Missing')
->assertSee('Baseline snapshot')
->assertSee('Environment snapshot')
->assertSee('Current environment evidence is present.')
->assertSee('Environment snapshot evidence is present.')
->assertSee('Compare run')
->assertSee('Decision output')
->assertSee('Available inputs')

View File

@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
use App\Models\BaselineProfile;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\ManagedEnvironmentLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
pest()->browser()->timeout(60_000);
function spec336BrowserScreenshotName(string $name): string
{
return 'spec336-baseline-compare-'.$name;
}
function spec336CopyBrowserScreenshot(string $name): void
{
$filename = spec336BrowserScreenshotName($name).'.png';
$source = base_path('tests/Browser/Screenshots/'.$filename);
$targetDirectory = repo_path('specs/336-baseline-compare-product-process-flow-alignment/artifacts/screenshots');
if (! is_dir($targetDirectory)) {
@mkdir($targetDirectory, 0755, true);
}
if (! is_file($source)) {
$source = \Pest\Browser\Support\Screenshot::path($filename);
}
for ($attempt = 0; $attempt < 10 && ! is_file($source); $attempt++) {
usleep(100_000);
clearstatcache(true, $source);
}
if (is_file($source) && is_dir($targetDirectory) && is_writable($targetDirectory)) {
@copy($source, $targetDirectory.DIRECTORY_SEPARATOR.$name.'.png');
}
}
function spec336AuthenticateBrowser(mixed $test, User $user, ManagedEnvironment $environment): void
{
$workspaceId = (int) $environment->workspace_id;
$test->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => $workspaceId,
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
(string) $workspaceId => (int) $environment->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
session()->put(WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $environment->getKey(),
]);
}
function spec336EnvironmentFor(User $user, ManagedEnvironment $baseEnvironment, string $name): ManagedEnvironment
{
$environment = ManagedEnvironment::factory()->create([
'workspace_id' => (int) $baseEnvironment->workspace_id,
'name' => $name,
]);
createUserWithTenant(tenant: $environment, user: $user, role: 'owner', workspaceRole: 'manager');
return $environment;
}
it('Spec336 smokes Baseline Compare product process flow states', function (): void {
[$user, $noBaselineEnvironment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
createInventorySyncOperationRunWithCoverage($noBaselineEnvironment, ['deviceConfiguration' => 'succeeded']);
$snapshotMissingEnvironment = spec336EnvironmentFor($user, $noBaselineEnvironment, 'Spec336 Snapshot Missing');
$missingProfile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $snapshotMissingEnvironment->workspace_id,
]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $snapshotMissingEnvironment->workspace_id,
'managed_environment_id' => (int) $snapshotMissingEnvironment->getKey(),
'baseline_profile_id' => (int) $missingProfile->getKey(),
]);
$compareRequiredEnvironment = spec336EnvironmentFor($user, $noBaselineEnvironment, 'Spec336 Compare Required');
seedActiveBaselineForTenant($compareRequiredEnvironment);
createInventorySyncOperationRunWithCoverage($compareRequiredEnvironment, ['deviceConfiguration' => 'succeeded']);
$compareAvailableEnvironment = spec336EnvironmentFor($user, $noBaselineEnvironment, 'Spec336 Compare Available');
[$availableProfile, $availableSnapshot] = seedActiveBaselineForTenant($compareAvailableEnvironment);
$availableRun = seedBaselineCompareRun($compareAvailableEnvironment, $availableProfile, $availableSnapshot, [
'reason_code' => BaselineCompareReasonCode::OverdueFindingsRemain->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
]);
Finding::factory()->create([
'managed_environment_id' => (int) $compareAvailableEnvironment->getKey(),
'workspace_id' => (int) $compareAvailableEnvironment->workspace_id,
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => 'baseline_profile:'.$availableProfile->getKey(),
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_NEW,
'source' => OperationRunType::BaselineCompare->value,
'baseline_operation_run_id' => (int) $availableRun->getKey(),
]);
$evidenceUnavailableEnvironment = spec336EnvironmentFor($user, $noBaselineEnvironment, 'Spec336 Evidence Unavailable');
[$evidenceProfile, $evidenceSnapshot] = seedActiveBaselineForTenant($evidenceUnavailableEnvironment);
$evidenceRun = seedBaselineCompareRun($evidenceUnavailableEnvironment, $evidenceProfile, $evidenceSnapshot, [
'reason_code' => BaselineCompareReasonCode::EvidenceCaptureIncomplete->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
'evidence_gaps' => [
'count' => 1,
'by_reason' => [
'inventory_record_missing' => 1,
],
],
], OperationRunStatus::Completed->value, OperationRunOutcome::PartiallySucceeded->value);
Finding::factory()->create([
'managed_environment_id' => (int) $evidenceUnavailableEnvironment->getKey(),
'workspace_id' => (int) $evidenceUnavailableEnvironment->workspace_id,
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => 'baseline_profile:'.$evidenceProfile->getKey(),
'severity' => Finding::SEVERITY_MEDIUM,
'status' => Finding::STATUS_NEW,
'source' => OperationRunType::BaselineCompare->value,
'baseline_operation_run_id' => (int) $evidenceRun->getKey(),
]);
spec336AuthenticateBrowser($this, $user, $noBaselineEnvironment);
$page = visit(ManagedEnvironmentLinks::baselineCompareUrl($noBaselineEnvironment))
->waitForText('Which baseline drift requires action?')
->assertSee('Baseline not assigned')
->assertSee('Open baseline profiles')
->assertSee('Compare readiness flow')
->assertSee('Available inputs')
->assertScript('document.querySelectorAll("[data-testid=\"baseline-compare-readiness-step\"]").length === 5', true)
->assertScript('document.querySelector("[data-step-label=\"Baseline assigned\"]")?.dataset.stepState === "Missing"', true)
->assertScript('document.querySelector("[data-testid=\"baseline-compare-diagnostics\"]")?.open === false', true)
->assertDontSee('raw diff')
->assertDontSee('raw payload')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$page->screenshot(true, spec336BrowserScreenshotName('01-no-baseline-assigned'));
spec336CopyBrowserScreenshot('01-no-baseline-assigned');
$page = visit(ManagedEnvironmentLinks::baselineCompareUrl($snapshotMissingEnvironment))
->waitForText('Baseline snapshot required')
->assertSee('Open baseline profile')
->assertSee('No usable baseline snapshot input is linked.')
->assertScript('document.querySelector("[data-step-label=\"Baseline snapshot\"]")?.dataset.stepState === "Missing"', true)
->assertScript('document.querySelector("[data-step-label=\"Baseline snapshot\"]")?.dataset.stepCurrentBlocker === "true"', true)
->assertDontSee('raw diff')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$page->screenshot(true, spec336BrowserScreenshotName('02-baseline-snapshot-required'));
spec336CopyBrowserScreenshot('02-baseline-snapshot-required');
$page = visit(ManagedEnvironmentLinks::baselineCompareUrl($compareRequiredEnvironment))
->waitForText('Compare run required')
->assertSee('Compare now')
->assertScript('document.querySelector("[data-step-label=\"Compare run\"]")?.dataset.stepState === "Required"', true)
->assertScript('document.querySelector("[data-step-label=\"Compare run\"]")?.dataset.stepCurrentBlocker === "true"', true)
->assertScript('document.body.innerText.includes("Total Findings") === false', true)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$page->screenshot(true, spec336BrowserScreenshotName('03-compare-run-required'));
spec336CopyBrowserScreenshot('03-compare-run-required');
$page = visit(ManagedEnvironmentLinks::baselineCompareUrl($compareAvailableEnvironment))
->waitForText('Drift findings available')
->assertSee('Review drift findings')
->assertSee('OperationRun proof')
->assertScript('document.querySelector("[data-step-label=\"Compare run\"]")?.dataset.stepState === "Available"', true)
->assertScript('document.querySelector("[data-step-label=\"Decision output\"]")?.dataset.stepState === "Available"', true)
->assertScript('Array.from(document.querySelectorAll("[data-testid=\"baseline-compare-status-badge\"]")).every((badge) => !badge.innerText.includes("...") && getComputedStyle(badge).overflow !== "hidden" && getComputedStyle(badge).textOverflow !== "ellipsis")', true)
->assertDontSee('customer-safe')
->assertDontSee('raw diff')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$page->screenshot(true, spec336BrowserScreenshotName('04-compare-result-available'));
spec336CopyBrowserScreenshot('04-compare-result-available');
$page = visit(ManagedEnvironmentLinks::baselineCompareUrl($evidenceUnavailableEnvironment))
->waitForText('Evidence unavailable - Evidence gaps need review')
->assertSee('Compare result exists, but evidence output is not available.')
->assertScript('document.querySelector("[data-step-label=\"Decision output\"]")?.dataset.stepState === "Needs review"', true)
->assertScript('document.querySelector("[data-testid=\"baseline-compare-diagnostics\"]")?.open === false', true)
->assertDontSee('raw diff')
->assertDontSee('raw payload')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$page->screenshot(true, spec336BrowserScreenshotName('05-evidence-unavailable'));
spec336CopyBrowserScreenshot('05-evidence-unavailable');
$page->screenshot(true, spec336BrowserScreenshotName('06-diagnostics-collapsed'));
spec336CopyBrowserScreenshot('06-diagnostics-collapsed');
$page->script("document.documentElement.classList.add('dark');");
$page->script('window.scrollTo(0, 0);');
$page->assertScript('document.documentElement.classList.contains("dark")', true);
$page->screenshot(true, spec336BrowserScreenshotName('07-dark-mode'));
spec336CopyBrowserScreenshot('07-dark-mode');
});

View File

@ -65,7 +65,7 @@ function baselineCompareRouteContractForbiddenQueryKeys(): array
->assertSeeText('Baseline Compare')
->assertSeeText($tenant->workspace()->firstOrFail()->name)
->assertSeeText($tenant->name)
->assertSeeText('This environment has no baseline assignment');
->assertSeeText('This environment does not have an assigned baseline.');
});
it('rejects old workspace-style baseline compare URLs and remembered environment fallback', function (): void {

View File

@ -151,9 +151,8 @@
$component = baselineCompareLandingLivewire($environment)
->assertSee('Which baseline drift requires action?')
->assertSee('Baseline not assigned')
->assertSee('Baseline compare cannot be used for governance decisions until an assignment exists.')
->assertSee('Compare trust is unavailable until a baseline assignment exists.')
->assertSee('No usable drift result is available yet.')
->assertSee('Baseline drift cannot be used for governance decisions until a baseline assignment exists.')
->assertSee('Evidence unavailable')
->assertSee('Open baseline profiles to assign a baseline to this environment.')
->assertSee('Evidence path')
->assertSee('Next action')
@ -161,16 +160,15 @@
->assertSee('Baseline comparison needs an assigned baseline, linked snapshots, a compare run, and a decision output.')
->assertSee('Baseline assigned')
->assertSee('Missing')
->assertSee('No baseline is assigned to this environment.')
->assertSee('No baseline is assigned.')
->assertSee('Baseline snapshot')
->assertSee('No baseline snapshot is linked.')
->assertSee('No snapshot linked.')
->assertSee('Environment snapshot')
->assertSee('Current environment evidence is present.')
->assertSee('Environment snapshot state is required for compare.')
->assertSee('Environment snapshot evidence is present.')
->assertSee('Compare run')
->assertSee('Compare cannot run until required inputs exist.')
->assertSee('Blocked by missing inputs.')
->assertSee('Decision output')
->assertSee('No drift decision output is available yet.')
->assertSee('No decision output.')
->assertSee('Available inputs')
->assertSee('Operation proof')
->assertSee('Unavailable because no baseline assigned.')

View File

@ -0,0 +1,351 @@
<?php
declare(strict_types=1);
use App\Models\BaselineProfile;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\ManagedEnvironmentLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\DB;
it('renders no baseline assigned as the first product process flow blocker', function (): void {
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
createInventorySyncOperationRunWithCoverage($environment, ['deviceConfiguration' => 'succeeded']);
$this->actingAs($user);
setAdminPanelContext($environment);
$component = baselineCompareLandingLivewire($environment)
->assertSee('Which baseline drift requires action?')
->assertSee('Baseline not assigned')
->assertSee('This environment does not have an assigned baseline.')
->assertSee('Open baseline profiles')
->assertSee('Compare readiness flow')
->assertSee('Available inputs')
->assertSee('Diagnostics - Collapsed')
->assertDontSee('raw diff')
->assertDontSee('raw payload')
->assertDontSee('provider response')
->assertDontSee('stack trace');
$content = $component->html();
spec336AssertFlowStep($content, 'Baseline assigned', 'Missing', true);
spec336AssertFlowStep($content, 'Baseline snapshot', 'Unavailable', false);
spec336AssertFlowStep($content, 'Environment snapshot', 'Available', false);
spec336AssertFlowStep($content, 'Compare run', 'Unavailable', false);
spec336AssertFlowStep($content, 'Decision output', 'Unavailable', false);
expect(substr_count($content, 'data-testid="baseline-compare-readiness-step"'))->toBe(5)
->and(substr_count($content, 'data-testid="baseline-compare-available-input"'))->toBe(3)
->and($content)->not->toContain('data-testid="baseline-compare-diagnostics" open');
});
it('renders baseline assigned with snapshot missing as a blocked compare flow', function (): void {
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $environment->workspace_id,
]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$this->actingAs($user);
setAdminPanelContext($environment);
$component = baselineCompareLandingLivewire($environment)
->assertSee('Baseline snapshot required')
->assertSee('A baseline is assigned, but no usable baseline snapshot is available.')
->assertSee('Compare cannot run until baseline snapshot input exists.')
->assertSee('Open baseline profile')
->assertSee('No usable baseline snapshot input is linked.')
->assertDontSee('Drift findings available')
->assertDontSee('No drift detected')
->assertDontSee('raw diff');
$content = $component->html();
spec336AssertFlowStep($content, 'Baseline assigned', 'Complete', false);
spec336AssertFlowStep($content, 'Baseline snapshot', 'Missing', true);
spec336AssertFlowStep($content, 'Compare run', 'Unavailable', false);
spec336AssertFlowStep($content, 'Decision output', 'Unavailable', false);
spec336AssertInputState($content, 'Assigned baseline', 'Available');
spec336AssertInputState($content, 'Baseline snapshot', 'Missing');
spec336AssertInputState($content, 'Drift findings', 'Unavailable');
});
it('renders compare required with the capability-aware primary action', function (): void {
[$owner, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
[$readonly] = createUserWithTenant(tenant: $environment, role: 'readonly', workspaceRole: 'readonly');
seedActiveBaselineForTenant($environment);
createInventorySyncOperationRunWithCoverage($environment, ['deviceConfiguration' => 'succeeded']);
$this->actingAs($owner);
setAdminPanelContext($environment);
$component = baselineCompareLandingLivewire($environment)
->assertSee('Compare run required')
->assertSee('Required inputs exist, but no compare run has been created for the current state.')
->assertSee('Compare now')
->assertActionEnabled('compareNow')
->assertDontSee('Ready to Compare');
$content = $component->html();
spec336AssertFlowStep($content, 'Baseline assigned', 'Complete', false);
spec336AssertFlowStep($content, 'Baseline snapshot', 'Available', false);
spec336AssertFlowStep($content, 'Compare run', 'Required', true);
spec336AssertFlowStep($content, 'Decision output', 'Required', false);
spec336AssertInputState($content, 'OperationRun proof', 'Unavailable');
baselineCompareLandingLivewire($environment, user: $readonly)
->assertSee('Compare unavailable')
->assertActionDisabled('compareNow');
});
it('renders in-progress and failed compare proof without claiming a decision output', function (): void {
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
[$profile, $snapshot] = seedActiveBaselineForTenant($environment);
$runningRun = seedBaselineCompareRun(
$environment,
$profile,
$snapshot,
[],
OperationRunStatus::Running->value,
OperationRunOutcome::Pending->value,
now(),
);
$this->actingAs($user);
setAdminPanelContext($environment);
$runningComponent = baselineCompareLandingLivewire($environment)
->assertSee('Compare in progress')
->assertSee('View operation progress')
->assertSet('operationRunId', (int) $runningRun->getKey());
$runningContent = $runningComponent->html();
spec336AssertFlowStep($runningContent, 'Compare run', 'In progress', true);
spec336AssertFlowStep($runningContent, 'Decision output', 'Unavailable', false);
spec336AssertInputState($runningContent, 'OperationRun proof', 'Available');
$failedRun = seedBaselineCompareRun(
$environment,
$profile,
$snapshot,
[],
OperationRunStatus::Completed->value,
OperationRunOutcome::Failed->value,
now()->addMinute(),
);
$failedComponent = baselineCompareLandingLivewire($environment)
->assertSee('Compare failed')
->assertSee('Review compare failure')
->assertSet('operationRunId', (int) $failedRun->getKey());
$failedContent = $failedComponent->html();
spec336AssertFlowStep($failedContent, 'Compare run', 'Failed', true);
spec336AssertFlowStep($failedContent, 'Decision output', 'Unavailable', false);
spec336AssertInputState($failedContent, 'OperationRun proof', 'Available');
});
it('renders drift findings and zero-drift outcomes without broad health or evidence claims', function (): void {
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
[$profile, $snapshot] = seedActiveBaselineForTenant($environment);
$run = seedBaselineCompareRun($environment, $profile, $snapshot, [
'reason_code' => BaselineCompareReasonCode::OverdueFindingsRemain->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
]);
Finding::factory()->create([
'managed_environment_id' => (int) $environment->getKey(),
'workspace_id' => (int) $environment->workspace_id,
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => 'baseline_profile:'.$profile->getKey(),
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_NEW,
'source' => OperationRunType::BaselineCompare->value,
'baseline_operation_run_id' => (int) $run->getKey(),
]);
$this->actingAs($user);
setAdminPanelContext($environment);
$driftComponent = baselineCompareLandingLivewire($environment)
->assertSee('Drift findings available')
->assertSee('Drift requires review before a decision is recorded.')
->assertSee('Review drift findings')
->assertSee('Evidence unavailable - Operation proof available')
->assertDontSee('environment is healthy')
->assertDontSee('customer-safe')
->assertDontSee('compliant');
$driftContent = $driftComponent->html();
spec336AssertFlowStep($driftContent, 'Compare run', 'Available', false);
spec336AssertFlowStep($driftContent, 'Decision output', 'Available', false);
spec336AssertInputState($driftContent, 'Drift findings', 'Available');
$quietEnvironment = createUserWithTenant(tenant: null, user: $user, role: 'owner', workspaceRole: 'manager')[1];
[$quietProfile, $quietSnapshot] = seedActiveBaselineForTenant($quietEnvironment);
seedBaselineCompareRun($quietEnvironment, $quietProfile, $quietSnapshot, [
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
]);
setAdminPanelContext($quietEnvironment);
$quietComponent = baselineCompareLandingLivewire($quietEnvironment)
->assertSee('No drift detected')
->assertSee('No governance action is required from this compare result within available compare coverage.')
->assertSee('Review evidence')
->assertSee('Evidence unavailable - Operation proof available')
->assertDontSee('environment is healthy')
->assertDontSee('customer-safe')
->assertDontSee('compliant');
spec336AssertFlowStep($quietComponent->html(), 'Decision output', 'Available', false);
});
it('keeps compare result, OperationRun proof, and evidence unavailable states separated', function (): void {
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
[$profile, $snapshot] = seedActiveBaselineForTenant($environment);
$run = seedBaselineCompareRun($environment, $profile, $snapshot, [
'reason_code' => BaselineCompareReasonCode::EvidenceCaptureIncomplete->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
'evidence_gaps' => [
'count' => 1,
'by_reason' => [
'inventory_record_missing' => 1,
],
],
], OperationRunStatus::Completed->value, OperationRunOutcome::PartiallySucceeded->value);
Finding::factory()->create([
'managed_environment_id' => (int) $environment->getKey(),
'workspace_id' => (int) $environment->workspace_id,
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => 'baseline_profile:'.$profile->getKey(),
'severity' => Finding::SEVERITY_MEDIUM,
'status' => Finding::STATUS_NEW,
'source' => OperationRunType::BaselineCompare->value,
'baseline_operation_run_id' => (int) $run->getKey(),
]);
$this->actingAs($user);
setAdminPanelContext($environment);
$component = baselineCompareLandingLivewire($environment)
->assertSee('Drift findings available')
->assertSee('OperationRun proof')
->assertSee('Evidence unavailable - Evidence gaps need review')
->assertSee('Compare result exists, but evidence output is not available. Evidence gaps need review.')
->assertSee('Diagnostics - Collapsed')
->assertDontSee('customer-safe')
->assertDontSee('raw diff');
$content = $component->html();
spec336AssertFlowStep($content, 'Compare run', 'Available', false);
spec336AssertFlowStep($content, 'Decision output', 'Needs review', true);
spec336AssertInputState($content, 'OperationRun proof', 'Available');
spec336AssertInputState($content, 'Evidence path', 'Needs review');
expect($content)->not->toContain('data-testid="baseline-compare-diagnostics" open');
});
it('preserves environment-owned routing and workspace isolation', function (): void {
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
$hiddenEnvironment = createUserWithTenant(user: $user, role: 'owner', workspaceRole: 'manager')[1];
[$hiddenProfile, $hiddenSnapshot] = seedActiveBaselineForTenant($hiddenEnvironment);
seedBaselineCompareRun($hiddenEnvironment, $hiddenProfile, $hiddenSnapshot, [
'reason_code' => BaselineCompareReasonCode::OverdueFindingsRemain->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
->get(ManagedEnvironmentLinks::baselineCompareUrl($environment))
->assertOk()
->assertSeeText('Which baseline drift requires action?')
->assertDontSeeText((string) $hiddenProfile->name)
->assertDontSeeText('MANAGED_ENVIRONMENT');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
->get('/admin/baseline-compare-landing?tenant_id='.(int) $environment->getKey())
->assertNotFound();
});
it('renders invalid assigned baseline scope as needs-review instead of running compare', function (): void {
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
[$profile] = seedActiveBaselineForTenant($environment);
DB::table('baseline_tenant_assignments')
->where('managed_environment_id', (int) $environment->getKey())
->where('baseline_profile_id', (int) $profile->getKey())
->update([
'override_scope_jsonb' => json_encode(['version' => 2, 'entries' => []], JSON_THROW_ON_ERROR),
'updated_at' => now(),
]);
$this->actingAs($user);
setAdminPanelContext($environment);
$component = baselineCompareLandingLivewire($environment)
->assertSee('Baseline scope requires review')
->assertSee('A baseline is assigned, but its scope cannot be used safely for compare.')
->assertSee('Open baseline profile')
->assertActionDisabled('compareNow');
$content = $component->html();
spec336AssertFlowStep($content, 'Baseline assigned', 'Needs review', true);
spec336AssertFlowStep($content, 'Compare run', 'Unavailable', false);
spec336AssertFlowStep($content, 'Decision output', 'Unavailable', false);
});
function spec336AssertFlowStep(string $content, string $label, string $state, bool $currentBlocker): void
{
$blocker = $currentBlocker ? 'true' : 'false';
expect($content)->toMatch(
'/data-step-label="'.preg_quote($label, '/').'"[\s\S]*?data-step-state="'.preg_quote($state, '/').'"[\s\S]*?data-step-current-blocker="'.$blocker.'"[\s\S]*?>\s*'.preg_quote($state, '/').'\s*</'
);
}
function spec336AssertInputState(string $content, string $label, string $state): void
{
expect($content)->toMatch(
'/data-input-label="'.preg_quote($label, '/').'"[\s\S]*?>\s*'.preg_quote($state, '/').'\s*</'
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

View File

@ -0,0 +1,165 @@
# Spec 336 - Baseline Compare State Contract
Status: implemented
Created: 2026-05-29
Scope: Baseline Compare Landing (`/admin/workspaces/{workspace}/environments/{environment}/baseline-compare`)
This contract defines what the Baseline Compare first screen must show per repo-backed state, without inventing compare/evidence truth.
## Flow Steps (Default)
The Compare readiness flow uses these fixed steps:
1. Baseline assigned
2. Baseline snapshot
3. Environment snapshot / coverage proof
4. Compare run
5. Decision output
**Allowed step state vocabulary (presentation, not a new enum family)**:
`Complete`, `Missing`, `Available`, `Required`, `In progress`, `Failed`, `Needs review`, `Unavailable`.
## State Contracts
### State: `no_assignment` (repo-backed)
- Visible status: **Baseline not assigned**
- Reason: This environment does not have an assigned baseline.
- Impact: Baseline drift cannot be used for governance decisions yet; compare trust is unavailable until assignment exists.
- Primary next action: **Open baseline profiles** (only when authorized; otherwise show unavailable state)
- Flow gate states:
- Baseline assigned: Missing (current blocker)
- Baseline snapshot: Unavailable
- Environment snapshot / coverage proof: Available or Unavailable (repo-backed; not a blocker for compare start yet)
- Compare run: Unavailable
- Decision output: Unavailable
- Available inputs: environment snapshot presence (repo-backed), but baseline artifacts and compare proof are unavailable.
- Compare proof: Unavailable
- Evidence state: Unavailable
- Diagnostics default: Collapsed
### State: `no_snapshot` (repo-backed)
- Visible status: **Baseline snapshot required**
- Reason: A baseline is assigned, but no complete/consumable baseline snapshot is available (reason code may indicate building/incomplete/superseded).
- Impact: Compare cannot start; drift decision output is unavailable.
- Primary next action: **Open baseline snapshots** or **Open baseline profile** (only if repo-supported and authorized).
- Flow gate states:
- Baseline assigned: Complete
- Baseline snapshot: Missing or Needs review (blocked by snapshot truth)
- Environment snapshot / coverage proof: Available / Needs review / Unavailable (repo-backed)
- Compare run: Unavailable
- Decision output: Unavailable
- Available inputs: assignment + environment snapshot truth; baseline snapshot link is shown only when a real snapshot record exists.
- Compare proof: Unavailable
- Evidence state: Unavailable
- Diagnostics default: Collapsed
### State: `invalid_scope` (repo-backed)
- Visible status: **Baseline scope requires review**
- Reason: The assigned baseline scope is invalid or no longer supported.
- Impact: Compare cannot start safely until scope is corrected; drift decisions stay unavailable.
- Primary next action: **Open baseline profile** (only when authorized).
- Flow gate states:
- Baseline assigned: Needs review
- Baseline snapshot: Available / Missing / Unavailable (repo-backed; do not guess)
- Environment snapshot / coverage proof: Available / Needs review / Unavailable (repo-backed)
- Compare run: Unavailable (blocked)
- Decision output: Unavailable
- Available inputs: assignment + any repo-backed baseline snapshot truth; no compare result.
- Compare proof: Unavailable (unless a historical run exists and is in-scope/authorized; do not surface by default without proof)
- Evidence state: Unavailable
- Diagnostics default: Collapsed
### State: `idle` (repo-backed)
- Visible status: **Compare run required**
- Reason: Baseline assignment and a usable snapshot exist, but no compare run exists for the current state.
- Impact: Drift findings and decision output are not available yet.
- Primary next action: **Compare now** (confirmation-required; capability-gated)
- Flow gate states:
- Baseline assigned: Complete
- Baseline snapshot: Available
- Environment snapshot / coverage proof: Available / Needs review / Unavailable (repo-backed; affects trust)
- Compare run: Required (current blocker)
- Decision output: Required
- Available inputs: assignment + baseline snapshot + environment coverage proof state.
- Compare proof: Unavailable (no run yet)
- Evidence state: Coverage/evidence state is “unknown” until a run exists; show only repo-backed environment snapshot/coverage truth.
- Diagnostics default: Collapsed
### State: `comparing` (repo-backed)
- Visible status: **Compare in progress**
- Reason: A compare run is queued or running.
- Impact: Drift findings are not final; decision output is unavailable until run completes.
- Primary next action: **View operation progress** (OperationRun proof link)
- Flow gate states:
- Baseline assigned: Complete
- Baseline snapshot: Available
- Environment snapshot / coverage proof: Available / Needs review / Unavailable (repo-backed)
- Compare run: In progress
- Decision output: Unavailable
- Available inputs: assignment + snapshot + OperationRun link.
- Compare proof: Available (OperationRun)
- Evidence state: Unavailable until completion (show only repo-backed pre-run truth)
- Diagnostics default: Collapsed
### State: `failed` (repo-backed)
- Visible status: **Compare failed**
- Reason: The latest compare run ended with errors (use failure summary when present).
- Impact: Drift findings cannot be trusted until failure is resolved; treat as no usable evidence.
- Primary next action: **Review compare failure** (OperationRun proof link)
- Flow gate states:
- Baseline assigned: Complete
- Baseline snapshot: Available
- Environment snapshot / coverage proof: Available / Needs review / Unavailable (repo-backed)
- Compare run: Failed
- Decision output: Unavailable
- Available inputs: assignment + snapshot + OperationRun proof.
- Compare proof: Available (OperationRun)
- Evidence state: Unavailable
- Diagnostics default: Collapsed
### State: `ready` (repo-backed; findings > 0)
- Visible status: **Drift findings available**
- Reason: The latest compare produced open drift findings for this baseline comparison.
- Impact: Review findings before presenting the environment as aligned; avoid “all clear” copy.
- Primary next action: **Review drift findings** (only when authorized)
- Flow gate states:
- Baseline assigned: Complete
- Baseline snapshot: Available
- Environment snapshot / coverage proof: Available or Needs review (repo-backed)
- Compare run: Available (completed)
- Decision output: Available
- Available inputs: assignment + snapshot + OperationRun proof + findings summary.
- Compare proof: Available (OperationRun)
- Evidence state: Needs review when coverage warnings/evidence gaps exist; otherwise “Operation proof available” only as proof basis (no evidence-pack claim).
- Diagnostics default: Collapsed
### State: `ready` (repo-backed; findings = 0)
- Visible status: **No drift detected** (scoped to available compare coverage)
- Reason: No open drift findings exist for this baseline comparison (may still include warnings/gaps).
- Impact: Must not be framed as “healthy/compliant/customer-safe”. If coverage warnings or evidence gaps exist, the outcome requires review.
- Primary next action: **Review evidence / operation proof** (repo-backed path only)
- Flow gate states:
- Baseline assigned: Complete
- Baseline snapshot: Available
- Environment snapshot / coverage proof: Available or Needs review (repo-backed)
- Compare run: Available (completed)
- Decision output: Available or Needs review (when gaps exist)
- Available inputs: assignment + snapshot + OperationRun proof + zero-findings outcome.
- Compare proof: Available (OperationRun)
- Evidence state: Needs review when gaps exist; otherwise evidence remains unavailable unless a repo-real evidence artifact is linked.
- Diagnostics default: Collapsed
## Universal Defaults
- Diagnostics default: **Collapsed** for all states.
- Raw diff/payload: **never default-visible**.
- One primary next action: **exactly one** per state.
- No evidence-pack/customer-safe claim without a linked repo-real artifact.

View File

@ -0,0 +1,56 @@
# Spec 336 Requirements Checklist
- Purpose: Preparation-quality validation for Spec 336 before runtime implementation.
- Created: 2026-05-29
- Feature: [spec.md](../spec.md)
## Content Quality
- [x] Spec Candidate Check is filled and justifies why this is Core Enterprise alignment work.
- [x] Scope is bounded to Baseline Compare Landing UX alignment; explicit non-goals exclude backend compare/evidence rewrites.
- [x] Acceptance criteria are testable (Feature + Browser smoke + screenshots).
- [x] Spec includes truthful-copy constraints (no healthy/compliant/customer-safe claim).
## Repo Truth
- [x] Spec number/branch reconciled to repo numbering:
- `335-restore-run-detail-post-execution-proof-productization` already exists.
- This package is correctly created as `336-baseline-compare-product-process-flow-alignment`.
- [x] Confirmed scope paths exist:
- `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`
- `apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php`
- `apps/platform/app/Support/Baselines/BaselineCompareStats.php`
- `apps/platform/app/Services/Baselines/BaselineSnapshotTruthResolver.php`
- `apps/platform/app/Services/Baselines/BaselineCompareService.php`
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- [x] Repo-truth artifacts exist and constrain state invention:
- [repo-truth-map.md](../repo-truth-map.md)
- [baseline-compare-state-contract.md](../baseline-compare-state-contract.md)
## Security / Isolation / RBAC
- [x] Capability-first RBAC is explicitly required for all actions and deep links.
- [x] Cross-workspace/environment leakage is explicitly tested and forbidden.
- [x] No `/admin/t` routes, no legacy tenant query aliases, and no remembered environment as authority are introduced.
## UI Guardrail / Disclosure
- [x] Decision-first ordering is explicit (decision card → flow → proof → diagnostics collapsed).
- [x] Exactly one dominant primary next action per state is required.
- [x] Diagnostics are collapsed by default; raw diff/payload is never default-visible.
## Test And Smoke Readiness
- [x] Plan includes the intended lane mix (Feature + Browser).
- [x] Tasks are ordered, small, and verifiable with explicit file targets.
- [x] Minimal validation commands are listed in spec/tasks.
- [x] Screenshot list is explicit and scoped to this spec directory.
## Preparation Analysis Outcome
- [x] Preparation artifacts are internally consistent (spec ↔ plan ↔ tasks ↔ truth map ↔ state contract).
- [x] Every requirement in the spec maps to one or more tasks.
- [x] No preparation issue requires application implementation to resolve.
- [x] Candidate Selection Gate result: PASS (user-provided candidate + referenced as follow-up in Spec 333; not a completed spec package).
- [x] Spec Readiness Gate result: PASS for later implementation.

View File

@ -0,0 +1,123 @@
# Implementation Plan: Spec 336 - Baseline Compare Product Process Flow Alignment
- Branch: `336-baseline-compare-product-process-flow-alignment`
- Date: 2026-05-29
- Spec: `specs/336-baseline-compare-product-process-flow-alignment/spec.md`
- Input: User-provided draft + repo inspection (`BaselineCompareLanding`, `BaselineCompareStats`, Spec 332 flow pattern).
## Summary
Align the existing Baseline Compare Landing page to the shared Product Process Flow contract introduced in Spec 332, across repo-backed states.
This is runtime UX alignment only:
- no backend compare engine rewrite
- no new persistence
- no new OperationRun semantics
- no new evidence generator backend
Baseline Compare must become decision-first and flow-driven for the full state family, not only the no-assignment state.
## Technical Context
- Language/Version: PHP 8.4.15, Laravel 12.x.
- Primary Dependencies: Filament v5 + Livewire v4, Pest v4, Tailwind v4.
- Storage: PostgreSQL; no schema change expected.
- Testing: Pest Feature/Livewire render tests + 1 browser smoke file.
- Validation Lanes: confidence + browser.
- Target Platform: Sail locally; Dokploy/container posture unchanged.
- Project Type: Laravel monolith under `apps/platform`.
- Performance Goals: DB-only render; no provider/Graph calls during page render; no new query families beyond existing BaselineCompareStats sources.
- Constraints: No migrations, packages, env vars, queue/scheduler/storage changes, route family changes, or legacy query alias reintroduction.
## UI / Surface Guardrail Plan
- Guardrail scope: changed existing operator-facing strategic surface.
- Affected route/page/view:
- `/admin/workspaces/{workspace}/environments/{environment}/baseline-compare`
- `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`
- `apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php`
- Native vs custom: keep Filament-native primitives; reuse Spec 332 flow pattern; no new local design system.
- Decision hierarchy: decision card first, readiness flow always visible, proof/evidence panel next, diagnostics collapsed.
- One-primary-action rule: exactly one state-specific primary next action; no competing CTAs.
- Disclosure policy: raw/support/diagnostics remain collapsed and capability-aware; no raw diff by default.
- Coverage artifacts: no change to `docs/ui-ux-enterprise-audit/*` expected (route/archetype unchanged); proof is Spec 336 tests + screenshots.
## Shared Pattern & System Fit
- Cross-cutting marker: yes (Product Process Flow consumer alignment; OperationRun proof links; decision-first vocabulary).
- Shared systems reused:
- Spec 332 Product Process Flow pattern (Restore consumer)
- Baseline compare truth: `BaselineCompareStats`, `BaselineSnapshotTruthResolver`
- Operation deep links: `OperationRunLinks`, `OperationUxPresenter`, `OpsUxBrowserEvents`
- New abstraction: add a small presenter/view-model only if required to avoid scattering state mapping logic across Blade and the Page class. Avoid a new generic “workflow engine” layer.
- Bounded deviation: if a shared horizontal flow renderer is introduced, it must be justified by 2+ consumers and remain a small UI primitive (not a taxonomy/framework).
## OperationRun UX Impact
- This spec must not change:
- `Compare now` confirmation requirement (`->requiresConfirmation()`),
- capability gating (`TENANT_SYNC` via `UiEnforcement`),
- queued toast + `Open operation` link semantics (`OperationUxPresenter`, `OperationRunLinks`),
- run identity/type (`OperationRunType::BaselineCompare`).
- Changes are limited to how Baseline Compare surfaces proof/evidence state on the page.
## Provider Boundary / Platform Core Check
N/A. No provider/platform seam is changed.
## Current Repo Truth Summary (Implementation-Relevant)
- Page state derives from `BaselineCompareStats::forTenant()`:
- `no_assignment`, `no_snapshot`, `invalid_scope`, `idle`, `comparing`, `failed`, `ready`.
- Readiness flow currently renders only for `no_assignment` (page-local array + Blade pipeline).
- Compare start uses `BaselineCompareService::startCompare()` and dispatches `CompareBaselineToTenantJob` (OperationRun-backed).
- Coverage/evidence gaps are derived from latest compare run context/diagnostics; “no drift” messaging must be caveated when gaps exist.
## Implementation Approach
### Phase 0 — Repo Truth Gate (No Runtime Edits)
1. Finalize `repo-truth-map.md` and `baseline-compare-state-contract.md` from actual code sources.
2. Confirm which step states are repo-real (no “stale” unless a real signal exists).
### Phase 1 — Flow State Presenter (Bounded)
1. Introduce a small Baseline Compare presenter (or expand an existing one) that computes:
- decision card fields
- readiness flow steps across the full state family
- proof/input panel items (repo-backed only)
- drift summary + evidence/coverage state (separated from OperationRun proof)
- diagnostics disclosure state (collapsed default)
2. Ensure presenter uses existing sources only (`BaselineCompareStats`, `BaselineSnapshotTruthResolver`, latest inventory sync/coverage proof, OperationRun).
### Phase 2 — UI Alignment
1. Replace the page-local readiness pipeline markup with the shared Product Process Flow render primitive (or a bounded extraction consistent with Spec 332).
2. Ensure readiness flow is visible across repo-backed states (not just `no_assignment`).
3. Remove any duplicated lower status/summary blocks introduced by state-specific sections.
4. Keep raw diagnostics and raw diff behind disclosure by default.
### Phase 3 — Truthful Drift/Evidence Messaging
1. Replace any broad “tenant matches baseline” claim with scoped wording:
- “no open drift findings for this baseline comparison”
- plus explicit caveat when coverage/evidence gaps exist.
2. Ensure evidence path copy never implies customer-safe output unless an actual evidence/report artifact exists and is linked.
### Phase 4 — Tests
1. Add `apps/platform/tests/Feature/Filament/Spec336BaselineCompareProductProcessFlowAlignmentTest.php` covering the core state family.
2. Update existing Baseline Compare tests (e.g., Spec 330 file) only where necessary, keeping equivalent or stronger assertions.
### Phase 5 — Browser Smoke + Screenshots
1. Add `apps/platform/tests/Browser/Spec336BaselineCompareProductProcessFlowAlignmentSmokeTest.php` covering:
- no baseline, snapshot required, compare required, compare in progress, compare ready, evidence gaps, diagnostics collapsed, dark mode (if practical).
2. Capture the required screenshots under `specs/336-baseline-compare-product-process-flow-alignment/artifacts/screenshots/`.
### Phase 6 — Hygiene + Validation
1. Run the specs minimal validation commands (Feature + Browser + filter run).
2. Run `./vendor/bin/sail pint --dirty` and `git diff --check`.

View File

@ -0,0 +1,61 @@
# Spec 336 Repo Truth Map
Status: implemented (runtime alignment completed)
Created: 2026-05-29
Purpose: classify each Baseline Compare Product Process Flow element before runtime implementation.
## Classification Legend
- `repo-verified`: exact runtime source exists and was inspected.
- `derived from existing model`: display value can be derived from existing persisted/domain truth.
- `foundation-real`: backend model/service exists, but exact page binding still needs implementation verification.
- `empty/unavailable state`: no safe source/action exists for v1; show explicit unavailable state or omit.
- `deferred future capability`: outside Spec 336 and must not be shown as live runtime truth.
## Required Data Areas
| Data area | Repo source | Preparation finding | Classification |
|---|---|---|---|
| Baseline Compare surface | `App\Filament\Pages\BaselineCompareLanding` + `filament.pages.baseline-compare-landing` | Environment-owned page with decision card + compare action; readiness flow now covers all repo-backed `BaselineCompareStats` states | repo-verified |
| Baseline assignment truth | `BaselineTenantAssignment` + relation to `BaselineProfile` | Missing assignment is a first-class state (`BaselineCompareStats.state = no_assignment`) | repo-verified |
| Baseline snapshot truth | `BaselineSnapshotTruthResolver::resolveCompareSnapshot()` | Snapshot “missing / building / incomplete / superseded / available” is repo-real via reason codes and lifecycle state | repo-verified |
| Environment snapshot / coverage proof | Latest inventory sync `OperationRunType::InventorySync` + `InventoryCoverage::fromContext(...)` + `InventoryItem` presence | There is repo truth for “coverage proof present” vs “missing/partial”; no explicit “stale” signal today | repo-verified |
| Compare run truth | `OperationRunType::BaselineCompare` (via `OperationCatalog::rawValuesForCanonical`) | `BaselineCompareStats` derives `idle/comparing/failed/ready` from latest run status/outcome | repo-verified |
| Decision output truth | Open drift findings (`Finding` with `finding_type=drift`, `source=baseline.compare`, scoped by baseline profile) + run outcome | “Decision output” is derived (findings present vs none, with caveats from coverage/evidence gaps) | derived from existing model |
| Evidence gap truth | `BaselineCompareStats::*ForRun(...)` reading latest run context/diagnostics | Evidence gaps are repo-real; they must remain separated from “operation proof” | repo-verified |
| OperationRun proof links | `OperationRunLinks::view(...)`, `OperationUxPresenter`, existing deep link helpers | Proof links are repo-real and already used by Baseline Compare | repo-verified |
| Baseline profile navigation | `BaselineProfileResource` | Baseline profile index exists and is capability-gated | repo-verified |
| Baseline snapshot navigation | `BaselineSnapshotResource` | Snapshot index exists and is workspace-scoped/capability-gated | repo-verified |
| Drift findings navigation | `FindingResource` + (optional) Baseline Compare Matrix | Findings index exists and is environment-scoped/capability-gated | repo-verified |
| “Generate evidence” action | N/A | No repo-real “compare evidence pack” generator is available as a Baseline Compare evidence artifact today | empty/unavailable state |
## State Family (Repo-Backed)
From `BaselineCompareStats::forTenant()`:
- `no_assignment`
- `no_snapshot`
- `invalid_scope`
- `idle` (inputs exist, run required)
- `comparing` (queued/running)
- `failed` (failed outcome)
- `ready` (completed + usable; findings may be 0 or >0, but coverage/evidence gaps may still require review)
## UI Element Map (Spec 336 Intended)
| UI element | Surface | Source model/service/page | Status source | Authorization/capability | Workspace/Environment scope | OperationRun/evidence link | Fallback/empty state | Classification |
|---|---|---|---|---|---|---|---|---|
| Decision card | Baseline Compare Landing | Expanded page view-model methods on `BaselineCompareLanding` | `BaselineCompareStats` + derived caveats | page access; links capability-gated | route-owned environment | links to baselines/findings/operation where allowed | “Compare unavailable” with truthful reason | repo-verified |
| Compare readiness flow (horizontal) | Baseline Compare Landing | `filament.components.product-process-flow-horizontal` | `BaselineCompareStats` + snapshot truth + coverage proof | page access; action visibility capability-gated | route-owned environment | step-level links only when repo-real | render `Unavailable` where truth is missing | repo-verified |
| Available inputs / proof panel | Baseline Compare Landing | repo-backed inputs only | snapshot/run/inventory proof state | capability-gated deep links | route-owned environment + workspace scope for baseline artifacts | operation proof link + evidence gap summary | explicit “Unavailable” states | repo-verified |
| Drift summary | Baseline Compare Landing | `Finding` severity counts + attention counts | derived from findings + run outcome | finding visibility policy | route-owned environment | link to findings only when authorized | “Drift result unavailable” | repo-verified |
| Evidence/coverage state | Baseline Compare Landing | `coverageStatus`, `uncoveredTypes`, `evidenceGaps*` from stats | run-derived caveats | page access; support/raw remains gated | route-owned environment | separate from operation proof | visible warnings before diagnostics | repo-verified |
| Diagnostics disclosure | Baseline Compare Landing | `baselineCompareDiagnostics` + evidence gap diagnostics | disclosure label only by default | collapsed and capability-aware | route-owned environment | optional deep link to run | “Diagnostics - Collapsed” even when present | repo-verified |
## Explicit Non-Truth / Deferred Claims
| Claim | v1 decision |
|---|---|
| “Environment is healthy/compliant/protected/customer-safe” | deferred future capability; must not be shown |
| “Evidence package exists” | empty/unavailable until a repo-real artifact exists and is linked |
| “Stale” snapshot semantics | only allowed if a repo-real staleness signal exists; otherwise render as `Unavailable` or omit |

View File

@ -0,0 +1,599 @@
# Feature Specification: Spec 336 - Baseline Compare Product Process Flow Alignment
**Feature Branch**: `336-baseline-compare-product-process-flow-alignment`
**Created**: 2026-05-29
**Status**: Draft
**Type**: Runtime UX alignment / Product Process Flow consumer / Baseline Compare productization hardening
**Runtime posture**: Reuse existing Spec 332 Product Process Flow system. No backend compare engine rewrite. No new persisted truth.
**Input**: User-provided Spec 335 draft (repo-truth reconciled to Spec 336 because `335-*` already exists in this repo).
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
<!-- This section MUST be completed before the spec progresses beyond Draft.
See .specify/memory/spec-approval-rubric.md for the full rubric. -->
- **Problem**: Baseline Compare is an operator-facing decision surface, but its readiness/proof/evidence language is not yet consistently aligned to the shared Product Process Flow vocabulary introduced in Spec 332, especially across non-trivial repo-backed states (snapshot blocked, compare run required/running/failed, evidence gaps, decision output availability).
- **Today's failure**: Operators must infer “what blocks compare” and “what proof exists” from mixed page sections and state-specific copy, instead of one consistent flow-based contract (complete/missing/blocked, proof/evidence separation, one primary next action).
- **User-visible improvement**: Baseline Compare answers in the first screen: Status, Reason, Impact, Primary next action, Compare readiness flow, Available inputs, Compare proof, Decision output, Evidence state, with diagnostics collapsed by default.
- **Smallest enterprise-capable version**: Reuse Spec 332s Product Process Flow pattern and adapt Baseline Compare Landing to render the same flow contract across repo-backed states without changing compare logic, persistence, or OperationRun semantics.
- **Explicit non-goals**: New compare engine, new snapshot generation engine, new drift detection algorithm, new evidence generator backend, OperationRun model changes, new migrations, new packages, new queues/scheduler/env/storage requirements, Restore flow changes, cross-tenant promotion, bulk governance workflow engine.
- **Permanent complexity imported**: A small Baseline Compare presenter/view-model (only if required), a reusable flow render primitive if already justified by 2+ consumers (Restore + Baseline Compare), focused Pest Feature tests + browser smoke, spec artifacts (repo truth map, state contract, screenshots).
- **Why now**: Spec 332 established the shared Product Process Flow system with Restore as the first consumer; Baseline Compare is the next highest-leverage consumer surface for vocabulary and governance consistency.
- **Why not local**: Copy tweaks alone do not align the step semantics, proof/evidence separation, and “next action” contract across the full state family; without a shared pattern, Baseline Compare will drift again as new states are productized.
- **Approval class**: Core Enterprise
- **Red flags triggered**: Reachable UI surface change (strategic surface), governance language/tone correctness, proof/evidence truth separation. Defense: reuse existing repo truth, no new backend foundation, tests + browser smoke required, no false “healthy/compliant/customer-safe” claims.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant (environment-owned operator surface)
- **Primary Routes**:
- Baseline Compare Landing route (environment-owned): `/admin/workspaces/{workspace}/environments/{environment}/baseline-compare`
- Downstream surfaces referenced but not reworked: Baseline Compare Matrix, Findings, Baseline Profiles, Baseline Snapshots, Operation Run detail.
- **Data Ownership**:
- Tenant/environment-owned: `ManagedEnvironment`, `BaselineTenantAssignment`, `OperationRun`, `Finding`, `InventoryItem` (observed state), evidence gap summaries (run context/diagnostics).
- Workspace-owned: `BaselineProfile`, `BaselineSnapshot` (workspace-owned baseline artifacts).
- No new tables or persisted truth introduced.
- **RBAC**:
- Environment membership + existing environment access checks required to view.
- Actions appear only when authorized:
- Start compare: capability `TENANT_SYNC` (existing `UiEnforcement` + server-side enforcement).
- View proofs/linked artifacts: existing policies/capabilities for OperationRuns, findings, baselines.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: N/A - environment-owned route.
- **Explicit entitlement checks preventing cross-tenant leakage**: Continue deny-as-not-found for non-members and ensure all linked artifacts remain workspace/environment scoped.
## UI Surface Impact *(mandatory — UI-COV-001)*
Does this spec add, remove, rename, or materially change any reachable UI surface?
- [ ] No UI surface impact
- [x] Existing page changed
- [ ] New page/route added
- [ ] Navigation changed
- [ ] Filament panel/provider surface changed
- [ ] New modal/drawer/wizard/action added
- [x] New table/form/state added
- [ ] Customer-facing surface changed
- [ ] Dangerous action changed
- [x] Status/evidence/review presentation changed
- [ ] Workspace/environment context presentation changed
If any box except "No UI surface impact" is checked, complete the UI/Productization Coverage section below. "No UI surface impact" MUST NOT be checked together with another impact box; if a guarded file path changes for non-surface reasons, keep only "No UI surface impact" checked and explain the rationale below.
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact"; otherwise write `N/A - no reachable UI surface impact` plus rationale)*
- **Route/page/surface**: Baseline Compare Landing (`App\Filament\Pages\BaselineCompareLanding`, `filament.pages.baseline-compare-landing` view).
- **Current or new page archetype**: Baseline Compare is an operator governance decision surface (existing strategic drift/compare surface; archetype unchanged).
- **Design depth**: Strategic Surface.
- **Repo-truth level**: repo-verified (existing implementation + tests; see `repo-truth-map.md`).
- **Existing pattern reused**: Spec 332 Product Process Flow system v1 (Restore consumer) + existing Baseline Compare decision-first workbench (Spec 330 baseline).
- **New pattern required**: none (reuse shared flow pattern); bounded extraction only if required for true reuse.
- **Screenshot required**: yes, browser smoke screenshots saved under `specs/336-baseline-compare-product-process-flow-alignment/artifacts/screenshots/`.
- **Page audit required**: no (no route/archetype change; keep feature-local screenshots + tests as proof).
- **Customer-safe review required**: medium. This is operator UI, but copy must not imply “healthy/compliant/customer-safe” without repo proof; “matches baseline within available coverage” is the max claim.
- **Dangerous-action review required**: no destructive-action change. Existing “Compare Now” remains a high-impact operation start and must keep confirmation + capability enforcement + OperationRun proof semantics intact.
- **Coverage files updated or explicitly not needed**:
- [ ] `docs/ui-ux-enterprise-audit/route-inventory.md`
- [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`
- [ ] `docs/ui-ux-enterprise-audit/page-reports/...`
- [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md`
- [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md`
- [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md`
- [ ] `N/A - no reachable UI surface impact`
- **No-impact rationale when applicable**: N/A (UI surface impact exists; coverage files unchanged because archetype/route family unchanged and feature-local artifacts serve as proof).
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: decision cards, status messaging, OperationRun proof links, flow gating, diagnostics disclosure, governance “next action” contract
- **Systems touched**: Spec 332 Product Process Flow system v1 + Baseline Compare Landing surface
- **Existing pattern(s) to extend**: Restore Run Create presenter + process flow render (Spec 332); Baseline Compare decision-first workbench (Spec 330)
- **Shared contract / presenter / builder / renderer to reuse**: reuse Spec 332 “flow model shape” and render conventions; reuse OperationRun deep links via `OperationRunLinks` (already used)
- **Why the existing shared path is sufficient or insufficient**: sufficient for vocabulary and gating model; Baseline Compare requires a horizontal readiness flow variant, so shared rendering may need a bounded extension (only if it reduces duplication vs 2 consumers).
- **Allowed deviation and why**: none by default; if a new shared horizontal flow renderer is introduced, it must be used by 2+ consumers (Baseline Compare + another consumer or extracted from Baseline Compare while preserving Restore behavior) and pass proportionality review.
- **Consistency impact**: Status/Reason/Impact/Next action ordering and copy rules stay consistent with Restore Safety; proof/evidence separation stays truthful; diagnostics remain collapsed by default.
- **Review focus**: no fake “all-clear” claims; no duplicate visible status blocks; capabilities gate action visibility; no raw/support disclosure by default.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: yes (existing Baseline Compare “Compare Now” + proof links)
- **Shared OperationRun UX contract/layer reused**: existing `OperationUxPresenter`, `OperationRunLinks`, `OpsUxBrowserEvents` (no new contract)
- **Delegated start/completion UX behaviors**: keep existing queued toast + “View run” deep link semantics; keep tenant/workspace-safe URL resolution through shared helpers.
- **Local surface-owned behavior that remains**: Baseline Compare-specific input selection (baseline snapshot selection) and state-specific next-action copy.
- **Queued DB-notification policy**: no change; no new DB notification.
- **Terminal notification path**: unchanged; stays via central lifecycle.
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
- `N/A - no shared provider/platform boundary touched` (UI alignment only; compare strategy selection remains internal and unchanged).
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
Use this section to classify UI and surface risk once. If the feature does
not change an operator-facing surface, write `N/A - no operator-facing surface
change` here and do not invent duplicate prose in the downstream surface tables.
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Baseline Compare Landing: process-flow alignment, state contract, proof/evidence separation | yes | Native Filament + shared primitives + existing Blade workbench | decision-first workbench + shared flow pattern | page | no | strategic surface, but bounded changes |
## Summary
Align Baseline Compare with the shared Product Process Flow system introduced in Spec 332.
Baseline Compare must answer, without operator inference:
- What is complete?
- What is missing or blocked?
- What proof exists?
- What is the next action?
This spec does not rebuild compare logic. It productizes and aligns the Baseline Compare surface across repo-backed states:
- no baseline assigned
- baseline assigned but compare snapshot missing/blocked
- compare run required
- compare in progress
- compare failed
- compare available with drift findings
- compare available with no open drift findings (with honest coverage/evidence constraints)
- evidence/coverage gaps present
- diagnostics available but collapsed
## Repo-Truth First Requirement
Before runtime changes, this spec requires:
- `specs/336-baseline-compare-product-process-flow-alignment/repo-truth-map.md`
- `specs/336-baseline-compare-product-process-flow-alignment/baseline-compare-state-contract.md`
These documents classify sources as `repo-verified`, `derived`, `not available`, or `deferred`. No compare/evidence states may be invented.
## Required Product Flow
Baseline Compare uses the Product Process Flow pattern (horizontal variant) with default steps:
1. Baseline assigned
2. Baseline snapshot
3. Environment snapshot / coverage proof
4. Compare run
5. Decision output
Step labels and user-facing states must remain repo-backed. If a “stale” concept is not repo-real, it must render as `Unavailable` with truthful copy.
## Out Of Scope
- Any new compare engine, drift algorithm, snapshot generation engine, evidence generation backend, migrations, packages, queues/scheduler changes, env var/storage changes.
- Restore flow changes.
- Cross-surface Product Process Flow migrations beyond Baseline Compare.
## Acceptance Criteria
- Baseline Compare renders a consistent decision-first surface with:
- Status, Reason, Impact, Primary next action
- Compare readiness flow visible across repo-backed states
- Proof/input panel (repo-backed only)
- Drift summary when repo-backed
- Evidence/coverage gap state separated from “operation proof”
- Diagnostics collapsed by default
- No false “healthy/compliant/customer-safe” claim is introduced.
- “No drift” claims are scoped to baseline-compare coverage truth and explicitly caveated when coverage/evidence gaps exist.
- Capability-first RBAC is respected for all actions and deep links.
- Feature tests + browser smoke exist for the declared state family.
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
If this feature adds or materially changes an operator-facing surface,
fill out one row per affected surface. This role is orthogonal to the
Action Surface Class / Surface Type below. Reuse the exact surface names
and classifications from the UI / Surface Guardrail Impact section above.
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Baseline Compare Landing | Primary Decision Surface | Operator decides whether baseline drift requires governance action, or what blocks compare | Status, Reason, Impact, Primary next action, Compare readiness flow, Available inputs/proof, Evidence/coverage status | Findings table/matrix, evidence gap buckets, OperationRun detail, raw compare diagnostics (collapsed) | Primary because it is the environment-owned compare decision surface | Follows baseline governance decision before matrix/detail | Prevents raw diff/zero findings from reading as an all-clear |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
If this feature adds or materially changes a detail or status surface,
fill out one row per affected surface. Reuse the same surface names
used above and make the disclosure hierarchy explicit instead of
assuming it.
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Baseline Compare Landing | operator-MSP, manager, support reviewer | Status, Reason, Impact, Primary next action, readiness flow, proof inputs, drift summary (repo-backed only) | Evidence gaps, coverage warnings, compare diagnostics disclosure, OperationRun proof link | Raw diff/payloads, deep provider diagnostics, stack traces (never default-visible) | One state-specific primary next action (assign baseline / resolve snapshot / run compare / open operation / review findings / review evidence gaps) | Raw/support sections collapsed and capability-aware; no raw diff by default | One top decision card; lower sections add proof/detail without repeating verdict blocks |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
fill out one row per affected surface. Declare the broad Action Surface
Class first, then the detailed Surface Type. Keep this table in sync
with the Decision-First Surface Role section above and avoid renaming the
same surface a second time.
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Baseline Compare Landing | Workbench / Monitoring | Environment-owned drift/compare decision surface | Take the next governance action based on readiness + proof | Header action + explicit deep links (baselines, snapshot, run, findings) | N/A | Inside decision card/proof panel; no row actions | None introduced; Compare Now remains confirmation-required and capability-gated | `/admin/workspaces/{workspace}/environments/{environment}/baseline-compare` | N/A (page, not record detail) | Workspace + Environment are route-bound | Baseline Compare | Status/Reason/Impact/Next action + readiness flow + proof/evidence separation | N/A (existing surface, bounded alignment only) |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
If this feature adds a new operator-facing page or materially refactors
one, fill out one row per affected page/surface. The contract MUST show
how one governance case or operator task becomes decidable without
unnecessary cross-page reconstruction.
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Baseline Compare Landing | Workspace manager / governance operator | Decide whether drift requires review, or what blocks compare | Monitoring / decision-first workbench | Can this environment be compared against a baseline, and what action is required? | Decision card (Status/Reason/Impact/Next action), Compare readiness flow, Available inputs/proof, Drift summary when repo-backed, Evidence/coverage state | Raw compare diagnostics + raw diff/payload (collapsed; capability-aware) | assignment, snapshot truth, inventory coverage proof, run status/outcome, findings count/severity, evidence gaps | TenantPilot writes: OperationRun + drift Findings; may fetch provider content evidence depending on capture mode (no config mutation) | Open baseline profiles, Open baseline snapshot/proof, Compare now, Open operation, Review findings | Compare now (requires confirmation; capability-gated; OperationRun-backed) |
## Proportionality Review *(mandatory when structural complexity is introduced)*
Fill this section if the feature introduces any of the following:
- a new source of truth
- a new persisted entity, table, or artifact
- a new abstraction (interface, contract, registry, resolver, strategy, factory, orchestration layer)
- a new enum, status family, reason code family, or lifecycle category
- a new cross-domain UI framework, taxonomy, or classification system
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes (small presenter/view-model to map repo-backed compare state to flow steps + decision card consistently)
- **New enum/state/reason family?**: no (reuse existing `BaselineCompareStats` state + baseline reason codes; no new taxonomy)
- **New cross-domain UI framework/taxonomy?**: no (reuse Spec 332 pattern; any shared renderer must stay a small primitive and be justified by 2+ consumers)
- **Current operator problem**: Baseline Compare readiness/proof/evidence state is not consistently expressed as a single “what is complete / what blocks / what proof exists / what next” contract across the full state family.
- **Existing structure is insufficient because**: current readiness flow is state-limited and relies on page-local composition; it encourages vocabulary drift and duplicative UI logic as more states are productized.
- **Narrowest correct implementation**: keep all domain truth as-is; add a thin presenter that computes decision/flow/proof panels from existing repo truth; render through one flow component/partial; update tests and browser smoke to lock the contract.
- **Ownership cost**: one presenter + one render partial (if needed), plus focused Feature tests + one browser smoke file + screenshot artifacts.
- **Alternative intentionally rejected**: new persisted readiness engine, new compare engine, new evidence backend, or “just copy tweaks” without a flow contract.
- **Release truth**: current-release productization and UX alignment over existing foundations.
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
For docs-only or template-only changes, state concise `N/A` or `none`. For runtime- or test-affecting work, classification MUST follow the proving purpose of the change rather than the file path or folder name.
- **Test purpose / classification**: Feature (Livewire render contract) + Browser (strategic surface smoke + disclosure guarantees).
- **Validation lane(s)**: confidence + browser.
- **Why this classification and these lanes are sufficient**: This change is operator-facing UI contract alignment, state gating, and disclosure hierarchy. Feature tests prove the rendered contract per repo-backed state; Browser smoke proves the surface is framed correctly and raw/support sections remain collapsed by default.
- **New or expanded test families**: one new Feature test file + one new Browser smoke file for Spec 336 (existing Spec 330 baseline tests may be updated, not replaced).
- **Fixture / helper cost impact**: reuse existing tenant/workspace helpers (`createUserWithTenant`, `setAdminPanelContext`, existing baseline compare helpers). No new default-heavy factories or seeds.
- **Heavy-family visibility / justification**: Browser coverage is explicit because Baseline Compare is a strategic operator surface with high risk of “false all-clear” and disclosure regressions.
- **Special surface test profile**: `monitoring-state-page` + `global-context-shell`.
- **Standard-native relief or required special coverage**: special coverage required; this is not a plain CRUD resource.
- **Reviewer handoff**: Verify (1) no fake drift/evidence claims, (2) one primary next action, (3) readiness flow visible across states, (4) capability-gated actions, (5) diagnostics collapsed/no raw diff by default, (6) route/context isolation.
- **Budget / baseline / trend impact**: none expected; browser smoke remains single-file scoped.
- **Escalation needed**: none (document-in-feature only if a new shared renderer is introduced and needs explicit exception rationale).
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage.
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament/Spec336BaselineCompareProductProcessFlowAlignmentTest.php --compact`
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Browser/Spec336BaselineCompareProductProcessFlowAlignmentSmokeTest.php --compact`
- `cd apps/platform && ./vendor/bin/sail artisan test --filter='BaselineCompare|ProductProcessFlow|EnvironmentOwned|Spec332|Spec330' --compact`
- `cd apps/platform && ./vendor/bin/sail pint --dirty`
- `git diff --check`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Baseline Compare Is Decidable (Priority: P1)
As a governance operator, I want Baseline Compare to answer whether compare is usable and which governance action is required without reading raw compare diagnostics.
**Why this priority**: Baseline Compare is a strategic decision surface and is easy to misread as “all clear” when evidence/coverage is missing.
**Independent Test**: Render Baseline Compare Landing for each repo-backed state and assert decision card + readiness flow + proof/evidence separation + collapsed diagnostics.
**Acceptance Scenarios**:
1. **Given** no baseline assignment (`BaselineCompareStats.state = no_assignment`), **When** the page renders, **Then** it shows `Baseline not assigned`, one primary next action (`Open baseline profiles` when authorized), readiness flow with `Baseline assigned: Missing`, and no raw diff by default.
2. **Given** baseline assigned but no usable snapshot (`state = no_snapshot`), **When** the page renders, **Then** it shows `Baseline snapshot required` (or equivalent truthful status), marks the snapshot step as missing/blocked, and does not present a drift “result”.
3. **Given** compare inputs exist but no run exists (`state = idle`), **When** the page renders, **Then** it shows `Compare run required` and a capability-gated `Compare now` action.
4. **Given** a compare run is queued/running (`state = comparing`), **When** the page renders, **Then** it shows `Compare in progress` plus OperationRun proof link, and keeps diagnostics collapsed.
5. **Given** a compare run failed (`state = failed`), **When** the page renders, **Then** it shows `Compare failed` plus OperationRun proof link, and does not imply usable drift evidence.
6. **Given** a compare run completed with drift findings (`state = ready`, findings > 0), **When** the page renders, **Then** it shows `Drift requires review` and offers `Review drift findings` as the primary next action when authorized.
7. **Given** a compare run completed with zero open findings (`state = ready`, findings = 0), **When** the page renders, **Then** it shows a scoped “no confirmed drift” outcome and does not claim “healthy/compliant/customer-safe”; if coverage/evidence gaps exist, the page must state that the outcome requires review.
---
### User Story 2 - Proof And Evidence Are Separated (Priority: P2)
As an operator, I want OperationRun proof (execution truth) and evidence/coverage truth to be visible as separate panels so I dont treat completion as evidence quality.
**Independent Test**: For a `ready` run with coverage warnings/evidence gaps, assert that the page shows evidence/coverage warnings and does not label the outcome as fully trustworthy.
---
### User Story 3 - Context + RBAC Are Enforced (Priority: P2)
As a workspace admin, I need Baseline Compare to never leak cross-workspace or cross-environment baseline/run/finding data and to hide or disable actions when unauthorized.
**Independent Test**: Use fixtures that create baseline profiles/snapshots/runs/findings in a different workspace/environment and assert they are not visible; assert unauthorized actions are hidden/disabled.
### Edge Cases
- Assigned baseline profile exists but is not active for compare start (`COMPARE_PROFILE_NOT_ACTIVE`): status becomes “Needs review” with truthful next action; no drift claim.
- Snapshot is building/incomplete/superseded: snapshot step reflects building/incomplete truth; compare start remains blocked; copy does not promise availability without proof.
- Invalid/unsupported/mixed scope: readiness flow shows baseline snapshot step as available but compare run step as blocked/unavailable with `Needs review` copy (no raw reason codes in primary UI).
## Requirements *(mandatory)*
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** If this feature introduces new persistence,
new abstractions, new states, or new semantic layers, the spec MUST explain:
- which current operator workflow or current product truth requires the addition now,
- why a narrower implementation is insufficient,
- whether the addition is current-release truth or future-release preparation,
- what ownership cost it creates,
- and how the choice follows the default bias of deriving before persisting, replacing before layering, and being explicit before generic.
If the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table, interface/contract/registry/resolver,
or taxonomy/classification system, the Proportionality Review section above is mandatory.
**Constitution alignment (XCUT-001):** If this feature touches a cross-cutting interaction class such as notifications, status messaging,
action links, header actions, dashboard signals/cards, alerts, navigation entry points, or evidence/report viewers, the spec MUST:
- state whether the feature is cross-cutting,
- name the existing shared pattern(s) and shared contract/presenter/builder/renderer to extend,
- explain why the existing shared path is sufficient or why it is insufficient for current-release truth,
- record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost,
- and make the reviewer focus explicit so parallel local UX paths do not appear silently.
**Constitution alignment (UI-COV-001):** Every spec MUST complete the UI Surface Impact block. If the feature adds, removes, renames, or materially changes any reachable UI surface, including navigation entries, Filament panel/provider surfaces, Livewire surfaces, Blade views, forms, tables, modals, drawers, actions, status/evidence/review presentation, customer-facing views, or workspace/environment context presentation, the spec MUST also complete UI/Productization Coverage and state:
- the affected route/page/surface,
- page archetype,
- design depth,
- repo-truth level,
- target pattern or mockup need,
- customer/operator safety review need,
- dangerous-action review need,
- and which `docs/ui-ux-enterprise-audit/` artifacts were updated or why the surface is unresolved/manual review.
If there is no reachable UI impact, the spec MUST check exactly `No UI surface impact` and provide a short rationale.
Coverage is proportional: small non-material UI diffs may use a checked no-impact rationale, normal domain pages may reuse existing patterns, and only strategic, customer-facing, dangerous-action-bearing, or materially new surfaces require screenshots or full page reports.
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** If this feature changes a detail or status surface, the spec MUST describe:
- how the surface separates customer-readable decision content, operator diagnostics, and support/raw evidence,
- which audience modes are in scope (`customer/read-only`, `operator/MSP`, `support/platform`),
- which content is hidden, collapsed, or capability-gated by default,
- how one dominant next action is preserved,
- and how duplicate visible truth is prevented.
**Constitution alignment (PROV-001):** If this feature touches a shared provider/platform seam, the spec MUST:
- classify each touched seam as provider-owned or platform-core,
- keep provider-specific semantics out of platform-core contracts, taxonomies, identifiers, compare semantics, and operator vocabulary unless explicitly justified,
- name the neutral platform terms or shared contracts being preserved,
- explain why any retained provider-specific semantics are the narrowest current-release truth,
- and state whether the remaining hotspot is resolved in-feature or escalated as a follow-up spec.
**Constitution alignment (TEST-GOV-001):** If this feature changes runtime behavior or tests, the spec MUST describe:
- the actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, or `Browser`) and why that classification matches the real proving purpose,
- the affected validation lane(s) and why they are the narrowest sufficient proof,
- any new or expanded heavy-governance or browser coverage,
- any fixture, helper, factory, seed, provider, workspace, membership, session, or default setup cost added or avoided,
- how any heavy family stays explicit rather than becoming accidental default breadth,
- the reviewer handoff for lane fit, hidden-cost checks, and the exact minimal validation commands,
- any expected budget, baseline, or trend impact,
- whether escalation stays inside this feature or resolves as `document-in-feature`, `follow-up-spec`, or `reject-or-split`,
- and the exact minimal validation commands reviewers should run.
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
- explicitly state compliance with the default Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification) and whether any queued DB notification is explicitly opted into,
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
- describe how `summary_counts` keys/values comply with `OperationSummaryKeys::all()` and numeric-only rules,
- clarify scheduled/system-run behavior (initiator null → no terminal DB notification; audit is via Monitoring),
- list which regression guard tests are added/updated to keep these rules enforceable in CI.
**Constitution alignment (OPS-UX-START-001):** If this feature creates, queues, deduplicates, resumes, blocks, completes, or links to an `OperationRun`, the spec MUST:
- include the `OperationRun UX Impact` section,
- name the shared OperationRun UX contract/layer being reused,
- delegate queued toast/link/artifact-link/browser-event/queued-DB-notification/dedupe-or-blocked messaging/tenant-safe URL resolution to that shared path,
- keep local surface code limited to initiation inputs and operation-specific data capture,
- keep queued DB notifications explicit opt-in unless the spec intentionally defines a different policy,
- route terminal notifications through the central lifecycle mechanism,
- and document any exception with an explicit spec decision, architecture note, test or guard-test rationale, and temporary follow-up migration decision.
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
- state which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`),
- ensure any cross-plane access is deny-as-not-found (404),
- explicitly define 404 vs 403 semantics:
- non-member / not entitled to workspace scope OR tenant scope → 404 (deny-as-not-found)
- member but missing capability → 403
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
**Constitution alignment (UI-FIL-001):** If this feature adds or changes Filament or Blade UI for admin/operator surfaces, the spec MUST describe:
- how the affected surface follows `docs/ui/tenantpilot-enterprise-ui-standards.md`,
- which native Filament components or shared UI primitives are used,
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
- how the feature avoids ad-hoc custom styling for cards, buttons, hovers, badges, icons, progress bars, empty states, and interactive rows,
- how any custom Blade, Livewire widget, page, or dashboard surface preserves Filament-native interaction semantics and avoids introducing an independent button, status-color, spacing, or card system,
- how each affected page or focused action area keeps at most one dominant primary action and keeps secondary actions neutral unless they are destructive or the semantic state change is the point of the action,
- how status is conveyed through BADGE-001 badges, labels, chips, or supporting text rather than arbitrary button colors or per-card custom action styling,
- how hover, pointer, focus, shadow, or similar interactive affordance is used only when a repo-real route/action and permitted capability exist, and how non-interactive rows remain visibly static,
- how any required local Blade/Tailwind cards still preserve dark mode correctness, spacing consistency, badge semantics, action hierarchy, progressive disclosure, accessibility, and Filament visual language, and are used to compose product-specific layout rather than a parallel local design system,
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
notifications, audit prose, or related helper copy, the spec MUST describe:
- the target object,
- the operator verb,
- whether source/domain disambiguation is actually needed,
- how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose,
- and how implementation-first terms are kept out of primary operator-facing labels.
**Constitution alignment (DECIDE-001):** If this feature adds or changes operator-facing surfaces, the spec MUST describe:
- whether each affected surface is a Primary Decision Surface,
Secondary Context Surface, or Tertiary Evidence / Diagnostics
Surface, and why,
- which human-in-the-loop moment each primary surface supports,
- what MUST be visible immediately for the first decision,
- what is preserved but only revealed on demand,
- why any new primary surface cannot live inside an existing decision
context,
- how navigation follows operator workflows rather than storage
structures,
- how one governance case remains decidable in one focused context,
- how any new automation, notifications, or autonomous governance logic
reduce search/review/click load,
- and how the resulting default experience is calmer and clearer rather
than merely larger.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** If this feature adds or changes an operator-facing surface, the spec MUST describe:
- the chosen broad action-surface class and why it is the correct classification,
- the chosen detailed surface type and why it is the correct refinement,
- the one most likely next operator action,
- the one and only primary inspect/open model,
- whether row click is required, allowed, or forbidden,
- whether explicit View or Inspect is present, and why it is present or forbidden,
- where pure navigation lives and why it is not competing with mutation,
- where secondary actions live,
- where destructive actions live,
- how grouped actions are ordered by meaning, frequency, and risk,
- the canonical collection route and canonical detail route,
- the scope signals shown to the operator and what real effect each one has,
- the canonical noun used across routes, labels, runs, notifications, and audit prose,
- which critical operational truth is visible by default,
- and any catalogued exception type, rationale, and dedicated test coverage.
**Constitution alignment (ACTSURF-001 - action hierarchy):** If this
feature adds or materially changes header actions, row actions, bulk
actions, or workbench controls, the spec MUST describe:
- how navigation, mutation, context signals, selection actions, and
dangerous actions are separated,
- why any visible secondary action deserves primary-plane placement,
- why any ActionGroup is structured rather than a mixed catch-all,
- and why any workflow-hub, wizard, system, or other special-type
exception is genuine rather than a convenience shortcut.
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
- which diagnostics are secondary and how they are explicitly revealed,
- how the dominant next action stays primary and how duplicate visible truth is avoided,
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
- how workspace and tenant context remain explicit in navigation, action copy, and page semantics,
- and the page contract for each new or materially refactored operator-facing page.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** If this feature adds UI semantics, presenters, explanation layers,
status taxonomies, or other interpretation layers, the spec MUST describe:
- why direct mapping from canonical domain truth to UI is insufficient,
- which existing layer is replaced or why no existing layer can serve,
- how the feature avoids creating redundant truth across models, service results, presenters, summaries, wrappers, and persisted mirrors,
- and how tests focus on business consequences rather than thin indirection alone.
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
The same section MUST state that each affected surface has exactly one primary inspect/open model, that redundant View actions are absent,
that empty `ActionGroup` / `BulkActionGroup` placeholders are absent, and that destructive actions follow the required placement rules for the chosen surface type.
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
The same section MUST also state whether UI-FIL-001 is satisfied and identify any approved exception.
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
title + explanation + exactly 1 CTA, and tables provide search/sort/filters for core dimensions.
If UX-001 is not fully satisfied, the spec MUST include an explicit exemption with documented rationale.
### Functional Requirements
- **FR-001**: Baseline Compare Landing MUST render a decision card with `Status`, `Reason`, `Impact`, and exactly one `Primary next action` for every repo-backed compare state.
- **FR-002**: Baseline Compare Landing MUST render the Compare readiness flow (Product Process Flow horizontal variant) across repo-backed states using the default step set:
1) Baseline assigned, 2) Baseline snapshot, 3) Environment snapshot / coverage proof, 4) Compare run, 5) Decision output.
- **FR-003**: Each readiness step MUST be derived from existing repository truth sources and MUST never show a fake “Available/Complete” state.
- **FR-004**: Operation proof (execution truth) MUST be separated from evidence/coverage truth; OperationRun completion MUST NOT be presented as “evidence-ready” by default.
- **FR-005**: Diagnostics MUST be collapsed by default; raw diff/payload MUST NOT be default-visible.
- **FR-006**: Baseline Compare MUST show drift summary and a “Review findings” path only when repo-backed findings exist and the user is authorized.
- **FR-007**: `Compare now` MUST remain `->requiresConfirmation()`, capability-gated (`TENANT_SYNC`), and OperationRun-backed (existing behavior preserved).
- **FR-008**: “No drift” outcomes MUST be scoped: they may claim “no open drift findings for this baseline comparison” and must explicitly caveat when coverage/evidence gaps exist.
- **FR-009**: The page MUST remain environment-owned: no `/admin/t` routes, no legacy tenant query aliases as authority, no cross-workspace leakage via linked artifacts.
### UX Requirements
- Decision-first ordering: Decision card → readiness flow → proof/inputs → drift summary → diagnostics (collapsed).
- One primary next action per state (no competing primary CTAs).
- No duplicate lower summary blocks repeating status/reason/impact.
- Horizontal readiness flow is the default; step badges are readable in dark mode; no clipped labels.
### RBAC / Security Requirements
- Action visibility MUST follow capability-first RBAC (hide action or show unavailable state per existing conventions).
- Cross-workspace/environment artifacts (baseline profiles, snapshots, runs, findings) MUST not be visible via this page unless authorized and scoped.
### Data / Truth-Source Requirements
- Readiness flow state MUST be derived from:
- baseline assignment: `BaselineTenantAssignment` + `BaselineProfile`
- snapshot truth: `BaselineSnapshotTruthResolver`
- environment snapshot / coverage proof: latest inventory sync coverage + observed inventory presence (repo-backed)
- compare run: `OperationRunType::BaselineCompare`
- decision output: open drift findings + completed run outcome + coverage/evidence gap truth (repo-backed)
- No new persisted “readiness” or “decision” entities.
### Required Tests
- Feature test: `apps/platform/tests/Feature/Filament/Spec336BaselineCompareProductProcessFlowAlignmentTest.php`
- Browser smoke: `apps/platform/tests/Browser/Spec336BaselineCompareProductProcessFlowAlignmentSmokeTest.php`
- Update existing Baseline Compare tests as needed to reflect the new readiness flow visibility across states (do not delete historical assertions without replacement coverage).
### Required Screenshots
Save under `specs/336-baseline-compare-product-process-flow-alignment/artifacts/screenshots/`:
1. `01-no-baseline-assigned.png`
2. `02-baseline-snapshot-required.png`
3. `03-compare-run-required.png`
4. `04-compare-result-available.png`
5. `05-evidence-unavailable.png`
6. `06-diagnostics-collapsed.png`
7. `07-dark-mode.png`
If a state is not reachable, implementation close-out MUST document why (repo-truth).
## UI Action Matrix *(mandatory when Filament is changed)*
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
RBAC gating (capability + enforcement helper), whether the mutation writes an audit log, and any exemption or exception used.
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|
| Baseline Compare Landing | `apps/platform/app/Filament/Pages/BaselineCompareLanding.php` | `Compare now` (confirmation + capability-gated), optional `Back` link | N/A | none | none | state-driven primary CTA inside decision card (e.g. `Open baseline profiles`) | N/A | N/A | yes (existing baseline compare OperationRun + audit logger; unchanged) | ActionSurfaceDeclaration is list-only read-only with explicit exemptions; no destructive actions introduced |
### Key Entities *(include if feature involves data)*
- **BaselineTenantAssignment**: environment baseline assignment.
- **BaselineProfile**: workspace-owned baseline definition and scope/capture mode.
- **BaselineSnapshot**: workspace-owned immutable baseline snapshot (compare input).
- **OperationRun**: execution proof for baseline compare (`OperationRunType::BaselineCompare`).
- **Finding**: drift findings derived from compare (`finding_type = drift`, `source = baseline.compare`).
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: A reviewer can open Baseline Compare and identify the visible status, reason, impact, proof basis, blocker, and primary next action within five seconds for each supported state.
- **SC-002**: The Compare readiness flow is visible across repo-backed states and does not show fake “Available/Complete” steps.
- **SC-003**: Diagnostics remain collapsed by default; raw diff/payload is not default-visible; no duplicate visible decision summary blocks exist.
- **SC-004**: Tests pass (Feature + Browser smoke), and the required screenshots are captured (or unreachable states documented with repo-truth reasons).

View File

@ -0,0 +1,99 @@
# Tasks: Spec 336 - Baseline Compare Product Process Flow Alignment
- Input: `specs/336-baseline-compare-product-process-flow-alignment/spec.md`, `specs/336-baseline-compare-product-process-flow-alignment/plan.md`
- Prerequisites: `spec.md`, `plan.md`, `repo-truth-map.md`, `baseline-compare-state-contract.md`
**Tests**: Required. This changes a strategic operator surface and must be validated with Feature tests plus a Browser smoke file.
## Test Governance Checklist
- [x] Lane assignment is explicit and is the narrowest sufficient proof (Feature + Browser).
- [x] Browser coverage stays single-file and scenario-scoped (no silent heavy-family expansion).
- [x] No new default-heavy helpers/factories/seeds are introduced; reuse existing fixture helpers.
- [x] Validation commands are minimal and directly prove the changed contract.
- [x] Any deviation resolves as `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
## Phase 1: Preparation And Repo Truth
**Purpose**: Confirm repo truth and lock the state contract before runtime edits.
- [x] T001 Re-read `spec.md`, `plan.md`, and this `tasks.md`.
- [x] T002 Confirm working tree intent and record baseline commit (`git status`, `git log -1`).
- [x] T003 Re-verify repo truth sources and step semantics:
- `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`
- `apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php`
- `apps/platform/app/Support/Baselines/BaselineCompareStats.php`
- `apps/platform/app/Services/Baselines/BaselineSnapshotTruthResolver.php`
- `apps/platform/app/Services/Baselines/BaselineCompareService.php`
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- [x] T004 Finalize/update `repo-truth-map.md` and `baseline-compare-state-contract.md` if runtime sources differ from the prepared truth map.
- [x] T005 Confirm Spec 332 Product Process Flow rendering conventions and decide the reuse strategy:
- reuse existing shared flow render primitive if present
- otherwise introduce the narrowest shared primitive only if justified by 2+ consumers (Restore + Baseline Compare)
## Phase 2: State Presenter / Flow Model
**Purpose**: Centralize “what is complete/missing/blocked/available” mapping in one place to avoid Blade drift.
- [x] T006 Implement a small Baseline Compare presenter/view-model (or expand an existing one) that computes:
- decision card (`Status/Reason/Impact/Primary next action`)
- readiness flow steps across repo-backed states
- available inputs/proof items (repo-backed only)
- drift summary and evidence/coverage state (separated from OperationRun proof)
- diagnostics disclosure state (collapsed default)
- [x] T007 Ensure presenter uses existing truth only (no new persistence, no new enum family, no new compare logic).
- [x] T008 Ensure primary next action is exactly one per state and is capability-aware (hide/disable per existing conventions).
## Phase 3: UI Alignment (Baseline Compare Landing)
**Purpose**: Render the shared Product Process Flow contract consistently on the page.
- [x] T009 Replace the current page-local readiness pipeline (currently limited to `no_assignment`) with the shared Product Process Flow horizontal flow renderer.
- [x] T010 Ensure the readiness flow is visible across the full repo-backed state family (`no_assignment`, `no_snapshot`, `invalid_scope`, `idle`, `comparing`, `failed`, `ready`).
- [x] T011 Keep diagnostics collapsed by default; ensure raw diff/payload is not default-visible.
- [x] T012 Remove any duplicated visible status/summary blocks introduced by state-specific sections.
- [x] T013 Keep “Compare now” semantics unchanged: confirmation, capability gate, OperationRun start UX, queued toast + “Open operation” link.
## Phase 4: Drift / Evidence / Copy Hardening
**Purpose**: Prevent false “all clear” outcomes and keep evidence claims truthful.
- [x] T014 Ensure “no drift” copy is scoped and caveated when coverage/evidence gaps exist (no “healthy/compliant/customer-safe” claim).
- [x] T015 Ensure evidence/coverage gap state is visible separately from OperationRun proof.
- [x] T016 Ensure state-specific copy aligns to the state contract and does not leak raw reason codes into primary UI.
## Phase 5: Feature Tests (Pest)
- [x] T017 Add `apps/platform/tests/Feature/Filament/Spec336BaselineCompareProductProcessFlowAlignmentTest.php` covering:
- no baseline assignment (decision card + readiness flow + primary action + diagnostics collapsed)
- baseline assigned but snapshot missing/blocked
- compare run required (`idle`) with `Compare now` enabled only when authorized
- compare in progress (`comparing`)
- compare failed (`failed`)
- compare ready with findings + compare ready with zero findings (with truthful caveats)
- evidence/coverage gap messaging + proof separation
- workspace/environment isolation and unauthorized action hiding
- [x] T018 Update existing Baseline Compare tests (including Spec 330) only where necessary, keeping equivalent or stronger assertions.
## Phase 6: Browser Smoke + Screenshots
- [x] T019 Add `apps/platform/tests/Browser/Spec336BaselineCompareProductProcessFlowAlignmentSmokeTest.php` covering the declared core states and disclosure guarantees.
- [x] T020 Capture screenshots into `specs/336-baseline-compare-product-process-flow-alignment/artifacts/screenshots/`:
- `01-no-baseline-assigned.png`
- `02-baseline-snapshot-required.png`
- `03-compare-run-required.png`
- `04-compare-result-available.png`
- `05-evidence-unavailable.png`
- `06-diagnostics-collapsed.png`
- `07-dark-mode.png`
- [x] T021 If a state is unreachable, document the repo-truth reason in implementation close-out (do not fake screenshots).
## Phase 7: Validation
- [x] T022 Run narrow tests first:
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament/Spec336BaselineCompareProductProcessFlowAlignmentTest.php --compact`
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Browser/Spec336BaselineCompareProductProcessFlowAlignmentSmokeTest.php --compact`
- [x] T023 Run overlapping guard filters:
- `cd apps/platform && ./vendor/bin/sail artisan test --filter='BaselineCompare|ProductProcessFlow|EnvironmentOwned|Spec332|Spec330' --compact`
- [x] T024 Run `cd apps/platform && ./vendor/bin/sail pint --dirty` and `git diff --check`.
- [x] T025 Report full-suite status honestly if not run.