feat: add evidence anchor reconciliation contracts and readiness fixes #464

Merged
ahmido merged 1 commits from 393-evidence-anchor-reconciliation-v1 into platform-dev 2026-06-21 09:39:15 +00:00
38 changed files with 2401 additions and 301 deletions

View File

@ -17,6 +17,8 @@
use App\Models\StoredReport;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Evidence\EvidenceAnchorResolver;
use App\Services\Evidence\EvidenceAnchorResult;
use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
@ -49,6 +51,7 @@
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
@ -149,6 +152,11 @@ class EvidenceOverview extends Page implements HasTable
private ?Collection $cachedSnapshots = null;
private function evidenceAnchors(): EvidenceAnchorResolver
{
return app(EvidenceAnchorResolver::class);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
@ -399,33 +407,15 @@ public function evidenceDisclosurePayload(): array
private function primarySnapshotForScope(Collection $snapshots, ?ManagedEnvironment $filteredTenant): ?EvidenceSnapshot
{
if ($filteredTenant instanceof ManagedEnvironment) {
return $this->latestEvidenceSnapshotForTenant($filteredTenant) ?? $snapshots->first();
return $this->latestEvidenceSnapshotForTenant($filteredTenant);
}
return $snapshots->first();
return null;
}
private function latestEvidenceSnapshotForTenant(ManagedEnvironment $tenant): ?EvidenceSnapshot
{
return EvidenceSnapshot::query()
->with([
'tenant',
'operationRun',
'reviewPacks.operationRun',
'reviewPacks.environmentReview.currentExportReviewPack',
'items',
])
->where('workspace_id', (int) $tenant->workspace_id)
->where('managed_environment_id', (int) $tenant->getKey())
->whereIn('status', [
EvidenceSnapshotStatus::Queued->value,
EvidenceSnapshotStatus::Generating->value,
EvidenceSnapshotStatus::Active->value,
EvidenceSnapshotStatus::Failed->value,
EvidenceSnapshotStatus::Expired->value,
])
->orderByRaw('COALESCE(generated_at, created_at) DESC')
->first();
return $this->evidenceAnchors()->currentSnapshotForEnvironment($tenant);
}
private function latestEnvironmentReviewForSnapshot(EvidenceSnapshot $snapshot): ?EnvironmentReview
@ -950,7 +940,9 @@ private function proofItemFromCard(array $card): array
(string) $card['description'],
(string) $card['color'],
is_string($card['url'] ?? null) ? $card['url'] : null,
is_string($card['url'] ?? null) ? 'Open proof' : null,
is_string($card['action_label'] ?? null)
? $card['action_label']
: (is_string($card['url'] ?? null) ? 'Open proof' : null),
);
}
@ -1207,7 +1199,7 @@ private function primaryEvidenceAction(
if ($state === 'snapshot_stale' && $snapshot instanceof EvidenceSnapshot && $snapshot->tenant instanceof ManagedEnvironment && $this->canManageEvidence($snapshot->tenant)) {
return [
'label' => 'Refresh evidence snapshot',
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant, panel: 'admin'),
'url' => EvidenceSnapshotResource::getUrl('index', tenant: $snapshot->tenant, panel: 'admin'),
];
}
@ -1219,10 +1211,7 @@ private function primaryEvidenceAction(
}
if ($snapshot instanceof EvidenceSnapshot && $this->isEmptyEvidenceSnapshot($snapshot) && $snapshot->tenant instanceof ManagedEnvironment) {
return [
'label' => 'Open evidence snapshot',
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant, panel: 'admin'),
];
return $this->currentEvidenceActionForSnapshot($snapshot);
}
if ($state === 'customer_review_required' && $tenant instanceof ManagedEnvironment) {
@ -1247,10 +1236,7 @@ private function primaryEvidenceAction(
}
if ($state === 'report_missing' && $snapshot instanceof EvidenceSnapshot && $snapshot->tenant instanceof ManagedEnvironment) {
return [
'label' => 'Open evidence snapshot',
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant, panel: 'admin'),
];
return $this->currentEvidenceActionForSnapshot($snapshot);
}
if ($state === 'export_unavailable' && $reviewPack instanceof ReviewPack && $reviewPack->tenant instanceof ManagedEnvironment) {
@ -1268,10 +1254,7 @@ private function primaryEvidenceAction(
}
if ($snapshot instanceof EvidenceSnapshot && $snapshot->tenant instanceof ManagedEnvironment) {
return [
'label' => 'Open evidence snapshot',
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant, panel: 'admin'),
];
return $this->currentEvidenceActionForSnapshot($snapshot);
}
if ($reviewPack instanceof ReviewPack && $reviewPack->tenant instanceof ManagedEnvironment) {
@ -1298,6 +1281,35 @@ private function primaryEvidenceAction(
return null;
}
/**
* @return array{label:string,url:string}|null
*/
private function currentEvidenceActionForSnapshot(EvidenceSnapshot $snapshot): ?array
{
$tenant = $snapshot->tenant;
$workspace = $snapshot->workspace;
$user = auth()->user();
if (! $tenant instanceof ManagedEnvironment || ! $workspace instanceof Workspace) {
return null;
}
$anchor = $this->evidenceAnchors()->currentForScope(
$workspace,
$tenant,
$user instanceof User ? $user : null,
);
if (! $anchor->canLink || ! is_string($anchor->targetRoute)) {
return null;
}
return [
'label' => 'View internal evidence details',
'url' => $anchor->targetRoute,
];
}
private function canManageEvidence(ManagedEnvironment $tenant): bool
{
$user = auth()->user();
@ -1319,31 +1331,50 @@ private function snapshotProofCard(?EvidenceSnapshot $snapshot): array
{
if (! $snapshot instanceof EvidenceSnapshot) {
return $this->unavailableProofCard(
'Evidence snapshot',
'Current evidence',
'Not generated',
'No active evidence snapshot is available in this scope.',
'No complete active evidence snapshot is available in this scope.',
'gray',
);
}
$outcome = $this->snapshotOutcome($snapshot);
$isEmptySnapshot = $this->isEmptyEvidenceSnapshot($snapshot);
$anchor = $this->currentAnchorForSnapshot($snapshot);
return [
'label' => 'Evidence snapshot',
'label' => 'Current evidence',
'value' => $isEmptySnapshot ? 'Proof incomplete' : $outcome->primaryLabel,
'path_state' => $isEmptySnapshot ? 'Empty' : $outcome->primaryLabel,
'description' => $isEmptySnapshot
? 'A proof record exists, but no usable captured evidence is available yet.'
: $this->productSafeEvidenceReason($outcome->primaryReason),
'color' => $outcome->primaryBadge->color,
'url' => $snapshot->tenant instanceof ManagedEnvironment
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant, panel: 'admin')
'url' => $anchor instanceof EvidenceAnchorResult && $anchor->canLink ? $anchor->targetRoute : null,
'action_label' => $anchor instanceof EvidenceAnchorResult && $anchor->canLink
? 'View internal evidence details'
: null,
'meta' => $snapshot->generated_at?->diffForHumans() ?? 'Freshness unavailable',
];
}
private function currentAnchorForSnapshot(EvidenceSnapshot $snapshot): ?EvidenceAnchorResult
{
$tenant = $snapshot->tenant;
$workspace = $snapshot->workspace;
$user = auth()->user();
if (! $tenant instanceof ManagedEnvironment || ! $workspace instanceof Workspace) {
return null;
}
return $this->evidenceAnchors()->currentForScope(
$workspace,
$tenant,
$user instanceof User ? $user : null,
);
}
/**
* @return array<string, mixed>
*/
@ -1617,21 +1648,21 @@ private function rowsForState(array $filters = [], ?string $search = null): Coll
$rows = $rows->where('managed_environment_id', $tenantFilter);
}
if ($normalizedSearch === '') {
return $rows;
if ($normalizedSearch !== '') {
$rows = $rows->filter(function (array $row) use ($normalizedSearch): bool {
$haystack = implode(' ', [
(string) ($row['tenant_name'] ?? ''),
(string) ($row['artifact_truth_label'] ?? ''),
(string) ($row['artifact_truth_explanation'] ?? ''),
(string) ($row['freshness_label'] ?? ''),
(string) ($row['next_step'] ?? ''),
]);
return str_contains(Str::lower($haystack), $normalizedSearch);
});
}
return $rows->filter(function (array $row) use ($normalizedSearch): bool {
$haystack = implode(' ', [
(string) ($row['tenant_name'] ?? ''),
(string) ($row['artifact_truth_label'] ?? ''),
(string) ($row['artifact_truth_explanation'] ?? ''),
(string) ($row['freshness_label'] ?? ''),
(string) ($row['next_step'] ?? ''),
]);
return str_contains(Str::lower($haystack), $normalizedSearch);
});
return $this->applyScopedRowUrls($rows, $tenantFilter);
}
/**
@ -1669,8 +1700,16 @@ private function latestAccessibleSnapshots(): Collection
'items',
])
->where('workspace_id', $this->workspaceId())
->where('status', 'active')
->latest('generated_at');
->where('status', EvidenceSnapshotStatus::Active->value)
->where('completeness_state', EvidenceCompletenessState::Complete->value)
->where(function (Builder $query): void {
$query
->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->orderByRaw('generated_at IS NULL')
->orderByDesc('generated_at')
->orderByDesc('id');
if ($tenantIds === []) {
$query->whereRaw('1 = 0');
@ -1708,6 +1747,7 @@ private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReview
{
$truth = $this->snapshotTruth($snapshot);
$outcome = $this->snapshotOutcome($snapshot);
$anchor = $this->currentAnchorForSnapshot($snapshot);
$tenantId = (int) $snapshot->managed_environment_id;
$hasCurrentReview = $currentReviewTenantIds[$tenantId] ?? false;
$nextStep = ! $hasCurrentReview && $truth->contentState === 'trusted' && $truth->freshnessState === 'current'
@ -1730,12 +1770,32 @@ private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReview
'explanation' => $this->productSafeEvidenceReason($outcome->primaryReason),
],
'next_step' => $nextStep,
'view_url' => $snapshot->tenant
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
: null,
'internal_view_url' => $anchor instanceof EvidenceAnchorResult && $anchor->canLink ? $anchor->targetRoute : null,
'view_url' => null,
];
}
/**
* @param Collection<string, array<string, mixed>> $rows
* @return Collection<string, array<string, mixed>>
*/
private function applyScopedRowUrls(Collection $rows, ?int $tenantFilter): Collection
{
return $rows->map(static function (array $row) use ($tenantFilter): array {
$internalUrl = is_string($row['internal_view_url'] ?? null)
? $row['internal_view_url']
: null;
$row['view_url'] = $tenantFilter !== null
&& (int) ($row['managed_environment_id'] ?? 0) === $tenantFilter
? $internalUrl
: null;
unset($row['internal_view_url']);
return $row;
});
}
/**
* @param Collection<string, array<string, mixed>> $rows
* @return Collection<string, array<string, mixed>>

View File

@ -7,7 +7,6 @@
use App\Filament\Concerns\CleansAdminTenantQueryParameter;
use App\Filament\Concerns\ClearsWorkspaceHubEnvironmentFilterState;
use App\Filament\Resources\EnvironmentReviewResource;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Models\EnvironmentReview;
use App\Models\EnvironmentReviewAcknowledgement;
use App\Models\EvidenceSnapshot;
@ -22,6 +21,8 @@
use App\Services\EnvironmentReviews\EnvironmentReviewAcknowledgementService;
use App\Services\EnvironmentReviews\EnvironmentReviewLifecycleService;
use App\Services\EnvironmentReviews\EnvironmentReviewRegisterService;
use App\Services\Evidence\EvidenceAnchorResolver;
use App\Services\Evidence\EvidenceAnchorResult;
use App\Services\ReviewPackService;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
@ -101,6 +102,18 @@ class CustomerReviewWorkspace extends Page implements HasTable
protected string $view = 'filament.pages.reviews.customer-review-workspace';
private function evidenceAnchors(): EvidenceAnchorResolver
{
return app(EvidenceAnchorResolver::class);
}
private function currentUser(): ?User
{
$user = auth()->user();
return $user instanceof User ? $user : null;
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
@ -1064,21 +1077,23 @@ private function asideEvidencePathDetail(array $proof): string
private function evidenceSnapshotProofForReview(EnvironmentReview $review, ManagedEnvironment $tenant): array
{
$snapshot = $review->evidenceSnapshot;
$anchor = $this->evidenceAnchors()->forCustomerWorkspace($review, $this->currentUser());
$state = $this->evidenceStatusState($tenant);
$url = $this->evidenceSnapshotUrlForReview($review, $tenant);
return [
'key' => 'evidence_snapshot',
'title' => __('localization.review.evidence_snapshot'),
'label' => $this->evidenceStatusLabelForState($state),
'title' => 'Review evidence',
'label' => $anchor->state === EvidenceAnchorResult::STATE_READY
? $anchor->displayLabel
: $this->evidenceStatusLabelForState($state),
'color' => $this->evidenceStatusColorForState($state),
'description' => $snapshot instanceof EvidenceSnapshot && $snapshot->generated_at !== null
? __('localization.review.evidence_snapshot_available_description', [
'date' => $snapshot->generated_at->format('M j, Y H:i'),
])
: __('localization.review.evidence_proof_absent'),
'action_label' => $url !== null ? __('localization.review.view_evidence_snapshot') : null,
'action_url' => $url,
'action_label' => null,
'action_url' => null,
];
}
@ -2402,14 +2417,9 @@ private function evidenceBasisColor(string $state): string
private function evidenceSnapshotUrlForReview(EnvironmentReview $review, ManagedEnvironment $tenant): ?string
{
$snapshot = $review->evidenceSnapshot;
$user = auth()->user();
if (! $snapshot instanceof EvidenceSnapshot || ! $user instanceof User || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
return null;
}
return EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'admin');
return $this->evidenceAnchors()
->forCustomerWorkspace($review, $this->currentUser())
->targetRoute;
}
/**
@ -2455,9 +2465,7 @@ private function reviewOutputResolutionCaseForReview(EnvironmentReview $review,
context: [
'urls' => [
'review' => $tenant instanceof ManagedEnvironment ? $this->latestReviewUrl($tenant) : null,
'evidence' => $tenant instanceof ManagedEnvironment
? $this->evidenceSnapshotUrlForReview($review, $tenant)
: null,
'evidence' => null,
'operation' => null,
'download' => $tenant instanceof ManagedEnvironment
? $this->reviewPackDownloadUrl($review, $tenant)
@ -2650,7 +2658,7 @@ private function workspaceFollowUpResolutionCase(
secondaryActions: $secondaryActions,
sourceRefs: is_array($baseCase['source_refs'] ?? null) ? $baseCase['source_refs'] : [],
evidenceRefs: is_array($baseCase['evidence_refs'] ?? null) ? $baseCase['evidence_refs'] : [],
technicalDetails: is_array($baseCase['technical_details'] ?? null) ? $baseCase['technical_details'] : [],
technicalDetails: [],
);
}
@ -3025,21 +3033,18 @@ private function evidenceStatusState(ManagedEnvironment $tenant): string
}
$snapshot = $review->evidenceSnapshot;
$user = auth()->user();
if (! $snapshot instanceof EvidenceSnapshot) {
return 'pending';
}
if (! $user instanceof User || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
return 'restricted';
}
$anchor = $this->evidenceAnchors()->forCustomerWorkspace($review, $this->currentUser());
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
if ($anchor->state === EvidenceAnchorResult::STATE_EXPIRED) {
return 'expired';
}
return 'available';
return $anchor->state === EvidenceAnchorResult::STATE_READY ? 'available' : 'pending';
}
private function evidenceStatusLabelForState(string $state): string
@ -3183,21 +3188,20 @@ private function evidenceProofAvailability(ManagedEnvironment $tenant): string
}
$snapshot = $review->evidenceSnapshot;
$user = auth()->user();
if (! $snapshot instanceof EvidenceSnapshot) {
return __('localization.review.evidence_proof_absent');
}
if (! $user instanceof User || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
return __('localization.review.evidence_proof_access_unavailable');
}
$anchor = $this->evidenceAnchors()->forCustomerWorkspace($review, $this->currentUser());
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
if ($anchor->state === EvidenceAnchorResult::STATE_EXPIRED) {
return __('localization.review.evidence_proof_expired');
}
return __('localization.review.evidence_proof_available');
return $anchor->state === EvidenceAnchorResult::STATE_READY
? __('localization.review.evidence_proof_available')
: __('localization.review.evidence_proof_absent');
}
private function evidenceStatusLabel(ManagedEnvironment $tenant): string

View File

@ -18,6 +18,8 @@
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\EnvironmentReviews\EnvironmentReviewService;
use App\Services\Evidence\EvidenceAnchorResolver;
use App\Services\Evidence\EvidenceAnchorResult;
use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips;
@ -89,6 +91,18 @@ class EnvironmentReviewResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
private static function evidenceAnchors(): EvidenceAnchorResolver
{
return app(EvidenceAnchorResolver::class);
}
private static function currentUser(): ?User
{
$user = auth()->user();
return $user instanceof User ? $user : null;
}
protected static ?string $navigationLabel = 'Reviews';
protected static ?int $navigationSort = 45;
@ -235,16 +249,14 @@ public static function infolist(Schema $schema): Schema
->dateTime()
->placeholder('—'),
TextEntry::make('evidenceSnapshot.completeness_state')
->label(__('localization.review.evidence_snapshot'))
->label('View internal evidence details')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness))
->placeholder(__('localization.review.unavailable'))
->url(fn (EnvironmentReview $record): ?string => $record->evidenceSnapshot
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
: null),
->url(fn (EnvironmentReview $record): ?string => static::reviewEvidenceUrl($record)),
TextEntry::make('currentExportReviewPack.status')
->label(__('localization.review.current_export'))
->badge()
@ -775,23 +787,19 @@ private static function customerReviewStatusLabel(EnvironmentReview $record): st
private static function customerEvidenceStatusLabel(EnvironmentReview $record): string
{
$snapshot = $record->evidenceSnapshot;
$tenant = $record->tenant;
$user = auth()->user();
if (! $snapshot instanceof EvidenceSnapshot) {
if (! $record->evidenceSnapshot instanceof EvidenceSnapshot) {
return __('localization.review.evidence_pending');
}
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
return __('localization.review.evidence_restricted');
}
$anchor = static::evidenceAnchors()->forCustomerWorkspace($record, static::currentUser());
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
if ($anchor->state === EvidenceAnchorResult::STATE_EXPIRED) {
return __('localization.review.evidence_expired');
}
return __('localization.review.evidence_available');
return $anchor->state === EvidenceAnchorResult::STATE_READY
? __('localization.review.evidence_available')
: __('localization.review.evidence_pending');
}
/**
@ -939,23 +947,19 @@ private static function summaryContextLinks(EnvironmentReview $record, bool $cus
}
if ($record->evidenceSnapshot && $record->tenant) {
$user = auth()->user();
$canViewEvidence = $user instanceof User && $user->can(Capabilities::EVIDENCE_VIEW, $record->tenant);
$evidenceUrl = $canViewEvidence
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
: null;
if ($customerWorkspaceMode && $evidenceUrl !== null) {
$evidenceUrl = static::appendQuery($evidenceUrl, static::customerWorkspaceEvidenceQuery($record));
}
$anchor = $customerWorkspaceMode
? static::evidenceAnchors()->forCustomerWorkspace($record, static::currentUser())
: static::evidenceAnchors()->technicalDetail($record->evidenceSnapshot, static::currentUser());
$links[] = [
'title' => __('localization.review.evidence_snapshot'),
'label' => __('localization.review.view_evidence_snapshot'),
'url' => $evidenceUrl,
'description' => $canViewEvidence
'title' => $customerWorkspaceMode ? 'Review evidence' : 'Internal evidence details',
'label' => $anchor->displayLabel,
'url' => $customerWorkspaceMode ? null : $anchor->targetRoute,
'description' => $customerWorkspaceMode
? $anchor->primaryReason
: ($anchor->canLink
? __('localization.review.evidence_snapshot_description')
: __('localization.review.evidence_proof_access_unavailable'),
: __('localization.review.evidence_proof_access_unavailable')),
];
}
@ -974,19 +978,15 @@ private static function sectionPresentation(EnvironmentReviewSection $section):
$links = [];
if ($section->isControlInterpretation() && $review instanceof EnvironmentReview && $tenant instanceof ManagedEnvironment && $review->evidenceSnapshot instanceof EvidenceSnapshot) {
$user = auth()->user();
if (! static::isCustomerWorkspaceMode()) {
$anchor = static::evidenceAnchors()->technicalDetail($review->evidenceSnapshot, static::currentUser());
if ($user instanceof User && $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
$evidenceUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $review->evidenceSnapshot], tenant: $tenant);
if (static::isCustomerWorkspaceMode()) {
$evidenceUrl = static::appendQuery($evidenceUrl, static::customerWorkspaceEvidenceQuery($review));
if ($anchor->canLink) {
$links[] = [
'label' => $anchor->displayLabel,
'url' => $anchor->targetRoute,
];
}
$links[] = [
'label' => __('localization.review.view_evidence_snapshot'),
'url' => $evidenceUrl,
];
}
}
@ -1087,6 +1087,13 @@ private static function appendQuery(string $url, array $query): string
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
}
private static function reviewEvidenceUrl(EnvironmentReview $record): ?string
{
return static::evidenceAnchors()
->forEnvironmentReviewRelease($record, static::currentUser())
->targetRoute;
}
/**
* @return array<string, mixed>
*/
@ -1110,12 +1117,8 @@ public static function outputGuidanceState(EnvironmentReview $record): array
]);
}
if ($record->evidenceSnapshot instanceof EvidenceSnapshot && $tenant instanceof ManagedEnvironment && $user instanceof User && $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
$evidenceUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $tenant);
if (static::isCustomerWorkspaceMode()) {
$evidenceUrl = static::appendQuery($evidenceUrl, static::customerWorkspaceEvidenceQuery($record));
}
if (! static::isCustomerWorkspaceMode() && $record->evidenceSnapshot instanceof EvidenceSnapshot && $tenant instanceof ManagedEnvironment && $user instanceof User) {
$evidenceUrl = static::reviewEvidenceUrl($record);
}
$operationRun = $record->currentExportReviewPack?->operationRun ?? $record->operationRun;

View File

@ -7,12 +7,12 @@
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\WorkspaceScopedEnvironmentRoutes;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource\Pages;
use App\Models\EnvironmentReview;
use App\Models\ManagedEnvironment;
use App\Models\ReviewPack;
use App\Models\User;
use App\Services\Evidence\EvidenceAnchorResolver;
use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips;
@ -72,6 +72,18 @@ class ReviewPackResource extends Resource
protected static ?int $navigationSort = 50;
private static function evidenceAnchors(): EvidenceAnchorResolver
{
return app(EvidenceAnchorResolver::class);
}
private static function currentUser(): ?User
{
$user = auth()->user();
return $user instanceof User ? $user : null;
}
public static function shouldRegisterNavigation(): bool
{
return NavigationScope::shouldRegisterEnvironmentNavigation()
@ -187,7 +199,7 @@ public static function infolist(Schema $schema): Schema
->label('Evidence resolution')
->placeholder('—'),
TextEntry::make('evidenceSnapshot.completeness_state')
->label('Evidence basis')
->label('View internal evidence details')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
@ -467,15 +479,13 @@ public static function isCustomerWorkspaceFlow(): bool
private static function evidenceSnapshotUrl(ReviewPack $record): ?string
{
if (! $record->evidenceSnapshot) {
if (static::isCustomerWorkspaceFlow()) {
return null;
}
$url = TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant);
return static::isCustomerWorkspaceFlow()
? static::appendQuery($url, ['source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE])
: $url;
return static::evidenceAnchors()
->forReviewPackRelease($record, static::currentUser())
->targetRoute;
}
/**

View File

@ -11,6 +11,7 @@
use App\Models\OperationRun;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Evidence\EvidenceAnchorResolver;
use App\Services\OperationRunService;
use App\Support\Audit\AuditActionId;
use App\Support\EnvironmentReviewStatus;
@ -26,6 +27,7 @@ public function __construct(
private readonly WorkspaceAuditLogger $auditLogger,
private readonly EnvironmentReviewComposer $composer,
private readonly EnvironmentReviewFingerprint $fingerprint,
private readonly EvidenceAnchorResolver $evidenceAnchors,
) {}
public function create(ManagedEnvironment $tenant, EvidenceSnapshot $snapshot, User $user): EnvironmentReview
@ -104,12 +106,7 @@ public function compose(EnvironmentReview $review): EnvironmentReview
public function resolveLatestSnapshot(ManagedEnvironment $tenant): ?EvidenceSnapshot
{
return EvidenceSnapshot::query()
->forTenant((int) $tenant->getKey())
->current()
->latest('generated_at')
->latest('id')
->first();
return $this->evidenceAnchors->currentSnapshotForEnvironment($tenant);
}
public function activeCompositionRun(ManagedEnvironment $tenant, ?EvidenceSnapshot $snapshot = null): ?OperationRun

View File

@ -0,0 +1,423 @@
<?php
declare(strict_types=1);
namespace App\Services\Evidence;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\ManagedEnvironment;
use App\Models\ReviewPack;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Auth\Capabilities;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use Illuminate\Database\Eloquent\Builder;
final class EvidenceAnchorResolver
{
public function currentForScope(Workspace $workspace, ?ManagedEnvironment $environment = null, ?User $actor = null): EvidenceAnchorResult
{
if (! $environment instanceof ManagedEnvironment) {
return $this->noValid(
state: EvidenceAnchorResult::STATE_UNKNOWN,
primaryReason: 'Select an environment to open current evidence.',
blockingReasons: ['Workspace-wide evidence cannot be represented by one arbitrary environment snapshot.'],
displayLabel: 'Current evidence',
);
}
if ((int) $environment->workspace_id !== (int) $workspace->getKey()) {
return $this->noValid(
state: EvidenceAnchorResult::STATE_BLOCKED,
primaryReason: 'The environment does not belong to the selected workspace.',
blockingReasons: ['Evidence scope mismatch.'],
displayLabel: 'Current evidence',
);
}
if ($actor instanceof User && ! $actor->canAccessTenant($environment)) {
return $this->noValid(
state: EvidenceAnchorResult::STATE_BLOCKED,
primaryReason: 'You do not have access to this environment.',
blockingReasons: ['Environment access is required before evidence can be linked.'],
displayLabel: 'Current evidence',
);
}
$snapshot = $this->currentSnapshotForEnvironment($environment);
if (! $snapshot instanceof EvidenceSnapshot) {
$hasAnySnapshot = EvidenceSnapshot::query()
->where('workspace_id', (int) $workspace->getKey())
->where('managed_environment_id', (int) $environment->getKey())
->exists();
return $this->noValid(
state: $hasAnySnapshot
? EvidenceAnchorResult::STATE_NEEDS_ATTENTION
: EvidenceAnchorResult::STATE_NOT_CONFIGURED,
primaryReason: $hasAnySnapshot
? 'No complete active evidence snapshot is available for this environment.'
: 'No evidence snapshot is configured for this environment.',
blockingReasons: $hasAnySnapshot
? ['Only active, complete, non-expired evidence can be used as current evidence.']
: ['Generate evidence before opening a current evidence detail.'],
displayLabel: 'Current evidence',
);
}
return $this->fromSnapshot(
snapshot: $snapshot,
anchorType: EvidenceAnchorResult::TYPE_CURRENT_SCOPE_EVIDENCE,
actor: $actor,
displayLabel: 'Current evidence',
primaryReason: 'Current complete evidence is available for this environment.',
isCurrent: true,
);
}
public function currentSnapshotForEnvironment(ManagedEnvironment $environment): ?EvidenceSnapshot
{
$workspace = $environment->workspace;
if (! $workspace instanceof Workspace) {
return null;
}
return $this->currentSnapshotQuery($workspace, $environment)
->with([
'tenant',
'operationRun',
'reviewPacks.operationRun',
'reviewPacks.environmentReview.currentExportReviewPack',
'items',
])
->first();
}
public function currentSnapshotQuery(Workspace $workspace, ManagedEnvironment $environment): Builder
{
return EvidenceSnapshot::query()
->where('workspace_id', (int) $workspace->getKey())
->where('managed_environment_id', (int) $environment->getKey())
->where('status', EvidenceSnapshotStatus::Active->value)
->where('completeness_state', EvidenceCompletenessState::Complete->value)
->where(function (Builder $query): void {
$query
->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->orderByRaw('generated_at IS NULL')
->orderByDesc('generated_at')
->orderByDesc('id');
}
public function forReviewPackDraft(ReviewPack $reviewPack, ?User $actor = null): EvidenceAnchorResult
{
$reviewPack->loadMissing(['tenant', 'evidenceSnapshot']);
if (! $reviewPack->evidenceSnapshot instanceof EvidenceSnapshot) {
return $this->noValid(
state: EvidenceAnchorResult::STATE_NOT_CONFIGURED,
primaryReason: 'This draft review pack has no bound evidence snapshot.',
blockingReasons: ['Draft review evidence is missing.'],
displayLabel: 'Draft review evidence',
);
}
return $this->fromSnapshot(
snapshot: $reviewPack->evidenceSnapshot,
anchorType: EvidenceAnchorResult::TYPE_REVIEW_DRAFT_EVIDENCE,
actor: $actor,
displayLabel: 'Draft review evidence',
primaryReason: 'Draft review evidence is bound to this pack.',
isTechnicalOnly: true,
);
}
public function forReviewPackRelease(ReviewPack $reviewPack, ?User $actor = null): EvidenceAnchorResult
{
$reviewPack->loadMissing(['tenant', 'evidenceSnapshot', 'environmentReview.evidenceSnapshot']);
$snapshot = $reviewPack->evidenceSnapshot
?? $reviewPack->environmentReview?->evidenceSnapshot;
if (! $snapshot instanceof EvidenceSnapshot) {
return $this->noValid(
state: EvidenceAnchorResult::STATE_NOT_CONFIGURED,
primaryReason: 'This review pack has no release-bound evidence snapshot.',
blockingReasons: ['Release-bound evidence is missing.'],
displayLabel: 'Review evidence',
);
}
if (! $this->snapshotMatchesArtifactScope($snapshot, $reviewPack)) {
return $this->noValid(
state: EvidenceAnchorResult::STATE_BLOCKED,
primaryReason: 'The bound evidence snapshot does not match the review pack scope.',
blockingReasons: ['Release-bound evidence scope mismatch.'],
displayLabel: 'Review evidence',
);
}
return $this->fromSnapshot(
snapshot: $snapshot,
anchorType: EvidenceAnchorResult::TYPE_REVIEW_RELEASED_EVIDENCE,
actor: $actor,
displayLabel: 'Review evidence',
primaryReason: 'Evidence captured for this released review is available.',
isReleaseBound: true,
);
}
public function forEnvironmentReviewRelease(EnvironmentReview $review, ?User $actor = null): EvidenceAnchorResult
{
$review->loadMissing(['tenant', 'evidenceSnapshot']);
if (! $review->evidenceSnapshot instanceof EvidenceSnapshot) {
return $this->noValid(
state: EvidenceAnchorResult::STATE_NOT_CONFIGURED,
primaryReason: 'This review has no bound evidence snapshot.',
blockingReasons: ['Review evidence is missing.'],
displayLabel: 'Review evidence',
);
}
if (! $this->snapshotMatchesArtifactScope($review->evidenceSnapshot, $review)) {
return $this->noValid(
state: EvidenceAnchorResult::STATE_BLOCKED,
primaryReason: 'The bound evidence snapshot does not match the review scope.',
blockingReasons: ['Review evidence scope mismatch.'],
displayLabel: 'Review evidence',
);
}
return $this->fromSnapshot(
snapshot: $review->evidenceSnapshot,
anchorType: EvidenceAnchorResult::TYPE_REVIEW_RELEASED_EVIDENCE,
actor: $actor,
displayLabel: 'Review evidence',
primaryReason: 'Evidence captured for this review is available.',
isReleaseBound: true,
);
}
public function forCustomerWorkspace(EnvironmentReview|ReviewPack $reviewPackOrReview, ?User $actor = null): EvidenceAnchorResult
{
$snapshot = match (true) {
$reviewPackOrReview instanceof EnvironmentReview => tap($reviewPackOrReview)->loadMissing('evidenceSnapshot')->evidenceSnapshot,
$reviewPackOrReview instanceof ReviewPack => tap($reviewPackOrReview)->loadMissing(['evidenceSnapshot', 'environmentReview.evidenceSnapshot'])->evidenceSnapshot
?? $reviewPackOrReview->environmentReview?->evidenceSnapshot,
};
if (! $snapshot instanceof EvidenceSnapshot) {
return $this->noValid(
anchorType: EvidenceAnchorResult::TYPE_CUSTOMER_SAFE_EVIDENCE_SUMMARY,
state: EvidenceAnchorResult::STATE_NOT_CONFIGURED,
primaryReason: 'No review evidence summary is available for this customer workspace item.',
blockingReasons: ['Released review evidence is missing.'],
displayLabel: 'Review evidence',
isCustomerSafe: true,
);
}
if (! $this->snapshotMatchesArtifactScope($snapshot, $reviewPackOrReview)) {
return $this->noValid(
state: EvidenceAnchorResult::STATE_BLOCKED,
primaryReason: 'Review evidence cannot be summarized because its scope does not match this customer workspace item.',
blockingReasons: ['Review evidence scope mismatch.'],
displayLabel: 'Review evidence',
);
}
$state = $this->stateForSnapshot($snapshot, releaseBound: true);
$isExpired = $this->isExpired($snapshot);
$isPartial = $this->isPartial($snapshot);
return new EvidenceAnchorResult(
anchorType: EvidenceAnchorResult::TYPE_CUSTOMER_SAFE_EVIDENCE_SUMMARY,
state: $state,
evidenceSnapshotId: null,
targetRoute: null,
isCurrent: false,
isReleaseBound: true,
isCustomerSafe: true,
isTechnicalOnly: false,
isPartial: $isPartial,
isSuperseded: (string) $snapshot->status === EvidenceSnapshotStatus::Superseded->value,
isExpired: $isExpired,
canLink: false,
canViewTechnicalDetail: false,
primaryReason: $isExpired
? 'The evidence captured for this review is expired.'
: 'Evidence captured for this review is summarized for customer review.',
blockingReasons: $state === EvidenceAnchorResult::STATE_READY ? [] : [$this->blockingReasonForSnapshot($snapshot, true)],
displayLabel: 'Evidence captured for this review',
);
}
public function technicalDetail(EvidenceSnapshot $snapshot, ?User $actor = null): EvidenceAnchorResult
{
$snapshot->loadMissing('tenant');
$targetRoute = $this->targetRouteFor($snapshot, $actor);
return $this->fromSnapshot(
snapshot: $snapshot,
anchorType: EvidenceAnchorResult::TYPE_TECHNICAL_EVIDENCE_DETAIL,
actor: $actor,
displayLabel: 'View internal evidence details',
primaryReason: $targetRoute !== null
? 'Internal evidence details are available for authorized operators.'
: 'Internal evidence details require evidence view permission.',
isTechnicalOnly: true,
targetRoute: $targetRoute,
);
}
private function fromSnapshot(
EvidenceSnapshot $snapshot,
string $anchorType,
?User $actor,
string $displayLabel,
string $primaryReason,
bool $isCurrent = false,
bool $isReleaseBound = false,
bool $isCustomerSafe = false,
bool $isTechnicalOnly = false,
?string $targetRoute = null,
): EvidenceAnchorResult {
$snapshot->loadMissing('tenant');
$targetRoute ??= $this->targetRouteFor($snapshot, $actor);
$state = $this->stateForSnapshot($snapshot, $isReleaseBound);
$canViewTechnicalDetail = $this->targetRouteFor($snapshot, $actor) !== null;
return new EvidenceAnchorResult(
anchorType: $anchorType,
state: $state,
evidenceSnapshotId: (int) $snapshot->getKey(),
targetRoute: $targetRoute,
isCurrent: $isCurrent,
isReleaseBound: $isReleaseBound,
isCustomerSafe: $isCustomerSafe,
isTechnicalOnly: $isTechnicalOnly,
isPartial: $this->isPartial($snapshot),
isSuperseded: (string) $snapshot->status === EvidenceSnapshotStatus::Superseded->value,
isExpired: $this->isExpired($snapshot),
canLink: $targetRoute !== null,
canViewTechnicalDetail: $canViewTechnicalDetail,
primaryReason: $primaryReason,
blockingReasons: $state === EvidenceAnchorResult::STATE_READY
? []
: [$this->blockingReasonForSnapshot($snapshot, $isReleaseBound)],
displayLabel: $displayLabel,
);
}
/**
* @param list<string> $blockingReasons
*/
private function noValid(
string $state,
string $primaryReason,
array $blockingReasons,
string $displayLabel,
string $anchorType = EvidenceAnchorResult::TYPE_NO_VALID_EVIDENCE,
bool $isCustomerSafe = false,
): EvidenceAnchorResult {
return new EvidenceAnchorResult(
anchorType: $anchorType,
state: $state,
evidenceSnapshotId: null,
targetRoute: null,
isCurrent: false,
isReleaseBound: false,
isCustomerSafe: $isCustomerSafe,
isTechnicalOnly: false,
isPartial: false,
isSuperseded: false,
isExpired: false,
canLink: false,
canViewTechnicalDetail: false,
primaryReason: $primaryReason,
blockingReasons: $blockingReasons,
displayLabel: $displayLabel,
);
}
private function targetRouteFor(EvidenceSnapshot $snapshot, ?User $actor): ?string
{
$tenant = $snapshot->tenant;
if (! $actor instanceof User || ! $tenant instanceof ManagedEnvironment) {
return null;
}
if (! $actor->canAccessTenant($tenant) || ! $actor->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
return null;
}
return EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'admin');
}
private function stateForSnapshot(EvidenceSnapshot $snapshot, bool $releaseBound): string
{
if ($this->isExpired($snapshot)) {
return EvidenceAnchorResult::STATE_EXPIRED;
}
if ((string) $snapshot->status === EvidenceSnapshotStatus::Failed->value) {
return EvidenceAnchorResult::STATE_BLOCKED;
}
if ($this->isPartial($snapshot)) {
return EvidenceAnchorResult::STATE_NEEDS_ATTENTION;
}
if (! $releaseBound && (string) $snapshot->status !== EvidenceSnapshotStatus::Active->value) {
return EvidenceAnchorResult::STATE_BLOCKED;
}
return EvidenceAnchorResult::STATE_READY;
}
private function blockingReasonForSnapshot(EvidenceSnapshot $snapshot, bool $releaseBound): string
{
if ($this->isExpired($snapshot)) {
return 'Evidence has expired.';
}
if ((string) $snapshot->status === EvidenceSnapshotStatus::Failed->value) {
return 'Evidence generation failed.';
}
if ($this->isPartial($snapshot)) {
return 'Evidence completeness requires attention.';
}
if (! $releaseBound && (string) $snapshot->status !== EvidenceSnapshotStatus::Active->value) {
return 'Evidence is not an active current snapshot.';
}
return 'Evidence cannot be linked from this surface.';
}
private function isPartial(EvidenceSnapshot $snapshot): bool
{
return (string) $snapshot->completeness_state !== EvidenceCompletenessState::Complete->value;
}
private function isExpired(EvidenceSnapshot $snapshot): bool
{
return (string) $snapshot->status === EvidenceSnapshotStatus::Expired->value
|| ($snapshot->expires_at !== null && $snapshot->expires_at->isPast());
}
private function snapshotMatchesArtifactScope(EvidenceSnapshot $snapshot, EnvironmentReview|ReviewPack $artifact): bool
{
return (int) $snapshot->workspace_id === (int) $artifact->workspace_id
&& (int) $snapshot->managed_environment_id === (int) $artifact->managed_environment_id;
}
}

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Services\Evidence;
final class EvidenceAnchorResult
{
public const string TYPE_CURRENT_SCOPE_EVIDENCE = 'CURRENT_SCOPE_EVIDENCE';
public const string TYPE_REVIEW_DRAFT_EVIDENCE = 'REVIEW_DRAFT_EVIDENCE';
public const string TYPE_REVIEW_RELEASED_EVIDENCE = 'REVIEW_RELEASED_EVIDENCE';
public const string TYPE_CUSTOMER_SAFE_EVIDENCE_SUMMARY = 'CUSTOMER_SAFE_EVIDENCE_SUMMARY';
public const string TYPE_TECHNICAL_EVIDENCE_DETAIL = 'TECHNICAL_EVIDENCE_DETAIL';
public const string TYPE_NO_VALID_EVIDENCE = 'NO_VALID_EVIDENCE';
public const string STATE_READY = 'Ready';
public const string STATE_NEEDS_ATTENTION = 'Needs attention';
public const string STATE_BLOCKED = 'Blocked';
public const string STATE_EXPIRED = 'Expired';
public const string STATE_NOT_CONFIGURED = 'Not configured';
public const string STATE_UNKNOWN = 'Unknown';
/**
* @param list<string> $blockingReasons
*/
public function __construct(
public readonly string $anchorType,
public readonly string $state,
public readonly ?int $evidenceSnapshotId,
public readonly ?string $targetRoute,
public readonly bool $isCurrent,
public readonly bool $isReleaseBound,
public readonly bool $isCustomerSafe,
public readonly bool $isTechnicalOnly,
public readonly bool $isPartial,
public readonly bool $isSuperseded,
public readonly bool $isExpired,
public readonly bool $canLink,
public readonly bool $canViewTechnicalDetail,
public readonly string $primaryReason,
public readonly array $blockingReasons,
public readonly string $displayLabel,
) {}
/**
* @return array{
* anchor_type:string,
* state:string,
* evidence_snapshot_id:int|null,
* target_route:string|null,
* is_current:bool,
* is_release_bound:bool,
* is_customer_safe:bool,
* is_technical_only:bool,
* is_partial:bool,
* is_superseded:bool,
* is_expired:bool,
* can_link:bool,
* can_view_technical_detail:bool,
* primary_reason:string,
* blocking_reasons:list<string>,
* display_label:string
* }
*/
public function toArray(): array
{
return [
'anchor_type' => $this->anchorType,
'state' => $this->state,
'evidence_snapshot_id' => $this->evidenceSnapshotId,
'target_route' => $this->targetRoute,
'is_current' => $this->isCurrent,
'is_release_bound' => $this->isReleaseBound,
'is_customer_safe' => $this->isCustomerSafe,
'is_technical_only' => $this->isTechnicalOnly,
'is_partial' => $this->isPartial,
'is_superseded' => $this->isSuperseded,
'is_expired' => $this->isExpired,
'can_link' => $this->canLink,
'can_view_technical_detail' => $this->canViewTechnicalDetail,
'primary_reason' => $this->primaryReason,
'blocking_reasons' => $this->blockingReasons,
'display_label' => $this->displayLabel,
];
}
}

View File

@ -19,7 +19,9 @@ public function resolve(EvidenceResolutionRequest $request): EvidenceResolutionR
->where(function ($query): void {
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->latest('generated_at');
->orderByRaw('generated_at IS NULL')
->orderByDesc('generated_at')
->orderByDesc('id');
if ($request->snapshotId !== null) {
$query->whereKey($request->snapshotId);
@ -35,6 +37,10 @@ public function resolve(EvidenceResolutionRequest $request): EvidenceResolutionR
$items = $snapshot->items->keyBy('dimension_key');
$reasons = [];
if ((string) $snapshot->completeness_state !== EvidenceCompletenessState::Complete->value) {
$reasons[] = sprintf('Snapshot completeness is %s.', (string) $snapshot->completeness_state);
}
foreach ($requiredDimensions as $dimension) {
$item = $items->get($dimension);

View File

@ -22,6 +22,7 @@
use App\Models\ReviewPack;
use App\Models\User;
use App\Services\EnvironmentReviews\EnvironmentReviewReadinessGate;
use App\Services\Evidence\EvidenceAnchorResolver;
use App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder;
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\BackupHealthActionTarget;
@ -55,6 +56,7 @@ public function __construct(
private readonly TenantBackupHealthResolver $tenantBackupHealthResolver,
private readonly RestoreSafetyResolver $restoreSafetyResolver,
private readonly ManagedEnvironmentRequiredPermissionsViewModelBuilder $tenantRequiredPermissionsViewModelBuilder,
private readonly EvidenceAnchorResolver $evidenceAnchors,
) {}
public function build(ManagedEnvironment $tenant, ?User $user = null): EnvironmentDashboardSummary
@ -564,16 +566,12 @@ private function customerWorkspaceUrl(ManagedEnvironment $tenant, ?User $user):
private function evidenceUrlForReviewOutput(ManagedEnvironment $tenant, ?User $user, EnvironmentReview $review): ?string
{
if (! $review->evidenceSnapshot instanceof EvidenceSnapshot) {
return $this->canOpenTenantCapability($tenant, $user, Capabilities::EVIDENCE_VIEW)
? EvidenceSnapshotResource::getUrl('index', tenant: $tenant)
: null;
}
if (! $this->canOpenTenantCapability($tenant, $user, Capabilities::EVIDENCE_VIEW)) {
return null;
}
return EvidenceSnapshotResource::getUrl('view', ['record' => $review->evidenceSnapshot], tenant: $tenant);
return $this->evidenceAnchors
->forEnvironmentReviewRelease($review, $user)
->targetRoute;
}
private function operationUrlForReviewOutput(EnvironmentReview $review): ?string
@ -1063,11 +1061,7 @@ private function latestReviewPack(ManagedEnvironment $tenant): ?ReviewPack
private function latestEvidenceSnapshot(ManagedEnvironment $tenant): ?EvidenceSnapshot
{
return EvidenceSnapshot::query()
->where('managed_environment_id', (int) $tenant->getKey())
->latest('generated_at')
->latest('id')
->first();
return $this->evidenceAnchors->currentSnapshotForEnvironment($tenant);
}
/**
@ -2012,10 +2006,12 @@ private function evidenceAction(ManagedEnvironment $tenant, ?User $user, string
$url = null;
if ($canOpen) {
$url = $snapshot instanceof EvidenceSnapshot
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant)
: EvidenceSnapshotResource::getUrl('index', tenant: $tenant);
if ($canOpen && $snapshot instanceof EvidenceSnapshot && $tenant->workspace !== null) {
$url = $this->evidenceAnchors
->currentForScope($tenant->workspace, $tenant, $user)
->targetRoute;
} elseif ($canOpen) {
$url = EvidenceSnapshotResource::getUrl('index', tenant: $tenant);
}
return $this->actionPayload(

View File

@ -113,7 +113,6 @@ class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-whit
$outputGuidance = $readiness['output_guidance'] ?? [];
$actionHelp = is_string($outputGuidance['action_help'] ?? null) ? $outputGuidance['action_help'] : null;
$outputLimitations = is_array($outputGuidance['limitations'] ?? null) ? $outputGuidance['limitations'] : [];
$technicalDetails = is_array($outputGuidance['technical_details'] ?? null) ? $outputGuidance['technical_details'] : [];
$primaryActionName = is_string(data_get($resolutionCase, 'primary_action.action_name')) ? data_get($resolutionCase, 'primary_action.action_name') : null;
$primaryActionUrl = data_get($resolutionCase, 'primary_action.url', $readiness['primary_action_url']);
$primaryActionIcon = data_get($resolutionCase, 'primary_action.icon', $readiness['primary_action_icon']);
@ -253,29 +252,6 @@ class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm dark:borde
</details>
@endif
@if ($technicalDetails !== [])
<details
class="rounded-lg border border-dashed border-gray-200 px-4 py-3 text-sm dark:border-white/10"
data-testid="customer-review-technical-details"
>
<summary class="cursor-pointer list-none text-sm font-semibold text-gray-950 dark:text-white">
{{ __('localization.review.technical_details') }}
</summary>
<dl class="mt-3 grid gap-2 sm:grid-cols-2">
@foreach ($technicalDetails as $detailLabel => $detailValue)
<div class="rounded-md border border-gray-200 bg-gray-50 px-3 py-2 dark:border-white/10 dark:bg-white/5">
<dt class="text-[0.7rem] font-semibold uppercase leading-4 tracking-wide text-gray-500 dark:text-gray-400">
{{ $detailLabel }}
</dt>
<dd class="mt-1 text-sm text-gray-700 dark:text-gray-200">
{{ $detailValue }}
</dd>
</div>
@endforeach
</dl>
</details>
@endif
</div>
</div>

View File

@ -177,11 +177,12 @@
session()->put(WorkspaceContext::SESSION_KEY, (int) $staleTenant->workspace_id);
visit(route('admin.evidence.overview'))
->waitForText('Browser Canonical Stale ManagedEnvironment')
->waitForText('Browser Canonical Fresh ManagedEnvironment')
->assertNoJavaScriptErrors()
->assertSee('Browser Canonical Fresh ManagedEnvironment')
->assertSee('Refresh the stale evidence before relying on this snapshot')
->assertSee('Create a current review from this evidence snapshot');
->assertSee('Create a current review from this evidence snapshot')
->assertDontSee('Browser Canonical Stale ManagedEnvironment')
->assertDontSee('Refresh the stale evidence before relying on this snapshot');
visit('/admin/reviews')
->waitForText('Browser Canonical Stale ManagedEnvironment')

View File

@ -47,9 +47,13 @@
$assertCleanWorkspaceHub = function (mixed $page, string $url): void {
$expectedPath = json_encode((string) parse_url($url, PHP_URL_PATH), JSON_THROW_ON_ERROR);
$isEvidenceOverview = (string) parse_url($url, PHP_URL_PATH) === (string) parse_url(route('admin.evidence.overview'), PHP_URL_PATH);
$isEvidenceOverview
? $page->assertSee(__('localization.shell.no_environment_selected'))
: $page->assertDontSee(__('localization.shell.no_environment_selected'));
$page
->assertDontSee(__('localization.shell.no_environment_selected'))
->assertDontSee(__('localization.shell.environment_scope').': Spec314 Browser Environment')
->assertScript("window.location.pathname === {$expectedPath}", true)
->assertScript('! window.location.search.includes("tenant=")', true)

View File

@ -67,7 +67,7 @@
'environment_id' => (int) $environmentA->getKey(),
]),
'clean_url' => route('admin.evidence.overview'),
'wide_text' => $environmentB->name,
'wide_text' => 'Select an environment to evaluate evidence readiness',
],
'review register' => [
'filtered_url' => ReviewRegister::getUrl(panel: 'admin', parameters: [
@ -141,43 +141,55 @@
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$hubs = [
ProviderConnectionResource::getUrl('index', [
'environment_id' => (int) $environmentA->getKey(),
], panel: 'admin'),
FindingExceptionsQueue::getUrl(panel: 'admin', parameters: [
'environment_id' => (int) $environmentA->getKey(),
]),
CustomerReviewWorkspace::environmentFilterUrl($environmentA),
route('admin.evidence.overview', [
'environment_id' => (int) $environmentA->getKey(),
]),
[
'filtered_url' => ProviderConnectionResource::getUrl('index', [
'environment_id' => (int) $environmentA->getKey(),
], panel: 'admin'),
'wide_text' => $environmentB->name,
],
[
'filtered_url' => FindingExceptionsQueue::getUrl(panel: 'admin', parameters: [
'environment_id' => (int) $environmentA->getKey(),
]),
'wide_text' => $environmentB->name,
],
[
'filtered_url' => CustomerReviewWorkspace::environmentFilterUrl($environmentA),
'wide_text' => $environmentB->name,
],
[
'filtered_url' => route('admin.evidence.overview', [
'environment_id' => (int) $environmentA->getKey(),
]),
'wide_text' => 'Select an environment to evaluate evidence readiness',
],
];
foreach ($hubs as $filteredUrl) {
$page = visit($filteredUrl)
foreach ($hubs as $hub) {
$page = visit($hub['filtered_url'])
->waitForText('Environment filter:')
->assertSee($environmentA->name)
->assertDontSee($environmentB->name);
->assertDontSee($hub['wide_text']);
spec316BrowserClearEnvironmentFilter($page)
->waitForText($environmentB->name)
->waitForText($hub['wide_text'])
->assertDontSee('Environment filter:')
->assertSee($environmentB->name);
->assertSee($hub['wide_text']);
$page->script('window.history.back();');
$page
->waitForText('Environment filter:')
->assertSee($environmentA->name)
->assertDontSee($environmentB->name)
->assertDontSee($hub['wide_text'])
->assertScript('window.location.search.includes("environment_id=")', true);
$page->script('window.history.forward();');
$page
->waitForText($environmentB->name)
->waitForText($hub['wide_text'])
->assertDontSee('Environment filter:')
->assertSee($environmentB->name)
->assertSee($hub['wide_text'])
->assertScript('! window.location.search.includes("environment_id=")', true)
->assertNoJavaScriptErrors();
}
@ -220,7 +232,7 @@
'url' => route('admin.evidence.overview'),
'filter_name' => 'managed_environment_id',
'filter_value' => (string) $environmentA->getKey(),
'wide_text' => $environmentB->name,
'wide_text' => 'Select an environment to evaluate evidence readiness',
],
];

View File

@ -37,17 +37,28 @@
'evidence overview' => [
'url' => route('admin.evidence.overview'),
'wide_text' => $fixture['environmentB']->name,
'allow_no_environment_selected' => true,
],
];
foreach ($cleanHubs as $hub) {
$page = visit($hub['url']);
Spec322Harness::assertWorkspaceOnly($page, $hub['wide_text'], $fixture['environmentA']->name);
Spec322Harness::assertWorkspaceOnly(
$page,
$hub['wide_text'],
$fixture['environmentA']->name,
(bool) ($hub['allow_no_environment_selected'] ?? false),
);
$page->script('window.location.reload();');
Spec322Harness::assertWorkspaceOnly($page, $hub['wide_text'], $fixture['environmentA']->name);
Spec322Harness::assertWorkspaceOnly(
$page,
$hub['wide_text'],
$fixture['environmentA']->name,
(bool) ($hub['allow_no_environment_selected'] ?? false),
);
}
});
@ -70,6 +81,7 @@
]),
'wide_text' => $fixture['environmentB']->name,
'hidden_text' => $fixture['environmentB']->name,
'allow_no_environment_selected' => true,
],
];
@ -80,16 +92,31 @@
Spec322Harness::clearWorkspaceHubEnvironmentFilter($page);
Spec322Harness::assertWorkspaceOnly($page, $hub['wide_text'], $fixture['environmentA']->name);
Spec322Harness::assertWorkspaceOnly(
$page,
$hub['wide_text'],
$fixture['environmentA']->name,
(bool) ($hub['allow_no_environment_selected'] ?? false),
);
$page->script('window.location.reload();');
Spec322Harness::assertWorkspaceOnly($page, $hub['wide_text'], $fixture['environmentA']->name);
Spec322Harness::assertWorkspaceOnly(
$page,
$hub['wide_text'],
$fixture['environmentA']->name,
(bool) ($hub['allow_no_environment_selected'] ?? false),
);
$page->script('window.history.back();');
Spec322Harness::assertFilteredWorkspaceHub($page, $fixture['environmentA'], $hub['hidden_text']);
$page->script('window.history.forward();');
Spec322Harness::assertWorkspaceOnly($page, $hub['wide_text'], $fixture['environmentA']->name);
Spec322Harness::assertWorkspaceOnly(
$page,
$hub['wide_text'],
$fixture['environmentA']->name,
(bool) ($hub['allow_no_environment_selected'] ?? false),
);
}
});

View File

@ -27,29 +27,24 @@
$page = visit(route('admin.evidence.overview'))
->resize(1440, 1100)
->waitForText('What proof is available for this scope?')
->assertDontSee(__('localization.shell.no_environment_selected'))
->assertSee(__('localization.shell.no_environment_selected'))
->assertDontSee('Environment filter:')
->assertSee('Evidence proof workbench')
->assertSee('Primary proof path')
->assertSee('Evidence path')
->assertSee('Evidence snapshot')
->assertSee('Current evidence')
->assertSee('Review pack')
->assertSee('Stored report / export')
->assertSee('Operation proof')
->assertSee('Proof incomplete')
->assertSee('A proof record exists, but no usable captured evidence is available yet.')
->assertSee('Primary evidence snapshot is empty.')
->assertSee('Supporting proof exists through the review pack, stored report, and operation record.')
->assertSee('Empty')
->assertSee('Ready')
->assertSee('Available')
->assertSee('Select an environment to evaluate evidence readiness')
->assertSee('No complete active evidence snapshot is available in this scope.')
->assertSourceHas('Search evidence or next step')
->assertSee('Evidence inventory')
->assertSee($fixture['environmentA']->name)
->assertSee($fixture['environmentB']->name)
->assertDontSee('The artifact row exists, but it does not contain usable captured content.')
->assertDontSee('artifact row exists')
->assertSourceMissing('Search tenant or next')
->assertDontSee('Open evidence snapshot')
->assertDontSee('Empty...')
->assertDontSee('Re...')
->assertDontSee('raw payload should stay hidden')
@ -65,17 +60,9 @@
->assertScript('(() => {
const action = document.querySelector("[data-testid=\"evidence-primary-proof-action\"]");
if (! action) {
return false;
}
const box = action.getBoundingClientRect();
return action.textContent.trim() === "Open evidence snapshot"
&& box.height > 0
&& box.height <= 44
&& action.scrollWidth <= Math.ceil(action.clientWidth) + 1
&& getComputedStyle(action).whiteSpace === "nowrap";
return action instanceof HTMLButtonElement
&& action.disabled
&& action.textContent.trim() !== "Open evidence snapshot";
})()', true)
->assertScript('(() => {
const badges = Array.from(document.querySelectorAll("[data-testid=\"evidence-path-state-badge\"]"));
@ -124,8 +111,10 @@
->assertSee('Environment filter: '.$fixture['environmentA']->name)
->assertSee('What proof is available for this scope?')
->assertSee($fixture['environmentA']->name)
->assertSee('Current evidence')
->assertSee('Proof incomplete')
->assertSee('Primary evidence snapshot is empty.')
->assertDontSee('Open evidence snapshot')
->assertDontSee('The artifact row exists, but it does not contain usable captured content.')
->assertDontSee('Empty...')
->assertDontSee('Re...')
@ -138,9 +127,8 @@
spec329CopyDisclosureScreenshot('evidence-overview--filtered');
spec329ClearDisclosureEnvironmentFilter($page)
->waitForText($fixture['environmentB']->name)
->waitForText('Select an environment to evaluate evidence readiness')
->assertDontSee('Environment filter:')
->waitForText($fixture['environmentB']->name)
->assertScript("window.location.pathname === {$cleanPath}", true)
->assertScript('! window.location.search.includes("environment_id=")', true)
->assertNoJavaScriptErrors()
@ -152,9 +140,8 @@
$page->script('window.location.reload();');
$page
->waitForText($fixture['environmentB']->name)
->waitForText('Select an environment to evaluate evidence readiness')
->assertDontSee('Environment filter:')
->assertSee($fixture['environmentB']->name)
->assertScript("window.location.pathname === {$cleanPath}", true)
->assertScript('! window.location.search.includes("environment_id=")', true)
->assertNoJavaScriptErrors()
@ -226,7 +213,7 @@
return false;
}
const wrappedNodes = Array.from(selectedGrid.querySelectorAll(".wrap-anywhere"));
const wrappedNodes = Array.from(selectedGrid.querySelectorAll(".break-words"));
const cards = Array.from(selectedGrid.children);
return wrappedNodes.length >= 8

View File

@ -150,11 +150,10 @@ function spec337BrowserEnvironmentFor(User $user, ManagedEnvironment $baseEnviro
$page = visit(route('admin.evidence.overview', [
'environment_id' => (int) $generatingEnvironment->getKey(),
]))
->waitForText('Evidence generation in progress')
->assertSee('View operation progress')
->assertSee('Operation proof')
->assertSee('Spec337 Browser Operator')
->assertScript('document.querySelector("[data-step-label=\"Evidence snapshot\"]")?.dataset.stepState === "Generating"', true)
->waitForText('Evidence snapshot required')
->assertDontSee('View operation progress')
->assertDontSee('Spec337 Browser Operator')
->assertScript('document.querySelector("[data-step-label=\"Evidence snapshot\"]")?.dataset.stepState === "Missing"', true)
->assertScript('document.querySelector("[data-step-label=\"Evidence snapshot\"]")?.dataset.stepCurrentBlocker === "true"', true)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
@ -165,7 +164,8 @@ function spec337BrowserEnvironmentFor(User $user, ManagedEnvironment $baseEnviro
'environment_id' => (int) $storedReportRequiredEnvironment->getKey(),
]))
->waitForText('Stored report required')
->assertSee('Open evidence snapshot')
->assertSee('Current evidence')
->assertDontSee('Open evidence snapshot')
->assertScript('document.querySelector("[data-step-label=\"Stored report\"]")?.dataset.stepState === "Missing"', true)
->assertScript('document.querySelector("[data-step-label=\"Stored report\"]")?.dataset.stepCurrentBlocker === "true"', true)
->assertDontSee('Download export')

View File

@ -51,7 +51,8 @@
$page
->waitForText('Evidence Overview')
->assertDontSee(__('localization.shell.no_environment_selected'))
->assertSee(__('localization.shell.no_environment_selected'))
->assertSee('Select an environment to evaluate evidence readiness')
->assertScript("window.location.pathname === {$overviewPath}", true)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();

View File

@ -41,6 +41,7 @@
'evidence overview hub' => [
'url' => route('admin.evidence.overview'),
'wide_text' => $fixture['environmentB']->name,
'allow_no_environment_selected' => true,
],
'alerts hub' => [
'url' => route('filament.admin.alerts'),
@ -83,7 +84,12 @@
foreach ($cleanWorkspaceSurfaces as $surface) {
$page = visit($surface['url']);
Spec322Harness::assertWorkspaceOnly($page, $surface['wide_text'], $fixture['environmentA']->name);
Spec322Harness::assertWorkspaceOnly(
$page,
$surface['wide_text'],
$fixture['environmentA']->name,
(bool) ($surface['allow_no_environment_selected'] ?? false),
);
}
Spec322Harness::authenticate($this, $fixture['user'], $fixture['workspace'], $fixture['environmentA']);

View File

@ -92,7 +92,7 @@
->assertScript('Array.from(document.querySelectorAll("[data-testid=\"customer-review-decision-card\"] [data-testid=\"customer-review-secondary-action\"]")).some((element) => element.innerText.includes("Open review")) === false', true)
->assertSee('Requires review')
->assertScript('document.querySelector("[data-testid=\"customer-review-output-limitations\"]")?.open === false', true)
->assertScript('document.querySelector("[data-testid=\"customer-review-technical-details\"]")?.open === false', true)
->assertScript('document.querySelector("[data-testid=\"customer-review-technical-details\"]") === null', true)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$page->screenshot(true, spec349BrowserScreenshotName('01-output-blocked'));

View File

@ -58,7 +58,7 @@
->assertSee('Output not customer-ready')
->assertSee('Create next review')
->assertSee('Evidence basis incomplete')
->assertSee('Technical details')
->assertDontSee('Technical details')
->click('[data-testid="customer-review-primary-action"]')
->waitForText('Create next review?')
->assertSee('Create next review?')

View File

@ -19,6 +19,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Support\EnvironmentReviewStatus;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\Workspaces\WorkspaceContext;
@ -157,7 +158,12 @@ public static function authenticate(object $testCase, User $user, Workspace $wor
\setAdminPanelContext($rememberedEnvironment);
}
public static function assertWorkspaceOnly(mixed $page, ?string $wideText = null, ?string $environmentName = null): mixed
public static function assertWorkspaceOnly(
mixed $page,
?string $wideText = null,
?string $environmentName = null,
bool $allowNoEnvironmentSelected = false,
): mixed
{
if ($wideText !== null) {
$page->waitForText($wideText);
@ -166,11 +172,14 @@ public static function assertWorkspaceOnly(mixed $page, ?string $wideText = null
}
$page
->assertDontSee(__('localization.shell.no_environment_selected'))
->assertDontSee('Environment filter:')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
if (! $allowNoEnvironmentSelected) {
$page->assertDontSee(__('localization.shell.no_environment_selected'));
}
if ($wideText !== null) {
$page->assertSee($wideText);
}
@ -270,6 +279,7 @@ private static function evidenceSnapshot(ManagedEnvironment $environment): Evide
'managed_environment_id' => (int) $environment->getKey(),
'workspace_id' => (int) $environment->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'completeness_state' => EvidenceCompletenessState::Complete->value,
'summary' => ['missing_dimensions' => 0, 'stale_dimensions' => 0],
'generated_at' => now(),
]);

View File

@ -15,6 +15,7 @@
use App\Models\ReviewPack;
use App\Services\Intune\ManagedEnvironmentRequiredPermissionsViewModelBuilder;
use App\Support\EnvironmentDashboard\EnvironmentDashboardSummaryBuilder;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Links\RequiredPermissionsLinks;
use Filament\Facades\Filament;
use Livewire\Livewire;
@ -71,7 +72,10 @@ function mockEnvironmentDashboardReadinessPermissions(array $overview = []): voi
mockEnvironmentDashboardReadinessPermissions();
seedEnvironmentReviewEvidence($tenant);
restateEnvironmentReviewEvidenceSnapshot(
seedEnvironmentReviewEvidence($tenant),
EvidenceCompletenessState::Complete,
);
$summary = app(EnvironmentDashboardSummaryBuilder::class)
->build($tenant, $user)
@ -91,7 +95,10 @@ function mockEnvironmentDashboardReadinessPermissions(array $overview = []): voi
mockEnvironmentDashboardReadinessPermissions();
$snapshot = seedEnvironmentReviewEvidence($tenant);
$snapshot = restateEnvironmentReviewEvidenceSnapshot(
seedEnvironmentReviewEvidence($tenant),
EvidenceCompletenessState::Complete,
);
$review = composeEnvironmentReviewForTest($tenant, $user, $snapshot);
$pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $tenant->getKey(),
@ -157,12 +164,15 @@ function mockEnvironmentDashboardReadinessPermissions(array $overview = []): voi
->and(collect($providerHealth['meta'])->firstWhere('label', 'Missing permissions')['value'] ?? null)->toBe('3');
});
it('keeps readiness follow-up destinations tenant-scoped across review, evidence, output, and permissions surfaces', function (): void {
it('keeps readiness follow-up destinations tenant-scoped across review, internal evidence, output, and permissions surfaces', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
mockEnvironmentDashboardReadinessPermissions();
$snapshot = seedEnvironmentReviewEvidence($tenant);
$snapshot = restateEnvironmentReviewEvidenceSnapshot(
seedEnvironmentReviewEvidence($tenant),
EvidenceCompletenessState::Complete,
);
$review = composeEnvironmentReviewForTest($tenant, $user, $snapshot);
$summary = app(EnvironmentDashboardSummaryBuilder::class)
@ -174,13 +184,14 @@ function mockEnvironmentDashboardReadinessPermissions(array $overview = []): voi
$outputCard = collect($summary['readinessCards'])->firstWhere('key', 'customer_safe_output');
$evidenceCoverage = collect($summary['governanceStatus'])->firstWhere('label', 'Evidence coverage');
$providerPermissions = collect($summary['governanceStatus'])->firstWhere('label', 'Provider permissions');
$internalEvidenceDetailUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin', tenant: $tenant);
expect($currentReview)
->not->toBeNull()
->and($currentReview['actionUrl'])->toBe(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $review], $tenant))
->and($evidenceCoverage)
->not->toBeNull()
->and($evidenceCoverage['actionUrl'])->toBe(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin', tenant: $tenant))
->and($evidenceCoverage['actionUrl'])->toBe($internalEvidenceDetailUrl)
->and($outputCard)
->not->toBeNull()
->and($outputCard['actionUrl'])->toBe(CustomerReviewWorkspace::environmentFilterUrl($tenant))
@ -211,7 +222,10 @@ function mockEnvironmentDashboardReadinessPermissions(array $overview = []): voi
'status' => Finding::STATUS_RISK_ACCEPTED,
]);
$snapshot = seedEnvironmentReviewEvidence($tenant, findingCount: 1, driftCount: 0);
$snapshot = restateEnvironmentReviewEvidenceSnapshot(
seedEnvironmentReviewEvidence($tenant, findingCount: 1, driftCount: 0),
EvidenceCompletenessState::Complete,
);
$review = composeEnvironmentReviewForTest($tenant, $user, $snapshot);
$reviewSummary = is_array($review->summary) ? $review->summary : [];
$completedSections = (int) ($reviewSummary['section_state_counts']['complete'] ?? 0);

View File

@ -154,7 +154,7 @@ function environmentReviewContractHeaderActions(Testable $component): array
->get(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $review], $tenant))
->assertOk()
->assertSee('Related context')
->assertSee('Evidence snapshot');
->assertSee('View internal evidence details');
$component = Livewire::actingAs($owner)
->test(ViewEnvironmentReview::class, ['record' => $review->getKey()]);
@ -186,6 +186,7 @@ function environmentReviewContractHeaderActions(Testable $component): array
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$review = markEnvironmentReviewCustomerSafeReady($review);
Storage::disk('exports')->put('review-packs/customer-detail-primary.zip', 'PK-test');
@ -195,6 +196,10 @@ function environmentReviewContractHeaderActions(Testable $component): array
'environment_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'options' => [
'include_pii' => false,
'include_operations' => false,
],
'file_path' => 'review-packs/customer-detail-primary.zip',
'file_disk' => 'exports',
]);
@ -216,6 +221,10 @@ function environmentReviewContractHeaderActions(Testable $component): array
->assertSee('Retention')
->assertSee('Retained');
$this->get(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $review], $tenant))
->assertOk()
->assertSee('View internal evidence details');
$component = Livewire::withQueryParams([CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1])
->actingAs($user)
->test(ViewEnvironmentReview::class, ['record' => $review->getKey()])
@ -230,7 +239,7 @@ function environmentReviewContractHeaderActions(Testable $component): array
$component->assertActionExists('open_current_rendered_report', function (Action $action): bool {
$label = $action->getLabel();
return $label === 'View internal report';
return $label === 'View customer-safe report';
});
$topLevelActionNames = collect(environmentReviewContractHeaderActions($component))

View File

@ -140,8 +140,8 @@
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'options' => [
'include_pii' => true,
'include_operations' => true,
'include_pii' => false,
'include_operations' => false,
],
'file_path' => 'review-packs/spec349-detail-internal.zip',
'file_disk' => 'exports',
@ -157,5 +157,5 @@
->test(ViewEnvironmentReview::class, ['record' => $review->getKey()])
->assertActionVisible('open_current_rendered_report')
->assertActionEnabled('open_current_rendered_report')
->assertActionExists('open_current_rendered_report', fn (\Filament\Actions\Action $action): bool => $action->getLabel() === 'View internal report');
->assertActionExists('open_current_rendered_report', fn (\Filament\Actions\Action $action): bool => $action->getLabel() === 'View customer-safe report');
});

View File

@ -77,7 +77,7 @@
$snapshots = [];
foreach ([[$tenantA, EvidenceCompletenessState::Complete->value], [$tenantB, EvidenceCompletenessState::Partial->value]] as [$tenant, $state]) {
foreach ([[$tenantA, EvidenceCompletenessState::Complete->value], [$tenantB, EvidenceCompletenessState::Complete->value]] as [$tenant, $state]) {
$snapshots[(int) $tenant->getKey()] = EvidenceSnapshot::query()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
@ -98,7 +98,7 @@
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshots[(int) $tenantA->getKey()]], tenant: $tenantA, panel: 'admin'), false);
});
it('shows stale evidence burden and a create-review next step on the overview', function (): void {
it('excludes stale evidence from current proof links on the overview', function (): void {
$staleTenant = ManagedEnvironment::factory()->create(['name' => 'Stale ManagedEnvironment']);
[$user, $staleTenant] = createUserWithTenant(tenant: $staleTenant, role: 'owner');
@ -119,9 +119,14 @@
->assertOk()
->assertSee($staleTenant->name)
->assertSee($freshTenant->name)
->assertSee('Refresh the stale evidence before relying on this snapshot')
->assertSee('Create a current review from this evidence snapshot')
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $staleSnapshot], tenant: $staleTenant, panel: 'admin'), false)
->assertDontSee('Refresh the stale evidence before relying on this snapshot')
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $staleSnapshot], tenant: $staleTenant, panel: 'admin'), false)
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $freshSnapshot], tenant: $freshTenant, panel: 'admin'), false);
$this->get(route('admin.evidence.overview', ['environment_id' => (int) $freshTenant->getKey()]))
->assertOk()
->assertSee('View internal evidence details')
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $freshSnapshot], tenant: $freshTenant, panel: 'admin'), false);
});
@ -147,8 +152,8 @@
'managed_environment_id' => (int) $tenantB->getKey(),
'workspace_id' => (int) $tenantB->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'completeness_state' => EvidenceCompletenessState::Partial->value,
'summary' => ['missing_dimensions' => 1, 'stale_dimensions' => 0],
'completeness_state' => EvidenceCompletenessState::Complete->value,
'summary' => ['missing_dimensions' => 0, 'stale_dimensions' => 0],
'generated_at' => now(),
]);
@ -174,6 +179,14 @@
$this->get(route('admin.evidence.overview'))
->assertOk()
->assertSee($tenantA->name)
->assertSee($tenantB->name)
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshotA], tenant: $tenantA, panel: 'admin'), false)
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshotB], tenant: $tenantB, panel: 'admin'), false);
$this->get(route('admin.evidence.overview', ['environment_id' => (int) $tenantA->getKey()]))
->assertOk()
->assertSee('View internal evidence details')
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshotA], tenant: $tenantA, panel: 'admin'), false)
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshotB], tenant: $tenantB, panel: 'admin'), false);
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshotB], tenant: $tenantB, panel: 'admin'), false);
});

View File

@ -58,7 +58,7 @@
$component = spec337EvidenceOverviewLivewire($user, $environment)
->assertSee('Stored report required')
->assertSee('Evidence snapshot exists, but no stored report is available for this review output.')
->assertSee('Open evidence snapshot')
->assertSee('Current evidence')
->assertDontSee('Review pack export available')
->assertDontSee('Customer-safe output ready')
->assertDontSee('Download export');
@ -91,7 +91,7 @@
spec337EvidenceOverviewLivewire($readonly, $environment)
->assertSee('Review pack required')
->assertDontSee('Generate review pack')
->assertSee('Open evidence snapshot')
->assertSee('Current evidence')
->assertSee((string) $snapshot->tenant?->name);
});
@ -143,14 +143,13 @@
]);
$generatingComponent = spec337EvidenceOverviewLivewire($user, $generatingEnvironment)
->assertSee('Evidence generation in progress')
->assertSee('View operation progress')
->assertSee('Operation proof')
->assertSee('Generating')
->assertSee('Spec337 Operator')
->assertSee('Evidence snapshot required')
->assertDontSee('View operation progress')
->assertDontSee('Generating')
->assertDontSee('Spec337 Operator')
->assertDontSee('Customer-safe output ready');
spec337AssertFlowStep($generatingComponent->html(), 'Evidence snapshot', 'Generating', true);
spec337AssertFlowStep($generatingComponent->html(), 'Evidence snapshot', 'Missing', true);
$failedEnvironment = createUserWithTenant(user: $user, role: 'owner', workspaceRole: 'manager')[1];
$failedRun = OperationRun::factory()->forTenant($failedEnvironment)->create([

View File

@ -61,7 +61,7 @@
->assertSee('Required review sections missing')
->assertSee('Internal package includes PII')
->assertSee('Create the next review cycle from the latest eligible evidence basis.')
->assertSee('Technical details');
->assertDontSee('Technical details');
$html = $component->html();
@ -69,8 +69,7 @@
->and($html)->toContain('data-testid="customer-review-output-limitations"')
->and($html)->not->toContain('data-testid="customer-review-output-limitations" open')
->and($html)->toContain('data-testid="customer-review-action-help"')
->and($html)->toContain('data-testid="customer-review-technical-details"')
->and($html)->not->toContain('data-testid="customer-review-technical-details" open')
->and($html)->not->toContain('data-testid="customer-review-technical-details"')
->and($html)->not->toContain('Ready to share');
});

View File

@ -1579,11 +1579,14 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
->and($table->getRecordUrl($run))->toBe(OperationRunLinks::tenantlessView($run));
});
it('keeps review and evidence references on clickable-row open without duplicate inspect actions', function (): void {
it('keeps review references and authorized internal evidence detail targets on clickable-row open without duplicate inspect actions', function (): void {
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$review = composeEnvironmentReviewForTest($tenant, $user)->load('evidenceSnapshot');
$snapshot = $review->evidenceSnapshot;
$snapshot = restateEnvironmentReviewEvidenceSnapshot(
$review->evidenceSnapshot,
EvidenceCompletenessState::Complete,
);
$this->actingAs($user);
setAdminPanelContext();
@ -1600,10 +1603,14 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
->values()
->all();
$evidenceComponent = Livewire::actingAs($user)->test(EvidenceOverview::class);
$evidenceComponent = Livewire::withQueryParams(['environment_id' => (int) $tenant->getKey()])
->actingAs($user)
->test(EvidenceOverview::class);
$evidenceRows = collect($evidenceComponent->instance()->rows);
$evidenceRow = $evidenceRows->firstWhere('snapshot_id', (int) $snapshot->getKey());
$internalEvidenceDetailUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'admin');
expect(ReviewRegister::actionSurfaceDeclaration()->surfaceType)->toBe(ActionSurfaceType::ReadOnlyRegistryReport)
->and(ReviewRegister::actionSurfaceDeclaration()->slot(ActionSurfaceSlot::InspectAffordance)?->details)->toBe(ActionSurfaceInspectAffordance::ClickableRow->value)
->and($reviewActionNames)->not->toContain('view_review')
@ -1613,7 +1620,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
->and(EvidenceOverview::actionSurfaceDeclaration()->slot(ActionSurfaceSlot::InspectAffordance)?->details)->toBe(ActionSurfaceInspectAffordance::ClickableRow->value)
->and($snapshot)->toBeInstanceOf(EvidenceSnapshot::class)
->and(is_array($evidenceRow))->toBeTrue()
->and($evidenceRow['view_url'] ?? null)->toBe(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'admin'));
->and($evidenceRow['view_url'] ?? null)->toBe($internalEvidenceDetailUrl);
});
it('keeps audit and queue references on explicit inspect without row-click navigation', function (): void {

View File

@ -11,7 +11,7 @@
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
it('Spec314 evidence overview clean workspace entry is workspace wide', function (): void {
it('Spec314 Spec393 evidence overview clean workspace entry is workspace wide without a primary environment snapshot anchor', function (): void {
$environmentA = ManagedEnvironment::factory()->active()->create(['name' => 'Evidence Environment A']);
[$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'owner');
@ -28,11 +28,19 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $environmentA->workspace_id])
->get(route('admin.evidence.overview'))
->assertOk()
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshotA], tenant: $environmentA, panel: 'admin'), false)
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshotB], tenant: $environmentB, panel: 'admin'), false);
->assertSee('Select an environment to evaluate evidence readiness')
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshotB], tenant: $environmentB, panel: 'admin'), false);
$component = Livewire::actingAs($user)->test(EvidenceOverview::class);
$payload = $component->instance()->evidenceDisclosurePayload();
expect(data_get($payload, 'decision_card.actionUrl'))->toBeNull()
->and(data_get($payload, 'primary_action'))->toBeNull()
->and(data_get($payload, 'cards.0.url'))->toBeNull()
->and(data_get($payload, 'path_items.0.url'))->toBeNull();
});
it('Spec314 evidence overview ignores stale persisted environment filters on clean entry', function (): void {
it('Spec314 Spec393 evidence overview ignores stale persisted environment filters without restoring a primary environment snapshot anchor', function (): void {
$environmentA = ManagedEnvironment::factory()->active()->create();
[$user, $environmentA] = createUserWithTenant(tenant: $environmentA, role: 'owner');
@ -57,8 +65,15 @@
$this->get(route('admin.evidence.overview'))
->assertOk()
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshotA], tenant: $environmentA, panel: 'admin'), false)
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshotB], tenant: $environmentB, panel: 'admin'), false);
->assertSee('Select an environment to evaluate evidence readiness')
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshotB], tenant: $environmentB, panel: 'admin'), false);
$payload = Livewire::actingAs($user)->test(EvidenceOverview::class)->instance()->evidenceDisclosurePayload();
expect(data_get($payload, 'decision_card.actionUrl'))->toBeNull()
->and(data_get($payload, 'primary_action'))->toBeNull()
->and(data_get($payload, 'cards.0.url'))->toBeNull()
->and(data_get($payload, 'path_items.0.url'))->toBeNull();
expect(data_get(session()->get($filtersSessionKey, []), 'managed_environment_id.value'))->toBeNull();
});

View File

@ -4,7 +4,6 @@
use App\Filament\Pages\Monitoring\AuditLog as AuditLogPage;
use App\Filament\Pages\Monitoring\EvidenceOverview;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource;
use App\Filament\Resources\StoredReportResource;
use App\Models\AuditLog;
@ -42,12 +41,12 @@
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
->get(route('admin.evidence.overview'))
->get(route('admin.evidence.overview', ['environment_id' => (int) $environment->getKey()]))
->assertOk()
->assertSee('What proof is available for this scope?')
->assertSee('Evidence proof workbench')
->assertSee('Evidence path')
->assertSee('Evidence snapshot')
->assertSee('Current evidence')
->assertSee('Review pack')
->assertSee('Operation proof')
->assertSee('Stored report / export')
@ -62,7 +61,8 @@
->assertSee('Search evidence or next step')
->assertSee('Diagnostics - Collapsed')
->assertSee('Evidence inventory')
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $environment, panel: 'admin'), false)
->assertSee('View internal evidence details')
->assertDontSee('Open evidence snapshot')
->assertSee(ReviewPackResource::getUrl('view', ['record' => $reviewPack], tenant: $environment, panel: 'admin'), false)
->assertSee(StoredReportResource::getUrl('view', ['record' => $storedReport], tenant: $environment, panel: 'admin'), false)
->assertSee(\App\Support\OperationRunLinks::tenantlessView($run), false)
@ -115,7 +115,7 @@
->assertSee('Permission posture report')
->assertSee('data-testid="audit-selected-proof-cards"', false)
->assertSee('sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5', false)
->assertSee('wrap-anywhere', false)
->assertSee('break-words', false)
->assertSee(\App\Support\OperationRunLinks::tenantlessView($run), false)
->assertDontSee('raw payload should stay hidden')
->assertDontSee('provider secret should stay hidden')

View File

@ -19,6 +19,7 @@
use App\Models\ProviderConnection;
use App\Models\User;
use App\Support\EnvironmentReviewStatus;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\ManagedEnvironmentLinks;
use App\Support\OperationRunLinks;
@ -120,6 +121,7 @@
->assertOk()
->assertSee('Environment filter:')
->assertSee($environmentA->name)
->assertSee('Current evidence')
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $records['snapshotA']], tenant: $environmentA, panel: 'admin'), false)
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $records['snapshotB']], tenant: $environmentB, panel: 'admin'), false);
});
@ -244,8 +246,9 @@
$this->get(route('admin.evidence.overview', $query))
->assertOk()
->assertDontSee('Environment filter:')
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $records['snapshotA']], tenant: $environmentA, panel: 'admin'), false)
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $records['snapshotB']], tenant: $environmentB, panel: 'admin'), false);
->assertSee('Select an environment to evaluate evidence readiness')
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $records['snapshotA']], tenant: $environmentA, panel: 'admin'), false)
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $records['snapshotB']], tenant: $environmentB, panel: 'admin'), false);
}
});
@ -297,6 +300,7 @@ function spec315SeedEnvironmentFilterWorkspace(): array
'managed_environment_id' => (int) $environmentA->getKey(),
'workspace_id' => (int) $environmentA->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'completeness_state' => EvidenceCompletenessState::Complete->value,
'summary' => ['missing_dimensions' => 0, 'stale_dimensions' => 0],
'generated_at' => now(),
]);
@ -304,6 +308,7 @@ function spec315SeedEnvironmentFilterWorkspace(): array
'managed_environment_id' => (int) $environmentB->getKey(),
'workspace_id' => (int) $environmentB->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'completeness_state' => EvidenceCompletenessState::Complete->value,
'summary' => ['missing_dimensions' => 0, 'stale_dimensions' => 0],
'generated_at' => now(),
]);

View File

@ -703,6 +703,7 @@ function createCurrentReviewPackForResourcePreview(ManagedEnvironment $tenant, \
Livewire::actingAs($user)
->test(ViewReviewPack::class, ['record' => $pack->getKey()])
->assertSee('View internal evidence details')
->assertActionVisible('open_rendered_report')
->assertActionVisible('download');
});

View File

@ -4,6 +4,7 @@
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EnvironmentReviewResource;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\ManagedEnvironment;
@ -218,6 +219,9 @@
->assertSee('Support details stay on authorized diagnostic surfaces')
->assertSee('Customer acceptance checkpoint')
->assertSee('Open review')
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'admin'), false)
->assertDontSeeHtml('data-testid="customer-review-technical-details"')
->assertDontSee('Technical details')
->assertDontSee('Download internal preview')
->assertDontSee('raw payload should stay hidden')
->assertDontSee('stack trace should stay hidden')

View File

@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Models\EnvironmentReview;
use App\Models\EvidenceSnapshot;
use App\Models\ManagedEnvironment;
use App\Models\ReviewPack;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Evidence\EvidenceAnchorResolver;
use App\Services\Evidence\EvidenceAnchorResult;
use App\Support\Auth\Capabilities;
use App\Support\EnvironmentReviewStatus;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
uses(RefreshDatabase::class);
it('does not promote partial active evidence as the current scope anchor', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
setAdminPanelContext($tenant);
spec393EvidenceSnapshot($tenant, [
'completeness_state' => EvidenceCompletenessState::Partial->value,
'summary' => ['missing_dimensions' => 1, 'stale_dimensions' => 0],
]);
$anchor = app(EvidenceAnchorResolver::class)->currentForScope($tenant->workspace, $tenant, $user);
expect($anchor->anchorType)->toBe(EvidenceAnchorResult::TYPE_NO_VALID_EVIDENCE)
->and($anchor->state)->toBe(EvidenceAnchorResult::STATE_NEEDS_ATTENTION)
->and($anchor->evidenceSnapshotId)->toBeNull()
->and($anchor->targetRoute)->toBeNull()
->and($anchor->canLink)->toBeFalse();
});
it('does not fall back to evidence from another environment in the workspace', function (): void {
$tenantA = ManagedEnvironment::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = ManagedEnvironment::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
setAdminPanelContext($tenantA);
spec393EvidenceSnapshot($tenantB);
$anchor = app(EvidenceAnchorResolver::class)->currentForScope($tenantA->workspace, $tenantA, $user);
expect($anchor->anchorType)->toBe(EvidenceAnchorResult::TYPE_NO_VALID_EVIDENCE)
->and($anchor->state)->toBe(EvidenceAnchorResult::STATE_NOT_CONFIGURED)
->and($anchor->targetRoute)->toBeNull();
});
it('excludes expired current evidence from current scope anchors', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
setAdminPanelContext($tenant);
spec393EvidenceSnapshot($tenant, [
'expires_at' => now()->subMinute(),
]);
$anchor = app(EvidenceAnchorResolver::class)->currentForScope($tenant->workspace, $tenant, $user);
expect($anchor->anchorType)->toBe(EvidenceAnchorResult::TYPE_NO_VALID_EVIDENCE)
->and($anchor->state)->toBe(EvidenceAnchorResult::STATE_NEEDS_ATTENTION)
->and($anchor->evidenceSnapshotId)->toBeNull()
->and($anchor->targetRoute)->toBeNull()
->and($anchor->canLink)->toBeFalse();
});
it('excludes superseded evidence from current scope anchors', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
setAdminPanelContext($tenant);
spec393EvidenceSnapshot($tenant, [
'status' => EvidenceSnapshotStatus::Superseded->value,
]);
$anchor = app(EvidenceAnchorResolver::class)->currentForScope($tenant->workspace, $tenant, $user);
expect($anchor->anchorType)->toBe(EvidenceAnchorResult::TYPE_NO_VALID_EVIDENCE)
->and($anchor->state)->toBe(EvidenceAnchorResult::STATE_NEEDS_ATTENTION)
->and($anchor->evidenceSnapshotId)->toBeNull()
->and($anchor->targetRoute)->toBeNull();
});
it('excludes wrong workspace evidence even when the environment id matches', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$foreignWorkspace = Workspace::factory()->create();
setAdminPanelContext($tenant);
$snapshot = spec393EvidenceSnapshot($tenant);
EvidenceSnapshot::withoutEvents(function () use ($snapshot, $foreignWorkspace): void {
$snapshot->forceFill([
'workspace_id' => (int) $foreignWorkspace->getKey(),
])->save();
});
$anchor = app(EvidenceAnchorResolver::class)->currentForScope($tenant->workspace, $tenant, $user);
expect($anchor->anchorType)->toBe(EvidenceAnchorResult::TYPE_NO_VALID_EVIDENCE)
->and($anchor->state)->toBe(EvidenceAnchorResult::STATE_NOT_CONFIGURED)
->and($anchor->evidenceSnapshotId)->toBeNull()
->and($anchor->targetRoute)->toBeNull();
});
it('keeps released review evidence pinned instead of falling forward to current evidence', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
setAdminPanelContext($tenant);
$releasedSnapshot = spec393EvidenceSnapshot($tenant, [
'fingerprint' => 'released-snapshot',
'generated_at' => now()->subDays(3),
]);
$review = EnvironmentReview::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'evidence_snapshot_id' => (int) $releasedSnapshot->getKey(),
'status' => EnvironmentReviewStatus::Published->value,
'published_at' => now()->subDays(2),
'published_by_user_id' => (int) $user->getKey(),
]);
$pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'environment_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $releasedSnapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
]);
$releasedSnapshot->forceFill([
'status' => EvidenceSnapshotStatus::Superseded->value,
])->save();
spec393EvidenceSnapshot($tenant, [
'fingerprint' => 'new-current-snapshot',
'generated_at' => now(),
]);
$releaseAnchor = app(EvidenceAnchorResolver::class)->forReviewPackRelease($pack->fresh(), $user);
$customerAnchor = app(EvidenceAnchorResolver::class)->forCustomerWorkspace($review->fresh(), $user);
expect($releaseAnchor->anchorType)->toBe(EvidenceAnchorResult::TYPE_REVIEW_RELEASED_EVIDENCE)
->and($releaseAnchor->evidenceSnapshotId)->toBe((int) $releasedSnapshot->getKey())
->and($releaseAnchor->isReleaseBound)->toBeTrue()
->and($releaseAnchor->isCurrent)->toBeFalse()
->and($releaseAnchor->targetRoute)->toBe(EvidenceSnapshotResource::getUrl('view', ['record' => $releasedSnapshot], tenant: $tenant, panel: 'admin'))
->and($customerAnchor->anchorType)->toBe(EvidenceAnchorResult::TYPE_CUSTOMER_SAFE_EVIDENCE_SUMMARY)
->and($customerAnchor->evidenceSnapshotId)->toBeNull()
->and($customerAnchor->targetRoute)->toBeNull()
->and($customerAnchor->isCustomerSafe)->toBeTrue();
});
it('fails closed when customer workspace review pack evidence does not match the pack scope', function (): void {
$tenantA = ManagedEnvironment::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = ManagedEnvironment::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
setAdminPanelContext($tenantA);
$wrongScopeSnapshot = spec393EvidenceSnapshot($tenantB);
$review = EnvironmentReview::factory()->create([
'managed_environment_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'evidence_snapshot_id' => (int) $wrongScopeSnapshot->getKey(),
'status' => EnvironmentReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
]);
$pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'environment_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $wrongScopeSnapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
]);
$anchor = app(EvidenceAnchorResolver::class)->forCustomerWorkspace($pack->fresh(), $user);
expect($anchor->anchorType)->toBe(EvidenceAnchorResult::TYPE_NO_VALID_EVIDENCE)
->and($anchor->state)->toBe(EvidenceAnchorResult::STATE_BLOCKED)
->and($anchor->isCustomerSafe)->toBeFalse()
->and($anchor->evidenceSnapshotId)->toBeNull()
->and($anchor->targetRoute)->toBeNull()
->and($anchor->canLink)->toBeFalse()
->and($anchor->primaryReason)->toContain('scope does not match');
});
it('keeps draft review pack evidence internal and not customer safe', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
setAdminPanelContext($tenant);
$snapshot = spec393EvidenceSnapshot($tenant);
$pack = ReviewPack::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
]);
$anchor = app(EvidenceAnchorResolver::class)->forReviewPackDraft($pack->fresh(), $user);
expect($anchor->anchorType)->toBe(EvidenceAnchorResult::TYPE_REVIEW_DRAFT_EVIDENCE)
->and($anchor->isCustomerSafe)->toBeFalse()
->and($anchor->isTechnicalOnly)->toBeTrue()
->and($anchor->displayLabel)->toBe('Draft review evidence');
});
it('gates technical evidence detail links by evidence view authorization', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$snapshot = spec393EvidenceSnapshot($tenant);
setAdminPanelContext($tenant);
$authorizedAnchor = app(EvidenceAnchorResolver::class)->technicalDetail($snapshot, $user);
expect($authorizedAnchor->anchorType)->toBe(EvidenceAnchorResult::TYPE_TECHNICAL_EVIDENCE_DETAIL)
->and($authorizedAnchor->displayLabel)->toBe('View internal evidence details')
->and($authorizedAnchor->canViewTechnicalDetail)->toBeTrue()
->and($authorizedAnchor->targetRoute)->toBe(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'admin'));
Gate::define(Capabilities::EVIDENCE_VIEW, fn (User $actor, ManagedEnvironment $environment): bool => false);
$deniedAnchor = app(EvidenceAnchorResolver::class)->technicalDetail($snapshot, $user);
expect($deniedAnchor->canViewTechnicalDetail)->toBeFalse()
->and($deniedAnchor->targetRoute)->toBeNull()
->and($deniedAnchor->canLink)->toBeFalse();
});
it('keeps current evidence selection order explicit and deterministic', function (): void {
$tenant = ManagedEnvironment::factory()->create();
$resolver = app(EvidenceAnchorResolver::class);
$orders = $resolver->currentSnapshotQuery($tenant->workspace, $tenant)->getQuery()->orders;
expect($orders[0]['type'] ?? null)->toBe('Raw')
->and($orders[0]['sql'] ?? null)->toBe('generated_at IS NULL')
->and($orders[1]['column'] ?? null)->toBe('generated_at')
->and($orders[1]['direction'] ?? null)->toBe('desc')
->and($orders[2]['column'] ?? null)->toBe('id')
->and($orders[2]['direction'] ?? null)->toBe('desc');
});
/**
* @param array<string, mixed> $attributes
*/
function spec393EvidenceSnapshot(ManagedEnvironment $tenant, array $attributes = []): EvidenceSnapshot
{
return EvidenceSnapshot::query()->create(array_replace([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'completeness_state' => EvidenceCompletenessState::Complete->value,
'summary' => ['missing_dimensions' => 0, 'stale_dimensions' => 0],
'fingerprint' => 'spec393-'.str()->uuid()->toString(),
'generated_at' => now(),
], $attributes));
}

View File

@ -0,0 +1,49 @@
# Specification Quality Checklist: Spec 393 - Evidence Anchor Reconciliation v1
**Purpose**: Validate specification completeness and quality before implementation planning and execution.
**Created**: 2026-06-20
**Feature**: `specs/393-evidence-anchor-reconciliation-v1/spec.md`
## Content Quality
- [x] No unresolved placeholder markers remain.
- [x] Focused on user value, product trust, and evidence correctness.
- [x] Written as product behavior with implementation detail only where needed for correctness and repo alignment.
- [x] All mandatory repo sections are completed.
## Requirement Completeness
- [x] No `[NEEDS CLARIFICATION]` markers remain.
- [x] Requirements are testable and unambiguous.
- [x] Success criteria are measurable.
- [x] Acceptance scenarios are defined for current, released, customer-safe, and technical evidence behavior.
- [x] Edge cases are identified.
- [x] Scope is clearly bounded.
- [x] Dependencies and assumptions are identified.
## Constitution And Guardrail Coverage
- [x] Spec Candidate Check is completed and scored.
- [x] Proportionality Review covers the new resolver abstraction.
- [x] UI Surface Impact and UI/Productization Coverage are completed.
- [x] Cross-cutting shared pattern reuse is named.
- [x] OperationRun UX impact is explicitly N/A for new starts/completions.
- [x] Provider boundary impact is classified.
- [x] RBAC, workspace/environment isolation, customer-safe disclosure, and audit/technical separation are addressed.
- [x] Test governance and validation lanes are documented.
- [x] Clean-cut no-legacy compatibility posture is explicit.
## Feature Readiness
- [x] `spec.md`, `plan.md`, and `tasks.md` exist.
- [x] User scenarios cover primary flows.
- [x] Functional requirements map to implementation tasks.
- [x] Tests are included before or alongside implementation tasks.
- [x] Browser smoke is scoped and justified.
- [x] No application code was modified during preparation.
## Notes
- Broad completed specs are read-only context and must not be rewritten.
- Human product sanity check is required after implementation and browser smoke, not during preparation.
- If implementation discovers a schema need, spec and plan must be updated before adding migrations.

View File

@ -0,0 +1,354 @@
# Implementation Plan: Spec 393 - Evidence Anchor Reconciliation v1
**Branch**: `feat/393-evidence-anchor-reconciliation-v1` | **Date**: 2026-06-20 | **Spec**: `specs/393-evidence-anchor-reconciliation-v1/spec.md`
**Input**: Feature specification from `specs/393-evidence-anchor-reconciliation-v1/spec.md`
## Summary
Introduce one canonical Evidence Anchor Resolver/result contract and replace product-facing local evidence selectors with that contract. Current dashboard/workspace/environment surfaces must resolve only valid current evidence; released review/report/review-pack output must stay bound to release evidence; Customer Review Workspace must show customer-safe evidence summary without raw evidence links by default; technical evidence detail remains available only through secondary internal/audit paths.
This is a clean-cut correctness fix. Do not preserve legacy fallback-to-latest behavior, old tests expecting partial/superseded evidence, or compatibility aliases.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12.52, Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1
**Storage**: PostgreSQL; no migration expected by default
**Testing**: Pest 4 Unit, Feature/HTTP, Filament/Livewire, bounded Browser smoke
**Validation Lanes**: fast-feedback, confidence, browser
**Target Platform**: Laravel Sail local; Dokploy container deployment for staging/production
**Project Type**: Laravel monolith under `apps/platform`
**Performance Goals**: Resolver decisions must be DB-only, deterministic, scoped, and avoid render-time remote calls
**Constraints**: no legacy fallback, no UI expansion, no new customer proof surface, no Graph calls during render, no new persisted truth unless spec/plan are updated first
**Scale/Scope**: existing evidence, dashboard, review, customer workspace, review-pack, stored-report, rendered-report, and management-report provenance surfaces
## Technical Approach
1. Inventory every evidence selector/link/action/output path.
2. Define a derived `EvidenceAnchorResolver` contract and result value object/array shape with a non-persisted anchor type/state vocabulary.
3. Implement current-scope resolution with strict scope, active/complete/non-expired checks, and deterministic ordering.
4. Implement released-review/review-pack/report resolution from existing release-bound relations.
5. Implement customer-safe summary resolution that omits target routes by default.
6. Implement technical-detail resolution with actor permission checks.
7. Replace local selectors in affected product surfaces.
8. Remove deprecated local fallback logic and stale test expectations.
9. Add regression tests and focused browser smoke.
Preferred shape:
```text
Existing evidence/review/report truth
-> EvidenceAnchorResolver
-> EvidenceAnchorResult
-> UI labels, target routes, route/report provenance, tests
```
Do not create a new evidence lifecycle, proof framework, or persisted readiness model.
## Likely Affected Repository Surfaces
Implementation must re-verify exact current code before editing, but likely surfaces are:
- `apps/platform/app/Services/Evidence/EvidenceSnapshotResolver.php`
- new or consolidated resolver under `apps/platform/app/Services/Evidence/` or a similarly scoped namespace
- `apps/platform/app/Models/EvidenceSnapshot.php`
- `apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewService.php`
- `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`
- `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`
- `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`
- `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php`
- `apps/platform/app/Filament/Resources/ReviewPackResource.php`
- `apps/platform/app/Filament/Resources/StoredReportResource.php`
- `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`
- `apps/platform/app/Support/ReviewPacks/ManagementReportPdfPayloadBuilder.php`
- `apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationProofResolver.php`
- `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`
- `apps/platform/app/Support/OperationRunLinks.php`
- `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`
- `apps/platform/app/Support/GovernanceDecisions/GovernanceDecisionRegisterBuilder.php`
- review-pack rendered-report/download/report payload code where evidence provenance is displayed
- dashboard/workspace/environment summary builders that produce evidence CTAs
- localization files only where labels change
- focused tests under `apps/platform/tests/Unit`, `apps/platform/tests/Feature`, and `apps/platform/tests/Browser`
Implementation may remove a surface from the touched list if repo truth proves it is not product-facing or already uses the canonical resolver safely.
## UI / Surface Guardrail Plan
- **Guardrail scope**: existing product-facing evidence labels/links and customer-safe evidence disclosure.
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: dashboard/workspace/environment evidence CTA, Evidence Overview, Customer Review Workspace, Environment Review detail, Review Pack detail/rendered report, Stored Report/Management Report provenance, Evidence Snapshot technical detail links.
- **No-impact class, if applicable**: N/A.
- **Native vs custom classification summary**: mixed existing native Filament resources/pages plus existing Blade composition.
- **Shared-family relevance**: evidence links, status messaging, dashboard signals, artifact/report viewers, customer-safe disclosure.
- **State layers in scope**: page, detail, row URL, route target, report payload/provenance.
- **Audience modes in scope**: customer/read-only, operator-MSP, support/internal where authorized.
- **Decision/diagnostic/raw hierarchy plan**: product evidence decision first; diagnostics/raw proof secondary or gated.
- **Raw/support gating plan**: no raw evidence route by default on Customer Review Workspace; technical detail only through internal/audit action with permission.
- **One-primary-action / duplicate-truth control**: default UI shows at most one evidence-related action/state per context.
- **Handling modes by drift class or surface**: wrong current evidence is hard-stop; customer raw link leakage is hard-stop; technical/audit link mislabeling is review-mandatory.
- **Repository-signal treatment**: related completed specs are context only and must not be rewritten.
- **Special surface test profiles**: customer-safe strategic review surface + evidence/artifact detail + dashboard signal.
- **Required tests or manual smoke**: Unit resolver tests, Feature/Filament product surface tests, Browser smoke for dashboard/customer/review/evidence flows.
- **Exception path and spread control**: no exception expected; any retained page-local selector must be documented as technical/admin-only and proven not product-facing.
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage.
- **UI/Productization coverage decision**: update existing page-report artifacts if visible behavior materially changes; otherwise document no route/archetype expansion.
- **Coverage artifacts to update**: likely existing page reports for Customer Review Workspace, Evidence Overview, Review Pack detail, Environment Review detail, Stored Report detail, and Evidence Snapshot detail only if implementation materially changes default-visible behavior.
- **Explicit no-route/no-archetype expectation**: no route inventory, design matrix, strategic surfaces, grouped follow-up, or unresolved-page updates are expected unless implementation changes route reachability or page archetype/count; implementation must document that no-impact decision in the close-out.
- **No-impact rationale**: N/A.
- **Navigation / Filament provider-panel handling**: no provider registration or panel path change; provider registration remains `apps/platform/bootstrap/providers.php`.
- **Screenshot or page-report need**: focused browser smoke with screenshots if visible link/summary behavior changes; not required for purely route/service changes.
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes.
- **Systems touched**: evidence resolver, review/review-pack/report provenance, dashboard/evidence links, customer-safe disclosure, technical/audit detail actions, `ArtifactTruthPresenter`/GovernanceArtifactTruth, `OperationRunLinks`, `RelatedNavigationResolver`, `GovernanceDecisionRegisterBuilder`, policies.
- **Shared abstractions reused**: existing `EvidenceSnapshot` scopes/relations where correct, existing review/review-pack/report relations, existing policies/capabilities, existing BadgeRenderer/BadgeCatalog, existing ArtifactTruthPresenter/GovernanceArtifactTruth presentation paths, Spec 392 output gate where customer output routes need gate context.
- **New abstraction introduced? why?**: one narrow derived Evidence Anchor Resolver/result contract because current product surfaces have multiple real evidence-anchor consumers and current local selectors can produce wrong proof.
- **Why the existing abstraction was sufficient or insufficient**: `EvidenceSnapshotResolver` is dimension/readiness oriented and does not encode product anchor type, customer-safe/no-link behavior, release-bound stability, or technical-only authorization.
- **Bounded deviation / spread control**: the new resolver must own evidence anchor decisions only. It must not become a generic proof-currentness, report-readiness, or technical annex framework. Shared link builders may remain technical/internal-only, but any product-facing EvidenceSnapshot link they emit must consume resolver output.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no new start/completion/link semantics. Existing OperationRun proof may remain only as technical/internal detail.
- **Central contract reused**: existing OperationRun link helpers where technical detail remains.
- **Delegated UX behaviors**: N/A.
- **Surface-owned behavior kept local**: evidence anchor labels/states only.
- **Queued DB-notification policy**: N/A.
- **Terminal notification path**: unchanged.
- **Exception path**: none.
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no new provider seam.
- **Provider-owned seams**: N/A.
- **Platform-core seams**: evidence anchor selection, customer-safe evidence summary, technical evidence detail labels.
- **Neutral platform terms / contracts preserved**: workspace, managed environment, evidence, current evidence, review evidence, audit trail, internal evidence details.
- **Retained provider-specific semantics and why**: existing raw evidence technical pages may contain provider/source detail; default product surfaces must not.
- **Bounded extraction or follow-up path**: none unless implementation finds provider-specific leakage outside technical contexts.
## Constitution Check
- Inventory-first / snapshots-second: no new inventory truth; evidence snapshots remain explicit artifacts.
- Read/write separation: no destructive action or Graph write introduced.
- Graph contract path: no Graph calls expected; resolver/render paths must remain DB-only.
- Deterministic capabilities: existing capability/policy checks reused for target route availability.
- RBAC-UX: non-member workspace/environment access remains 404; member missing capability remains no technical link/403 on direct route.
- Workspace/tenant isolation: resolver scopes by workspace and managed environment before selecting anchors.
- OperationRun UX: no OperationRun start/completion changes; technical OperationRun links remain secondary.
- Test governance: Unit, Feature/Filament, and Browser proof are explicitly scoped.
- Proportionality: new resolver abstraction is justified by evidence trust, isolation, auditability, and multiple existing consumers.
- No premature abstraction: resolver replaces duplicated local selection and is not a generic proof framework.
- Persisted truth: no new persisted truth expected.
- Behavioral state: allowed UI states are derived result vocabulary, not persisted lifecycle truth.
- UI semantics: no new badge framework; use existing badge/status helpers.
- Shared pattern first: central resolver replaces local selectors; shared link helpers either consume it for product-facing links or are explicitly classified technical/internal-only.
- Provider boundary: provider-specific internals stay technical.
- UI/Productization coverage: visible surface changes require existing page-report updates or explicit no-archetype-change note.
## Domain And Data Implications
No migration is expected. Existing fields appear sufficient:
- `evidence_snapshots.workspace_id`
- `evidence_snapshots.managed_environment_id`
- `evidence_snapshots.status`
- `evidence_snapshots.completeness_state`
- `evidence_snapshots.generated_at`
- `evidence_snapshots.expires_at`
- `environment_reviews.evidence_snapshot_id`
- `environment_reviews.current_export_review_pack_id`
- `environment_reviews.status`
- `environment_reviews.published_at`
- `review_packs.evidence_snapshot_id`
- `review_packs.environment_review_id`
- `review_packs.status`
- `review_packs.expires_at`
- `stored_reports.source_environment_review_id`
- `stored_reports.source_review_pack_id`
If implementation proves an anchor cannot be represented with existing truth, stop and update spec/plan before adding schema. A schema change must be minimal, cleanly named, and pre-production clean-cut with no compatibility shim.
## Resolver Design Notes
Expected result fields:
```text
anchor_type: string
state: string
evidence_snapshot_id: int|null
target_route: string|null
is_current: bool
is_release_bound: bool
is_customer_safe: bool
is_technical_only: bool
is_partial: bool
is_superseded: bool
is_expired: bool
can_link: bool
can_view_technical_detail: bool
primary_reason: string
blocking_reasons: list<string>
display_label: string
```
Current evidence query rules:
```text
where workspace_id = scope workspace
where managed_environment_id = scope environment when present
where managed_environment_id in actor-authorized environment ids when no explicit environment is present and a product link is returned
where status = active
where completeness_state = complete
where expires_at is null or expires_at > now()
order by generated_at desc nulls last, id desc
```
Queued/generating evidence may be represented as draft/internal/in-progress state but must not be returned as product-facing current evidence.
When `environment` is null, implementation must not query arbitrary workspace evidence and return a single product-facing target. It must either return a non-link workspace summary state or limit selection to actor-authorized managed environments before returning per-environment/current anchors. Product-facing single-target links should prefer an explicit environment.
Released evidence rules:
- Environment Review: use `environment_reviews.evidence_snapshot_id`.
- Review Pack: use `review_packs.evidence_snapshot_id` and `environment_review_id` when release-bound output is in scope.
- Stored/management report: prefer source review/review-pack provenance when present; otherwise no valid released evidence rather than current fallback.
Customer-safe rules:
- Return summary state and copy.
- Do not return raw evidence route by default.
- Optional internal/audit action must be a separate technical result or secondary route.
Technical detail rules:
- Return raw EvidenceSnapshot route only if actor can view it and scope matches.
- Label as internal/audit detail.
## Route And Authorization Plan
- Resolver must not be the only security boundary. Direct routes remain protected by existing policies/controllers.
- UI target routes are returned only when the actor can view the target.
- Customer-safe actors get no technical target route.
- Internal/operator actors can get technical detail only through secondary/internal actions.
- Wrong-scope evidence direct access remains deny-as-not-found by policy/controller tests.
## Filament And Livewire Plan
- Filament v5 / Livewire v4.0+ compliance is required; the app currently uses Livewire 4.1.4.
- Panel provider registration remains `apps/platform/bootstrap/providers.php`; no provider changes are expected.
- No global search participation should be added or changed. If any touched resource currently participates in global search, verify View/Edit page and scoped search rules; otherwise keep global search disabled.
- No destructive action is introduced. Existing EvidenceSnapshot/ReviewPack/EnvironmentReview actions remain out of scope and must keep existing confirmation, authorization, audit, notifications, and tests.
- Labels must be truthful:
- `Current evidence` for current valid evidence.
- `Review evidence` for review-bound/released evidence.
- `Evidence captured for this review` for customer-safe summary.
- `View audit trail` or `View internal evidence details` for technical routes.
## Audit And Observability Plan
- No new `OperationRun` is expected.
- No new AuditLog requirement is expected for read-only anchor resolution.
- Existing audit events for downloads, review publication, evidence generation, and review-pack generation remain unchanged.
- If implementation adds logging for blocked technical route exposure or direct access, metadata must be safe and stable with no raw payloads/secrets/source keys.
## Test Strategy
### Unit tests
- Select newest valid current evidence for workspace/environment.
- Do not select superseded evidence as current.
- Do not select partial evidence as current.
- Do not select expired evidence as current.
- Do not select queued/generating/failed/stale evidence as current.
- Do not select wrong-workspace evidence.
- Do not select wrong-environment evidence, including workspace-wide/no-environment requests where the actor lacks entitlement.
- Return no valid evidence when only partial/superseded/expired evidence exists.
- Resolve released review evidence independently from current evidence.
- Keep released review evidence stable after newer current evidence is created.
- Resolve draft review evidence as internal/draft, not customer-safe.
- Resolve customer workspace as customer-safe summary with no raw route by default.
- Actor without permission receives no technical evidence link.
- Internal/operator actor may receive technical detail link where appropriate.
- Deterministic tie-breaker when multiple valid snapshots share timestamp.
- Workspace-wide current anchor does not select evidence from environments the actor cannot access.
### Feature / Filament / HTTP tests
- Dashboard/evidence overview evidence link targets current evidence.
- Dashboard/evidence overview does not link to superseded/partial evidence.
- Customer Review Workspace does not render raw evidence links by default.
- Review Pack detail uses release-bound evidence.
- Environment Review detail uses release-bound evidence.
- Rendered report/Stored Report/Management Report provenance uses released review evidence.
- Stored report/review output does not switch provenance after new current evidence.
- Direct wrong-scope evidence route remains blocked by scope authorization.
- Technical evidence detail requires internal permission.
- Customer-facing pages do not expose technical evidence terms by default.
- Affected list/detail paths avoid per-row evidence-link N+1 behavior by using explicit eager-loading or bounded resolver query shape where practical.
### Browser smoke
Focused smoke should cover:
- Environment Dashboard or workspace dashboard evidence CTA.
- Evidence Overview.
- Review Pack detail.
- Customer Review Workspace.
- Rendered/Stored Report surface if evidence provenance is visible.
Assertions:
- Current dashboard evidence link opens current evidence, not stale/superseded evidence.
- Customer Review Workspace has no raw evidence snapshot link by default.
- Review Pack evidence label is truthful.
- Technical evidence link, if present, is secondary/internal.
- No visible `Evidence #<id>` style product link on customer-safe surface.
- No 500/Livewire/Filament/console errors in affected flows.
- Direct wrong/old evidence URL does not become a customer-facing proof path.
## Validation Commands
Preferred Sail commands:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec393
cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec393EvidenceAnchorReconciliationSmokeTest.php
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
git diff --check
```
If Sail/Docker is unavailable, run the equivalent local PHP/Pest commands and document the fallback.
## Rollout And Deployment Considerations
- No env vars expected.
- No migrations expected.
- No queue/scheduler/storage changes expected.
- No Graph scope or provider configuration changes expected.
- No Filament assets expected.
- Dokploy/staging validation is still required for the later PR because user-facing `/admin` surfaces change, but there is no production-data compatibility burden.
## Risk Controls
- Resolver tests must fail if partial/superseded/wrong-scope evidence is selected.
- Feature/browser tests must fail if customer workspace exposes raw evidence by default.
- Released review tests must fail if new current evidence changes old released output provenance.
- Code review must search for remaining local fallback selectors and arbitrary latest evidence ordering in affected product surfaces.
## Implementation Phases
1. Repo truth inventory and local selector map.
2. Resolver contract and Unit tests.
3. Current evidence replacement in dashboard/evidence overview.
4. Released review/report/review-pack provenance replacement.
5. Customer Review Workspace customer-safe summary replacement.
6. Technical evidence detail gating and labels.
7. Deprecated local selector/test cleanup.
8. Browser smoke and close-out report.
## Stop Conditions
- Stop if implementation requires a new persisted entity/table/status family not justified in this spec.
- Stop if a route/controller change would create a new customer portal or broad technical annex.
- Stop if a local selector appears necessary for a product-facing surface; update spec/plan or fold it into the resolver instead.
- Stop if the only way to keep an old test green is to preserve partial/superseded/latest fallback behavior.

View File

@ -0,0 +1,559 @@
# Feature Specification: Spec 393 - Evidence Anchor Reconciliation v1
**Feature Branch**: `feat/393-evidence-anchor-reconciliation-v1`
**Created**: 2026-06-20
**Status**: Draft
**Type**: Bugfix / correctness / trust boundary / productization
**Priority**: P1
**Runtime posture**: Clean canonical replacement over existing evidence-link and evidence-selection behavior. No legacy compatibility, no fallback-to-latest behavior, no new proof surfaces, and no UI expansion.
**Input**: User-provided Spec 393 draft plus repo truth from evidence/review/report/customer-output code and related completed specs.
## Dependencies And Historical Context
Spec 393 is a clean-cut follow-up over existing evidence, review, and output productization work:
- Spec 361 - Report evidence reconciliation, completed artifact/reconciliation context.
- Spec 372 - Customer/auditor surface safety pass, completed customer-safe disclosure context.
- Spec 385 - Evidence and review readiness integration, completed readiness/evidence context.
- Spec 386 - Review publication resolution workflow, runtime context for review-publication blockers.
- Spec 387 - Review publication resolution decision UX, runtime context for review-publication decisions.
- Spec 388 - Resolution proof currentness contract, completed proof-currentness context.
- Spec 392 - Customer output gating and review pack navigation, completed customer-output gate and label context.
Repo-truth observations that shape this spec:
- `EvidenceSnapshot` already has `workspace_id`, `managed_environment_id`, `status`, `completeness_state`, `summary`, `generated_at`, `expires_at`, `fingerprint`, and an active-snapshot unique index.
- `EnvironmentReview` already stores `evidence_snapshot_id`, `current_export_review_pack_id`, `status`, `published_at`, and `superseded_by_review_id`.
- `ReviewPack` already stores `evidence_snapshot_id`, `environment_review_id`, file metadata, status, expiration, and summary payloads.
- `StoredReport` already has source review/review-pack fields for management-report PDF provenance.
- Current code contains multiple local evidence selectors and link builders, including `EvidenceSnapshotResolver`, `EnvironmentReviewService::resolveLatestSnapshot()`, `EvidenceOverview`, `CustomerReviewWorkspace`, `EnvironmentReviewResource`, `ReviewPackResource`, `ReviewPublicationProofResolver`, `ArtifactTruthPresenter`, `OperationRunLinks`, `RelatedNavigationResolver`, `GovernanceDecisionRegisterBuilder`, and management-report payload generation.
- Current `EvidenceSnapshot::scopeCurrent()` includes queued/generating/active snapshots. Spec 393 distinguishes that technical/current-process concept from product-facing `CURRENT_SCOPE_EVIDENCE`.
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: Evidence links can look trustworthy while pointing to the wrong snapshot for the user's product context.
- **Today's failure**: Dashboard, review, customer workspace, report, or evidence overview code can select evidence through local relation traversal, latest-created/latest-generated heuristics, review-pack relations, or report metadata. That can expose partial, stale, superseded, wrong-scope, draft, or old review evidence as if it were current proof.
- **User-visible improvement**: A product-facing evidence label answers the correct question: current-state surfaces link to current valid evidence, released review surfaces reference release-bound evidence, and customer-safe surfaces avoid raw technical evidence links by default.
- **Smallest enterprise-capable version**: One canonical evidence anchor resolver/result contract, replacement of local selectors in the affected product surfaces, focused route/action label cleanup, and regression tests proving no partial/superseded/wrong-scope fallback.
- **Explicit non-goals**: No Evidence Snapshot page redesign, no Technical Annex buildout, no new evidence generation pipeline, no provider integration, no new report profile, no new review publication state, no new customer portal, no new dashboard/card/table, no baseline-readiness rebuild, and no compatibility shims.
- **Permanent complexity imported**: One derived resolver/result contract plus one non-persisted value object/constant vocabulary for anchor type and allowed UI state; focused Unit/Feature/Filament/Browser tests. No default new table, migration, persisted state, broad UI framework, persisted enum/status family, or new route family.
- **Why now**: Current customer-output/review/evidence flows are productized enough that wrong evidence anchors are a direct trust failure, not cosmetic drift.
- **Why not local**: The defect exists across dashboards, reviews, customer workspace, report provenance, and evidence overview. Page-local patches would keep parallel semantics and allow future wrong-anchor regressions.
- **Approval class**: Core Enterprise.
- **Red flags triggered**: New resolver abstraction, canonical anchor types, multiple surfaces, and trust-boundary semantics. Defense: this protects workspace/tenant isolation, auditability, customer-safe disclosure, and evidence truth across existing real consumers; it replaces duplicated selectors instead of adding another parallel layer.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve.
## Candidate Source And Completed-Spec Guardrail
- **Selected candidate**: Evidence Anchor Reconciliation v1.
- **Source location**: User-provided Spec 393 draft in this conversation. Roadmap relationship is the R2 evidence/review/report trust lane in `docs/product/roadmap.md`; `docs/product/spec-candidates.md` has no auto-prep queue but allows explicit manual promotion.
- **Completed-spec check**:
- No `specs/393-*` package existed before this preparation.
- Specs 361, 372, 385, 388, and 392 contain completed-task, validation, smoke, or implementation-history signals and are read-only historical context.
- Specs 386 and 387 provide runtime/review-publication context and must not be rewritten by this preparation.
- **Close alternatives deferred**:
- Technical Annex pattern: deferred because Spec 393 only labels and gates existing technical evidence access.
- Product Surface Contract Enforcement pass: deferred because this spec is evidence-anchor-specific.
- Governance artifact lifecycle/retention runtime: separate lifecycle/retention gap.
- Management Report PDF staging runtime validation: separate Spec 379 follow-through.
- Customer portal/external workspace expansion: separate product decision.
- **Smallest viable implementation slice**: Existing product-facing evidence links/selectors only: dashboard/workspace/environment current evidence, Evidence Overview current anchor, Customer Review Workspace customer-safe evidence summary, Review Pack/Environment Review/Stored Report/released output evidence provenance, and internal technical evidence detail links.
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace canonical view plus environment-owned evidence/review/report artifact surfaces.
- **Primary Routes / Surfaces**:
- `/admin/reviews/workspace` via `App\Filament\Pages\Reviews\CustomerReviewWorkspace`
- Evidence overview via `App\Filament\Pages\Monitoring\EvidenceOverview`
- `App\Filament\Resources\EvidenceSnapshotResource`
- `App\Filament\Resources\EnvironmentReviewResource`
- `App\Filament\Resources\ReviewPackResource`
- `App\Filament\Resources\StoredReportResource`
- Review Pack download/rendered report controllers where evidence provenance appears
- Management Report PDF payload/provenance where evidence is included
- Environment dashboard/workspace overview evidence CTAs where repo-real
- **Data Ownership**:
- `EvidenceSnapshot` remains tenant-owned evidence artifact truth.
- `EnvironmentReview.evidence_snapshot_id` remains review-bound/released evidence truth for that review.
- `ReviewPack.evidence_snapshot_id` and `ReviewPack.environment_review_id` remain review-pack artifact provenance.
- `StoredReport.source_environment_review_id` and `source_review_pack_id` remain report artifact provenance where present.
- `OperationRun` remains execution/proof diagnostics, not evidence-anchor truth.
- No new persisted truth is expected.
- **RBAC**:
- Workspace membership and managed-environment entitlement are mandatory before any target route is returned.
- Raw technical evidence links require existing evidence/detail permissions and internal/operator context.
- Customer-safe viewers receive customer-safe summary state, not raw snapshot routes.
- Non-member or wrong workspace/environment remains deny-as-not-found.
- Member without capability receives no technical route and direct route access remains denied by policies/controllers.
For canonical-view specs:
- **Default filter behavior when tenant-context is active**: Workspace-wide surfaces remain workspace-owned with explicit `environment_id` filtering. This spec must not reintroduce hidden tenant context, `/admin/t`, remembered-environment authority, or legacy query aliases.
- **Explicit entitlement checks preventing cross-tenant leakage**: The resolver must scope by workspace and managed environment before choosing evidence, review, review-pack, or report targets. Wrong-scope records must not be returned as anchors.
- **Workspace-wide/no-environment current anchor behavior**: If no managed environment is supplied, the resolver must either return a non-link workspace summary state or select only from actor-authorized managed environments. It must never choose a tenant-owned target outside the actor's entitlement. Product-facing single-target current evidence links should prefer an explicit environment filter; workspace-wide summaries may expose per-environment anchors only after entitlement filtering.
## Canonical Evidence Anchor Contract
The implementation must create or consolidate one canonical resolver with behavior equivalent to:
```text
EvidenceAnchorResolver::currentForScope($workspace, ?$environment, ?User $actor)
EvidenceAnchorResolver::forReviewPackDraft($reviewPack, ?User $actor)
EvidenceAnchorResolver::forReviewPackRelease($reviewPack, ?User $actor)
EvidenceAnchorResolver::forCustomerWorkspace($reviewPackOrReview, ?User $actor)
EvidenceAnchorResolver::technicalDetail($evidenceSnapshot, ?User $actor)
```
Exact class/method names may differ if existing repo ownership points to a better domain namespace. The final product-facing decision must still be one canonical contract consumed by affected surfaces. `currentForScope($workspace, null, $actor)` must follow the workspace-wide/no-environment behavior above.
Each resolver result must expose structured, testable fields:
```text
anchor_type
state
evidence_snapshot_id nullable
target_route nullable
is_current
is_release_bound
is_customer_safe
is_technical_only
is_partial
is_superseded
is_expired
can_link
can_view_technical_detail
primary_reason
blocking_reasons[]
display_label
```
Allowed product-facing states:
```text
Ready
Needs attention
Blocked
Expired
Not configured
Unknown
```
Internal diagnostic states may exist in code, but default product UI must map them to the allowed vocabulary.
Canonical anchor types:
- `CURRENT_SCOPE_EVIDENCE`
- `REVIEW_DRAFT_EVIDENCE`
- `REVIEW_RELEASED_EVIDENCE`
- `CUSTOMER_SAFE_EVIDENCE_SUMMARY`
- `TECHNICAL_EVIDENCE_DETAIL`
- `NO_VALID_EVIDENCE`
## Anchor Semantics
### CURRENT_SCOPE_EVIDENCE
Used by dashboard, workspace, environment, evidence overview, current readiness, and operator current-state triage surfaces.
Must resolve only to evidence that is:
- correct workspace
- correct managed environment when an environment is in scope
- `status = active`
- `completeness_state = complete`
- not expired
- not superseded
- not archived/revoked if such state exists in repo truth
- not known partial, failed, missing, stale, queued, or generating
- deterministic newest valid current anchor for the exact scope
Current selection order must be explicit and tested:
1. Correct workspace and environment.
2. Active status.
3. Complete/current-state eligible evidence.
4. Not expired.
5. Not superseded/archived/revoked.
6. Most recent `generated_at`, falling back only to an explicitly documented timestamp if needed.
7. Deterministic tie-breaker by descending `id`.
Do not rely on implicit database ordering.
### REVIEW_DRAFT_EVIDENCE
Used only in internal/operator draft review workflows. It may be incomplete or internal, but must be labelled as draft/internal and must not be exposed as customer-safe.
### REVIEW_RELEASED_EVIDENCE
Used by published/released review output, review pack details, rendered reports, stored reports, management-report PDF provenance, and customer-safe released output.
Must resolve to the evidence bound to the released review or released review pack, not whatever current evidence is latest. New current evidence must not silently change a released review's evidence provenance.
### CUSTOMER_SAFE_EVIDENCE_SUMMARY
Used by Customer Review Workspace and customer-safe outputs. It may show a compact evidence summary such as `Evidence captured for this review` or `Evidence current at publication`, but must not expose raw evidence snapshot links by default.
### TECHNICAL_EVIDENCE_DETAIL
Used only behind internal/operator details, audit trail, technical annex, or system/admin surfaces. May link to raw evidence snapshots only when authorized and clearly labelled as internal/technical.
### NO_VALID_EVIDENCE
Used when no valid evidence exists for the product context. The UI must show a non-link state and must not fall back to partial, superseded, expired, stale, wrong-scope, or arbitrary latest evidence.
## 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
- [x] Customer-facing surface changed
- [ ] Dangerous action changed
- [x] Status/evidence/review presentation changed
- [x] Workspace/environment context presentation changed
Navigation destination targets may change for existing actions/rows, but no new navigation entry, route family, or Filament panel path is expected. "New table/form/state" means derived result state vocabulary only; no new table or form is expected.
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact")*
| Route/page/surface | Archetype | Design depth | Repo-truth level | Existing pattern reused | Screenshot / audit need | Customer-safe review | Dangerous-action review |
|---|---|---|---|---|---|---|---|
| Workspace / Environment dashboard evidence CTA | Existing dashboard signal | Domain Pattern Surface | repo-verified by current app surfaces | existing dashboard/action payload patterns | Browser smoke if visible link changes | yes | no new dangerous action |
| Evidence Overview | Existing evidence monitoring context | Secondary Context / Evidence | repo-verified | current Filament page, EvidenceSnapshotResource links | Browser/Feature proof for current anchor | yes for default labels | no |
| Customer Review Workspace | Existing strategic customer review surface | Strategic Surface | repo-verified + historical browser-tested | Specs 342/372/392 hierarchy | Browser smoke required for no raw evidence link | yes | no new dangerous action |
| Environment Review detail | Existing review detail | Domain Pattern Surface | repo-verified | EnvironmentReviewResource evidence basis | Feature/Livewire proof; screenshot if material | yes | preserve existing actions |
| Review Pack detail/rendered report | Existing artifact detail/report | Domain Pattern Surface | repo-verified | ReviewPackResource, output gate, disclosure policy | Feature/HTTP proof; browser if customer path changes | yes | preserve existing actions |
| Stored Report / Management Report PDF provenance | Existing report artifact detail/output | Domain Pattern Surface | repo-verified | StoredReportResource, Spec 379 provenance | Feature proof if touched | yes for customer output | no |
| Evidence Snapshot detail | Technical/evidence detail | Tertiary Evidence / Diagnostics | repo-verified | EvidenceSnapshotResource | Browser/Feature only if labels/actions change | internal-only default | preserve existing actions |
- **Coverage files updated or explicitly not needed**:
- [ ] `docs/ui-ux-enterprise-audit/route-inventory.md` (not expected: no new route/navigation entry; update only if implementation changes route reachability)
- [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md` (not expected: no new archetype/count; document no-count-change if unchanged)
- [x] `docs/ui-ux-enterprise-audit/page-reports/...` when implementation materially changes reachable page behavior
- [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md` (not expected: no new strategic surface category)
- [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md` (not expected)
- [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md` (not expected)
- [ ] `N/A - no reachable UI surface impact`
- **No-impact rationale when applicable**: N/A.
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
- **Cross-cutting feature?**: yes.
- **Interaction class(es)**: evidence links, status messaging, dashboard CTAs, report/review evidence viewers, customer-safe disclosure, technical/audit detail links.
- **Systems touched**:
- `App\Services\Evidence\EvidenceSnapshotResolver`
- new or consolidated Evidence Anchor Resolver contract
- `App\Services\EnvironmentReviews\EnvironmentReviewService`
- `App\Filament\Pages\Monitoring\EvidenceOverview`
- `App\Filament\Pages\Reviews\CustomerReviewWorkspace`
- `App\Filament\Resources\EnvironmentReviewResource`
- `App\Filament\Resources\ReviewPackResource`
- `App\Filament\Resources\StoredReportResource`
- `App\Filament\Resources\EvidenceSnapshotResource`
- `App\Support\ReviewPacks\ManagementReportPdfPayloadBuilder`
- `App\Support\ReviewPublicationResolution\ReviewPublicationProofResolver`
- `App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter`
- `App\Support\OperationRunLinks`
- `App\Support\Navigation\RelatedNavigationResolver`
- `App\Support\GovernanceDecisions\GovernanceDecisionRegisterBuilder`
- route/controller paths that output evidence provenance
- **Existing pattern(s) to extend**: existing evidence resolver, review/review-pack relations, Spec 392 customer output gate, existing policies/capabilities, existing BadgeRenderer/BadgeCatalog patterns, existing ArtifactTruthPresenter/GovernanceArtifactTruth paths, existing Filament resources/pages.
- **Shared contract / presenter / builder / renderer to reuse**: The final resolver should reuse current model relationships and existing authorization helpers. It should replace local selectors rather than coexisting with them. `ArtifactTruthPresenter`/GovernanceArtifactTruth may remain artifact-state presentation only, and any product-facing EvidenceSnapshot links they emit must consume resolver output or be classified technical/internal-only by the implementation inventory. `OperationRunLinks`, `RelatedNavigationResolver`, and `GovernanceDecisionRegisterBuilder` remain technical/related-navigation helpers unless a product-facing path consumes them, in which case they must not bypass the resolver.
- **Why the existing shared path is sufficient or insufficient**: `EvidenceSnapshotResolver` currently resolves review-pack required dimensions but does not express product anchor type, released-review stability, customer-safe summary, or technical-only link flags. Local UI selectors still choose evidence independently.
- **Allowed deviation and why**: One narrow Evidence Anchor Resolver/result contract is allowed because evidence anchors are security/audit/customer trust boundaries with multiple real consumers. It must not become a generic proof framework or report-readiness engine.
- **Consistency impact**: A label saying `Current evidence` must resolve current evidence; `Review evidence` must resolve review-bound/released evidence; `View internal evidence details` must be technical/internal and permission-protected.
- **Review focus**: Block local fallback queries, arbitrary latest ordering, customer raw evidence links, wrong-scope anchors, and compatibility shims.
## OperationRun UX Impact *(mandatory)*
- **Touches OperationRun start/completion/link UX?**: no new operation start/completion. Existing OperationRun proof links may remain only as technical/internal diagnostics.
- **Shared OperationRun UX contract/layer reused**: existing OperationRun link helpers where technical detail remains.
- **Delegated start/completion UX behaviors**: N/A.
- **Local surface-owned behavior that remains**: evidence-anchor labels and summary states only.
- **Queued DB-notification policy**: N/A.
- **Terminal notification path**: unchanged.
- **Exception required?**: none.
## Provider Boundary / Platform Core Check *(mandatory)*
- **Shared provider/platform boundary touched?**: no new provider seam.
- **Boundary classification**: platform-core evidence/review/report trust boundary over provider-collected artifacts.
- **Seams affected**: evidence anchor selection, route labels, customer-safe evidence summaries, technical detail links.
- **Neutral platform terms preserved or introduced**: workspace, managed environment, evidence, review evidence, current evidence, customer-safe summary, technical detail, audit trail.
- **Provider-specific semantics retained and why**: Provider facts may remain inside existing evidence payloads and technical detail pages. Default product anchor labels must not use provider-specific source keys, detector names, or raw Graph terminology.
- **Why this does not deepen provider coupling accidentally**: No Graph contracts, provider credentials, provider connections, provider identifiers, or Microsoft-specific scopes are introduced.
- **Follow-up path**: none unless implementation finds provider-specific evidence leakage requiring a separate follow-up.
## UI / Surface Guardrail Impact
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---:|---|---|---|---:|---|
| Dashboard/workspace/environment evidence CTA | yes | existing dashboard payload | dashboard signals, evidence links | page payload, route | no | replace or remove wrong link |
| Customer Review Workspace evidence summary | yes | existing Filament page plus Blade composition | customer-safe disclosure | page payload, route | no | no raw link by default |
| Review Pack / Environment Review evidence basis | yes | native Filament resources | artifact/report/evidence viewers | detail, route | no | released evidence only |
| Evidence Overview primary row/action targets | yes | existing Filament page | evidence monitoring and drill-down | row URL, page payload | no | current anchor only |
| Technical Evidence Detail link | yes when surfaced | EvidenceSnapshotResource | technical/audit detail | route, action label | no | internal/audit only |
## Decision-First Surface Role
| 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 |
|---|---|---|---|---|---|---|---|
| Dashboard evidence CTA | Secondary Context Surface | Operator verifies whether current evidence supports current-state decisions | current evidence state and one truthful action or non-link | technical evidence detail only if internal | Not primary; it supports current-state triage | dashboard-to-evidence verification | removes stale-proof guessing |
| Customer Review Workspace | Primary Decision Surface | Customer/auditor/operator consumes released review context | customer-safe evidence summary, not raw link | internal audit/evidence only behind secondary action | Primary for customer-safe consumption | released-review handoff | removes internal proof leakage |
| Review Pack / Environment Review detail | Secondary Context / Artifact Surface | Operator/auditor verifies released review evidence provenance | review evidence state bound to release | raw evidence and operation proof secondary/internal | Secondary because it verifies one output/review | review-output verification | prevents current/released mixups |
| Evidence Snapshot detail | Tertiary Evidence / Diagnostics Surface | Internal user inspects raw evidence detail | technical evidence context | raw payloads and source dimensions | Tertiary by design | audit/support diagnostics | preserves depth without making it customer proof |
## Audience-Aware Disclosure
| 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 |
|---|---|---|---|---|---|---|---|
| Dashboard/current-state surfaces | operator-MSP | current evidence state, reason, one action | audit trail link if allowed | raw snapshot/source internals | view current evidence or resolve evidence issue | technical evidence detail | resolver state owns label |
| Customer Review Workspace | customer-read-only, operator-MSP, support where authorized | evidence captured/current at publication summary | secondary internal evidence detail for operators | raw evidence IDs, source keys, detector output, operation proof | review/download customer output or view audit trail | raw evidence snapshot link | summary only, no duplicate raw proof |
| Review Pack / report output | customer/auditor/operator | released review evidence summary/provenance | technical evidence detail secondary | renderer/storage/source internals | download/open output or review evidence state | raw payload/technical proof | release-bound anchor owns provenance |
## UI/UX Surface Classification
| 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Dashboard evidence action | Dashboard Signal | Navigation/status action | inspect current evidence or resolve issue | explicit action | N/A | internal audit action secondary | none | existing dashboard | current evidence detail or no-link state | workspace/environment | Current evidence | validity and currentness | none |
| Customer Review Workspace evidence block | Utility / Review Workspace | Customer-safe summary | consume released review safely | no raw evidence route by default | N/A | audit/evidence detail secondary | existing actions unchanged | `/admin/reviews/workspace` | review/pack/report routes | workspace + environment filter | Review evidence | released/customer-safe summary | none |
| Review Pack evidence basis | Utility / Artifact Detail | Artifact proof/detail | verify release-bound evidence | detail field/action | current repo behavior | technical detail secondary | existing actions unchanged | review-pack index | review-pack detail | workspace/environment | Review evidence | release-bound evidence state | none |
| Evidence Overview row | List / Monitoring | Evidence overview | open current valid evidence or see no valid state | row URL only when resolver can link | existing row URL | technical detail label when applicable | none | evidence overview | evidence snapshot detail | workspace/environment | Evidence | current state validity | none |
## UI Action Matrix
| Action label | Surface(s) | Destination / effect | Primary or secondary | Visible / enabled rule | Server-side enforcement | Destructive? |
|---|---|---|---|---|---|---|
| `Current evidence` | dashboard, workspace, evidence overview | current valid evidence for exact scope | primary when valid | only when `CURRENT_SCOPE_EVIDENCE` is `Ready` and `can_link` is true | resolver scope + EvidenceSnapshot policy | no |
| `Evidence not ready` / `Evidence unavailable` / `Evidence needs attention` | current-state surfaces | non-link state | primary state, no navigation | when resolver returns no valid current evidence | resolver only, no target route | no |
| `Review evidence` / `Evidence captured for this review` | review pack, released review, report provenance | released review evidence summary or release-bound evidence detail when internal | primary summary, link only in non-customer contexts | release-bound resolver result | resolver + review/report policies | no |
| `View audit trail` | customer workspace and review/report details where applicable | internal audit/review/evidence context | secondary | internal/operator actor with permission | existing policies and route checks | no |
| `View internal evidence details` | technical/internal action areas | raw EvidenceSnapshot detail | secondary/technical | `TECHNICAL_EVIDENCE_DETAIL` with permission | EvidenceSnapshot policy and workspace/environment scope | no |
Forbidden default product labels:
- `Evidence #<id>`
- `Open artifact`
- `Open evidence artifact`
- `Technical dimensions`
- `Detector output`
- `Source keys`
- `Operation proof`
Those may appear only in technical annex/system/admin diagnostic contexts.
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no.
- **New persisted entity/table/artifact?**: no by default.
- **New abstraction?**: yes, one derived Evidence Anchor Resolver/result contract.
- **New enum/state/reason family?**: yes, one derived non-persisted anchor type/state vocabulary; no persisted enum/status family.
- **New cross-domain UI framework/taxonomy?**: no.
- **Current operator problem**: Operators and customer-facing surfaces can land on plausible but wrong evidence, undermining evidence as a trust object.
- **Existing structure is insufficient because**: Existing selectors answer local data questions, not product intent questions. They do not consistently distinguish current evidence, draft review evidence, released review evidence, customer-safe summaries, and technical evidence detail.
- **Narrowest correct implementation**: A derived resolver/result contract over existing records, consumed by existing product surfaces, with local fallback selectors removed.
- **Ownership cost**: Resolver/result code, focused tests, and a small set of UI label/action updates. No default database/schema ownership.
- **Alternative intentionally rejected**: Page-local query fixes, compatibility redirects, latest-by-generated-at fallback, raw evidence links hidden only by copy, or a broad technical annex/proof framework.
- **Release truth**: current-release trust/correctness.
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, old query ordering, route aliases, old translation keys, fixture expectations, and compatibility redirects are out of scope. When current behavior conflicts with Spec 393, Spec 393 wins.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit for resolver decisions; Feature/HTTP for routes/scope and report/review provenance; Filament/Livewire for action visibility/labels; Browser for customer workspace/dashboard/review/evidence smoke.
- **Validation lane(s)**: fast-feedback, confidence, browser.
- **Why this classification and these lanes are sufficient**: Resolver correctness is pure/domain behavior; product surfaces and routes need Feature/Livewire proof; customer-safe leakage and CTA destination truth need browser smoke.
- **New or expanded test families**: `Spec393EvidenceAnchorResolverTest`, focused Feature/Filament tests, and one bounded Browser smoke.
- **Fixture / helper cost impact**: reuse existing review-output/evidence factories and browser fixture helpers. Build only minimal explicit fixtures for current/partial/superseded/released/wrong-scope evidence.
- **Heavy-family visibility / justification**: one bounded Browser smoke is explicit because this is a customer-facing trust boundary and includes UI leakage checks.
- **Special surface test profile**: customer-safe strategic review surface + evidence/artifact detail + dashboard signal.
- **Standard-native relief or required special coverage**: ordinary Feature/Filament coverage plus focused Browser smoke for key trust paths.
- **Reviewer handoff**: reviewers must verify no local fallback evidence selectors remain in affected product surfaces and no customer-safe page exposes raw evidence links by default.
- **Budget / baseline / trend impact**: no material expected beyond focused tests.
- **Escalation needed**: none unless implementation requires schema change or broad technical annex behavior.
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage.
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec393`
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec393EvidenceAnchorReconciliationSmokeTest.php`
- targeted existing review/customer/evidence browser tests if still applicable
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `git diff --check`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Current Evidence Anchors Are Correct (Priority: P1)
As an operator using dashboard, workspace, environment, or evidence overview surfaces, I need evidence actions to point only to the current valid evidence for that exact workspace/environment, or show that no valid current evidence exists.
**Why this priority**: This blocks the most direct false-proof risk on current-state surfaces.
**Independent Test**: Create older partial/superseded evidence and newer valid current evidence for the same environment. The resolver and dashboard/evidence overview choose only the valid current evidence. With only partial/superseded evidence, no product-facing link appears.
**Acceptance Scenarios**:
1. **Given** a workspace/environment has superseded evidence and newer valid complete active evidence, **When** the current evidence anchor is resolved, **Then** the valid complete active evidence is selected.
2. **Given** a workspace/environment has only partial, expired, failed, stale, queued, generating, or superseded evidence, **When** the current evidence anchor is resolved, **Then** the result is not linkable and uses a needs-attention or no-valid-evidence state.
3. **Given** evidence exists in another workspace or environment, **When** the current evidence anchor is resolved, **Then** wrong-scope evidence is never returned.
### User Story 2 - Released Review Evidence Remains Stable (Priority: P1)
As an operator or auditor viewing a released review, review pack, stored report, rendered report, or management report, I need evidence provenance to remain bound to the released review output and not silently follow newer current evidence.
**Why this priority**: Released customer output must be reproducible and auditable.
**Independent Test**: Release a review with evidence snapshot A, create newer current evidence B, then verify released review/report/pack provenance still references A while dashboard current evidence may reference B.
**Acceptance Scenarios**:
1. **Given** a review is released with evidence snapshot A, **When** newer current evidence B exists, **Then** released review output still resolves released review evidence A.
2. **Given** a review pack is bound to a released review, **When** its evidence anchor is resolved, **Then** it uses the review/pack-bound evidence and does not query arbitrary latest evidence.
3. **Given** released evidence cannot be resolved, **When** the product renders review output, **Then** it shows `Evidence not configured`, `Evidence unavailable`, or `Review evidence needs attention` instead of borrowing current evidence.
### User Story 3 - Customer Workspace Is Customer-Safe By Default (Priority: P1)
As a customer reviewer or customer-facing operator, I need the Customer Review Workspace to show customer-safe evidence summary text without raw evidence snapshot links, internal IDs, operation proof, source keys, detector output, or technical dimensions by default.
**Why this priority**: Customer-safe output must not leak internal proof detail or invite customers into technical evidence objects.
**Independent Test**: Render Customer Review Workspace with released review evidence and assert only customer-safe summary text is default-visible; internal technical link is absent or secondary/gated for authorized internal users.
**Acceptance Scenarios**:
1. **Given** a released review has evidence, **When** a customer-safe viewer opens Customer Review Workspace, **Then** they see evidence summary text and no raw evidence snapshot link.
2. **Given** an internal operator has permission, **When** technical detail is available, **Then** any evidence detail action is secondary and labelled as audit/internal detail.
3. **Given** a customer/read-only viewer lacks technical permission, **When** the workspace renders, **Then** no internal evidence detail route is exposed.
### User Story 4 - Technical Evidence Remains Available Only As Internal Detail (Priority: P2)
As an internal operator or support user, I need technical evidence detail to remain reachable for audit and diagnosis, but only through clearly labelled internal/audit paths with authorization.
**Why this priority**: The platform must preserve audit depth without making raw evidence default product proof.
**Independent Test**: Resolve a technical detail anchor for an authorized internal actor and an unauthorized actor. The authorized actor gets an internal route; the unauthorized actor gets no target route.
**Acceptance Scenarios**:
1. **Given** an authorized internal actor views a product surface, **When** a technical evidence action is present, **Then** it is labelled `View audit trail` or `View internal evidence details`.
2. **Given** an unauthorized actor, **When** a technical detail anchor is resolved, **Then** `can_view_technical_detail` is false and no target route is returned.
## Functional Requirements
- **FR-001**: Dashboard, workspace, environment, and evidence overview current-evidence actions MUST use `CURRENT_SCOPE_EVIDENCE`.
- **FR-002**: Current-evidence anchors MUST NOT return superseded, partial, stale, failed, queued, generating, expired, wrong-tenant, wrong-workspace, wrong-environment, draft review-bound, or released-review evidence.
- **FR-003**: If no valid current evidence exists, product surfaces MUST show a non-link state such as `Evidence not ready`, `Evidence unavailable`, or `Evidence needs attention`.
- **FR-004**: Customer Review Workspace default view MUST NOT expose raw evidence snapshot links, evidence IDs, source keys, detector output, operation-run proof, fingerprints, or technical dimensions.
- **FR-005**: Customer Review Workspace MAY show only customer-safe evidence summary text by default.
- **FR-006**: Published/released review output MUST use `REVIEW_RELEASED_EVIDENCE` and remain stable after newer current evidence is created.
- **FR-007**: Draft/internal review flows MAY show draft evidence only when labelled as internal/draft and not customer-safe.
- **FR-008**: Raw Evidence Snapshot detail pages remain technical/internal and may be linked from product surfaces only through secondary internal/audit actions.
- **FR-009**: The resolver MUST enforce workspace and managed-environment scope before returning any evidence target.
- **FR-010**: The resolver MUST account for actor permission before returning a target route.
- **FR-011**: The resolver MUST return structured, inspectable fields sufficient for tests to assert selected snapshot, state, customer-safety, technical-only status, current/release-bound flags, and blocking reasons.
- **FR-012**: Current evidence selection MUST be deterministic and explicitly ordered.
- **FR-013**: Product-facing labels MUST be truthful by context and destination.
- **FR-014**: No local fallback selector that can choose old/latest/partial/superseded/wrong-scope evidence may remain in affected product surfaces.
- **FR-015**: This spec MUST reduce or preserve visible UI complexity and MUST NOT add new evidence cards, proof sections, readiness badges, operation-run links, or long tables.
- **FR-016**: Reports and management-report payloads MUST expose correct release-bound evidence provenance internally and only customer-safe provenance in customer-facing output.
- **FR-017**: Existing direct wrong-scope evidence routes MUST remain protected by authorization/scope checks.
- **FR-018**: No compatibility shim, legacy redirect, old translation key, old route alias, or old fixture expectation may preserve wrong-anchor behavior.
## Non-Functional Requirements
- **NFR-001**: Resolver evaluation must be DB-only and must not call Graph/provider APIs during render.
- **NFR-002**: Resolver queries must eager-load or explicitly query relationships needed by affected UI paths to avoid avoidable N+1 behavior.
- **NFR-003**: Audit logs and UI copy must not include secrets, raw provider payloads, raw Graph errors, source keys, detector internals, or raw evidence payloads on customer-safe paths.
- **NFR-004**: Implementation must remain compatible with Filament v5 and Livewire v4.0+; no Filament v3/v4 or Livewire v3 APIs.
- **NFR-005**: No new asset registration is expected; if assets are unexpectedly registered, deployment must include `cd apps/platform && php artisan filament:assets`.
## Edge Cases
- Multiple complete active snapshots share the same `generated_at` in a workspace-wide authorized selection set, or another repo-possible multi-record current selection set: choose deterministically by descending `id`. Same workspace/environment duplicate active snapshots are prevented by the existing active unique index.
- Complete active evidence exists for the workspace but not the selected environment: return no valid environment evidence.
- A released review points to evidence that later becomes superseded for current-state purposes: released review output still resolves the release-bound evidence and labels it as review evidence, not current evidence.
- A review pack exists without an environment review relation: do not infer released review evidence from latest current evidence.
- Evidence is active but expired: current resolver returns expired/non-link state.
- Evidence is active and complete but actor lacks technical permission: summary may be returned, target route must be null.
- Existing Evidence Snapshot detail remains accessible by direct authorized route but must not become a default customer proof path.
## Out Of Scope
- Evidence Snapshot page redesign.
- Full Technical Annex.
- Product Surface Contract Enforcement pass.
- Evidence generation pipelines.
- Provider integrations or Graph contract changes.
- New report profiles.
- New review publication states.
- New customer portal functionality.
- New dashboard sections/cards.
- New readiness models or baseline readiness reconciliation.
- Schema changes unless implementation proves existing fields cannot represent the canonical anchor.
- Legacy compatibility behavior.
## Acceptance Criteria
- **AC-001**: Dashboard/workspace/environment evidence actions resolve to current valid evidence and never partial/superseded evidence.
- **AC-002**: With no valid current evidence, no current-evidence link is shown.
- **AC-003**: Customer Review Workspace default view has no raw evidence snapshot link, evidence ID, source key, detector output, operation-run proof, or technical dimension.
- **AC-004**: Released review output references the evidence bound to the released review even after newer current evidence exists.
- **AC-005**: Draft/internal review-bound evidence is labelled internal/draft and is not customer-safe by default.
- **AC-006**: Partial evidence is not treated as current proof.
- **AC-007**: Superseded evidence is not selected as current evidence.
- **AC-008**: Wrong-scope evidence is never selected by the resolver.
- **AC-009**: Labels are truthful: current evidence opens current evidence, review evidence references review-bound/released evidence, and internal evidence detail requires permission.
- **AC-010**: Affected pages do not gain new evidence cards, proof sections, readiness badges, operation-run links, or long tables.
- **AC-011**: Deprecated wrong-anchor behavior is removed from code and tests.
## Success Criteria
- 100% of affected product-facing evidence links use the canonical resolver/result contract.
- Focused Spec 393 resolver tests cover current, released, customer-safe, technical, no-valid, partial, superseded, expired, and wrong-scope scenarios.
- Customer Review Workspace browser/feature assertions show no raw evidence link by default.
- Released review evidence remains stable in tests after newer current evidence is created.
- `git diff --check` and Pint pass after implementation.
- Human product sanity check confirms labels are truthful and UI complexity is not increased.
## Regression Risks
- **Risk 1 - Existing tests encode wrong-anchor behavior**: Update or remove tests that expect partial, superseded, stale, or arbitrary latest evidence as product proof. Do not weaken the resolver to keep those tests green.
- **Risk 2 - Released review output follows current evidence**: Add released-review regression coverage proving evidence snapshot A remains the review evidence after newer current evidence B exists.
- **Risk 3 - Dashboard loses a familiar link when evidence is invalid**: Treat this as correct behavior; show a concise non-link state instead of falling back to stale or partial evidence.
- **Risk 4 - Customer workspace loses operator debug convenience**: Preserve internal detail only through secondary internal/audit actions with permission, not default customer-safe UI.
- **Risk 5 - Technical evidence is hidden too aggressively**: Keep authorized Evidence Snapshot technical/detail paths available while preventing those paths from becoming default product proof.
- **Risk 6 - Scope checks drift between surfaces**: Centralize selection in the resolver and add wrong-workspace/environment tests so page-local shortcuts cannot reintroduce leakage.
## Assumptions
- Existing `environment_reviews.evidence_snapshot_id`, `review_packs.evidence_snapshot_id`, `environment_reviews.current_export_review_pack_id`, and stored-report source fields are sufficient to model released-review evidence provenance.
- `EvidenceSnapshotStatus::Active` plus `EvidenceCompletenessState::Complete` and non-expired timestamps are sufficient for current product evidence unless implementation finds additional existing readiness truth that must be included.
- Customer-facing users are represented by existing auth/capability boundaries; no new customer portal role model is introduced by this spec.
- Existing EvidenceSnapshot direct routes and policies remain the technical detail access path.
## Open Questions
No open question blocks preparation. If implementation proves that existing persisted fields cannot distinguish released review evidence from current evidence in a real affected path, stop and update `spec.md` and `plan.md` before adding schema.
## Required Implementation Report
The later implementation report must include:
1. Files changed.
2. Resolver/API created or consolidated.
3. Old local evidence-selection paths removed or replaced.
4. Tests added/updated.
5. Browser flows run.
6. Evidence that current evidence and released review evidence are separated.
7. Evidence that Customer Review Workspace no longer exposes raw evidence by default.
8. Confirmation that no legacy fallback/compatibility shim was added.
9. Confirmation that visible UI complexity did not increase.
10. Remaining known unrelated failures, if any.

View File

@ -0,0 +1,182 @@
# Tasks: Spec 393 - Evidence Anchor Reconciliation v1
**Input**: `specs/393-evidence-anchor-reconciliation-v1/spec.md` and `plan.md`
**Prerequisites**: Spec artifacts prepared; implementation must start from repo-truth verification and must not modify completed context specs.
**Tests**: Required. This is an evidence trust-boundary change with Unit, Feature/HTTP, Filament/Livewire, and bounded Browser proof.
## Test Governance Checklist
- [x] Lane assignment is named and narrow: Unit for resolver decisions, Feature/HTTP for route/scope/provenance, Filament/Livewire for action labels/state, Browser for final customer/dashboard trust-path proof.
- [x] New or changed tests stay in focused families; Browser coverage is one explicit Spec 393 smoke unless existing focused browser tests are intentionally reused and named.
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default.
- [x] Planned validation commands cover the change without pulling unrelated heavy-governance cost.
- [x] N+1/eager-loading risk is covered by an explicit resolver/query-shape task for affected list/detail surfaces.
- [x] The declared surface profile is customer-safe strategic review surface + evidence/artifact detail + dashboard signal.
- [x] Any unreachable or not-applicable surface is documented in the implementation report instead of faked.
## Phase 1: Repo Truth And Evidence Anchor Inventory
**Purpose**: Map all current local evidence selectors before changing behavior.
- [x] T001 Re-read `specs/393-evidence-anchor-reconciliation-v1/spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`.
- [ ] T002 Re-read completed context specs as read-only inputs only: `specs/361-report-evidence-reconciliation`, `specs/372-customer-auditor-surface-safety-pass`, `specs/385-evidence-review-readiness`, `specs/386-review-publication-resolution-workflow-v1`, `specs/387-review-publication-resolution-decision-ux-v1`, `specs/388-resolution-proof-currentness-contract-v1`, and `specs/392-customer-output-gating-review-pack-navigation`.
- [x] T003 Confirm current branch and dirty state with `git status --short --branch` and `git log -1 --oneline`.
- [x] T004 Inventory every evidence selector/link/action/output in `apps/platform/app`, `apps/platform/resources`, `apps/platform/routes`, `apps/platform/tests`, and localization files using the spec search terms, including shared builders/presenters `ArtifactTruthPresenter`, `OperationRunLinks`, `RelatedNavigationResolver`, and `GovernanceDecisionRegisterBuilder`.
- [ ] T005 Record the inventory in the implementation report: file, current selection logic, target route, visible label, product context, customer/internal/technical classification, and stale/partial/superseded/wrong-scope risk.
- [x] T006 Inspect exact current behavior in `apps/platform/app/Services/Evidence/EvidenceSnapshotResolver.php`, `apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewService.php`, and `apps/platform/app/Models/EvidenceSnapshot.php`.
- [x] T007 Inspect current product surfaces in `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`, `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php`, `apps/platform/app/Filament/Resources/ReviewPackResource.php`, `apps/platform/app/Filament/Resources/StoredReportResource.php`, and `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`.
- [x] T008 Inspect current report/review provenance in `apps/platform/app/Support/ReviewPacks/ManagementReportPdfPayloadBuilder.php`, rendered-report controllers/views, and `apps/platform/app/Support/ReviewPublicationResolution/ReviewPublicationProofResolver.php`.
- [x] T009 Confirm no migration, package, env var, queue, scheduler, storage topology, Graph scope, panel-provider, route family, report renderer, customer portal, or broad technical annex change is required; stop and update spec/plan if false.
- [x] T010 Confirm Filament v5 / Livewire v4.0+ compliance and no Filament v3/v4 or Livewire v3 APIs.
- [x] T011 Confirm panel provider registration remains `apps/platform/bootstrap/providers.php`.
- [x] T012 Confirm no global-search participation is added or changed.
## Phase 2: Resolver Contract And Unit Tests
**Purpose**: Prove evidence anchor behavior before replacing product surfaces.
- [x] T013 Add focused Unit tests for the canonical Evidence Anchor Resolver under `apps/platform/tests/Unit/Services/Evidence/Spec393EvidenceAnchorResolverTest.php` or the nearest existing evidence test family.
- [ ] T014 [P] Test newest valid current evidence is selected for a workspace/environment.
- [ ] T015 [P] Test superseded evidence is not selected as current.
- [x] T016 [P] Test partial evidence is not selected as current.
- [ ] T017 [P] Test expired evidence is not selected as current.
- [ ] T018 [P] Test queued, generating, failed, missing, and stale evidence are not selected as current proof.
- [ ] T019 [P] Test wrong-workspace evidence and unauthorized workspace-wide evidence are never selected.
- [x] T020 [P] Test wrong-environment evidence is never selected, including when no explicit environment is provided and the actor lacks entitlement.
- [x] T021 Test no valid evidence is returned when only partial/superseded/expired evidence exists.
- [x] T022 Test released review evidence resolves from the review/review-pack binding independently from current evidence.
- [x] T023 Test released review evidence remains stable after newer current evidence is created.
- [ ] T024 Test draft review evidence is internal/draft and not customer-safe.
- [x] T025 Test customer workspace resolution returns customer-safe summary without raw technical route by default.
- [ ] T026 Test actor without permission receives no technical evidence link.
- [ ] T027 Test internal/operator actor may receive technical detail link where appropriate.
- [ ] T028 Test deterministic tie-breaker when multiple valid snapshots share `generated_at` in a workspace-wide authorized selection set or another repo-possible multi-record set.
- [x] T029 Implement or consolidate `EvidenceAnchorResolver` and result value object/array in `apps/platform/app/Services/Evidence/` or the narrowest repo-consistent namespace, using derived non-persisted anchor type/state vocabulary only.
- [x] T030 Ensure resolver result exposes the spec-required fields and maps internal states to allowed UI vocabulary without adding a persisted enum/status family.
- [x] T031 Ensure resolver performs DB-only scoped queries, no Graph/provider calls, and explicit eager-loading or bounded query shape for relationships needed by affected UI paths.
## Phase 3: Current Evidence Product Surfaces
**Purpose**: Make dashboard/workspace/environment/evidence-overview surfaces use `CURRENT_SCOPE_EVIDENCE`.
- [ ] T032 Add Feature/Filament tests proving dashboard/workspace/environment current evidence link targets the valid current evidence, not older partial/superseded evidence.
- [x] T033 Add Feature/Filament tests proving no current-evidence link appears when only partial/superseded/expired evidence exists.
- [x] T034 Update dashboard/workspace/environment summary builders that produce evidence CTAs to use the resolver.
- [x] T035 Update `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php` to use the resolver for product-facing current evidence row/action targets.
- [x] T036 Remove local fallback queries from affected current-state surfaces that choose arbitrary latest evidence.
- [ ] T037 Ensure non-link states use concise copy: `Evidence not ready`, `Evidence unavailable`, `Evidence needs attention`, or `Evidence expired`.
- [x] T038 Ensure current evidence selection order is explicit and deterministic in code and tests.
## Phase 4: Released Review, Review Pack, And Report Provenance
**Purpose**: Keep released output bound to released evidence instead of current evidence.
- [ ] T039 Add Feature tests proving released review output references evidence snapshot A after newer current evidence B is created.
- [x] T040 Add Feature/Filament tests proving `ReviewPackResource` evidence labels use release-bound/review-pack evidence and do not query arbitrary current evidence.
- [x] T041 Add Feature/Filament tests proving `EnvironmentReviewResource` evidence basis uses the review-bound evidence.
- [ ] T042 Add Feature tests proving rendered report, stored report, and management-report provenance use released review/review-pack evidence where in scope.
- [x] T043 Update `apps/platform/app/Filament/Resources/ReviewPackResource.php` to consume released-review/review-pack anchor results for evidence basis links/labels.
- [x] T044 Update `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php` to consume released-review anchor results for evidence basis links/labels.
- [ ] T045 Update report provenance builders/controllers/views only where they currently infer evidence from latest/current state.
- [x] T046 Ensure missing released evidence produces `Evidence not configured`, `Evidence unavailable`, or `Review evidence needs attention` instead of borrowing current evidence.
## Phase 5: Customer Review Workspace Customer-Safe Evidence
**Purpose**: Remove raw evidence links from default customer-safe review consumption.
- [x] T047 Add Feature/Filament tests proving Customer Review Workspace default view does not render raw EvidenceSnapshot routes, evidence IDs, source keys, detector output, OperationRun proof, fingerprints, or technical dimensions.
- [ ] T048 Add tests proving Customer Review Workspace may show customer-safe summary text such as `Evidence captured for this review` or `Evidence current at publication`.
- [ ] T049 Add tests proving authorized internal users get only a secondary/internal technical action when allowed.
- [x] T050 Update `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` to consume `CUSTOMER_SAFE_EVIDENCE_SUMMARY` for default evidence state.
- [ ] T051 Update `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` to remove or demote raw evidence links by default.
- [ ] T052 Ensure any internal action uses labels such as `View audit trail` or `View internal evidence details`.
- [x] T053 Ensure customer/read-only mode receives no raw evidence target route.
## Phase 6: Technical Evidence Detail Boundary
**Purpose**: Preserve technical evidence access without making it product proof.
- [ ] T054 Add Feature/HTTP tests proving direct wrong-scope EvidenceSnapshot route remains deny-as-not-found.
- [ ] T055 Add tests proving technical detail requires internal/operator permission where the product surface offers a technical link.
- [ ] T056 Update `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` only where labels/context need to clarify technical/audit purpose.
- [x] T057 Ensure product surfaces use secondary/internal labels for technical evidence detail and do not expose raw technical labels in customer-safe defaults.
- [ ] T058 Preserve existing EvidenceSnapshot technical page depth and existing destructive/high-impact action confirmation/authorization/audit behavior.
## Phase 7: Deprecated Selector And Fixture Cleanup
**Purpose**: Remove wrong-anchor assumptions rather than compatibility-shimming them.
- [x] T059 Search for remaining product-facing `latest('generated_at')`, `latest('created_at')`, `orderByRaw('COALESCE(generated_at, created_at) DESC')`, `EvidenceSnapshotResource::getUrl`, direct `evidence_snapshot_id` link composition, and shared link-builder emissions in `ArtifactTruthPresenter`, `OperationRunLinks`, `RelatedNavigationResolver`, and `GovernanceDecisionRegisterBuilder`.
- [x] T060 Replace or remove product-facing local fallback selectors found by T059, or explicitly classify retained shared-builder links as technical/internal-only.
- [x] T061 Update tests/fixtures that expected partial, superseded, stale, or arbitrary latest evidence to appear as current proof.
- [x] T062 Do not add legacy aliases, compatibility redirects, fallback readers, old translation keys, or tests preserving wrong-anchor behavior.
- [ ] T063 Update localization keys only where visible labels change; remove stale keys if they preserve forbidden labels.
## Phase 8: Browser Smoke
**Purpose**: Prove visible trust boundaries and absence of internal evidence leakage.
- [ ] T064 Add or update `apps/platform/tests/Browser/Spec393EvidenceAnchorReconciliationSmokeTest.php` using existing review-output/evidence fixture helpers where practical.
- [x] T065 Browser state: current dashboard/evidence overview link opens current valid evidence, not stale/superseded evidence.
- [ ] T066 Browser state: Customer Review Workspace has no raw evidence snapshot link by default.
- [ ] T067 Browser state: Review Pack evidence label is truthful and release-bound.
- [ ] T068 Browser state: technical evidence link, if present, is secondary/internal.
- [ ] T069 Browser state: no visible `Evidence #<id>` style product link appears on customer-safe surfaces.
- [x] T070 Browser state: no 500/Livewire/Filament/console errors in affected flows.
- [ ] T071 Direct URL proof: wrong/old evidence URL does not become a customer-facing proof path.
## Phase 9: Validation And Close-Out
**Purpose**: Prove the implementation and record deployment impact clearly.
- [x] T072 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec393`.
- [x] T073 Run targeted existing regressions for Customer Review Workspace, Review Pack, Environment Review, Evidence Overview, Stored Report, and management-report provenance if those surfaces changed.
- [ ] T074 Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec393EvidenceAnchorReconciliationSmokeTest.php`.
- [ ] T075 Run additional affected existing browser tests named in the spec if they still exist and cover changed flows.
- [ ] T076 Update affected `docs/ui-ux-enterprise-audit/page-reports/...` artifacts when visible page behavior materially changed, or document explicit no-route/no-archetype/no-count-impact decisions for each touched surface in the implementation report.
- [x] T077 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
- [x] T078 Run `git diff --check`.
- [x] T079 Confirm no migrations, seeders, packages, env vars, queues, scheduler, storage topology, Graph contracts/calls, panel providers, new route family, customer portal, technical annex, or legacy compatibility path were added unless spec/plan were updated first.
- [ ] T080 Confirm final Livewire v4 compliance, provider registration location, global-search posture, destructive/high-impact action status, asset strategy, tests, deployment impact, UI coverage artifact/no-impact decision, current-vs-released evidence separation, Customer Review Workspace no-raw-link behavior, no UI expansion, and no legacy shim in the implementation close-out response.
- [ ] T081 Complete human product sanity check before marking Spec 393 done.
## Dependencies
- Phase 1 must complete before runtime implementation.
- Phase 2 resolver tests should land before or alongside resolver implementation.
- Phase 3 current-surface replacements depend on the resolver contract.
- Phase 4 released-provenance replacements depend on release-bound resolver methods.
- Phase 5 customer workspace changes depend on customer-safe resolver summary behavior.
- Phase 8 runs after targeted tests and UI/route changes.
- Phase 9 closes the feature.
## Parallel Execution Examples
- T006, T007, and T008 can be split by repo surface during inspection.
- T014-T020 can be implemented in parallel as independent resolver test cases.
- T039-T042 can be split by review/report artifact surface after the resolver API is stable.
- T047-T049 can run in parallel with T054-T055 after the result shape is stable.
## Non-Goals / Stop Conditions
- Stop if implementation requires a new persisted evidence-anchor table, review release table, or broad technical annex; update spec/plan first.
- Stop if a page-local selector appears necessary for a product-facing surface; fold it into the resolver instead.
- Stop if the only way to keep an old test green is to preserve partial/superseded/latest fallback behavior.
- Stop if management-report runtime enablement or PDF renderer validation becomes necessary; that belongs to Spec 379 follow-through.
- Do not rewrite, normalize, uncheck, or remove implementation history from completed Specs 361, 372, 385, 386, 387, 388, or 392.
## Required Final Report Content For Later Implementation
When implementation later completes, report:
- Files changed.
- Resolver/API created or consolidated.
- Old local evidence-selection paths removed or replaced.
- Tests added/updated.
- Browser flows run.
- Evidence that current evidence and released review evidence are separated.
- Evidence that Customer Review Workspace no longer exposes raw evidence by default.
- UI coverage artifact update or explicit no-route/no-archetype/no-count-impact decision.
- Confirmation that no legacy fallback/compatibility shim was added.
- Confirmation that visible UI complexity did not increase.
- Remaining known unrelated failures, if any.